@nitra/cursor 1.8.180 → 1.8.185

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.
@@ -17,7 +17,11 @@
17
17
  * `node:process` (для опційних). Коли `env` імпортовано з `@nitra/check-env`,
18
18
  * кожен `env.X` має бути закритий літеральним викликом `checkEnv(['X', ...])`
19
19
  * у тому ж файлі або коментарем `// \@nitra/cursor ignore-next-line checkEnv`
20
- * на попередньому рядку (див. `utils/check-env-scan.mjs`).
20
+ * на попередньому рядку (див. `utils/check-env-scan.mjs`);
21
+ * - «depcheck у GitHub Actions з path-фільтром»: для кожного workflow з `paths:`,
22
+ * обмеженим каталогом цього пакета (`<rootDir>/...`), має бути крок
23
+ * `npx depcheck --ignores="graphql,bun"` (плюс інші, за потреби) з
24
+ * `working-directory: <rootDir>` (див. `utils/depcheck-workflow.mjs`).
21
25
  */
22
26
  import { existsSync } from 'node:fs'
23
27
  import { readFile } from 'node:fs/promises'
@@ -30,6 +34,7 @@ import {
30
34
  } from './utils/bunyan-imports.mjs'
31
35
  import { findUncheckedProcessEnvInText, isCheckEnvScanSourceFile } from './utils/check-env-scan.mjs'
32
36
  import { createCheckReporter } from './utils/check-reporter.mjs'
37
+ import { findDepcheckViolationsForPackage, readAllWorkflowFiles } from './utils/depcheck-workflow.mjs'
33
38
  import {
34
39
  findConnFactoryImportsInText,
35
40
  isConnImportsScanSourceFile,
@@ -161,11 +166,12 @@ async function checkProcessEnvUsage(absPackageRoot, sourcePaths, label, fail) {
161
166
  * Перевіряє відповідність правилам js-run.mdc для одного workspace-пакета.
162
167
  * @param {string} rootDir відносний шлях workspace (не `'.'`)
163
168
  * @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
169
+ * @param {{ relPath: string, content: string }[]} workflows кешований список workflow-файлів репо
164
170
  * @param {(msg: string) => void} fail функція зворотного виклику для реєстрації помилки перевірки
165
171
  * @param {(msg: string) => void} passFn успішне повідомлення (як у check-reporter)
166
172
  * @returns {Promise<void>} завершується після перевірок цього пакета
167
173
  */
168
- async function checkWorkspacePackage(rootDir, ignorePaths, fail, passFn) {
174
+ async function checkWorkspacePackage(rootDir, ignorePaths, workflows, fail, passFn) {
169
175
  const label = `[${rootDir}] `
170
176
  const absPackageRoot = join(process.cwd(), rootDir)
171
177
  const pkgJson = await loadPackageJsonAndCheckBunyanDeps(rootDir, label, fail)
@@ -200,6 +206,33 @@ async function checkWorkspacePackage(rootDir, ignorePaths, fail, passFn) {
200
206
  }
201
207
 
202
208
  await checkOtelConfigmap(rootDir, label, fail, passFn)
209
+
210
+ checkDepcheckInWorkflows(rootDir, workflows, label, fail, passFn)
211
+ }
212
+
213
+ /**
214
+ * Перевіряє правило «depcheck у workflow» для одного пакета.
215
+ *
216
+ * Для кожного `.github/workflows/*.yml`, чий `paths:` обмежено до `<rootDir>/...`,
217
+ * має бути крок `npx depcheck --ignores="graphql,bun"` з `working-directory: <rootDir>`.
218
+ * Якщо в репо немає каталогу `.github/workflows`, перевірка no-op.
219
+ * @param {string} rootDir відносний шлях workspace-пакета
220
+ * @param {{ relPath: string, content: string }[]} workflows кешований список workflow-файлів
221
+ * @param {string} label префікс повідомлення `[<pkg>] `
222
+ * @param {(msg: string) => void} fail callback при помилці
223
+ * @param {(msg: string) => void} passFn успішне повідомлення
224
+ * @returns {void}
225
+ */
226
+ function checkDepcheckInWorkflows(rootDir, workflows, label, fail, passFn) {
227
+ if (workflows.length === 0) return
228
+ const violations = findDepcheckViolationsForPackage(workflows, rootDir.replace(/\\/g, '/'))
229
+ if (violations.length === 0) {
230
+ passFn(`${label}depcheck у path-scoped workflow налаштовано (або відсутній path-scoped workflow для пакета)`)
231
+ return
232
+ }
233
+ for (const v of violations) {
234
+ fail(`${label}${v}`)
235
+ }
203
236
  }
204
237
 
205
238
  /**
@@ -281,8 +314,9 @@ export async function check() {
281
314
  }
282
315
 
283
316
  const ignorePaths = await loadCursorIgnorePaths(process.cwd())
317
+ const workflows = await readAllWorkflowFiles(process.cwd())
284
318
  for (const r of workspaceRoots) {
285
- await checkWorkspacePackage(r, ignorePaths, fail, pass)
319
+ await checkWorkspacePackage(r, ignorePaths, workflows, fail, pass)
286
320
  }
287
321
 
288
322
  return reporter.getExitCode()
@@ -1339,43 +1339,44 @@ function failIfExplicitPatchTargetsNotInCatalog(rel, first, catalog, fail) {
1339
1339
  * @returns {void}
1340
1340
  */
1341
1341
  function failIfExplicitPatchTargetsHaveRedundantGroupVersion(rel, first, catalog, fail) {
1342
- for (const { section, index, target } of extractExplicitPatchTargetsFromKustomization(first)) {
1343
- if (target === null || typeof target !== 'object' || Array.isArray(target)) {
1344
- continue
1345
- }
1346
- const t = /** @type {Record<string, unknown>} */ (target)
1347
- const kind = typeof t.kind === 'string' ? t.kind.trim() : ''
1348
- const name = typeof t.name === 'string' ? t.name.trim() : ''
1349
- if (kind === '' || name === '') {
1350
- continue
1351
- }
1352
- if (patchTargetUsesSelector(t)) {
1353
- continue
1354
- }
1355
- const tgtGroup = typeof t.group === 'string' ? t.group.trim() : ''
1356
- const tgtVersion = typeof t.version === 'string' ? t.version.trim() : ''
1357
- if (tgtGroup === '' && tgtVersion === '') {
1358
- continue
1359
- }
1360
- const matchingByKindName = catalog.filter(r => r.kind === kind && r.name === name)
1361
- const distinctGvk = new Set(matchingByKindName.map(r => `${r.group}/${r.version}`))
1362
- if (distinctGvk.size > 1) {
1363
- continue
1364
- }
1365
- /** @type {string[]} */
1366
- const redundant = []
1367
- if (tgtGroup !== '') {
1368
- redundant.push('group')
1369
- }
1370
- if (tgtVersion !== '') {
1371
- redundant.push('version')
1372
- }
1342
+ for (const entry of extractExplicitPatchTargetsFromKustomization(first)) {
1343
+ const violation = describePatchTargetRedundancy(entry, catalog)
1344
+ if (violation === null) continue
1345
+ const { section, index, kind, name, redundant } = violation
1373
1346
  fail(
1374
1347
  `${rel}: ${section}[${index}].target — прибери зайві поля ${redundant.join(', ')}; для kind=${kind}, name=${name} в інвентарі немає колізії між різними API-групами/версіями (див. k8s.mdc «patches[].target: лише kind і name»)`
1375
1348
  )
1376
1349
  }
1377
1350
  }
1378
1351
 
1352
+ /**
1353
+ * Аналізує один patch.target: повертає опис надлишкових полів `group`/`version`,
1354
+ * якщо в інвентарі для пари (kind, name) немає колізії GVK; інакше `null`.
1355
+ * @param {{ section: string, index: number, target: unknown }} entry елемент із `extractExplicitPatchTargetsFromKustomization`
1356
+ * @param {KustomizeResourceDescriptor[]} catalog інвентар resources/bases/…
1357
+ * @returns {{ section: string, index: number, kind: string, name: string, redundant: string[] } | null} опис порушення або `null`
1358
+ */
1359
+ function describePatchTargetRedundancy(entry, catalog) {
1360
+ const { section, index, target } = entry
1361
+ if (target === null || typeof target !== 'object' || Array.isArray(target)) return null
1362
+ const t = /** @type {Record<string, unknown>} */ (target)
1363
+ const kind = typeof t.kind === 'string' ? t.kind.trim() : ''
1364
+ const name = typeof t.name === 'string' ? t.name.trim() : ''
1365
+ if (kind === '' || name === '') return null
1366
+ if (patchTargetUsesSelector(t)) return null
1367
+ const tgtGroup = typeof t.group === 'string' ? t.group.trim() : ''
1368
+ const tgtVersion = typeof t.version === 'string' ? t.version.trim() : ''
1369
+ if (tgtGroup === '' && tgtVersion === '') return null
1370
+ const matchingByKindName = catalog.filter(r => r.kind === kind && r.name === name)
1371
+ const distinctGvk = new Set(matchingByKindName.map(r => `${r.group}/${r.version}`))
1372
+ if (distinctGvk.size > 1) return null
1373
+ /** @type {string[]} */
1374
+ const redundant = []
1375
+ if (tgtGroup !== '') redundant.push('group')
1376
+ if (tgtVersion !== '') redundant.push('version')
1377
+ return { section, index, kind, name, redundant }
1378
+ }
1379
+
1379
1380
  /**
1380
1381
  * Документи з YAML-файлу мають мати дескриптор у **catalog** (інвентар resources).
1381
1382
  * @param {string} rel відносний шлях до kustomization.yaml
@@ -115,6 +115,7 @@ async function collectEsbuildMatchesInFiles(absPackageRoot, files, maxMatches) {
115
115
  * Сканує дерево пакета на згадки `esbuild` і підказує заміну на `rolldown`.
116
116
  * @param {string} rootDir відносний шлях до пакета
117
117
  * @param {string} absPackageRoot абсолютний шлях до кореня пакета
118
+ * @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
118
119
  * @param {string} prefix параметр prefix
119
120
  * @param {(msg: string) => void} passFn callback при успішній перевірці
120
121
  * @param {(msg: string) => void} fail callback при помилці
@@ -342,9 +343,7 @@ async function checkVueNodeImportViolations(rootDir, absPackageRoot, ignorePaths
342
343
  }
343
344
  }
344
345
  if (nodeImportViolations === 0) {
345
- passFn(
346
- `${prefix}немає імпортів Node-нативних модулів у .vue (проскановано ${ukFilesCountPhrase(vuePaths.length)})`
347
- )
346
+ passFn(`${prefix}немає імпортів Node-нативних модулів у .vue (проскановано ${ukFilesCountPhrase(vuePaths.length)})`)
348
347
  }
349
348
  }
350
349
 
@@ -354,18 +353,17 @@ async function checkVueNodeImportViolations(rootDir, absPackageRoot, ignorePaths
354
353
  * Якщо `unplugin-auto-import` не сконфігурований на `'vue'` у `vite.config`, явні value-імпорти
355
354
  * формально не заборонені — їх видалення зламає код. У цьому випадку перевірка пропускається,
356
355
  * а fail про відсутній `'vue'` у `AutoImport.imports` уже зареєстровано в `checkViteConfig`.
357
- * @param {string} rootDir параметр rootDir
358
- * @param {string} absPackageRoot параметр absPackageRoot
359
- * @param {string} prefix параметр prefix
356
+ * @param {string} rootDir відносний шлях до пакета
357
+ * @param {string} absPackageRoot абсолютний шлях до кореня пакета
358
+ * @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
360
359
  * @param {boolean} hasVueAutoImport чи `AutoImport({ imports: [..., 'vue', ...] })` сконфігуровано
360
+ * @param {string} prefix префікс повідомлення `[<pkg>] `
361
361
  * @param {(msg: string) => void} passFn callback при успішній перевірці
362
362
  * @param {(msg: string) => void} fail callback при помилці
363
363
  */
364
364
  async function checkVueImportViolations(rootDir, absPackageRoot, ignorePaths, hasVueAutoImport, prefix, passFn, fail) {
365
365
  if (!hasVueAutoImport) {
366
- passFn(
367
- `${prefix}value-імпорти з 'vue' не заборонені — спершу додай 'vue' до AutoImport.imports у vite.config`
368
- )
366
+ passFn(`${prefix}value-імпорти з 'vue' не заборонені — спершу додай 'vue' до AutoImport.imports у vite.config`)
369
367
  return
370
368
  }
371
369
  /** @type {string[]} */
@@ -400,6 +398,7 @@ async function checkVueImportViolations(rootDir, absPackageRoot, ignorePaths, ha
400
398
  /**
401
399
  * Перевіряє залежності та vite.config одного Vue-пакета.
402
400
  * @param {string} rootDir відносний шлях до пакета
401
+ * @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
403
402
  * @param {(msg: string) => void} fail функція зворотного виклику для реєстрації помилки перевірки
404
403
  * @param {(msg: string) => void} passFn успішне повідомлення (як у check-reporter)
405
404
  * @returns {Promise<void>} завершується після перевірок залежностей, `vite.config` і сканування джерел на імпорти з `vue`
@@ -444,7 +443,15 @@ async function checkVuePackage(rootDir, ignorePaths, fail, passFn) {
444
443
  )
445
444
 
446
445
  const { hasVueAutoImport } = await checkViteConfig(rootDir, prefix, passFn, fail)
447
- await checkVueImportViolations(rootDir, join(process.cwd(), rootDir), ignorePaths, hasVueAutoImport, prefix, passFn, fail)
446
+ await checkVueImportViolations(
447
+ rootDir,
448
+ join(process.cwd(), rootDir),
449
+ ignorePaths,
450
+ hasVueAutoImport,
451
+ prefix,
452
+ passFn,
453
+ fail
454
+ )
448
455
  await checkVueNodeImportViolations(rootDir, join(process.cwd(), rootDir), ignorePaths, prefix, passFn, fail)
449
456
  await checkEsbuildMentions(rootDir, join(process.cwd(), rootDir), ignorePaths, prefix, passFn, fail)
450
457
  }
@@ -62,6 +62,7 @@ export async function runStopHookCli() {
62
62
  if (isRecursiveStopHookCall(stdin)) {
63
63
  return 0
64
64
  }
65
+ // eslint-disable-next-line sonarjs/no-os-command-from-path -- npx як стандартне dev-середовище через PATH; альтернативи (хардкод шляху) непортативні
65
66
  const child = spawn('npx', ['--no', '@nitra/cursor', 'check'], { stdio: 'inherit' })
66
67
  try {
67
68
  const [code] = await once(child, 'exit')
@@ -18,7 +18,6 @@ import { resolveCmd } from './utils/resolve-cmd.mjs'
18
18
 
19
19
  /**
20
20
  * Опис залежності preflight-ом: бінарник, для чого потрібен, і команди встановлення.
21
- *
22
21
  * @typedef {object} PreflightDep
23
22
  * @property {string} bin ім'я виконуваного файлу (на Windows додається `.exe` за потреби)
24
23
  * @property {string[]} winBins альтернативні імена на Windows (`shellcheck.exe`); якщо нема — fallback на `bin`
@@ -29,6 +29,7 @@ export function isLintDockerfileName(name) {
29
29
  /**
30
30
  * Збирає абсолютні шляхи для lint-docker.
31
31
  * @param {string} root корінь репозиторію
32
+ * @param {string[]} [ignorePaths] абсолютні шляхи каталогів, повністю виключених з обходу
32
33
  * @returns {Promise<string[]>} відсортовані абсолютні шляхи
33
34
  */
34
35
  export async function findLintDockerfilePaths(root, ignorePaths = []) {
@@ -59,6 +59,7 @@ export function k8sRootFromFile(absFile) {
59
59
  /**
60
60
  * Унікальні корені `k8s` за наявності `*.yaml` під деревом cwd.
61
61
  * @param {string} root корінь репозиторію
62
+ * @param {string[]} [ignorePaths] абсолютні шляхи каталогів, повністю виключених з обходу
62
63
  * @returns {Promise<string[]>} відсортовані абсолютні шляхи до каталогів `k8s`
63
64
  */
64
65
  export async function findK8sRoots(root, ignorePaths = []) {
@@ -296,8 +296,7 @@ export function findBunSqlPerRequestConnectionInText(content, virtualPath = 'sca
296
296
  * на тому ж рядку або рядком вище. `sql.unsafe` за замовчуванням заборонено: дозволено
297
297
  * лише коли значення контролюється кодом (не user input) і потрібно підставити те, що
298
298
  * не можна параметризувати — назву таблиці/колонки або dynamic SQL/DDL. У всіх інших
299
- * випадках — переробити на tagged template `sql\`...\${value}...\``.
300
- *
299
+ * випадках — переробити на tagged template виду `sql` із інтерполяцією значень.
301
300
  * Маркер-коментар фіксує причину для ревʼюера й одночасно слугує opt-in: без нього
302
301
  * перевірка падає, навіть якщо у `unsafe` лежить статичний рядок без інтерполяції.
303
302
  * @param {string} content вихідний код
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Аналіз GitHub Actions workflow на правило «depcheck для path-scoped backend-пакета»
3
+ * (див. секцію в `npm/mdc/js-run.mdc`).
4
+ *
5
+ * Алгоритм для одного workspace-пакета (`<rootDir>`):
6
+ * 1. Шукаємо всі workflow, у яких `on.push.paths` або `on.pull_request.paths` містить
7
+ * glob, що починається з `<rootDir>/` — це означає, що workflow обмежено саме цим пакетом
8
+ * (повністю або частково).
9
+ * 2. У кожному такому workflow має бути крок, чий `run` починається з `npx depcheck …`,
10
+ * `working-directory` дорівнює `<rootDir>`, а список `--ignores="…"` містить
11
+ * щонайменше `graphql` і `bun` (інші значення допустимі).
12
+ *
13
+ * Якщо паттерн `paths:` стосується цього пакета, але крок depcheck відсутній / без потрібних
14
+ * ignores / у неправильному working-directory — фіксується порушення.
15
+ *
16
+ * Workflow без `paths:` або з глобальними патернами (`**\/*.js`, `npm/**`) ігноруються —
17
+ * вони не «належать» жодному окремому пакету і виходять за межі правила.
18
+ */
19
+ import { readdir, readFile } from 'node:fs/promises'
20
+ import { join, relative } from 'node:path'
21
+
22
+ import {
23
+ flattenWorkflowSteps,
24
+ getStepRun,
25
+ parseWorkflowYaml
26
+ } from './gha-workflow.mjs'
27
+
28
+ const WORKFLOWS_DIR_REL = '.github/workflows'
29
+ const REQUIRED_IGNORES = ['graphql', 'bun']
30
+ const DEPCHECK_RUN_RE = /(?:^|[\s;&|])npx\s+depcheck\b([^\n]*)/u
31
+ const IGNORES_FLAG_RE = /--ignores\s*=?\s*(?:"([^"]*)"|'([^']*)'|([^\s"']+))/u
32
+
33
+ /**
34
+ * Чи містить workflow.on[event].paths хоча б один patten, що починається з `<pkgRoot>/`.
35
+ * @param {Record<string, unknown>} root корінь workflow
36
+ * @param {string} pkgRoot відносний (POSIX) шлях каталогу пакета (наприклад `cron-jobs/refund-loyalty-points`)
37
+ * @returns {boolean} `true`, якщо знайдено хоча б один підходящий glob
38
+ */
39
+ export function workflowHasPathsScopedToPackage(root, pkgRoot) {
40
+ const prefix = `${pkgRoot.replace(/\\/g, '/').replace(/\/+$/, '')}/`
41
+ const on = root?.on
42
+ if (!on || typeof on !== 'object') return false
43
+ for (const event of /** @type {const} */ (['push', 'pull_request'])) {
44
+ const ev = /** @type {Record<string, unknown>} */ (on)[event]
45
+ if (!ev || typeof ev !== 'object') continue
46
+ const paths = /** @type {Record<string, unknown>} */ (ev).paths
47
+ if (!Array.isArray(paths)) continue
48
+ if (paths.some(p => typeof p === 'string' && p.startsWith(prefix))) return true
49
+ }
50
+ return false
51
+ }
52
+
53
+ /**
54
+ * Розбирає `--ignores="a,b,c"` (також `--ignores=a,b`, single-quotes тощо) з аргументів `npx depcheck`.
55
+ * @param {string} depcheckArgs частина рядка `run` після `npx depcheck`
56
+ * @returns {string[] | null} масив значень ignores або `null`, якщо прапор відсутній
57
+ */
58
+ export function parseDepcheckIgnoresArg(depcheckArgs) {
59
+ const m = IGNORES_FLAG_RE.exec(depcheckArgs)
60
+ if (!m) return null
61
+ const raw = m[1] ?? m[2] ?? m[3] ?? ''
62
+ return raw
63
+ .split(',')
64
+ .map(s => s.trim())
65
+ .filter(s => s.length > 0)
66
+ }
67
+
68
+ /**
69
+ * Шукає `npx depcheck` у `run` кроку. Повертає рядок аргументів після `npx depcheck` або `null`.
70
+ * @param {string} runText значення `run:` (можливо багаторядкове)
71
+ * @returns {string | null} текст аргументів depcheck або `null`
72
+ */
73
+ export function extractDepcheckArgs(runText) {
74
+ if (typeof runText !== 'string' || runText.length === 0) return null
75
+ const m = DEPCHECK_RUN_RE.exec(runText)
76
+ return m ? m[1] : null
77
+ }
78
+
79
+ /**
80
+ * Чи `working-directory` кроку дорівнює очікуваному pkgRoot (з нормалізацією слешів і хвостових `/`).
81
+ * @param {Record<string, unknown>} step об'єкт кроку
82
+ * @param {string} pkgRoot очікуваний шлях
83
+ * @returns {boolean} `true`, якщо збігаються
84
+ */
85
+ export function stepWorkingDirectoryEquals(step, pkgRoot) {
86
+ const wd = step['working-directory']
87
+ if (typeof wd !== 'string') return false
88
+ const norm = wd.replace(/\\/g, '/').replace(/\/+$/, '')
89
+ const expected = pkgRoot.replace(/\\/g, '/').replace(/\/+$/, '')
90
+ return norm === expected
91
+ }
92
+
93
+ /**
94
+ * Перевіряє один workflow на наявність валідного depcheck-кроку для пакета.
95
+ * @param {Record<string, unknown>} root корінь workflow
96
+ * @param {string} pkgRoot відносний шлях пакета
97
+ * @returns {{ kind: 'ok' } | { kind: 'missing' } | { kind: 'wrong-cwd', actual: string } | { kind: 'missing-ignores', missing: string[] }} результат
98
+ */
99
+ export function evaluateDepcheckStepForPackage(root, pkgRoot) {
100
+ /** @type {{ args: string, step: Record<string, unknown> }[]} */
101
+ const depcheckSteps = []
102
+ for (const { step } of flattenWorkflowSteps(root)) {
103
+ const args = extractDepcheckArgs(getStepRun(step))
104
+ if (args !== null) depcheckSteps.push({ args, step })
105
+ }
106
+ if (depcheckSteps.length === 0) return { kind: 'missing' }
107
+
108
+ // Серед усіх знайдених depcheck-кроків шукаємо хоча б один, що відповідає пакету.
109
+ const stepsForThisPackage = depcheckSteps.filter(s => stepWorkingDirectoryEquals(s.step, pkgRoot))
110
+ if (stepsForThisPackage.length === 0) {
111
+ const actual = depcheckSteps
112
+ .map(s => /** @type {string} */ (s.step['working-directory'] ?? '<repo root>'))
113
+ .join(', ')
114
+ return { kind: 'wrong-cwd', actual }
115
+ }
116
+
117
+ for (const { args } of stepsForThisPackage) {
118
+ const ignores = parseDepcheckIgnoresArg(args) ?? []
119
+ const missing = REQUIRED_IGNORES.filter(req => !ignores.includes(req))
120
+ if (missing.length === 0) return { kind: 'ok' }
121
+ }
122
+ // Усі знайдені кроки існують, але жоден не має повного списку обов'язкових ignores —
123
+ // повертаємо missing з першого, щоб дати конкретний фідбек.
124
+ const firstMissing = REQUIRED_IGNORES.filter(
125
+ req => !((parseDepcheckIgnoresArg(stepsForThisPackage[0].args) ?? []).includes(req))
126
+ )
127
+ return { kind: 'missing-ignores', missing: firstMissing }
128
+ }
129
+
130
+ /**
131
+ * Зчитує всі `.github/workflows/*.yml` (без `*.yaml` — за правилом n-ga) з коренем у `repoRoot`.
132
+ * @param {string} repoRoot абсолютний корінь репозиторію
133
+ * @returns {Promise<{ relPath: string, content: string }[]>} список workflow-файлів
134
+ */
135
+ export async function readAllWorkflowFiles(repoRoot) {
136
+ const dir = join(repoRoot, WORKFLOWS_DIR_REL)
137
+ /** @type {{ relPath: string, content: string }[]} */
138
+ const out = []
139
+ let entries
140
+ try {
141
+ entries = await readdir(dir, { withFileTypes: true })
142
+ } catch {
143
+ return out
144
+ }
145
+ for (const ent of entries) {
146
+ if (!ent.isFile() || !ent.name.endsWith('.yml')) continue
147
+ const abs = join(dir, ent.name)
148
+ const content = await readFile(abs, 'utf8')
149
+ out.push({ relPath: relative(repoRoot, abs).split('\\').join('/'), content })
150
+ }
151
+ return out
152
+ }
153
+
154
+ /**
155
+ * Знаходить порушення правила depcheck для конкретного workspace-пакета.
156
+ *
157
+ * Повертає список повідомлень про порушення (порожній — все ok). Для кожного workflow,
158
+ * чий `paths:` обмежено до цього пакета, перевіряє, що серед кроків є валідний `npx depcheck`
159
+ * з потрібним `working-directory` та `--ignores`.
160
+ * @param {{ relPath: string, content: string }[]} workflows список workflow-файлів (з `readAllWorkflowFiles`)
161
+ * @param {string} pkgRoot відносний шлях workspace-пакета
162
+ * @returns {string[]} повідомлення про порушення, по одному на workflow
163
+ */
164
+ export function findDepcheckViolationsForPackage(workflows, pkgRoot) {
165
+ /** @type {string[]} */
166
+ const violations = []
167
+ for (const { relPath, content } of workflows) {
168
+ const root = parseWorkflowYaml(content)
169
+ if (!root) continue
170
+ if (!workflowHasPathsScopedToPackage(root, pkgRoot)) continue
171
+ const result = evaluateDepcheckStepForPackage(root, pkgRoot)
172
+ if (result.kind === 'ok') continue
173
+ if (result.kind === 'missing') {
174
+ violations.push(
175
+ `${relPath}: paths обмежено до '${pkgRoot}/**', але немає кроку 'npx depcheck --ignores="graphql,bun"' з working-directory: ${pkgRoot}`
176
+ )
177
+ } else if (result.kind === 'wrong-cwd') {
178
+ violations.push(
179
+ `${relPath}: 'npx depcheck' знайдено, але working-directory не дорівнює '${pkgRoot}' (фактично: ${result.actual})`
180
+ )
181
+ } else {
182
+ violations.push(
183
+ `${relPath}: 'npx depcheck' у '${pkgRoot}' має містити --ignores з '${result.missing.join(',')}' (мінімум: graphql,bun)`
184
+ )
185
+ }
186
+ }
187
+ return violations
188
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Спільна утиліта для check-скриптів: збирає всі `package.json` у дереві (крім пропущених
3
+ * каталогів у `walkDir`), сортує за відносним шляхом. Винесена з check-js-bun-db / check-js-mssql,
4
+ * щоб уникнути дублювання (jscpd).
5
+ */
6
+ import { relative, sep } from 'node:path'
7
+
8
+ import { walkDir } from './walkDir.mjs'
9
+
10
+ /**
11
+ * Знаходить всі `package.json` у репозиторії (крім пропущених директорій у walkDir).
12
+ * @param {string} repoRoot абсолютний шлях до кореня репозиторію
13
+ * @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
14
+ * @returns {Promise<string[]>} абсолютні шляхи, відсортовані за відносним шляхом
15
+ */
16
+ export async function findAllPackageJsonPaths(repoRoot, ignorePaths) {
17
+ /** @type {string[]} */
18
+ const paths = []
19
+ await walkDir(
20
+ repoRoot,
21
+ absPath => {
22
+ if (absPath.endsWith(`${sep}package.json`)) {
23
+ paths.push(absPath)
24
+ }
25
+ },
26
+ ignorePaths
27
+ )
28
+ paths.sort((a, b) => relative(repoRoot, a).localeCompare(relative(repoRoot, b)))
29
+ return paths
30
+ }
@@ -20,7 +20,9 @@ const CONFIG_FILE = '.n-cursor.json'
20
20
  function toAbsPosix(root, p) {
21
21
  const trimmed = String(p).trim()
22
22
  const abs = isAbsolute(trimmed) ? trimmed : resolve(root, trimmed)
23
- return abs.split(sep).join('/').replace(/\/+$/, '')
23
+ let posix = abs.split(sep).join('/')
24
+ while (posix.endsWith('/')) posix = posix.slice(0, -1)
25
+ return posix
24
26
  }
25
27
 
26
28
  /**
@@ -1,17 +1,7 @@
1
1
  {
2
2
  "$schema": "./node_modules/oxlint/configuration_schema.json",
3
- "plugins": [
4
- "unicorn",
5
- "oxc",
6
- "import",
7
- "jsdoc",
8
- "promise",
9
- "node",
10
- "vue"
11
- ],
12
- "jsPlugins": [
13
- "@e18e/eslint-plugin"
14
- ],
3
+ "plugins": ["unicorn", "oxc", "import", "jsdoc", "promise", "node", "vue"],
4
+ "jsPlugins": ["@e18e/eslint-plugin"],
15
5
  "categories": {},
16
6
  "rules": {
17
7
  "e18e/prefer-includes": "error",
@@ -393,8 +383,5 @@
393
383
  "builtin": true
394
384
  },
395
385
  "globals": {},
396
- "ignorePatterns": [
397
- "**/schema.graphql",
398
- "**/auto-imports.d.ts"
399
- ]
386
+ "ignorePatterns": ["**/schema.graphql", "**/auto-imports.d.ts"]
400
387
  }
@@ -17,7 +17,9 @@ import { isAbsolute, join, resolve, sep } from 'node:path'
17
17
  */
18
18
  function toAbsPosix(p) {
19
19
  const abs = isAbsolute(p) ? p : resolve(p)
20
- return abs.split(sep).join('/').replace(/\/+$/, '')
20
+ let posix = abs.split(sep).join('/')
21
+ while (posix.endsWith('/')) posix = posix.slice(0, -1)
22
+ return posix
21
23
  }
22
24
 
23
25
  /**
@@ -43,7 +45,7 @@ function isIgnoredDir(dirAbsPosix, ignorePosix) {
43
45
  * @returns {Promise<void>} резолвиться по завершенню обходу
44
46
  */
45
47
  export async function walkDir(dir, onFile, ignorePaths = []) {
46
- const ignorePosix = ignorePaths.map(toAbsPosix)
48
+ const ignorePosix = ignorePaths.map(p => toAbsPosix(p))
47
49
  await walkDirInner(dir, onFile, ignorePosix)
48
50
  }
49
51