@nitra/cursor 1.27.9 → 1.28.1

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/package.json +1 -1
  3. package/rules/abie/js/applies.mjs +3 -2
  4. package/rules/abie/js/env_dns.mjs +4 -2
  5. package/rules/abie/js/firebase_hosting.mjs +3 -2
  6. package/rules/abie/js/hc_pairing.mjs +3 -2
  7. package/rules/abie/js/ua_http_route.mjs +4 -2
  8. package/rules/abie/js/ua_node_selector.mjs +4 -2
  9. package/rules/adr/js/hooks.mjs +36 -28
  10. package/rules/bun/js/layout.mjs +16 -11
  11. package/rules/capacitor/js/platforms.mjs +3 -2
  12. package/rules/changelog/js/consistency.mjs +85 -63
  13. package/rules/changelog/lib/package-manifest.mjs +5 -4
  14. package/rules/docker/js/lint.mjs +3 -2
  15. package/rules/ga/js/workflows.mjs +41 -32
  16. package/rules/graphql/js/tooling.mjs +15 -11
  17. package/rules/hasura/js/internal_urls.mjs +14 -10
  18. package/rules/image-avif/js/avif_generation.mjs +36 -23
  19. package/rules/image-compress/js/package_setup.mjs +18 -12
  20. package/rules/js-bun-db/js/safety.mjs +3 -2
  21. package/rules/js-lint/js/tooling.mjs +45 -32
  22. package/rules/js-run/js/runtime.mjs +21 -15
  23. package/rules/k8s/js/manifests.mjs +3 -2
  24. package/rules/nginx-default-tpl/js/template.mjs +7 -6
  25. package/rules/npm-module/js/package_structure.mjs +82 -57
  26. package/rules/rego/js/applies.mjs +4 -4
  27. package/rules/rust/js/applies.mjs +5 -3
  28. package/rules/security/js/sample_secret.mjs +2 -2
  29. package/rules/security/js/trufflehog.mjs +6 -4
  30. package/rules/style-lint/js/tooling.mjs +15 -8
  31. package/rules/test/coverage/coverage.mjs +1 -1
  32. package/rules/test/js/data/vitest_config/vitest.config.baseline.js +7 -0
  33. package/rules/test/js/location.mjs +3 -2
  34. package/rules/test/js/no-process-chdir.mjs +89 -0
  35. package/rules/test/js/no-relative-fs-path.mjs +259 -0
  36. package/rules/test/js/vitest-config-pool-forks.mjs +52 -0
  37. package/rules/test/test.mdc +21 -0
  38. package/rules/text/js/forbidden-prettier.mjs +4 -2
  39. package/rules/text/js/formatting.mjs +25 -16
  40. package/rules/vue/js/packages.mjs +33 -25
@@ -23,6 +23,7 @@
23
23
  * Версія та CHANGELOG: перший заголовок `## [version]` у `npm/CHANGELOG.md` має збігатися з `version` у
24
24
  * `npm/package.json` (найсвіжіший реліз зверху). Якщо в git є незакомічені зміни під `npm/`, `version` у робочому
25
25
  * файлі має відрізнятися від `HEAD` — інакше типовий пропуск bump після правок у пакеті.
26
+ * @param {string} cwd корінь репозиторію
26
27
  */
27
28
  import { execFile } from 'node:child_process'
28
29
  import { existsSync } from 'node:fs'
@@ -61,6 +62,7 @@ const TEST_DIR_NAMES = new Set(['tests', '__tests__', 'fixtures', '__fixtures__'
61
62
  * (`*_test.rego`) свідомо не входить: за конвенцією conftest юніт-тест лежить
62
63
  * поруч з полісі у тому самому `package` — і це дозволений виняток усередині
63
64
  * опублікованого `policy/`-каталогу (npm-module.mdc).
65
+ * @param {string} cwd корінь репозиторію
64
66
  */
65
67
  const TEST_FILE_PATTERNS = [/^.+\.(test|spec)\.[cm]?[jt]sx?$/iu]
66
68
 
@@ -91,9 +93,10 @@ const GLOBSTAR_TRAILING_RE = /\/__GLOBSTAR__$/u
91
93
  * Чи є під `npm/src` хоча б один `.js` (рекурсивно).
92
94
  * @param {string[]} [ignorePaths] абсолютні шляхи каталогів, повністю виключених з обходу
93
95
  * @returns {Promise<boolean>} `true`, якщо знайдено хоча б один `.js`
96
+ * @param {string} cwd корінь репозиторію
94
97
  */
95
- async function npmSrcTreeHasJsFile(ignorePaths = []) {
96
- const root = 'npm/src'
98
+ async function npmSrcTreeHasJsFile(cwd, ignorePaths = []) {
99
+ const root = join(cwd, 'npm/src')
97
100
  if (!existsSync(root)) {
98
101
  return false
99
102
  }
@@ -113,12 +116,14 @@ async function npmSrcTreeHasJsFile(ignorePaths = []) {
113
116
  /**
114
117
  * Знаходить текстовий вміст конфігурації hk для перевірки npm-module.
115
118
  * @returns {Promise<{ path: string, text: string } | null>} знайдений файл або `null`
119
+ * @param {string} cwd корінь репозиторію
116
120
  */
117
- async function readHkConfig() {
121
+ async function readHkConfig(cwd) {
118
122
  const candidates = ['hk.pkl', '.config/hk.pkl']
119
123
  for (const p of candidates) {
120
- if (existsSync(p)) {
121
- const text = await readFile(p, 'utf8')
124
+ const abs = join(cwd, p)
125
+ if (existsSync(abs)) {
126
+ const text = await readFile(abs, 'utf8')
122
127
  return { path: p, text }
123
128
  }
124
129
  }
@@ -174,18 +179,20 @@ function npmTypesFileFromPackageField(typesField) {
174
179
  * @param {boolean} useSrcJsLayout чи використовується layout з npm/src
175
180
  * @param {(msg: string) => void} passFn callback при успішній перевірці
176
181
  * @param {(msg: string) => void} failFn callback при помилці
182
+ * @param {string} cwd корінь репозиторію
177
183
  */
178
- async function checkNpmPackageJson(useSrcJsLayout, passFn, failFn) {
179
- if (!existsSync('npm/package.json')) return
180
- const npmPkg = JSON.parse(await readFile('npm/package.json', 'utf8'))
184
+ async function checkNpmPackageJson(useSrcJsLayout, passFn, failFn, cwd) {
185
+ const npmPkgPath = join(cwd, 'npm/package.json')
186
+ if (!existsSync(npmPkgPath)) return
187
+ const npmPkg = JSON.parse(await readFile(npmPkgPath, 'utf8'))
181
188
  const typesField = npmPkg.types
182
189
 
183
- const typesPath = useSrcJsLayout ? join('npm', 'types', 'index.d.ts') : npmTypesFileFromPackageField(typesField)
190
+ const typesRel = useSrcJsLayout ? join('npm', 'types', 'index.d.ts') : npmTypesFileFromPackageField(typesField)
184
191
  const missingTypesMsg = useSrcJsLayout
185
192
  ? `Відсутній ${join('npm', 'types', 'index.d.ts')} (згенеруй tsc з npm-module.mdc)`
186
193
  : `Файл для поля types не знайдено або шлях не під ./types/ — ${String(typesField)}`
187
- if (typesPath && existsSync(typesPath)) {
188
- passFn(`${typesPath} існує`)
194
+ if (typesRel && existsSync(join(cwd, typesRel))) {
195
+ passFn(`${typesRel} існує`)
189
196
  } else {
190
197
  failFn(missingTypesMsg)
191
198
  }
@@ -196,9 +203,10 @@ async function checkNpmPackageJson(useSrcJsLayout, passFn, failFn) {
196
203
  * валідує `npm/policy/npm_module/emit_types_config/`).
197
204
  * @param {(msg: string) => void} passFn callback при успішній перевірці
198
205
  * @param {(msg: string) => void} failFn callback при помилці
206
+ * @param {string} cwd корінь репозиторію
199
207
  */
200
- function checkEmitTypesConfig(passFn, failFn) {
201
- if (!existsSync(EMIT_TYPES_CONFIG)) {
208
+ function checkEmitTypesConfig(passFn, failFn, cwd) {
209
+ if (!existsSync(join(cwd, EMIT_TYPES_CONFIG))) {
202
210
  failFn(
203
211
  `Без .js під npm/src потрібен ${EMIT_TYPES_CONFIG} (див. npm-module.mdc: emit через tsconfig, без штучного src/index.js)`
204
212
  )
@@ -211,14 +219,16 @@ function checkEmitTypesConfig(passFn, failFn) {
211
219
  * Перевіряє npm-publish.yml workflow.
212
220
  * @param {(msg: string) => void} passFn callback при успішній перевірці
213
221
  * @param {(msg: string) => void} failFn callback при помилці
222
+ * @param {string} cwd корінь репозиторію
214
223
  */
215
224
  /**
216
225
  * Чи виконано `git` у корені робочого дерева.
217
226
  * @returns {Promise<boolean>} true, якщо процес запущено в межах git work tree
227
+ * @param {string} cwd корінь репозиторію
218
228
  */
219
- async function gitInsideWorkTree() {
229
+ async function gitInsideWorkTree(cwd) {
220
230
  try {
221
- const { stdout } = await execFileAsync('git', ['rev-parse', '--is-inside-work-tree'], { encoding: 'utf8' })
231
+ const { stdout } = await execFileAsync('git', ['rev-parse', '--is-inside-work-tree'], { encoding: 'utf8', cwd })
222
232
  return stdout.trim() === 'true'
223
233
  } catch {
224
234
  return false
@@ -227,11 +237,15 @@ async function gitInsideWorkTree() {
227
237
 
228
238
  /**
229
239
  * Список незакомічених шляхів під `npm/` відносно `HEAD`.
240
+ * @param {string} cwd корінь репозиторію
230
241
  * @returns {Promise<string[] | null>} шляхи або `null`, якщо `git` недоступний
231
242
  */
232
- async function gitDiffNameOnlyNpm() {
243
+ async function gitDiffNameOnlyNpm(cwd) {
233
244
  try {
234
- const { stdout } = await execFileAsync('git', ['diff', '--name-only', 'HEAD', '--', 'npm'], { encoding: 'utf8' })
245
+ const { stdout } = await execFileAsync('git', ['diff', '--name-only', 'HEAD', '--', 'npm'], {
246
+ encoding: 'utf8',
247
+ cwd
248
+ })
235
249
  return stdout.trim().split('\n').filter(Boolean)
236
250
  } catch {
237
251
  return null
@@ -241,11 +255,12 @@ async function gitDiffNameOnlyNpm() {
241
255
  /**
242
256
  * Поле `version` з `npm/package.json` на заданому git-ref (`HEAD:npm/package.json`).
243
257
  * @param {string} refPath на кшталт `HEAD:npm/package.json`
258
+ * @param {string} cwd корінь репозиторію
244
259
  * @returns {Promise<string | null>} значення поля `version` або `null`, якщо ref недоступний
245
260
  */
246
- async function gitShowNpmPackageVersionAt(refPath) {
261
+ async function gitShowNpmPackageVersionAt(refPath, cwd) {
247
262
  try {
248
- const { stdout } = await execFileAsync('git', ['show', refPath], { encoding: 'utf8' })
263
+ const { stdout } = await execFileAsync('git', ['show', refPath], { encoding: 'utf8', cwd })
249
264
  const m = stdout.match(PACKAGE_JSON_VERSION_RE)
250
265
  return m ? m[1] : null
251
266
  } catch {
@@ -268,16 +283,17 @@ function firstChangelogSectionVersion(changelogText) {
268
283
  * @param {(msg: string) => void} passFn callback при успішній перевірці
269
284
  * @param {(msg: string) => void} failFn callback при виявленому порушенні
270
285
  * @returns {Promise<void>}
286
+ * @param {string} cwd корінь репозиторію
271
287
  */
272
- async function checkChangelogTopMatchesPackageVersion(passFn, failFn) {
273
- if (!existsSync('npm/CHANGELOG.md') || !existsSync('npm/package.json')) return
274
- const pkg = JSON.parse(await readFile('npm/package.json', 'utf8'))
288
+ async function checkChangelogTopMatchesPackageVersion(passFn, failFn, cwd) {
289
+ if (!existsSync(join(cwd, 'npm/CHANGELOG.md')) || !existsSync(join(cwd, 'npm/package.json'))) return
290
+ const pkg = JSON.parse(await readFile(join(cwd, 'npm/package.json'), 'utf8'))
275
291
  const ver = typeof pkg.version === 'string' ? pkg.version : null
276
292
  if (!ver) {
277
293
  failFn('npm/package.json: відсутнє поле version')
278
294
  return
279
295
  }
280
- const cl = await readFile('npm/CHANGELOG.md', 'utf8')
296
+ const cl = await readFile(join(cwd, 'npm/CHANGELOG.md'), 'utf8')
281
297
  const first = firstChangelogSectionVersion(cl)
282
298
  if (!first) {
283
299
  failFn('npm/CHANGELOG.md: не знайдено жодного заголовка ## [version]')
@@ -298,23 +314,24 @@ async function checkChangelogTopMatchesPackageVersion(passFn, failFn) {
298
314
  * @param {(msg: string) => void} passFn callback при успішній перевірці
299
315
  * @param {(msg: string) => void} failFn callback при виявленому порушенні
300
316
  * @returns {Promise<void>}
317
+ * @param {string} cwd корінь репозиторію
301
318
  */
302
- async function checkDirtyNpmRequiresVersionBump(passFn, failFn) {
303
- if (!(await gitInsideWorkTree())) {
319
+ async function checkDirtyNpmRequiresVersionBump(passFn, failFn, cwd) {
320
+ if (!(await gitInsideWorkTree(cwd))) {
304
321
  passFn('npm-module: git недоступний або поза work tree — перевірку незакоміченого bump пропущено')
305
322
  return
306
323
  }
307
- const changed = await gitDiffNameOnlyNpm()
324
+ const changed = await gitDiffNameOnlyNpm(cwd)
308
325
  if (changed === null) {
309
326
  passFn('npm-module: git diff під npm/ недоступний — пропущено')
310
327
  return
311
328
  }
312
329
  if (changed.length === 0) return
313
330
 
314
- const headVer = await gitShowNpmPackageVersionAt('HEAD:npm/package.json')
331
+ const headVer = await gitShowNpmPackageVersionAt('HEAD:npm/package.json', cwd)
315
332
  if (headVer === null) return
316
333
 
317
- const pkg = JSON.parse(await readFile('npm/package.json', 'utf8'))
334
+ const pkg = JSON.parse(await readFile(join(cwd, 'npm/package.json'), 'utf8'))
318
335
  const cur = typeof pkg.version === 'string' ? pkg.version : null
319
336
  if (!cur) return
320
337
 
@@ -334,10 +351,11 @@ async function checkDirtyNpmRequiresVersionBump(passFn, failFn) {
334
351
  * `npm/policy/npm_module/npm_publish_yml/`.
335
352
  * @param {(msg: string) => void} passFn callback при успішній перевірці
336
353
  * @param {(msg: string) => void} failFn callback при виявленому порушенні
354
+ * @param {string} cwd корінь репозиторію
337
355
  */
338
- function checkPublishWorkflow(passFn, failFn) {
356
+ function checkPublishWorkflow(passFn, failFn, cwd) {
339
357
  const publishWf = '.github/workflows/npm-publish.yml'
340
- if (existsSync(publishWf)) {
358
+ if (existsSync(join(cwd, publishWf))) {
341
359
  passFn(`${publishWf} є (структуру перевіряє npx @nitra/cursor fix → npm_module.npm_publish_yml)`)
342
360
  } else {
343
361
  failFn(`Відсутній ${publishWf} (npm-module.mdc: npm publish)`)
@@ -386,14 +404,16 @@ export function globToRegex(glob) {
386
404
  * простір імен `files`, бо саме його сканує check.
387
405
  * @param {string[]} filesField значення поля `files`
388
406
  * @returns {Promise<string[]>} відсортовані posix-шляхи без `npm/` префікса
407
+ * @param {string} cwd корінь репозиторію
389
408
  */
390
- async function collectPublishedFiles(filesField) {
409
+ async function collectPublishedFiles(filesField, cwd) {
391
410
  const positives = filesField.filter(p => typeof p === 'string' && !p.startsWith('!'))
392
411
  const negatives = filesField.filter(p => typeof p === 'string' && p.startsWith('!')).map(p => globToRegex(p.slice(1)))
393
412
  /** @type {Set<string>} */
394
413
  const collected = new Set()
414
+ const npmRoot = join(cwd, 'npm')
395
415
  for (const entry of positives) {
396
- const fullPath = join('npm', entry)
416
+ const fullPath = join(npmRoot, entry)
397
417
  if (!existsSync(fullPath)) continue
398
418
  const s = await stat(fullPath)
399
419
  if (s.isFile()) {
@@ -402,7 +422,7 @@ async function collectPublishedFiles(filesField) {
402
422
  }
403
423
  if (!s.isDirectory()) continue
404
424
  await walkDir(fullPath, p => {
405
- const rel = p.slice('npm/'.length).split(sep).join('/')
425
+ const rel = p.slice(npmRoot.length + 1).split(sep).join('/')
406
426
  collected.add(rel)
407
427
  })
408
428
  }
@@ -460,8 +480,9 @@ export function findTestFrameworkImport(content, virtualPath) {
460
480
  * Подальші сегменти (наприклад, `rules/<r>/js/<c>/tests/`) продовжують перевірятись.
461
481
  * @param {string} relPath posix-шлях відносно `npm/`
462
482
  * @returns {Promise<string | null>} причина порушення або `null`
483
+ * @param {string} [cwd] корінь репозиторію
463
484
  */
464
- export async function classifyPublishedFileAsTest(relPath) {
485
+ export async function classifyPublishedFileAsTest(relPath, cwd = process.cwd()) {
465
486
  const segments = relPath.split('/')
466
487
  const base = segments.at(-1)
467
488
  const dirs = segments.slice(0, -1)
@@ -474,7 +495,7 @@ export async function classifyPublishedFileAsTest(relPath) {
474
495
  if (testDir) return `test-style каталог "${testDir}/"`
475
496
  if (TEST_FILE_PATTERNS.some(re => re.test(base))) return `test-style ім'я файлу`
476
497
  if (JS_LIKE_EXT_RE.test(base)) {
477
- const content = await readFile(join('npm', relPath), 'utf8')
498
+ const content = await readFile(join(cwd, 'npm', relPath), 'utf8')
478
499
  const mod = findTestFrameworkImport(content, relPath)
479
500
  if (mod) return `імпорт test-фреймворку "${mod}"`
480
501
  }
@@ -488,16 +509,17 @@ export async function classifyPublishedFileAsTest(relPath) {
488
509
  * @param {(msg: string) => void} pass callback при успіху
489
510
  * @param {(msg: string) => void} fail callback при порушенні
490
511
  * @returns {Promise<void>}
512
+ * @param {string} cwd корінь репозиторію
491
513
  */
492
- async function checkNoTestsInPublishedFiles(pass, fail) {
493
- if (!existsSync('npm/package.json')) return
494
- const pkg = JSON.parse(await readFile('npm/package.json', 'utf8'))
514
+ async function checkNoTestsInPublishedFiles(pass, fail, cwd) {
515
+ if (!existsSync(join(cwd, 'npm/package.json'))) return
516
+ const pkg = JSON.parse(await readFile(join(cwd, 'npm/package.json'), 'utf8'))
495
517
  if (!Array.isArray(pkg.files)) return
496
- const files = await collectPublishedFiles(pkg.files)
518
+ const files = await collectPublishedFiles(pkg.files, cwd)
497
519
  /** @type {{ file: string, reason: string }[]} */
498
520
  const violations = []
499
521
  for (const rel of files) {
500
- const reason = await classifyPublishedFileAsTest(rel)
522
+ const reason = await classifyPublishedFileAsTest(rel, cwd)
501
523
  if (reason) violations.push({ file: rel, reason })
502
524
  }
503
525
  if (violations.length === 0) {
@@ -518,16 +540,18 @@ async function checkNoTestsInPublishedFiles(pass, fail) {
518
540
  * валідує `npm/policy/npm_module/root_package_json/`.
519
541
  * @param {(msg: string) => void} pass callback при успішній перевірці
520
542
  * @param {(msg: string) => void} fail callback при помилці
543
+ * @param {string} cwd корінь репозиторію
521
544
  */
522
- async function checkNpmModuleBasicStructure(pass, fail) {
523
- if (existsSync('package.json')) {
545
+ async function checkNpmModuleBasicStructure(pass, fail, cwd) {
546
+ if (existsSync(join(cwd, 'package.json'))) {
524
547
  pass('package.json існує')
525
548
  } else {
526
549
  fail('package.json не існує')
527
550
  }
528
551
 
529
- if (existsSync('npm')) {
530
- const s = await stat('npm')
552
+ const npmDir = join(cwd, 'npm')
553
+ if (existsSync(npmDir)) {
554
+ const s = await stat(npmDir)
531
555
  if (s.isDirectory()) {
532
556
  pass('npm/ директорія існує')
533
557
  } else {
@@ -537,7 +561,7 @@ async function checkNpmModuleBasicStructure(pass, fail) {
537
561
  fail('npm/ директорія не існує')
538
562
  }
539
563
 
540
- if (existsSync('npm/package.json')) {
564
+ if (existsSync(join(cwd, 'npm/package.json'))) {
541
565
  pass('npm/package.json існує')
542
566
  } else {
543
567
  fail('npm/package.json не існує — створи package.json для npm модуля')
@@ -546,26 +570,27 @@ async function checkNpmModuleBasicStructure(pass, fail) {
546
570
 
547
571
  /**
548
572
  * Перевіряє відповідність проєкту правилам npm-module.mdc
573
+ * @param {string} [cwd] корінь репозиторію
549
574
  * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
550
575
  */
551
- export async function check() {
576
+ export async function check(cwd = process.cwd()) {
552
577
  const reporter = createCheckReporter()
553
578
  const { pass, fail } = reporter
554
579
 
555
- await checkNpmModuleBasicStructure(pass, fail)
556
- await checkNoTestsInPublishedFiles(pass, fail)
580
+ await checkNpmModuleBasicStructure(pass, fail, cwd)
581
+ await checkNoTestsInPublishedFiles(pass, fail, cwd)
557
582
 
558
- const ignorePaths = await loadCursorIgnorePaths(process.cwd())
559
- const useSrcJsLayout = await npmSrcTreeHasJsFile(ignorePaths)
583
+ const ignorePaths = await loadCursorIgnorePaths(cwd)
584
+ const useSrcJsLayout = await npmSrcTreeHasJsFile(cwd, ignorePaths)
560
585
 
561
- await checkNpmPackageJson(useSrcJsLayout, pass, fail)
586
+ await checkNpmPackageJson(useSrcJsLayout, pass, fail, cwd)
562
587
 
563
588
  if (!useSrcJsLayout) {
564
- await checkEmitTypesConfig(pass, fail)
589
+ await checkEmitTypesConfig(pass, fail, cwd)
565
590
  }
566
591
 
567
592
  const layoutLabel = useSrcJsLayout ? 'layout src' : 'tsconfig emit-types'
568
- const hk = await readHkConfig()
593
+ const hk = await readHkConfig(cwd)
569
594
  if (hk) {
570
595
  pass(`${hk.path} існує`)
571
596
  const missing = useSrcJsLayout ? missingHkSrcLayoutFragments(hk.text) : missingHkEmitTypesConfigFragments(hk.text)
@@ -578,16 +603,16 @@ export async function check() {
578
603
  fail('Очікується hk.pkl або .config/hk.pkl з pre-commit і tsc (npm-module.mdc)')
579
604
  }
580
605
 
581
- if (existsSync('.github/workflows')) {
606
+ if (existsSync(join(cwd, '.github/workflows'))) {
582
607
  pass('.github/workflows/ існує')
583
608
  } else {
584
609
  fail('.github/workflows/ не існує')
585
610
  }
586
611
 
587
- await checkPublishWorkflow(pass, fail)
612
+ await checkPublishWorkflow(pass, fail, cwd)
588
613
 
589
- await checkChangelogTopMatchesPackageVersion(pass, fail)
590
- await checkDirtyNpmRequiresVersionBump(pass, fail)
614
+ await checkChangelogTopMatchesPackageVersion(pass, fail, cwd)
615
+ await checkDirtyNpmRequiresVersionBump(pass, fail, cwd)
591
616
 
592
617
  return reporter.getExitCode()
593
618
  }
@@ -35,12 +35,12 @@ async function projectHasRegoFiles(root, ignorePaths) {
35
35
 
36
36
  /**
37
37
  * Rule-level applies-гейт: CLI пропускає правило, якщо в репо немає `.rego` файлів.
38
+ * @param {string} [cwd] корінь репозиторію
38
39
  * @returns {Promise<boolean>} `true`, якщо правило застосовне
39
40
  */
40
- export async function applies() {
41
- const root = process.cwd()
42
- const ignorePaths = await loadCursorIgnorePaths(root)
43
- return projectHasRegoFiles(root, ignorePaths)
41
+ export async function applies(cwd = process.cwd()) {
42
+ const ignorePaths = await loadCursorIgnorePaths(cwd)
43
+ return projectHasRegoFiles(cwd, ignorePaths)
44
44
  }
45
45
 
46
46
  /**
@@ -6,6 +6,7 @@
6
6
  * `check()` друкує тільки context-pass; реальна робота — у policy-концернах.
7
7
  */
8
8
  import { existsSync } from 'node:fs'
9
+ import { join } from 'node:path'
9
10
 
10
11
  import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
11
12
 
@@ -14,11 +15,12 @@ import { hasCargoTomlInTree } from '../lib/has-cargo-toml.mjs'
14
15
  const IGNORED_DIR_NAMES = new Set(['node_modules', '.git', '.next', '.turbo'])
15
16
 
16
17
  /**
18
+ * @param {string} [cwd] корінь репозиторію
17
19
  * @returns {Promise<boolean>} `true` — правило застосовне; `false` — пропустити
18
20
  */
19
- export function applies() {
20
- if (existsSync('Cargo.toml')) return Promise.resolve(true)
21
- return Promise.resolve(hasCargoTomlInTree(process.cwd(), IGNORED_DIR_NAMES))
21
+ export function applies(cwd = process.cwd()) {
22
+ if (existsSync(join(cwd, 'Cargo.toml'))) return Promise.resolve(true)
23
+ return Promise.resolve(hasCargoTomlInTree(cwd, IGNORED_DIR_NAMES))
22
24
  }
23
25
 
24
26
  /**
@@ -61,12 +61,12 @@ function isExampleFile(relPosix) {
61
61
  }
62
62
 
63
63
  /**
64
+ * @param {string} [cwd] корінь репозиторію
64
65
  * @returns {Promise<number>} exit-код перевірки (0 — OK, 1 — є bare `secret`)
65
66
  */
66
- export async function check() {
67
+ export async function check(cwd = process.cwd()) {
67
68
  const reporter = createCheckReporter()
68
69
  const { pass, fail } = reporter
69
- const cwd = process.cwd()
70
70
 
71
71
  /** @type {Array<{ abs: string, rel: string }>} */
72
72
  const examples = []
@@ -18,24 +18,26 @@ const HERE = dirname(fileURLToPath(import.meta.url))
18
18
  const SNIPPET_PATH = join(HERE, 'templates', 'trufflehog', '.trufflehog-exclude.snippet.txt')
19
19
 
20
20
  /**
21
+ * @param {string} [cwd] корінь репозиторію
21
22
  * @returns {Promise<number>} exit-код перевірки
22
23
  */
23
- export async function check() {
24
+ export async function check(cwd = process.cwd()) {
24
25
  const reporter = createCheckReporter()
25
26
  const { pass, fail } = reporter
26
27
 
27
- if (!existsSync('package.json')) {
28
+ if (!existsSync(join(cwd, 'package.json'))) {
28
29
  fail('package.json не знайдено в корені — додай (security.mdc)')
29
30
  return reporter.getExitCode()
30
31
  }
31
32
  pass('package.json є (структуру перевіряє Rego)')
32
33
 
33
- if (!existsSync('.trufflehog-exclude')) {
34
+ const trufflePath = join(cwd, '.trufflehog-exclude')
35
+ if (!existsSync(trufflePath)) {
34
36
  fail('.trufflehog-exclude не знайдено в корені — додай за каноном (security.mdc)')
35
37
  return reporter.getExitCode()
36
38
  }
37
39
 
38
- const actual = await readFile('.trufflehog-exclude', 'utf8')
40
+ const actual = await readFile(trufflePath, 'utf8')
39
41
  const template = await readFile(SNIPPET_PATH, 'utf8')
40
42
  const errors = checkTextSubset(actual, template, {
41
43
  targetPath: '.trufflehog-exclude',
@@ -15,9 +15,11 @@
15
15
  * у `recommendations` `.vscode/extensions.json`;
16
16
  * - `npm/policy/style_lint/vscode_settings/` — `css.validate`/`scss.validate`/
17
17
  * `less.validate: false` у `.vscode/settings.json`.
18
+ * @param {string} cwd корінь репозиторію
18
19
  */
19
20
  import { existsSync } from 'node:fs'
20
21
  import { readFile } from 'node:fs/promises'
22
+ import { join } from 'node:path'
21
23
 
22
24
  import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
23
25
 
@@ -26,14 +28,18 @@ import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
26
28
  * поля немає і файлу немає, фейлимося; якщо є хоч щось — пропускаємо. Поле
27
29
  * `stylelint.extends == "@nitra/stylelint-config"` сам формат — у Rego.
28
30
  * @param {import('../../../scripts/lib/check-reporter.mjs').CheckReporter} reporter репортер
31
+ * @param {string} cwd корінь репозиторію
29
32
  */
30
- async function checkStylelintConfigPresence(reporter) {
33
+ async function checkStylelintConfigPresence(reporter, cwd) {
31
34
  const { pass, fail } = reporter
32
- if (!existsSync('package.json')) return
33
- const pkg = JSON.parse(await readFile('package.json', 'utf8'))
35
+ const pkgPath = join(cwd, 'package.json')
36
+ if (!existsSync(pkgPath)) return
37
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
34
38
  const hasField = pkg.stylelint && typeof pkg.stylelint === 'object'
35
39
  const hasExternalCfg =
36
- existsSync('.stylelintrc.json') || existsSync('.stylelintrc.js') || existsSync('stylelint.config.js')
40
+ existsSync(join(cwd, '.stylelintrc.json')) ||
41
+ existsSync(join(cwd, '.stylelintrc.js')) ||
42
+ existsSync(join(cwd, 'stylelint.config.js'))
37
43
  if (hasField || hasExternalCfg) {
38
44
  pass('Конфіг stylelint є — у package.json або окремим файлом')
39
45
  } else {
@@ -48,22 +54,23 @@ async function checkStylelintConfigPresence(reporter) {
48
54
 
49
55
  /**
50
56
  * Перевіряє відповідність проєкту правилам style-lint.mdc
57
+ * @param {string} [cwd] корінь репозиторію
51
58
  * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
52
59
  */
53
- export async function check() {
60
+ export async function check(cwd = process.cwd()) {
54
61
  const reporter = createCheckReporter()
55
62
  const { pass, fail } = reporter
56
63
 
57
- await checkStylelintConfigPresence(reporter)
64
+ await checkStylelintConfigPresence(reporter, cwd)
58
65
 
59
- if (existsSync('.stylelintignore')) {
66
+ if (existsSync(join(cwd, '.stylelintignore'))) {
60
67
  pass('.stylelintignore існує')
61
68
  } else {
62
69
  fail('.stylelintignore не існує — створи з вмістом: dist/')
63
70
  }
64
71
 
65
72
  const wfPath = '.github/workflows/lint-style.yml'
66
- if (existsSync(wfPath)) {
73
+ if (existsSync(join(cwd, wfPath))) {
67
74
  pass(`${wfPath} є (структуру перевіряє npx @nitra/cursor fix → style_lint.lint_style_yml)`)
68
75
  } else {
69
76
  fail(`${wfPath} не існує — створи його`)
@@ -189,7 +189,7 @@ export async function runCoverageSteps(opts = {}) {
189
189
  if (opts.fix) {
190
190
  const allSurvived = rows.flatMap(r => r.survived ?? [])
191
191
  // eslint-disable-next-line no-unsanitized/method -- шлях відносний до пакету, не user-input
192
- const { fixSurvivedMutants } = await import(new URL('../../scripts/coverage-fix.mjs', import.meta.url).href)
192
+ const { fixSurvivedMutants } = await import(new URL('../../../scripts/coverage-fix.mjs', import.meta.url).href)
193
193
  await fixSurvivedMutants(allSurvived, cwd)
194
194
  }
195
195
 
@@ -6,6 +6,13 @@ export default defineConfig({
6
6
  // у піддиректоріях `tests/`) і top-level integration suites у `<root>/tests/`.
7
7
  include: ['**/*.test.{js,mjs}', 'tests/**/*.test.{js,mjs}'],
8
8
  environment: 'node',
9
+ // `pool: 'forks'` — defense-in-depth ізоляція процесів між test-файлами.
10
+ // У default `pool: 'threads'` усі workers ділять один процес → паралельний
11
+ // `process.chdir(dir)` у тестовій фікстурі перехоплює cwd сусіда посеред
12
+ // FS- або `git`-операції. Реальний інцидент: `git init`+`git commit` із
13
+ // tmp-фікстури потрапив у реальний робочий репозиторій. Forks гарантують
14
+ // ізоляцію. Канон тестів — `withTmpDir(async dir => ...)` (test.mdc).
15
+ pool: 'forks',
9
16
  coverage: { provider: 'v8', reporter: ['lcov', 'text-summary'] }
10
17
  }
11
18
  })
@@ -35,13 +35,14 @@ function isInsideTestsDir(absPath) {
35
35
 
36
36
  /**
37
37
  * Перевіряє розміщення тестових файлів у каталозі `tests/` (test.mdc).
38
+ * @param {string} [cwdParam] корінь репозиторію
38
39
  * @returns {Promise<number>} 0 — всі тести у `tests/`, 1 — є порушення
39
40
  */
40
- export async function check() {
41
+ export async function check(cwdParam = process.cwd()) {
41
42
  const reporter = createCheckReporter()
42
43
  const { pass, fail } = reporter
43
44
 
44
- const cwd = process.cwd()
45
+ const cwd = cwdParam
45
46
  const ignorePaths = await loadCursorIgnorePaths(cwd)
46
47
 
47
48
  /** @type {string[]} */
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Заборона `process.chdir(...)` у тестах.
3
+ *
4
+ * Контекст (test.mdc, секція "Заборона `process.chdir` у тестах"):
5
+ * `process.chdir` — process-wide мутація. Vitest за замовчуванням ставить
6
+ * `pool: 'threads'`, де workers ділять один процес: паралельний test file
7
+ * може перехопити cwd сусіда посеред FS- або `git`-операції. Реальний
8
+ * інцидент — `git init`+`git commit` із tmp-фікстури `withTmpCwd` потрапив у
9
+ * реальний робочий репозиторій і створив rogue commit з автором `test
10
+ * <test@test>`. Тому канон: `withTmpDir(async dir => ...)` зі
11
+ * `scripts/utils/test-helpers.mjs` (без `chdir`) + явні `cwd: dir` у child-
12
+ * процесах + `await check(dir)` для concern-функцій.
13
+ *
14
+ * Цей concern сканує `**\/*.test.{js,mjs}` і падає на будь-яке вживання
15
+ * `process.chdir(`. Виняток — коментарі/документація: regex знаходить лише
16
+ * викликний паттерн (відкривна дужка), тож згадки у JSDoc типу
17
+ * "не використовуй `process.chdir`" не тригерять.
18
+ *
19
+ * Скіпи: `node_modules`, `.git`, `dist`, `build`, `.venv`, `venv` (через
20
+ * `walkDir`) і шляхи з `.n-cursor.json:ignore`.
21
+ */
22
+ import { readFile } from 'node:fs/promises'
23
+ import { basename, relative } from 'node:path'
24
+
25
+ import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
26
+ import { loadCursorIgnorePaths } from '../../../scripts/lib/load-cursor-config.mjs'
27
+ import { walkDir } from '../../../scripts/utils/walkDir.mjs'
28
+
29
+ /** Шукаємо викликний паттерн з відкривною дужкою — не зачепить згадку у docstring. */
30
+ const CHDIR_CALL_RE = /process\.chdir\s*\(/u
31
+
32
+ /**
33
+ * Чи файл — JS-тест (`*.test.mjs` або `*.test.js`).
34
+ * @param {string} absPath абсолютний шлях
35
+ * @returns {boolean} `true` для імен з `.test.{mjs,js}` суфіксом
36
+ */
37
+ function isTestFile(absPath) {
38
+ const name = basename(absPath)
39
+ return name.endsWith('.test.mjs') || name.endsWith('.test.js')
40
+ }
41
+
42
+ /**
43
+ * Перевіряє, що жоден `*.test.{mjs,js}` файл не викликає `process.chdir(`.
44
+ * @param {string} [cwdParam] корінь репозиторію
45
+ * @returns {Promise<number>} 0 — чисто, 1 — знайдено `process.chdir(` у тесті
46
+ */
47
+ export async function check(cwdParam = process.cwd()) {
48
+ const reporter = createCheckReporter()
49
+ const { pass, fail } = reporter
50
+
51
+ const cwd = cwdParam
52
+ const ignorePaths = await loadCursorIgnorePaths(cwd)
53
+
54
+ /** @type {string[]} */
55
+ const testFiles = []
56
+ await walkDir(
57
+ cwd,
58
+ absPath => {
59
+ if (isTestFile(absPath)) testFiles.push(absPath)
60
+ },
61
+ ignorePaths
62
+ )
63
+
64
+ /** @type {Array<{file: string, line: number}>} */
65
+ const offenders = []
66
+ for (const absPath of testFiles) {
67
+ const body = await readFile(absPath, 'utf8')
68
+ if (!CHDIR_CALL_RE.test(body)) continue
69
+ const lines = body.split('\n')
70
+ for (const [i, line] of lines.entries()) {
71
+ if (CHDIR_CALL_RE.test(line)) {
72
+ offenders.push({ file: relative(cwd, absPath), line: i + 1 })
73
+ }
74
+ }
75
+ }
76
+
77
+ if (offenders.length === 0) {
78
+ pass(`Жоден з ${testFiles.length} тестових файлів не викликає process.chdir() (test.mdc)`)
79
+ return reporter.getExitCode()
80
+ }
81
+
82
+ for (const { file, line } of offenders) {
83
+ fail(
84
+ `${file}:${line}: process.chdir() у тесті заборонений — використовуй withTmpDir(async dir => …) + явні join(dir, …) + cwd: dir (test.mdc)`
85
+ )
86
+ }
87
+
88
+ return reporter.getExitCode()
89
+ }