@nitra/cursor 1.13.76 → 1.13.82

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 (47) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/package.json +1 -1
  3. package/rules/abie/abie.mdc +11 -3
  4. package/rules/abie/policy/base_deployment_preem/base_deployment_preem.rego +9 -11
  5. package/rules/abie/policy/health_check_policy/health_check_policy.rego +9 -10
  6. package/rules/abie/policy/http_route_base/http_route_base.rego +7 -8
  7. package/rules/changelog/fix/consistency/check.mjs +16 -16
  8. package/{scripts/utils → rules/changelog/fix/consistency}/package-manifest.mjs +1 -1
  9. package/rules/docker/docker.mdc +3 -3
  10. package/rules/docker/fix/lint/check.mjs +3 -3
  11. package/{scripts/utils → rules/docker/fix/lint}/docker-hadolint.mjs +3 -3
  12. package/rules/docker/lint/lint.mjs +2 -2
  13. package/rules/ga/lint/lint.mjs +4 -1
  14. package/rules/graphql/fix/tooling/check.mjs +1 -1
  15. package/{scripts/utils → rules/graphql/fix/tooling}/graphql-gql-scan.mjs +47 -12
  16. package/rules/hasura/fix/internal_urls/check.mjs +1 -1
  17. package/{scripts/utils → rules/js-bun-db/fix/safety}/bun-sql-scan.mjs +1 -1
  18. package/rules/js-bun-db/fix/safety/check.mjs +1 -1
  19. package/rules/js-lint/fix/tooling/check.mjs +3 -15
  20. package/{scripts/utils → rules/js-lint/fix/tooling}/rebuild-oxlint-canonical.mjs +1 -1
  21. package/rules/js-lint/js-lint.mdc +2 -2
  22. package/rules/js-mssql/fix/deps/check.mjs +1 -1
  23. package/{scripts/utils → rules/js-mssql/fix/deps}/mssql-pool-scan.mjs +1 -1
  24. package/{scripts/utils → rules/js-run/fix/runtime}/bunyan-imports.mjs +1 -1
  25. package/{scripts/utils → rules/js-run/fix/runtime}/check-env-scan.mjs +1 -1
  26. package/rules/js-run/fix/runtime/check.mjs +5 -5
  27. package/{scripts/utils → rules/js-run/fix/runtime}/conn-file-rules.mjs +1 -1
  28. package/{scripts/utils → rules/js-run/fix/runtime}/conn-imports-scan.mjs +1 -1
  29. package/{scripts/utils → rules/js-run/fix/runtime}/promise-settimeout-scan.mjs +1 -1
  30. package/rules/k8s/k8s.mdc +1 -1
  31. package/rules/npm-module/fix/package_structure/check.mjs +11 -1
  32. package/rules/test/auto.md +1 -0
  33. package/rules/test/fix/location/check.mjs +77 -0
  34. package/rules/test/test.mdc +60 -0
  35. package/rules/vue/fix/packages/check.mjs +2 -2
  36. package/rules/vue/vue.mdc +1 -1
  37. package/scripts/auto-rules.mjs +3 -3
  38. package/scripts/utils/ast-scan-utils.mjs +3 -2
  39. package/scripts/utils/with-lock.mjs +120 -0
  40. package/scripts/utils/workspaces.mjs +1 -1
  41. package/scripts/utils/worktree-fingerprint.mjs +30 -0
  42. /package/{scripts/utils → rules/docker/fix/lint}/docker-mirror.mjs +0 -0
  43. /package/{scripts/utils → rules/js-lint/fix/tooling}/knip-canonical.json +0 -0
  44. /package/{scripts/utils → rules/js-lint/fix/tooling}/oxlint-canonical-skeleton.json +0 -0
  45. /package/{scripts/utils → rules/js-lint/fix/tooling}/oxlint-canonical.json +0 -0
  46. /package/{scripts/utils → rules/js-lint/fix/tooling}/oxlint-rules.tsv +0 -0
  47. /package/{scripts/utils → rules/vue/fix/packages}/vue-forbidden-imports.mjs +0 -0
@@ -2,7 +2,7 @@
2
2
  * Перевіряє лінт JavaScript за правилом js-lint.mdc.
3
3
  *
4
4
  * Flat ESLint з getConfig і ignore для auto-imports,
5
- * `.oxlintrc.json` має збігатися з каноном oxlint у пакеті (`npm/scripts/utils/oxlint-canonical.json`):
5
+ * `.oxlintrc.json` має збігатися з каноном oxlint у пакеті (`npm/rules/js-lint/fix/tooling/oxlint-canonical.json`):
6
6
  * plugins, jsPlugins, categories, усі правила з канону (додаткові записи в `rules` дозволені), settings, env,
7
7
  * globals, ignorePatterns. Також перевіряє workspace `package.json` на `type: "module"`
8
8
  * і `engines`, workflow-дубль у `lint.yml`, `knip.json` autofill і застарілі `.eslintrc*`.
@@ -20,24 +20,12 @@ import { createCheckReporter } from '../../../../scripts/utils/check-reporter.mj
20
20
  /** Шлях до канонічного oxlint JSON у цьому пакеті (для перевірки та тестів). */
21
21
  export const OXLINT_CANONICAL_JSON_PATH = join(
22
22
  dirname(fileURLToPath(import.meta.url)),
23
- '..',
24
- '..',
25
- '..',
26
- '..',
27
- 'scripts',
28
- 'utils',
29
23
  'oxlint-canonical.json'
30
24
  )
31
25
 
32
26
  /** Шлях до канонічного knip JSON у цьому пакеті — копіюється у корінь проєкту-споживача, якщо відсутній. */
33
27
  export const KNIP_CANONICAL_JSON_PATH = join(
34
28
  dirname(fileURLToPath(import.meta.url)),
35
- '..',
36
- '..',
37
- '..',
38
- '..',
39
- 'scripts',
40
- 'utils',
41
29
  'knip-canonical.json'
42
30
  )
43
31
 
@@ -166,7 +154,7 @@ export function verifyOxlintRcAgainstCanonical(cfg, canonical) {
166
154
 
167
155
  if (!deepEqualOxlintCanonical(actual, expected)) {
168
156
  failures.push(
169
- `.oxlintrc.json: поле "${key}" має збігатися з каноном пакета @nitra/cursor (npm/scripts/utils/oxlint-canonical.json)`
157
+ `.oxlintrc.json: поле "${key}" має збігатися з каноном пакета @nitra/cursor (npm/rules/js-lint/fix/tooling/oxlint-canonical.json)`
170
158
  )
171
159
  }
172
160
  }
@@ -396,7 +384,7 @@ async function checkKnipConfig(passFn, failFn) {
396
384
  return
397
385
  }
398
386
  await copyFile(KNIP_CANONICAL_JSON_PATH, 'knip.json')
399
- passFn('knip.json створено з канонічного npm/scripts/utils/knip-canonical.json (js-lint.mdc)')
387
+ passFn('knip.json створено з канонічного npm/rules/js-lint/fix/tooling/knip-canonical.json (js-lint.mdc)')
400
388
  }
401
389
 
402
390
  /**
@@ -2,7 +2,7 @@
2
2
  * Збирає `oxlint-canonical.json` з `oxlint-canonical-skeleton.json` (без поля rules) та списку
3
3
  * правил у `oxlint-rules.tsv` (колонки: ім’я правила, TAB, severity: deny | off | error).
4
4
  *
5
- * Після змін у TSV або скелеті запускай з каталогу пакета: `bun ./scripts/utils/rebuild-oxlint-canonical.mjs`,
5
+ * Після змін у TSV або скелеті запускай з каталогу пакета: `bun ./rules/js-lint/fix/tooling/rebuild-oxlint-canonical.mjs`,
6
6
  * потім скопіюй оновлений канон у корінь споживача як `.oxlintrc.json` за потреби.
7
7
  */
8
8
  import { readFileSync, writeFileSync } from 'node:fs'
@@ -25,7 +25,7 @@ version: '1.23'
25
25
 
26
26
  У `.vscode/extensions.json` `recommendations` мають містити `dbaeumer.vscode-eslint`, `github.vscode-github-actions`, `oxc.oxc-vscode`: [extensions.json.snippet.json](./policy/vscode_extensions/template/extensions.json.snippet.json)
27
27
 
28
- У корені має бути **`.oxlintrc.json`**, який **збігається з каноном** oxlint з пакета **`@nitra/cursor`**: файл **`npm/scripts/utils/oxlint-canonical.json`** (plugins, jsPlugins з **`@e18e/eslint-plugin`**, categories, повний набір **rules** із канону — додаткові записи в **`rules`** дозволені; також **`settings`**, **`env`**, **`globals`**). Поле **`ignorePatterns`** працює як **`rules`**: канонічні патерни з **`oxlint-canonical.json`** (наразі **`**/schema.graphql`**, **`**/auto-imports.d.ts`**) мають бути присутні, додаткові локальні glob-и дозволені. Оновити канон можна з репозиторію пакета або скопіювавши файл після **`bun ./scripts/utils/rebuild-oxlint-canonical.mjs`** (джерело правил — **`oxlint-rules.tsv`** + скелет **`oxlint-canonical-skeleton.json`**). Модуль **`@e18e/eslint-plugin`** не оголошуй окремо в **`package.json`** — він уже в залежностях **`@nitra/eslint-config`** (з **3.8.0**), oxlint підвантажує його з **`node_modules`**.
28
+ У корені має бути **`.oxlintrc.json`**, який **збігається з каноном** oxlint з пакета **`@nitra/cursor`**: файл **`npm/rules/js-lint/fix/tooling/oxlint-canonical.json`** (plugins, jsPlugins з **`@e18e/eslint-plugin`**, categories, повний набір **rules** із канону — додаткові записи в **`rules`** дозволені; також **`settings`**, **`env`**, **`globals`**). Поле **`ignorePatterns`** працює як **`rules`**: канонічні патерни з **`oxlint-canonical.json`** (наразі **`**/schema.graphql`**, **`**/auto-imports.d.ts`**) мають бути присутні, додаткові локальні glob-и дозволені. Оновити канон можна з репозиторію пакета або скопіювавши файл після **`bun ./rules/js-lint/fix/tooling/rebuild-oxlint-canonical.mjs`** (джерело правил — **`oxlint-rules.tsv`** + скелет **`oxlint-canonical-skeleton.json`**). Модуль **`@e18e/eslint-plugin`** не оголошуй окремо в **`package.json`** — він уже в залежностях **`@nitra/eslint-config`** (з **3.8.0**), oxlint підвантажує його з **`node_modules`**.
29
29
 
30
30
  Мінімум для розуміння структури (реальний корінь конфігу має збігатися з каноном повністю):
31
31
 
@@ -62,7 +62,7 @@ version: '1.23'
62
62
 
63
63
  Перевірку невикористаних залежностей і експортів виконує **knip** (заміна `depcheck`). Викликається у скрипті `lint-js` і в CI разом з oxlint/eslint/jscpd — окремий крок у CI не потрібен.
64
64
 
65
- У корені проєкту має бути **`knip.json`**, який стартує з канонічного baseline з пакета `@nitra/cursor` — файл [`npm/scripts/utils/knip-canonical.json`](../scripts/utils/knip-canonical.json). Він покриває типові false-positives для наших правил: `entry` зі CLI-конфігами (eslint, stylelint, oxlint, jscpd, markdownlint-cli2, `commitlint`), `project` для `**/*.{js,mjs,cjs,jsx,ts,tsx,mts,cts}`, `ignore` для `**/__fixtures__/**`, `ignoreDependencies` для пакетів, посилання на які є лише в не-JS-конфігах (`@nitra/cspell-dict`, `/@cspell\/dict-.+/`), і `ignoreBinaries` для CLI, які канон вимагає викликати через `npx`/`bunx` і яких заборонено додавати в `devDependencies` (`actionlint`, `cspell`, `depcheck`, `eslint`, `git-ai`, `jscpd`, `markdownlint-cli2`, `oxfmt`, `oxlint`, `shellcheck`, `uvx`, `v8r`, `zizmor`).
65
+ У корені проєкту має бути **`knip.json`**, який стартує з канонічного baseline з пакета `@nitra/cursor` — файл [`npm/rules/js-lint/fix/tooling/knip-canonical.json`](./fix/tooling/knip-canonical.json). Він покриває типові false-positives для наших правил: `entry` зі CLI-конфігами (eslint, stylelint, oxlint, jscpd, markdownlint-cli2, `commitlint`), `project` для `**/*.{js,mjs,cjs,jsx,ts,tsx,mts,cts}`, `ignore` для `**/__fixtures__/**`, `ignoreDependencies` для пакетів, посилання на які є лише в не-JS-конфігах (`@nitra/cspell-dict`, `/@cspell\/dict-.+/`), і `ignoreBinaries` для CLI, які канон вимагає викликати через `npx`/`bunx` і яких заборонено додавати в `devDependencies` (`actionlint`, `cspell`, `depcheck`, `eslint`, `git-ai`, `jscpd`, `markdownlint-cli2`, `oxfmt`, `oxlint`, `shellcheck`, `uvx`, `v8r`, `zizmor`).
66
66
 
67
67
  Якщо `knip.json` відсутній — `npx @nitra/cursor check js-lint` копіює канон у корінь проєкту (side effect). Після створення модифікуй файл під свій проєкт як завгодно: перевіряємо лише наявність, зміст подальших змін не валідується.
68
68
 
@@ -22,7 +22,7 @@ import {
22
22
  findUnsafeMssqlInListUnparsedInText,
23
23
  findUnsafeMssqlInListMissingEmptyGuardInText,
24
24
  isMssqlScanSourceFile
25
- } from '../../../../scripts/utils/mssql-pool-scan.mjs'
25
+ } from './mssql-pool-scan.mjs'
26
26
  import { loadCursorIgnorePaths } from '../../../../scripts/utils/load-cursor-config.mjs'
27
27
  import { walkDir } from '../../../../scripts/utils/walkDir.mjs'
28
28
 
@@ -30,7 +30,7 @@ import {
30
30
  normalizeSnippet,
31
31
  offsetToLine,
32
32
  walkAstWithAncestors
33
- } from './ast-scan-utils.mjs'
33
+ } from '../../../../scripts/utils/ast-scan-utils.mjs'
34
34
 
35
35
  const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/
36
36
  const IN_PLACEHOLDER_END_RE = /\bin\s*\(\s*$/iu
@@ -18,7 +18,7 @@ import {
18
18
  offsetToLine,
19
19
  requireCallModule,
20
20
  walkAstWithAncestors
21
- } from './ast-scan-utils.mjs'
21
+ } from '../../../../scripts/utils/ast-scan-utils.mjs'
22
22
 
23
23
  const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/u
24
24
  const FORBIDDEN_MODULES = new Set(['@nitra/bunyan', 'bunyan'])
@@ -29,7 +29,7 @@
29
29
  * Якщо ключ обчислюваний (`process.env[varName]`) — пропускаємо без помилки,
30
30
  * бо за статичним AST неможливо встановити, яка саме змінна оточення використовується.
31
31
  */
32
- import { offsetToLine, parseProgramOrNull, walkAstWithAncestors } from './ast-scan-utils.mjs'
32
+ import { offsetToLine, parseProgramOrNull, walkAstWithAncestors } from '../../../../scripts/utils/ast-scan-utils.mjs'
33
33
 
34
34
  const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/u
35
35
  const IGNORE_DIRECTIVE_RE = /\/\/\s*@nitra\/cursor\s+ignore-next-line\s+checkEnv\b/u
@@ -40,22 +40,22 @@ import {
40
40
  findBunyanImportsInText,
41
41
  isBunyanScanSourceFile,
42
42
  shouldSkipFileForBunyanScan
43
- } from '../../../../scripts/utils/bunyan-imports.mjs'
44
- import { findUncheckedProcessEnvInText, isCheckEnvScanSourceFile } from '../../../../scripts/utils/check-env-scan.mjs'
43
+ } from './bunyan-imports.mjs'
44
+ import { findUncheckedProcessEnvInText, isCheckEnvScanSourceFile } from './check-env-scan.mjs'
45
45
  import { createCheckReporter } from '../../../../scripts/utils/check-reporter.mjs'
46
46
  import { runConftestBatch } from '../../../../scripts/utils/run-conftest-batch.mjs'
47
- import { findConnFileRuleViolations, isConnFileRulesSourceFile } from '../../../../scripts/utils/conn-file-rules.mjs'
47
+ import { findConnFileRuleViolations, isConnFileRulesSourceFile } from './conn-file-rules.mjs'
48
48
  import {
49
49
  findConnFactoryImportsInText,
50
50
  isConnImportsScanSourceFile,
51
51
  isInsideConnDir,
52
52
  resolveConnDirFromPackageJson
53
- } from '../../../../scripts/utils/conn-imports-scan.mjs'
53
+ } from './conn-imports-scan.mjs'
54
54
  import { loadCursorIgnorePaths } from '../../../../scripts/utils/load-cursor-config.mjs'
55
55
  import {
56
56
  findPromiseSetTimeoutInText,
57
57
  isPromiseSetTimeoutScanSourceFile
58
- } from '../../../../scripts/utils/promise-settimeout-scan.mjs'
58
+ } from './promise-settimeout-scan.mjs'
59
59
  import { walkDir } from '../../../../scripts/utils/walkDir.mjs'
60
60
  import { getMonorepoPackageRootDirs } from '../../../../scripts/utils/workspaces.mjs'
61
61
 
@@ -14,7 +14,7 @@
14
14
  * Парсимо через oxc-parser; коли файл не парситься — повертаємо порожні результати, щоб
15
15
  * не змішувати помилки синтаксису з порушеннями цього правила.
16
16
  */
17
- import { parseProgramOrNull } from './ast-scan-utils.mjs'
17
+ import { parseProgramOrNull } from '../../../../scripts/utils/ast-scan-utils.mjs'
18
18
 
19
19
  const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/u
20
20
 
@@ -16,7 +16,7 @@
16
16
  * використовується. Якщо файл не парситься — повертаємо порожній результат, спочатку
17
17
  * треба полагодити синтаксис.
18
18
  */
19
- import { langFromPath, normalizeSnippet, offsetToLine } from './ast-scan-utils.mjs'
19
+ import { langFromPath, normalizeSnippet, offsetToLine } from '../../../../scripts/utils/ast-scan-utils.mjs'
20
20
  import { parseSync } from 'oxc-parser'
21
21
 
22
22
  const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/u
@@ -12,7 +12,7 @@
12
12
  * Сканер не вимагає, щоб файл компілювався: при синтаксичних помилках повертається
13
13
  * порожній результат (як інші сканери — спочатку треба полагодити синтаксис).
14
14
  */
15
- import { normalizeSnippet, offsetToLine, parseProgramOrNull } from './ast-scan-utils.mjs'
15
+ import { normalizeSnippet, offsetToLine, parseProgramOrNull } from '../../../../scripts/utils/ast-scan-utils.mjs'
16
16
 
17
17
  const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/
18
18
 
package/rules/k8s/k8s.mdc CHANGED
@@ -375,7 +375,7 @@ images:
375
375
 
376
376
  Для `$schema` у першому рядку див. приклад **HealthCheckPolicy** у тому ж розділі (datree CRDs-catalog).
377
377
 
378
- **`spec.targetRef`** (типово **`kind: Service`**) має вказувати на **headless** сервіс — ім’я з суфіксом **`-hl`** (див. **«Service: `svc.yaml` і `svc-hl.yaml`»**); для проєктів **abie** точні умови — **`check-abie.mjs`** / **abie.mdc**.
378
+ **`spec.targetRef`** (типово **`kind: Service`**) має вказувати на **headless** сервіс — ім’я з суфіксом **`-hl`** (див. **«Service: `svc.yaml` і `svc-hl.yaml`»**); для проєктів **abie** точні умови — Rego-пакет **`abie.health_check_policy`** + **abie.mdc**.
379
379
 
380
380
  За потреби розшир **`target`** (`name`, `namespace`), щоб однозначно вказати об’єкт.
381
381
 
@@ -453,6 +453,11 @@ export function findTestFrameworkImport(content, virtualPath) {
453
453
  * Класифікує опублікований файл як test/fixture, якщо хоча б одна з ознак:
454
454
  * (1) у шляху є каталог із `TEST_DIR_NAMES`; (2) basename відповідає
455
455
  * `TEST_FILE_PATTERNS`; (3) для JS/TS-розширень — імпорт test-фреймворку.
456
+ *
457
+ * Carve-out: для шляху `rules/<rule-name>/...` сегмент `<rule-name>` (індекс 1)
458
+ * — це ім'я правила, а не каталог. Зокрема, правило з id `test` (або `tests`)
459
+ * описує конвенцію розміщення тестів і саме по собі не є test-fixture'ом.
460
+ * Подальші сегменти (наприклад, `rules/<r>/fix/<c>/tests/`) продовжують перевірятись.
456
461
  * @param {string} relPath posix-шлях відносно `npm/`
457
462
  * @returns {Promise<string | null>} причина порушення або `null`
458
463
  */
@@ -460,7 +465,12 @@ export async function classifyPublishedFileAsTest(relPath) {
460
465
  const segments = relPath.split('/')
461
466
  const base = segments.at(-1)
462
467
  const dirs = segments.slice(0, -1)
463
- const testDir = dirs.find(seg => TEST_DIR_NAMES.has(seg.toLowerCase()))
468
+ const testDir = dirs.find((seg, idx) => {
469
+ if (idx === 1 && dirs[0] === 'rules') {
470
+ return false
471
+ }
472
+ return TEST_DIR_NAMES.has(seg.toLowerCase())
473
+ })
464
474
  if (testDir) return `test-style каталог "${testDir}/"`
465
475
  if (TEST_FILE_PATTERNS.some(re => re.test(base))) return `test-style ім'я файлу`
466
476
  if (JS_LIKE_EXT_RE.test(base)) {
@@ -0,0 +1 @@
1
+ якщо у проекті є хоча б один файл `*.test.mjs`
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Перевіряє, що всі `*.test.mjs` лежать у каталозі `tests/` (а не поряд із джерельним файлом).
3
+ *
4
+ * Конвенція (test.mdc): `dir/foo.mjs` → тест у `dir/tests/foo.test.mjs`.
5
+ * `*_test.rego` виключені: Rego unit-тести живуть поряд із полісі (OPA community convention).
6
+ *
7
+ * Пропускає: `node_modules`, `.git`, `dist`, `build`, `.venv`, `venv` (через `walkDir`)
8
+ * і шляхи з `.n-cursor.json:ignore`.
9
+ */
10
+ import { basename, dirname, relative } from 'node:path'
11
+
12
+ import { createCheckReporter } from '../../../../scripts/utils/check-reporter.mjs'
13
+ import { loadCursorIgnorePaths } from '../../../../scripts/utils/load-cursor-config.mjs'
14
+ import { walkDir } from '../../../../scripts/utils/walkDir.mjs'
15
+
16
+ const TESTS_DIR_NAME = 'tests'
17
+
18
+ /**
19
+ * Чи файл є JS-тестом (`*.test.mjs`).
20
+ * @param {string} absPath абсолютний шлях
21
+ * @returns {boolean}
22
+ */
23
+ function isTestFile(absPath) {
24
+ return basename(absPath).endsWith('.test.mjs')
25
+ }
26
+
27
+ /**
28
+ * Перевіряє, чи лежить тест у каталозі з іменем `tests`.
29
+ * @param {string} absPath абсолютний шлях до тесту
30
+ * @returns {boolean} `true`, якщо басенейм батьківської директорії — `tests`
31
+ */
32
+ function isInsideTestsDir(absPath) {
33
+ return basename(dirname(absPath)) === TESTS_DIR_NAME
34
+ }
35
+
36
+ /**
37
+ * Перевіряє розміщення тестових файлів у каталозі `tests/` (test.mdc).
38
+ * @returns {Promise<number>} 0 — всі тести у `tests/`, 1 — є порушення
39
+ */
40
+ export async function check() {
41
+ const reporter = createCheckReporter()
42
+ const { pass, fail } = reporter
43
+
44
+ const cwd = process.cwd()
45
+ const ignorePaths = await loadCursorIgnorePaths(cwd)
46
+
47
+ /** @type {string[]} */
48
+ const offenders = []
49
+ let totalTests = 0
50
+
51
+ await walkDir(
52
+ cwd,
53
+ absPath => {
54
+ if (!isTestFile(absPath)) {
55
+ return
56
+ }
57
+ totalTests++
58
+ if (!isInsideTestsDir(absPath)) {
59
+ offenders.push(relative(cwd, absPath))
60
+ }
61
+ },
62
+ ignorePaths
63
+ )
64
+
65
+ if (offenders.length === 0) {
66
+ pass(`Всі ${totalTests} файлів *.test.mjs у каталозі tests/ (test.mdc)`)
67
+ return reporter.getExitCode()
68
+ }
69
+
70
+ for (const offenderPath of offenders) {
71
+ const parentDir = dirname(offenderPath)
72
+ const base = basename(offenderPath)
73
+ fail(`${offenderPath}: тест має лежати у tests/ — перенеси у ${parentDir}/${TESTS_DIR_NAME}/${base} (test.mdc)`)
74
+ }
75
+
76
+ return reporter.getExitCode()
77
+ }
@@ -0,0 +1,60 @@
1
+ ---
2
+ description: JS-тести (*.test.mjs) живуть у каталозі tests/ поряд із джерельним файлом, а не безпосередньо в тій же директорії
3
+ version: '1.1'
4
+ alwaysApply: true
5
+ ---
6
+
7
+ ## Конвенція розміщення тестів
8
+
9
+ JS-тести у пакеті лежать у **піддиректорії `tests/`** поряд із кодом, який тестується, а **не безпосередньо біля джерельного файлу**.
10
+
11
+ ```
12
+ rules/foo/fix/bar/
13
+ ├── check.mjs ← JS-джерело
14
+ └── tests/
15
+ └── check.test.mjs ← тест check.mjs
16
+ ```
17
+
18
+ **Чому так:**
19
+
20
+ - Каталог-з-кодом залишається чистим: продакшен-модулі, политики, темплейти не миготять серед тестових файлів.
21
+ - Один погляд на дерево показує, що піде у npm tarball (все, крім `tests/` і `__fixtures__/` — їх ловить `package.json#files` через негативні globs).
22
+ - Якщо тест переростає у мульти-файловий — він уже у власному каталозі, без хаотичного розкидання.
23
+
24
+ **Виняток — Rego unit-тести (`*_test.rego`):** лишаються **поряд із полісі-файлом** відповідно до загальноприйнятого OPA/Conftest community-патерну. `conftest verify -p <dir>` рекурсивно підхоплює їх незалежно від місця в каталозі.
25
+
26
+ ```
27
+ rules/foo/policy/package_json/
28
+ ├── package_json.rego ← Rego-правило
29
+ ├── package_json_test.rego ← Rego unit-тести (поряд, не у tests/)
30
+ └── target.json
31
+ ```
32
+
33
+ **Спеціальні випадки:**
34
+
35
+ - **Integration-тести на рівні пакета** — у `<root>/tests/` (наприклад `npm/tests/`). Це не «біля файлу», а виокремлений каталог для тестів, що покривають весь пакет.
36
+ - **Fixtures**: `__fixtures__/` (для shared) і `fixtures/` (для rule-specific) також живуть **усередині `tests/`**: `tests/__fixtures__/...` і `tests/fixtures/...`.
37
+ - **Test helpers** (`test-helpers.mjs`) можуть лишатися у `scripts/utils/` як shared infra — конвенція стосується файлів `*.test.mjs`.
38
+
39
+ **Гарантії tarball-чистоти** через `package.json#files`:
40
+
41
+ ```json
42
+ "files": [
43
+ "...",
44
+ "!**/*.test.mjs",
45
+ "!**/*_test.rego",
46
+ "!**/__fixtures__/**",
47
+ "!**/fixtures/**",
48
+ "!**/test-helpers.mjs"
49
+ ]
50
+ ```
51
+
52
+ Recursive globs ловлять файли всередині `tests/` так само, як ловили б їх біля джерела.
53
+
54
+ ## Що перевіряє правило
55
+
56
+ `npx @nitra/cursor check test` (concern `location`) проходить деревом пакета й перевіряє: **кожен `*.test.mjs` файл лежить усередині каталогу з ім'ям `tests`** (точне співпадіння басенейму батьківської директорії). Файли поза цим каталогом репортуються з порадою куди їх перенести.
57
+
58
+ `*_test.rego` перевіркою **не охоплюються** — вони не переміщуються.
59
+
60
+ Пропускаються: `node_modules`, `.git`, `dist`, `build`, `.venv`, `venv`, шляхи з `.n-cursor.json:ignore`.
@@ -15,7 +15,7 @@
15
15
  * натомість використовуй `mode` з `defineConfig(({ mode }) => ...)`.
16
16
  *
17
17
  * Заборонені явні value-імпорти з `vue` у джерелах пакета — сканування `.vue`/`.ts`/`.js` тощо
18
- * через **oxc-parser** (`module.staticImports`; див. `utils/vue-forbidden-imports.mjs`); дозволені лише type-only та side-effect `import 'vue'`.
18
+ * через **oxc-parser** (`module.staticImports`; див. `./vue-forbidden-imports.mjs`); дозволені лише type-only та side-effect `import 'vue'`.
19
19
  *
20
20
  * Окремо в `.vue` SFC заборонено імпорти Node-нативних модулів — `node:*` префікс або bare-ім’я
21
21
  * вбудованого модуля Node (`fs`, `path`, `timers/promises` тощо). Vue SFC виконується у браузері,
@@ -31,7 +31,7 @@ import {
31
31
  findForbiddenVueImportsInSourceFile,
32
32
  isVueImportScanSourceFile,
33
33
  shouldSkipFileForVueImportScan
34
- } from '../../../../scripts/utils/vue-forbidden-imports.mjs'
34
+ } from './vue-forbidden-imports.mjs'
35
35
  import { loadCursorIgnorePaths } from '../../../../scripts/utils/load-cursor-config.mjs'
36
36
  import { walkDir } from '../../../../scripts/utils/walkDir.mjs'
37
37
  import { getMonorepoPackageRootDirs } from '../../../../scripts/utils/workspaces.mjs'
package/rules/vue/vue.mdc CHANGED
@@ -341,4 +341,4 @@ import path from 'node:path'
341
341
 
342
342
  ## Перевірка
343
343
 
344
- `npx @nitra/cursor check vue` — перевіряє залежності, `vite.config`, наявність **`src/vite-env.d.ts`** з `/// <reference types="vite/client" />` та **`jsconfig.json`** у корені Vue-пакета; обходить джерела Vue-пакета (`.vue`, `.ts`, `.js` тощо) на заборонені value-імпорти з модуля `vue` (дозволені лише type-only та side-effect `import 'vue'`) і додатково сканує `.vue` SFC на імпорти Node-нативних модулів (`node:*` префікс або bare-ім’я вбудованого модуля Node — `fs`, `path`, `timers/promises` тощо). Імпорти аналізуються через **oxc-parser** (`module.staticImports`); для `.vue` вміст `<script>` витягується з SFC, далі той самий парсер (логіка в `npm/scripts/utils/vue-forbidden-imports.mjs`).
344
+ `npx @nitra/cursor check vue` — перевіряє залежності, `vite.config`, наявність **`src/vite-env.d.ts`** з `/// <reference types="vite/client" />` та **`jsconfig.json`** у корені Vue-пакета; обходить джерела Vue-пакета (`.vue`, `.ts`, `.js` тощо) на заборонені value-імпорти з модуля `vue` (дозволені лише type-only та side-effect `import 'vue'`) і додатково сканує `.vue` SFC на імпорти Node-нативних модулів (`node:*` префікс або bare-ім’я вбудованого модуля Node — `fs`, `path`, `timers/promises` тощо). Імпорти аналізуються через **oxc-parser** (`module.staticImports`); для `.vue` вміст `<script>` витягується з SFC, далі той самий парсер (логіка в `npm/rules/vue/fix/packages/vue-forbidden-imports.mjs`).
@@ -16,13 +16,13 @@ import { existsSync } from 'node:fs'
16
16
  import { readdir, readFile } from 'node:fs/promises'
17
17
  import { basename, join, relative } from 'node:path'
18
18
 
19
- import { textHasBunSqlImport } from './utils/bun-sql-scan.mjs'
19
+ import { textHasBunSqlImport } from '../rules/js-bun-db/fix/safety/bun-sql-scan.mjs'
20
20
  import {
21
21
  isGqlScanSourceFile,
22
22
  shouldSkipFileForGqlScan,
23
23
  sourceFileHasGqlTaggedTemplate
24
- } from './utils/graphql-gql-scan.mjs'
25
- import { contentForVueImportScan } from './utils/vue-forbidden-imports.mjs'
24
+ } from '../rules/graphql/fix/tooling/graphql-gql-scan.mjs'
25
+ import { contentForVueImportScan } from '../rules/vue/fix/packages/vue-forbidden-imports.mjs'
26
26
 
27
27
  /** Порядок автододавання правил відповідно до `rules/<rule>/auto.md`. */
28
28
  export const AUTO_RULE_ORDER = Object.freeze([
@@ -5,8 +5,9 @@
5
5
  * розпізнавання типових вузлів (функцій, `*.join(...)`),
6
6
  * робота з `TemplateLiteral` (текст quasis, контекст SQL-списку).
7
7
  *
8
- * Використовується файлами `bun-sql-scan.mjs`, `mssql-pool-scan.mjs` та іншими сканерами
9
- * для уникнення дублювання boilerplate.
8
+ * Використовується сканерами у `rules/<rule>/fix/...` (наприклад,
9
+ * `rules/js-bun-db/fix/safety/bun-sql-scan.mjs`, `rules/js-mssql/fix/deps/mssql-pool-scan.mjs`,
10
+ * `rules/js-run/fix/runtime/*-scan.mjs`) для уникнення дублювання boilerplate.
10
11
  */
11
12
  import { parseSync } from 'oxc-parser'
12
13
 
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Guard-механізм: атомарний lock + dedup для важких команд.
3
+ * Алгоритм: mkdirSync-based lock, перевірка живості PID, sha256-dedup з TTL.
4
+ */
5
+ import * as fs from 'node:fs'
6
+ import * as path from 'node:path'
7
+ import * as os from 'node:os'
8
+ import { worktreeFingerprint } from './worktree-fingerprint.mjs'
9
+
10
+ const DEFAULTS = {
11
+ ttl: 600_000,
12
+ staleThreshold: 1_800_000,
13
+ waitTimeout: 1_200_000,
14
+ pollInterval: 1_500,
15
+ }
16
+
17
+ function isAlive(pid) {
18
+ try { process.kill(pid, 0); return true } catch { return false }
19
+ }
20
+
21
+ function sleep(ms) {
22
+ return new Promise(r => setTimeout(r, ms))
23
+ }
24
+
25
+ function makeRelease(lockDir) {
26
+ return () => fs.rmSync(lockDir, { recursive: true, force: true })
27
+ }
28
+
29
+ /**
30
+ * @param {{exitCode:number, fingerprint:string|null, finishedAt:number}} result
31
+ * @param {string|null} fingerprint
32
+ * @param {number} ttl
33
+ */
34
+ export function shouldDedup(result, fingerprint, ttl) {
35
+ if (result.exitCode !== 0) return false
36
+ if (fingerprint === null || result.fingerprint !== fingerprint) return false
37
+ if (Date.now() - result.finishedAt >= ttl) return false
38
+ return true
39
+ }
40
+
41
+ /**
42
+ * @param {string} key
43
+ * @param {() => Promise<number>} runFn
44
+ * @param {{ttl?:number, staleThreshold?:number, waitTimeout?:number, pollInterval?:number, cacheDir?:string}} [opts]
45
+ * @returns {Promise<number>}
46
+ */
47
+ export async function withLock(key, runFn, opts = {}) {
48
+ const { ttl, staleThreshold, waitTimeout, pollInterval } = { ...DEFAULTS, ...opts }
49
+ const getFingerprint = opts.getFingerprint ?? worktreeFingerprint
50
+ const cacheDir = opts.cacheDir ?? path.join(process.cwd(), 'node_modules/.cache/n-cursor', key)
51
+ const lockDir = path.join(cacheDir, 'lock')
52
+ const ownerFile = path.join(lockDir, 'owner.json')
53
+ const resultFile = path.join(cacheDir, 'result.json')
54
+ const release = makeRelease(lockDir)
55
+
56
+ const fingerprint = getFingerprint()
57
+ fs.mkdirSync(cacheDir, { recursive: true })
58
+
59
+ const loopStart = Date.now()
60
+ let locked = false
61
+
62
+ // eslint-disable-next-line no-constant-condition
63
+ while (true) {
64
+ if (Date.now() - loopStart >= waitTimeout) {
65
+ console.error(`⚠️ ${key}: чекав ${waitTimeout / 60_000} хв — запускаю без локу`)
66
+ return await runFn()
67
+ }
68
+ try {
69
+ fs.mkdirSync(lockDir)
70
+ fs.writeFileSync(ownerFile, JSON.stringify({ pid: process.pid, host: os.hostname(), startedAt: Date.now(), fingerprint }))
71
+ locked = true
72
+ break
73
+ } catch (error) {
74
+ if (error.code !== 'EEXIST') throw error
75
+ let owner
76
+ try { owner = JSON.parse(fs.readFileSync(ownerFile, 'utf8')) } catch {
77
+ fs.rmSync(lockDir, { recursive: true, force: true })
78
+ continue
79
+ }
80
+ const stale = (Date.now() - owner.startedAt > staleThreshold) ||
81
+ (os.hostname() === owner.host && !isAlive(owner.pid))
82
+ if (stale) {
83
+ console.error(`🧹 ${key}: знайдено застарілий лок — очищаю`)
84
+ fs.rmSync(lockDir, { recursive: true, force: true })
85
+ continue
86
+ }
87
+ console.error(`⏳ ${key}: чекаю, лок тримає pid ${owner.pid}…`)
88
+ await sleep(pollInterval)
89
+ }
90
+ }
91
+
92
+ console.error(`🔒 ${key}: лок взято`)
93
+
94
+ try {
95
+ const raw = fs.readFileSync(resultFile, 'utf8')
96
+ const result = JSON.parse(raw)
97
+ if (shouldDedup(result, fingerprint, ttl)) {
98
+ const elapsed = Math.round((Date.now() - result.finishedAt) / 1000)
99
+ console.error(`♻️ ${key}: те саме дерево, ${elapsed}с тому — пропускаю`)
100
+ release()
101
+ return 0
102
+ }
103
+ } catch { /* result.json не існує або пошкоджений */ }
104
+
105
+ const onSignal = () => { release(); process.exit(130) }
106
+ process.once('SIGINT', onSignal)
107
+ process.once('SIGTERM', onSignal)
108
+
109
+ let code
110
+ try {
111
+ code = await runFn()
112
+ fs.writeFileSync(resultFile, JSON.stringify({ finishedAt: Date.now(), exitCode: code, fingerprint }))
113
+ } finally {
114
+ process.off('SIGINT', onSignal)
115
+ process.off('SIGTERM', onSignal)
116
+ release()
117
+ }
118
+
119
+ return code
120
+ }
@@ -11,7 +11,7 @@ import { dirname, join, relative } from 'node:path'
11
11
  const TRAILING_SLASH_RE = /\/$/
12
12
  const LEADING_DOTSLASH_RE = /^\.\//
13
13
 
14
- /** Glob-ігнор для workspace-патернів із `*` (узгоджено з `package-manifest.mjs`). */
14
+ /** Glob-ігнор для workspace-патернів із `*` (узгоджено з `rules/changelog/fix/consistency/package-manifest.mjs`). */
15
15
  export const WORKSPACE_GLOB_IGNORE = Object.freeze(['**/node_modules/**', '**/.git/**', '**/.venv/**', '**/venv/**'])
16
16
 
17
17
  /**
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Fingerprint поточного стану git-робочого дерева.
3
+ * Повертає sha256-hex (64 символи) або null, якщо не в git-репо.
4
+ * @param {typeof import('child_process').spawnSync} spawn
5
+ */
6
+ import { spawnSync } from 'node:child_process'
7
+ import { createHash } from 'node:crypto'
8
+
9
+ export function worktreeFingerprint(spawn = spawnSync) {
10
+ /** @param {string[]} args */
11
+ function git(args) {
12
+ const r = spawn('git', args, { encoding: 'utf8' })
13
+ if (r.status !== 0 || r.error) throw new Error(`git ${args[0]} failed`)
14
+ return r.stdout
15
+ }
16
+
17
+ try {
18
+ const commitHash = git(['rev-parse', 'HEAD']).trim()
19
+ const diffText = git(['diff', 'HEAD'])
20
+ const untrackedRaw = git(['ls-files', '--others', '--exclude-standard'])
21
+ const untrackedFiles = untrackedRaw.split('\n').filter(Boolean)
22
+ const pairs = untrackedFiles
23
+ .map(f => `${f}:${git(['hash-object', f]).trim()}`)
24
+ .sort()
25
+ const raw = [commitHash, diffText, ...pairs].join('\n')
26
+ return createHash('sha256').update(raw).digest('hex')
27
+ } catch {
28
+ return null
29
+ }
30
+ }