@nitra/cursor 1.9.4 → 1.9.5

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.
@@ -21,9 +21,17 @@ npx @nitra/cursor check changelog
21
21
  npx @nitra/cursor check npm-module
22
22
  ```
23
23
 
24
- ## Перш ніж писати `check-*.mjs`
24
+ ## Перш ніж писати / розширювати `check-*.mjs`
25
25
 
26
- Перед створенням нового `npm/scripts/check-<rule>.mjs` оціни, чи задача лягає на rego-полісі. **Default Rego**: пер-документні структурні перевірки (kind/apiVersion, поля, форма масивів) пишуться у `npm/policy/<rule>/<name>/<name>.rego` + `_test.rego`. JS тільки для cross-file resolution, file-system access (`readdir`/`stat`), autofix/rewrite або парсингу до YAML-body. Гібрид (rego як швидкий gate + JS-оркестратор для cross-file) — нормальний патерн; референс — `npm/policy/k8s/*` ↔ `npm/scripts/check-k8s.mjs`. Деталі алгоритму рішення — `.cursor/rules/conftest.mdc` (alwaysApply).
26
+ **STOP спершу пройди алгоритм Rego-first** (`.cursor/rules/conftest.mdc`, alwaysApply). Це стосується **і нової** перевірки, **і додавання нового deny у вже існуючий** `check-<rule>.mjs`: подивись `npm/policy/<rule>/`, чи задача не лягає у вже існуючий rego-пакет як ще одне `deny contains`.
27
+
28
+ Швидкий self-check для нової перевірки (порядок важливий):
29
+
30
+ 1. **Це пер-документна перевірка одного JSON/YAML?** (наявність / форма поля, regex по значенню, перелік дозволених літералів). → **Rego, без JS-коду.** Пиши у `npm/policy/<rule>/<name>/<name>.rego` + `<name>_test.rego`.
31
+ 2. Потрібен `readdir`, `stat`, парність файлів, AST-парсинг JS/TS, autofix, modeline до YAML-body? → **JS** у `check-<rule>.mjs`. Per-document частина (якщо є) усе одно лишається у rego — JS викликає її через `runConftestBatch`.
32
+ 3. Не впевнений? Подивись референс **`npm/policy/k8s/*`** ↔ **`npm/scripts/check-k8s.mjs`** (Plan B: Rego-authoritative + JS-orchestrator) і список «що Rego об'єктивно не вміє» у `conftest.mdc`.
33
+
34
+ **Червоний прапор:** дописуєш `if (pkg.<field>) fail(…)` у JS — майже завжди це варто було робити як `deny contains msg if { … }` у відповідному rego-пакеті. Перевір `npm/policy/<rule>/` **перед** редагуванням `check-<rule>.mjs`.
27
35
 
28
36
  ## Джерело правил
29
37
 
package/CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@
4
4
 
5
5
  Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
6
6
 
7
+ ## [1.9.5] - 2026-05-12
8
+
9
+ ### Changed
10
+
11
+ - **npm-module — компактний пакет: whitelist `files`, без `devDependencies`, тести/фікстури поза опублікованим деревом:** правило `npm-module.mdc` тепер вимагає максимально компактний tarball. (1) Поле `"files"` у `npm/package.json` обовʼязкове як whitelist (без нього npm пакує майже все). (2) `npm/package.json` не повинен містити `devDependencies` — інструментарій для розробки тримаємо у кореневому `package.json` монорепо, щоб `npm install @nitra/<pkg>` не тягнув його кінцевим користувачам. (3) Тести й фікстури не повинні потрапляти у tarball: канонічне місце — `npm/tests/` (не додається до `"files"`); це стосується і test-style каталогів (`tests/`, `__tests__/`, `fixtures/`, `__fixtures__/`, `spec/`, `test/`), і файлів за патернами `*.test.*` / `*.spec.*`, і JS/TS-файлів з імпортами test-фреймворків (`bun:test`, `node:test`, `vitest`, `@jest/globals`, `mocha`, `jest`, `ava`, …). **Виняток — Rego (`*_test.rego`):** за конвенцією conftest юніт-тест лежить поруч з полісі у тому самому `package`, тож rego-тести дозволені всередині опублікованого `policy/`-каталогу і входять у tarball.
12
+ - **npm-module — пер-документні deny у rego (Rego-authoritative):** `npm/policy/npm_module/npm_package_json/npm_package_json.rego` розширено двома deny: (а) `"files"` як whitelist обовʼязковий (відсутній / не масив / порожній); (б) `"devDependencies"` мають бути відсутні або порожні. Додано `npm_package_json_test.rego` з happy-path + 7 негативних кейсів (`json.patch` фікстури). Покривається `bun run lint-rego` (`conftest verify`) і `bun run lint-conftest` (батч проти реального `npm/package.json`). Раніше я помилково реалізував ці перевірки у JS — це порушує `.cursor/rules/conftest.mdc` (Rego-default для пер-документних структурних перевірок). Тепер виправлено: JS-функцію `checkPackageCompactness` видалено з `check-npm-module.mjs` разом з виклик-сайтом.
13
+ - **npm-module — `check-npm-module.mjs` лишає лише FS/AST-частину:** функція `checkNoTestsInPublishedFiles` резолвить позитивні patterns поля `files`, віднімає негативні (підтримка `!…` glob з `*` / `**` / `?`), і для кожного файлу-кандидата ловить test-style ім'я каталога/файлу або імпорт тест-фреймворку через oxc-parser (`module.staticImports` + `require()` + динамічний `import()`). `*_test.rego` свідомо не входить у `TEST_FILE_PATTERNS` — дозволений виняток для conftest-конвенції (юніт-тест поруч з полісі у тому самому `package`).
14
+ - **npm/package.json — приведено до правила:** видалено секцію `devDependencies` (`@nitra/cursor` вже є у корені як `workspace:*`). `policy/**/*_test.rego` свідомо лишаються у tarball — як виняток для conftest-конвенції.
15
+ - **conftest.mdc + npm/.claude-template/npm-CLAUDE.md — гостріший Rego-first сигнал:** у `.cursor/rules/conftest.mdc` додано STOP-блок перед `Edit` будь-якого `check-<rule>.mjs` (стосується і нових перевірок, і розширення вже існуючих; типовий ляп — `if (pkg.<field>) fail(…)` у JS замість ще одного `deny contains` у відповідному rego-пакеті). Перший пункт алгоритму уточнено прикладом «заборона/наявність ключа верхнього рівня типу `devDependencies` / `scripts.<name>`». У `npm-CLAUDE.md` секцію «Перш ніж писати `check-*.mjs`» переписано у self-check з 3 пунктів і червоним прапором. Регенеровано `npm/CLAUDE.md`.
16
+
7
17
  ## [1.9.4] - 2026-05-11
8
18
 
9
19
  ### Removed
@@ -1,11 +1,24 @@
1
1
  ---
2
2
  description: Оформлення репозиторію для npm модуля
3
3
  alwaysApply: true
4
- version: '1.10'
4
+ version: '1.11'
5
5
  ---
6
6
 
7
7
  Bun monorepo: workspace **`npm/`**, кореневий **`package.json`**, **`.github/workflows/`**; опційно **`demo/`**.
8
8
 
9
+ ## Компактний пакет
10
+
11
+ Мета — **максимально компактний** опублікований пакет: у npm потрапляє тільки те, що потрібно під час `require`/`import` користувачем.
12
+
13
+ - **`"files"` обовʼязковий** у `npm/package.json` як **whitelist** того, що публікується (без `"files"` npm пакує майже все — це антипатерн для цього правила).
14
+ - **Тести й фікстури — поза будь-яким шляхом з `"files"`.** Канонічне місце для всього тестового — `npm/tests/` (його **не** додаємо до `"files"`). Це стосується **і** фреймворк-тестів (`bun:test`, `node:test`, `vitest`, `@jest/globals`, `mocha`, …), **і** test-style каталогів (`tests/`, `__tests__/`, `fixtures/`, `__fixtures__/`, `spec/`), **і** файлів за патернами `*.test.*` / `*.spec.*`. **Виняток — Rego (`*_test.rego`):** за конвенцією conftest юніт-тест лежить поруч з полісі у тому самому `package`, тож rego-тести дозволені всередині опублікованого `policy/`-каталогу.
15
+ - **Лише runtime-залежності у `npm/package.json`.** `devDependencies` тримай у **кореневому** `package.json` монорепо — тоді `npm install @nitra/<pkg>` не тягне інструментарій, потрібний лише для розробки самого пакета.
16
+
17
+ Деталі алгоритму перевірки:
18
+
19
+ - Пер-документні правила для `npm/package.json` (whitelist `files`, заборона `devDependencies`, форма `types`) — у rego-пакеті `npm_module.npm_package_json` (`npm/policy/npm_module/npm_package_json/`).
20
+ - Скан опублікованого простору файлів на тест-патерни (walking + AST JS/TS) — у `check-npm-module.mjs::checkNoTestsInPublishedFiles` (FS / AST не лягають у rego).
21
+
9
22
  ## TypeScript declaration (`npm/types`)
10
23
 
11
24
  Файл **`npm/package.json`** має містити **`"types"`** (шлях до головного `.d.ts` або `.d.mts` під **`./types/…`**) і запис **`"types"`** у **`files`**, щоб npm публікував декларації.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.9.4",
3
+ "version": "1.9.5",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -45,9 +45,6 @@
45
45
  "oxc-parser": "^0.128.0",
46
46
  "yaml": "^2.8.3"
47
47
  },
48
- "devDependencies": {
49
- "@nitra/cursor": "^1.8.170"
50
- },
51
48
  "engines": {
52
49
  "bun": ">=1.3",
53
50
  "node": ">=25"
@@ -5,13 +5,18 @@
5
5
  # conftest test npm/package.json -p npm/policy/npm_module \
6
6
  # --namespace npm_module.npm_package_json
7
7
  #
8
- # Перевіряє: поле `types` має будь-який з двох канонічних патернів:
9
- # - `./types/index.d.ts` (layout `npm/src` з `.js`); або
10
- # - `./types/<…>.d.ts` чи `.d.mts` (layout `tsconfig.emit-types.json`).
8
+ # Перевіряє:
9
+ # - поле `types` має один із двох канонічних патернів: `./types/index.d.ts`
10
+ # (layout `npm/src` з `.js`) або `./types/<…>.d.ts`/`.d.mts` (emit-types);
11
+ # - масив `files` присутній, непорожній і містить `"types"` (whitelist
12
+ # обовʼязковий — без нього npm пакує майже все);
13
+ # - `devDependencies` відсутні або порожні: dev-інструментарій тримаємо у
14
+ # кореневому `package.json` монорепо, щоб `npm install @nitra/<pkg>` його
15
+ # не тягнув (npm-module.mdc: компактний пакет).
11
16
  #
12
- # Масив `files` має містити `"types"`. Те, який саме layout активний (зокрема
13
- # наявність `.js` під `npm/src`), а також існування файлу зі шляху `types` —
14
- # у JS-перевірці (`check-npm-module.mjs`).
17
+ # Те, який саме типовий layout активний (наявність `.js` під `npm/src`),
18
+ # існування файлу зі шляху `types` і скан тест-патернів у tarball у
19
+ # JS-перевірці (`check-npm-module.mjs`: cross-file / FS-access / AST).
15
20
  #
16
21
  # Структура каталогу збігається зі шляхом пакету (regal: directory-package-mismatch).
17
22
  # Конвенція проєкту — `import rego.v1` + multi-value `deny contains msg if { … }`
@@ -27,6 +32,12 @@ types_field_template := concat(" ", [
27
32
  "або \"./types/<…>.d.ts|.d.mts\" (зараз: %v) (npm-module.mdc)",
28
33
  ])
29
34
 
35
+ # Шаблон повідомлення про присутність `devDependencies`.
36
+ dev_deps_template := concat(" ", [
37
+ "npm/package.json: \"devDependencies\" не публікуються користувачам пакета —",
38
+ "перенеси у кореневий package.json: %v (npm-module.mdc: компактний пакет)",
39
+ ])
40
+
30
41
  # ── deny: types ────────────────────────────────────────────────────────────
31
42
 
32
43
  deny contains msg if {
@@ -35,19 +46,35 @@ deny contains msg if {
35
46
  msg := sprintf(types_field_template, [types_field])
36
47
  }
37
48
 
38
- # ── deny: files має містити "types" ───────────────────────────────────────
49
+ # ── deny: files має існувати, бути непорожнім, містити "types" ────────────
39
50
 
40
51
  deny contains msg if {
41
52
  not is_array(object.get(input, "files", null))
42
- msg := "npm/package.json: масив \"files\" відсутній має містити \"types\" (npm-module.mdc)"
53
+ msg := "npm/package.json: обовʼязковий whitelist \"files\" (без нього npm пакує майже все) (npm-module.mdc)"
54
+ }
55
+
56
+ deny contains msg if {
57
+ is_array(input.files)
58
+ count(input.files) == 0
59
+ msg := "npm/package.json: масив \"files\" не повинен бути порожнім (npm-module.mdc: компактний пакет)"
43
60
  }
44
61
 
45
62
  deny contains msg if {
46
63
  is_array(input.files)
64
+ count(input.files) > 0
47
65
  not "types" in {f | some f in input.files}
48
66
  msg := "npm/package.json: масив \"files\" має містити \"types\" (npm-module.mdc)"
49
67
  }
50
68
 
69
+ # ── deny: жодних devDependencies у npm/package.json ───────────────────────
70
+
71
+ deny contains msg if {
72
+ dev := object.get(input, "devDependencies", {})
73
+ count(dev) > 0
74
+ names := concat(", ", sort([n | some n, _ in dev]))
75
+ msg := sprintf(dev_deps_template, [names])
76
+ }
77
+
51
78
  # ── helpers ────────────────────────────────────────────────────────────────
52
79
 
53
80
  valid_types_field("./types/index.d.ts")
@@ -0,0 +1,81 @@
1
+ # Тести для `npm_module.npm_package_json`. Запуск:
2
+ # conftest verify -p npm/policy/npm_module/npm_package_json
3
+ package npm_module.npm_package_json_test
4
+
5
+ import rego.v1
6
+
7
+ import data.npm_module.npm_package_json
8
+
9
+ valid_pkg := {
10
+ "name": "@nitra/cursor",
11
+ "version": "1.9.5",
12
+ "types": "./types/bin/n-cursor.d.ts",
13
+ "files": [
14
+ "types",
15
+ "mdc",
16
+ "bin",
17
+ "CHANGELOG.md",
18
+ ],
19
+ "dependencies": {"oxc-parser": "^0.128.0"},
20
+ }
21
+
22
+ # ── happy path ────────────────────────────────────────────────────────────
23
+
24
+ test_allow_canonical if {
25
+ count(npm_package_json.deny) == 0 with input as valid_pkg
26
+ }
27
+
28
+ test_allow_types_index_d_ts if {
29
+ pkg := json.patch(valid_pkg, [{"op": "replace", "path": "/types", "value": "./types/index.d.ts"}])
30
+ count(npm_package_json.deny) == 0 with input as pkg
31
+ }
32
+
33
+ # ── types ─────────────────────────────────────────────────────────────────
34
+
35
+ test_deny_types_outside_types_dir if {
36
+ pkg := json.patch(valid_pkg, [{"op": "replace", "path": "/types", "value": "./dist/index.d.ts"}])
37
+ count(npm_package_json.deny) > 0 with input as pkg
38
+ }
39
+
40
+ test_deny_types_wrong_extension if {
41
+ pkg := json.patch(valid_pkg, [{"op": "replace", "path": "/types", "value": "./types/index.ts"}])
42
+ count(npm_package_json.deny) > 0 with input as pkg
43
+ }
44
+
45
+ # ── files ─────────────────────────────────────────────────────────────────
46
+
47
+ test_deny_missing_files if {
48
+ pkg := json.patch(valid_pkg, [{"op": "remove", "path": "/files"}])
49
+ count(npm_package_json.deny) > 0 with input as pkg
50
+ }
51
+
52
+ test_deny_files_not_array if {
53
+ pkg := json.patch(valid_pkg, [{"op": "replace", "path": "/files", "value": "types"}])
54
+ count(npm_package_json.deny) > 0 with input as pkg
55
+ }
56
+
57
+ test_deny_files_empty if {
58
+ pkg := json.patch(valid_pkg, [{"op": "replace", "path": "/files", "value": []}])
59
+ count(npm_package_json.deny) > 0 with input as pkg
60
+ }
61
+
62
+ test_deny_files_without_types if {
63
+ pkg := json.patch(valid_pkg, [{"op": "replace", "path": "/files", "value": ["bin", "mdc"]}])
64
+ count(npm_package_json.deny) > 0 with input as pkg
65
+ }
66
+
67
+ # ── devDependencies ──────────────────────────────────────────────────────
68
+
69
+ test_allow_no_dev_dependencies if {
70
+ count(npm_package_json.deny) == 0 with input as valid_pkg
71
+ }
72
+
73
+ test_allow_empty_dev_dependencies if {
74
+ pkg := json.patch(valid_pkg, [{"op": "add", "path": "/devDependencies", "value": {}}])
75
+ count(npm_package_json.deny) == 0 with input as pkg
76
+ }
77
+
78
+ test_deny_dev_dependencies_present if {
79
+ pkg := json.patch(valid_pkg, [{"op": "add", "path": "/devDependencies", "value": {"@nitra/cursor": "^1.9.5"}}])
80
+ count(npm_package_json.deny) > 0 with input as pkg
81
+ }
@@ -11,6 +11,15 @@
11
11
  *
12
12
  * Поля workflow перевіряються після **YAML parse**, щоб не плутати з коментарями.
13
13
  *
14
+ * Компактність опублікованого пакета (cross-file / FS / AST частина):
15
+ * - Пер-документні структурні deny для `npm/package.json` (`files` whitelist обовʼязковий,
16
+ * без `devDependencies`) — у rego-пакеті `npm_module.npm_package_json` (Rego-authoritative).
17
+ * - Тут лишається лише `checkNoTestsInPublishedFiles`: walk шляхів з `"files"` (з урахуванням
18
+ * негативних glob-патернів) і скан test-style каталогів (`tests/`, `__tests__/`, `fixtures/`,
19
+ * `__fixtures__/`, `spec/`, `test/`), імен файлів (`*.test.*` / `*.spec.*`) і AST-імпортів
20
+ * test-фреймворків (`bun:test`, `node:test`, `vitest`, `@jest/globals`, `mocha`, `jest`, `ava`, …).
21
+ * Виняток: `*_test.rego` дозволені поруч з полісі — це конвенція conftest.
22
+ *
14
23
  * Версія та CHANGELOG: перший заголовок `## [version]` у `npm/CHANGELOG.md` має збігатися з `version` у
15
24
  * `npm/package.json` (найсвіжіший реліз зверху). Якщо в git є незакомічені зміни під `npm/`, `version` у робочому
16
25
  * файлі має відрізнятися від `HEAD` — інакше типовий пропуск bump після правок у пакеті.
@@ -18,9 +27,17 @@
18
27
  import { execFile } from 'node:child_process'
19
28
  import { existsSync } from 'node:fs'
20
29
  import { readFile, stat } from 'node:fs/promises'
21
- import { join } from 'node:path'
30
+ import { join, sep } from 'node:path'
22
31
  import { promisify } from 'node:util'
23
32
 
33
+ import { parseSync } from 'oxc-parser'
34
+
35
+ import {
36
+ dynamicImportModule,
37
+ langFromPath,
38
+ requireCallModule,
39
+ walkAstWithAncestors
40
+ } from './utils/ast-scan-utils.mjs'
24
41
  import { createCheckReporter } from './utils/check-reporter.mjs'
25
42
  import { loadCursorIgnorePaths } from './utils/load-cursor-config.mjs'
26
43
  import { walkDir } from './utils/walkDir.mjs'
@@ -36,6 +53,33 @@ const PACKAGE_JSON_VERSION_RE = /"version":\s*"([^"]+)"/u
36
53
  /** Файл проєкту TypeScript для emit без каталогу `src` (див. npm-module.mdc) */
37
54
  const EMIT_TYPES_CONFIG = 'npm/tsconfig.emit-types.json'
38
55
 
56
+ /** Каталоги, які за конвенцією тримають тести / фікстури і не повинні публікуватися. */
57
+ const TEST_DIR_NAMES = new Set(['tests', '__tests__', 'fixtures', '__fixtures__', 'spec', 'test'])
58
+
59
+ /**
60
+ * Імена файлів за патернами test/spec (тільки basename, без path). Rego
61
+ * (`*_test.rego`) свідомо не входить: за конвенцією conftest юніт-тест лежить
62
+ * поруч з полісі у тому самому `package` — і це дозволений виняток усередині
63
+ * опублікованого `policy/`-каталогу (npm-module.mdc).
64
+ */
65
+ const TEST_FILE_PATTERNS = [/^.+\.(test|spec)\.[cm]?[jt]sx?$/iu]
66
+
67
+ /** Розширення, у яких ловимо імпорти test-фреймворків. */
68
+ const JS_LIKE_EXT_RE = /\.[cm]?[jt]sx?$/iu
69
+
70
+ /** Імпорти/require/dynamic-import, які видають test-файл. */
71
+ const TEST_FRAMEWORK_MODULES = new Set([
72
+ 'bun:test',
73
+ 'node:test',
74
+ 'vitest',
75
+ '@jest/globals',
76
+ 'jest',
77
+ 'mocha',
78
+ 'ava',
79
+ 'tap',
80
+ 'tape'
81
+ ])
82
+
39
83
  /**
40
84
  * Чи є під `npm/src` хоча б один `.js` (рекурсивно).
41
85
  * @param {string[]} [ignorePaths] абсолютні шляхи каталогів, повністю виключених з обходу
@@ -293,6 +337,162 @@ function checkPublishWorkflow(passFn, failFn) {
293
337
  }
294
338
  }
295
339
 
340
+ /**
341
+ * Перетворює glob-патерн (як у npm `files`) у анкоровану `RegExp`. Підтримує
342
+ * globstar (нуль або більше сегментів), `*` (символи без `/`) і `?` (один
343
+ * символ без `/`). Не підтримує brace-expansion і class `[…]` — у негативних
344
+ * патернах `files` цього достатньо для практичних випадків (приклад:
345
+ * negation з префіксом `!` і двома зірочками поряд з `_test.rego`).
346
+ * @param {string} glob posix-шлях у glob-нотації
347
+ * @returns {RegExp} регулярка, анкорована з обох боків
348
+ */
349
+ export function globToRegex(glob) {
350
+ const parts = glob.split('/')
351
+ const tokens = parts.map(p => {
352
+ if (p === '**') return '__GLOBSTAR__'
353
+ let out = ''
354
+ for (const c of p) {
355
+ if (c === '*') out += '[^/]*'
356
+ else if (c === '?') out += '[^/]'
357
+ else if ('.+^${}()|[]\\'.includes(c)) out += `\\${c}`
358
+ else out += c
359
+ }
360
+ return out
361
+ })
362
+ let re = tokens.join('/')
363
+ re = re.replace(/\/__GLOBSTAR__\//gu, '(?:/.*/|/)')
364
+ re = re.replace(/^__GLOBSTAR__\//u, '(?:.*/)?')
365
+ re = re.replace(/\/__GLOBSTAR__$/u, '(?:/.*)?')
366
+ re = re.replace(/__GLOBSTAR__/gu, '.*')
367
+ return new RegExp(`^${re}$`, 'u')
368
+ }
369
+
370
+ /**
371
+ * Збирає список файлів, що потраплять у tarball, виходячи з `files` у
372
+ * `npm/package.json`. Підтримує позитивні patterns (директорії або файли) і
373
+ * негативні (`!…`). Шляхи повертаються у posix-формі, відносно `npm/`.
374
+ * Не пробує дублювати всю логіку `npm pack` (license/readme/тощо) — тут лише
375
+ * простір імен `files`, бо саме його сканує check.
376
+ * @param {string[]} filesField значення поля `files`
377
+ * @returns {Promise<string[]>} відсортовані posix-шляхи без `npm/` префікса
378
+ */
379
+ async function collectPublishedFiles(filesField) {
380
+ const positives = filesField.filter(p => typeof p === 'string' && !p.startsWith('!'))
381
+ const negatives = filesField
382
+ .filter(p => typeof p === 'string' && p.startsWith('!'))
383
+ .map(p => globToRegex(p.slice(1)))
384
+ /** @type {Set<string>} */
385
+ const collected = new Set()
386
+ for (const entry of positives) {
387
+ const fullPath = join('npm', entry)
388
+ if (!existsSync(fullPath)) continue
389
+ const s = await stat(fullPath)
390
+ if (s.isFile()) {
391
+ collected.add(entry)
392
+ continue
393
+ }
394
+ if (!s.isDirectory()) continue
395
+ await walkDir(fullPath, p => {
396
+ const rel = p.slice('npm/'.length).split(sep).join('/')
397
+ collected.add(rel)
398
+ })
399
+ }
400
+ const filtered = [...collected].filter(rel => !negatives.some(re => re.test(rel)))
401
+ filtered.sort()
402
+ return filtered
403
+ }
404
+
405
+ /**
406
+ * Чи є у файлі імпорт/require/dynamic-import з модуля тест-фреймворку.
407
+ * Парсимо через oxc-parser (як `bunyan-imports`/`redis-imports`). При помилці
408
+ * парсингу повертаємо `null` — це не наш checker для синтаксису.
409
+ * @param {string} content повний текст файлу
410
+ * @param {string} virtualPath шлях для вибору `lang`
411
+ * @returns {string | null} модуль, через який видно тест, або `null`
412
+ */
413
+ export function findTestFrameworkImport(content, virtualPath) {
414
+ const lang = langFromPath(virtualPath)
415
+ let result
416
+ try {
417
+ result = parseSync(virtualPath, content, { lang, sourceType: 'module' })
418
+ } catch {
419
+ return null
420
+ }
421
+ if (result.errors?.length) return null
422
+ for (const imp of result.module?.staticImports ?? []) {
423
+ const mod = imp.moduleRequest?.value
424
+ if (typeof mod === 'string' && TEST_FRAMEWORK_MODULES.has(mod)) return mod
425
+ }
426
+ /** @type {string | null} */
427
+ let found = null
428
+ walkAstWithAncestors(result.program, [], node => {
429
+ if (found) return
430
+ const reqMod = requireCallModule(node)
431
+ if (reqMod && TEST_FRAMEWORK_MODULES.has(reqMod)) {
432
+ found = reqMod
433
+ return
434
+ }
435
+ const dynMod = dynamicImportModule(node)
436
+ if (dynMod && TEST_FRAMEWORK_MODULES.has(dynMod)) {
437
+ found = dynMod
438
+ }
439
+ })
440
+ return found
441
+ }
442
+
443
+ /**
444
+ * Класифікує опублікований файл як test/fixture, якщо хоча б одна з ознак:
445
+ * (1) у шляху є каталог із `TEST_DIR_NAMES`; (2) basename відповідає
446
+ * `TEST_FILE_PATTERNS`; (3) для JS/TS-розширень — імпорт test-фреймворку.
447
+ * @param {string} relPath posix-шлях відносно `npm/`
448
+ * @returns {Promise<string | null>} причина порушення або `null`
449
+ */
450
+ export async function classifyPublishedFileAsTest(relPath) {
451
+ const segments = relPath.split('/')
452
+ const base = segments[segments.length - 1]
453
+ const dirs = segments.slice(0, -1)
454
+ const testDir = dirs.find(seg => TEST_DIR_NAMES.has(seg.toLowerCase()))
455
+ if (testDir) return `test-style каталог "${testDir}/"`
456
+ if (TEST_FILE_PATTERNS.some(re => re.test(base))) return `test-style ім'я файлу`
457
+ if (JS_LIKE_EXT_RE.test(base)) {
458
+ const content = await readFile(join('npm', relPath), 'utf8')
459
+ const mod = findTestFrameworkImport(content, relPath)
460
+ if (mod) return `імпорт test-фреймворку "${mod}"`
461
+ }
462
+ return null
463
+ }
464
+
465
+ /**
466
+ * Для всіх файлів, що потрапили б у tarball (positive `files` мінус negative
467
+ * patterns), забороняє test-style каталоги/імена/імпорти. Так пакет лишається
468
+ * компактним і не везе користувачам тести й фікстури.
469
+ * @param {(msg: string) => void} pass callback при успіху
470
+ * @param {(msg: string) => void} fail callback при порушенні
471
+ * @returns {Promise<void>}
472
+ */
473
+ async function checkNoTestsInPublishedFiles(pass, fail) {
474
+ if (!existsSync('npm/package.json')) return
475
+ const pkg = JSON.parse(await readFile('npm/package.json', 'utf8'))
476
+ if (!Array.isArray(pkg.files)) return
477
+ const files = await collectPublishedFiles(pkg.files)
478
+ /** @type {{ file: string, reason: string }[]} */
479
+ const violations = []
480
+ for (const rel of files) {
481
+ const reason = await classifyPublishedFileAsTest(rel)
482
+ if (reason) violations.push({ file: rel, reason })
483
+ }
484
+ if (violations.length === 0) {
485
+ pass(`npm/: усі ${files.length} опублікованих файли без тестів/фікстур`)
486
+ return
487
+ }
488
+ for (const v of violations) {
489
+ fail(
490
+ `npm/${v.file}: ${v.reason} — винеси за межі шляхів з "files" або додай негативний glob ` +
491
+ '(наприклад "!**/*_test.rego") у npm/package.json (npm-module.mdc)'
492
+ )
493
+ }
494
+ }
495
+
296
496
  /**
297
497
  * Перевіряє базову структуру монорепо: наявність каталогу `npm/` і
298
498
  * `npm/package.json`. Поле `workspaces ∋ "npm"` у кореневому `package.json`
@@ -334,6 +534,7 @@ export async function check() {
334
534
  const { pass, fail } = reporter
335
535
 
336
536
  await checkNpmModuleBasicStructure(pass, fail)
537
+ await checkNoTestsInPublishedFiles(pass, fail)
337
538
 
338
539
  const ignorePaths = await loadCursorIgnorePaths(process.cwd())
339
540
  const useSrcJsLayout = await npmSrcTreeHasJsFile(ignorePaths)