@nitra/cursor 1.13.2 → 1.13.8

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 (35) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/bin/n-cursor.js +4 -2
  3. package/package.json +4 -2
  4. package/rules/ga/fix/workflows/check.mjs +6 -109
  5. package/rules/ga/policy/package_json/package_json.rego +24 -0
  6. package/rules/ga/policy/package_json/target.json +8 -0
  7. package/rules/ga/policy/vscode_extensions/target.json +8 -0
  8. package/rules/ga/policy/vscode_extensions/vscode_extensions.rego +16 -0
  9. package/rules/ga/policy/vscode_settings/target.json +8 -0
  10. package/rules/ga/policy/vscode_settings/vscode_settings.rego +24 -0
  11. package/rules/ga/policy/zizmor_yml/target.json +8 -0
  12. package/rules/ga/policy/zizmor_yml/zizmor_yml.rego +17 -0
  13. package/rules/js-lint/fix/tooling/check.mjs +6 -83
  14. package/rules/js-lint/policy/jscpd/jscpd.rego +38 -0
  15. package/rules/js-lint/policy/jscpd/target.json +8 -0
  16. package/rules/js-lint/policy/vscode_extensions/target.json +8 -0
  17. package/rules/js-lint/policy/vscode_extensions/vscode_extensions.rego +25 -0
  18. package/rules/security/fix/gitleaks/check.mjs +8 -45
  19. package/rules/security/fix/gitleaks/template/.gitleaks.toml.snippet.toml +12 -0
  20. package/rules/security/policy/gitleaks/gitleaks.rego +17 -0
  21. package/rules/security/policy/gitleaks/target.json +8 -0
  22. package/rules/security/policy/package_json/package_json.rego +22 -59
  23. package/rules/security/policy/package_json/template/package.json.contains.json +1 -0
  24. package/rules/security/policy/package_json/template/package.json.deny.json +4 -0
  25. package/rules/security/policy/package_json/template/package.json.snippet.json +1 -0
  26. package/rules/security/security.mdc +7 -26
  27. package/rules/vue/fix/packages/check.mjs +7 -64
  28. package/rules/vue/policy/package_json/package_json.rego +45 -2
  29. package/rules/vue/vue.mdc +15 -2
  30. package/scripts/ensure-nitra-cursor-dev-dependencies.mjs +41 -21
  31. package/scripts/utils/check-mdc-template-refs.mjs +47 -0
  32. package/scripts/utils/inline-template-links.mjs +60 -0
  33. package/scripts/utils/run-conftest-batch.mjs +60 -33
  34. package/scripts/utils/run-rule.mjs +16 -1
  35. package/scripts/utils/template.mjs +215 -0
@@ -1,62 +1,25 @@
1
1
  /**
2
- * FS-частина правила `security` (security.mdc).
2
+ * FS-частина правила `security`.
3
3
  *
4
- * **Що тут лишилося** (FS / cross-file):
5
- * - наявність `package.json` у корені (структуру валідує Rego);
6
- * - наявність `.gitleaks.toml` у корені — нагадування створити з канону `security.mdc`;
7
- * - `.gitleaks.toml` має `useDefault = true` у блоці `[extend]` (інакше дефолтні правила
8
- * gitleaks перетираються і скан стає сліпим до 95% типових витоків).
4
+ * Перевіряє:
5
+ * - наявність `package.json` (структуру валідує Rego);
6
+ * - контекстне pass-повідомлення для JS-концерну.
9
7
  *
10
- * **Що покрила Rego** (`npx \@nitra/cursor check`, `npm/policy/security/package_json/`):
11
- * - `scripts.lint-security` існує і викликає `gitleaks` з `detect`/`git` subcommand;
12
- * - агрегований `scripts.lint` (якщо є) містить `bun run lint-security`;
13
- * - `gitleaks` НЕ у `dependencies` / `devDependencies` (бо це глобальний CLI).
8
+ * Наявність і вміст `.gitleaks.toml` (`[extend].useDefault = true`) тепер
9
+ * перевіряє policy `security.gitleaks`.
14
10
  */
15
11
  import { existsSync } from 'node:fs'
16
- import { readFile } from 'node:fs/promises'
17
12
 
18
13
  import { createCheckReporter } from '../../../../scripts/utils/check-reporter.mjs'
19
14
 
20
- const GITLEAKS_CONFIG = '.gitleaks.toml'
21
-
22
- /**
23
- * Перевіряє наявність `.gitleaks.toml` у корені та канонічну вимогу `useDefault = true`
24
- * у блоці `[extend]`. Користувач сам наповнює `[allowlist]` локальними патернами.
25
- * @param {(msg: string) => void} pass callback при успішній перевірці
26
- * @param {(msg: string) => void} fail callback при помилці
27
- * @returns {Promise<void>}
28
- */
29
- async function checkGitleaksConfig(pass, fail) {
30
- if (!existsSync(GITLEAKS_CONFIG)) {
31
- fail(`${GITLEAKS_CONFIG} не знайдено в корені — створи за каноном security.mdc (useDefault = true + [allowlist])`)
32
- return
33
- }
34
- const raw = await readFile(GITLEAKS_CONFIG, 'utf8')
35
- if (!/useDefault\s*=\s*true/u.test(raw)) {
36
- fail(
37
- `${GITLEAKS_CONFIG}: відсутнє \`useDefault = true\` у блоці [extend] — без нього вбудовані ` +
38
- 'gitleaks-правила перетираються і скан стає сліпим (security.mdc)'
39
- )
40
- return
41
- }
42
- pass(`${GITLEAKS_CONFIG} існує і успадковує дефолтні gitleaks-правила (useDefault = true)`)
43
- }
44
-
45
- /**
46
- * Запускає всі FS-перевірки правила security.
47
- * @returns {Promise<number>} 0 — все OK, 1 — є зауваження
48
- */
49
15
  export async function check() {
50
16
  const reporter = createCheckReporter()
51
17
  const { pass, fail } = reporter
52
-
53
18
  if (!existsSync('package.json')) {
54
19
  fail('package.json не знайдено в корені — додай (security.mdc)')
55
20
  return reporter.getExitCode()
56
21
  }
57
- pass('package.json є (структуру перевіряє npx @nitra/cursor check → security.package_json)')
58
-
59
- await checkGitleaksConfig(pass, fail)
60
-
22
+ pass('package.json є (структуру перевіряє Rego)')
23
+ pass('.gitleaks.toml перевіряє npx @nitra/cursor check → security.gitleaks')
61
24
  return reporter.getExitCode()
62
25
  }
@@ -0,0 +1,12 @@
1
+ [extend]
2
+ useDefault = true
3
+
4
+ [allowlist]
5
+ paths = [
6
+ '''(^|/)node_modules(/|$)''',
7
+ '''(^|/)\.git(/|$)''',
8
+ '''(^|/)dist(/|$)''',
9
+ '''(^|/)build(/|$)''',
10
+ '''.*\.lock$''',
11
+ '''.*fixtures?/.*'''
12
+ ]
@@ -0,0 +1,17 @@
1
+ # Перевірка `.gitleaks.toml` для security (security.mdc).
2
+ #
3
+ # Канонічна мінімальна вимога: `[extend].useDefault = true`, щоб локальний
4
+ # конфіг не вимикав стандартні правила gitleaks. Додаткові локальні правила
5
+ # дозволені.
6
+ #
7
+ # Структура каталогу збігається зі шляхом пакету (regal: directory-package-mismatch).
8
+ # Конвенція проєкту — `import rego.v1` + multi-value `deny contains msg if { … }`
9
+ # (.cursor/rules/conftest.mdc). Лінт — `bun run lint-rego` (regal).
10
+ package security.gitleaks
11
+
12
+ import rego.v1
13
+
14
+ deny contains msg if {
15
+ object.get(object.get(input, "extend", {}), "useDefault", null) != true
16
+ msg := ".gitleaks.toml: [extend].useDefault має бути true (security.mdc)"
17
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "$schema": "https://unpkg.com/@nitra/cursor/schemas/target.json",
3
+ "files": {
4
+ "single": ".gitleaks.toml",
5
+ "required": true
6
+ },
7
+ "missingMessage": ".gitleaks.toml не знайдено — створи за каноном security.mdc"
8
+ }
@@ -1,75 +1,38 @@
1
1
  # Перевірка `package.json` для правила security (security.mdc).
2
- #
3
- # Запуск (локально):
4
- # conftest test package.json -p npm/policy/security \
5
- # --namespace security.package_json
6
- #
7
- # Перевіряє: наявність `scripts.lint-security`, виклик `gitleaks detect`,
8
- # входження `bun run lint-security` у агрегований `scripts.lint` (якщо `lint` є),
9
- # та заборону `gitleaks` у dependencies/devDependencies (інструмент глобальний).
10
- #
11
- # FS-перевірки (наявність `.gitleaks.toml`, `useDefault = true`) — у JS.
12
- #
13
- # Структура каталогу збігається зі шляхом пакету (regal: directory-package-mismatch).
14
- # Конвенція проєкту — `import rego.v1` + multi-value `deny contains msg if { … }`
15
- # (.cursor/rules/conftest.mdc). Лінт — `bun run lint-rego` (regal).
2
+ # Канон надходить через --data: { "template": { "snippet": ..., "deny": ..., "contains": ... } }
3
+ # Структура --data сформована з template/<target>.{snippet,deny,contains}.json концерну.
16
4
  package security.package_json
17
5
 
18
6
  import rego.v1
19
7
 
20
- gitleaks_pkg := "gitleaks"
21
-
22
- dep_template := concat(" ", [
23
- "package.json: %q не повинен бути в %s —",
24
- "gitleaks встановлюється глобально (security.mdc)",
25
- ])
26
-
27
- # ── deny: scripts.lint-security ──────────────────────────────────────────
28
-
8
+ # ── deny: кожен snippet leaf має співпадати з input ──────────────────────────
29
9
  deny contains msg if {
30
- scripts := object.get(input, "scripts", {})
31
- not "lint-security" in object.keys(scripts)
32
- msg := "package.json: відсутній scripts.lint-security — додай `gitleaks detect --no-banner` (security.mdc)"
10
+ some script_name, expected in data.template.snippet.scripts
11
+ actual := object.get(object.get(input, "scripts", {}), script_name, "")
12
+ actual != expected
13
+ msg := sprintf("package.json: scripts.%s має бути %q (security.mdc)", [script_name, expected])
33
14
  }
34
15
 
16
+ # ── deny: жодного ключа з deny у dependencies/devDependencies ────────────────
35
17
  deny contains msg if {
36
- lint_security := object.get(object.get(input, "scripts", {}), "lint-security", "")
37
- lint_security != ""
38
- not contains(lint_security, "gitleaks")
39
- msg := "package.json: lint-security має викликати `gitleaks` (security.mdc)"
18
+ some pkg, reason in data.template.deny.dependencies
19
+ pkg in object.keys(object.get(input, "dependencies", {}))
20
+ msg := sprintf("package.json: dependencies.%s — %s (security.mdc)", [pkg, reason])
40
21
  }
41
22
 
42
23
  deny contains msg if {
43
- lint_security := object.get(object.get(input, "scripts", {}), "lint-security", "")
44
- contains(lint_security, "gitleaks")
45
- not has_detect_or_git_subcommand(lint_security)
46
- msg := "package.json: lint-security має містити `detect` або `git` як gitleaks-subcommand (security.mdc)"
24
+ some pkg, reason in data.template.deny.devDependencies
25
+ pkg in object.keys(object.get(input, "devDependencies", {}))
26
+ msg := sprintf("package.json: devDependencies.%s — %s (security.mdc)", [pkg, reason])
47
27
  }
48
28
 
49
- # ── deny: агрегований `lint` має кликати `bun run lint-security` ─────────
50
-
29
+ # ── deny: рядкові поля з contains мають містити кожен substring ──────────────
30
+ # Перевіряємо лише наявні поля (якщо `scripts.<name>` відсутній — поле опціональне).
51
31
  deny contains msg if {
52
- "lint-security" in object.keys(object.get(input, "scripts", {}))
53
- lint := object.get(object.get(input, "scripts", {}), "lint", "")
54
- lint != ""
55
- not contains(lint, "bun run lint-security")
56
- msg := "package.json: агрегований `lint` має містити `bun run lint-security` (security.mdc)"
32
+ some script_name, needles in data.template.contains.scripts
33
+ actual := object.get(object.get(input, "scripts", {}), script_name, "")
34
+ actual != ""
35
+ some needle in needles
36
+ not contains(actual, needle)
37
+ msg := sprintf("package.json: scripts.%s має містити %q (security.mdc)", [script_name, needle])
57
38
  }
58
-
59
- # ── deny: `gitleaks` НЕ в dependencies/devDependencies ───────────────────
60
-
61
- deny contains msg if {
62
- gitleaks_pkg in object.keys(object.get(input, "dependencies", {}))
63
- msg := sprintf(dep_template, [gitleaks_pkg, "dependencies"])
64
- }
65
-
66
- deny contains msg if {
67
- gitleaks_pkg in object.keys(object.get(input, "devDependencies", {}))
68
- msg := sprintf(dep_template, [gitleaks_pkg, "devDependencies"])
69
- }
70
-
71
- # ── helpers ──────────────────────────────────────────────────────────────
72
-
73
- # Чи містить рядок subcommand `detect` або `git` (як слово, не як підрядок випадкового шляху).
74
- # `gitleaks detect ...`, `gitleaks git --no-banner`, `gitleaks detect --source=.` — усі OK.
75
- has_detect_or_git_subcommand(s) if regex.match(`\bgitleaks\s+(detect|git)\b`, s)
@@ -0,0 +1 @@
1
+ { "scripts": { "lint": ["bun run lint-security"] } }
@@ -0,0 +1,4 @@
1
+ {
2
+ "dependencies": { "gitleaks": "глобальний CLI — не додавай у dependencies" },
3
+ "devDependencies": { "gitleaks": "глобальний CLI — не додавай у devDependencies" }
4
+ }
@@ -0,0 +1 @@
1
+ { "scripts": { "lint-security": "gitleaks detect --no-banner" } }
@@ -9,14 +9,11 @@ version: '1.1'
9
9
 
10
10
  ## Канон `package.json#scripts`
11
11
 
12
- ```json title="package.json"
13
- {
14
- "scripts": {
15
- "lint-security": "gitleaks detect --no-banner",
16
- "lint": "bun run lint-rego && bun run lint-js && bun run lint-text && bun run lint-security && oxfmt ."
17
- }
18
- }
19
- ```
12
+ - `lint-security` скрипт: [package.json.snippet.json](./policy/package_json/template/package.json.snippet.json)
13
+ - `lint` агрегатор повинен містити: [package.json.contains.json](./policy/package_json/template/package.json.contains.json)
14
+ - Заборонено `gitleaks` у `dependencies`/`devDependencies`: [package.json.deny.json](./policy/package_json/template/package.json.deny.json)
15
+
16
+ **Зауваження:**
20
17
 
21
18
  - `gitleaks detect` — сканує робоче дерево (uncommitted + tracked); швидше і безпечніше для частого `bun run lint`, ніж `gitleaks git`.
22
19
  - `--no-banner` — прибирає ASCII-арт (CI-friendly).
@@ -24,25 +21,9 @@ version: '1.1'
24
21
 
25
22
  ## `.gitleaks.toml` (рекомендована основа)
26
23
 
27
- ```toml title=".gitleaks.toml"
28
- title = "Project gitleaks config"
29
-
30
- [extend]
31
- useDefault = true
32
-
33
- [allowlist]
34
- description = "Файли й шляхи, які навмисно містять test-фікстури з паттернами секретів."
35
- paths = [
36
- '''(^|/)node_modules(/|$)''',
37
- '''(^|/)\.git(/|$)''',
38
- '''(^|/)dist(/|$)''',
39
- '''(^|/)build(/|$)''',
40
- '''.*\.lock$''',
41
- '''.*fixtures?/.*'''
42
- ]
43
- ```
24
+ Канон (допускає розширення в `[allowlist].paths`): [.gitleaks.toml.snippet.toml](./fix/gitleaks/template/.gitleaks.toml.snippet.toml)
44
25
 
45
- `useDefault = true` — НЕ перетирай дефолтний `rules`-масив; реальні винятки лише через `[allowlist]`.
26
+ **Важливо:** `useDefault = true` — НЕ перетирай дефолтний `rules`-масив; реальні винятки лише через `[allowlist]`.
46
27
 
47
28
  ## GitHub Actions (опційно)
48
29
 
@@ -1,8 +1,12 @@
1
1
  /**
2
2
  * Знаходить пакети з `vue` у dependencies і перевіряє їх за правилом vue.mdc.
3
3
  *
4
- * Версії Vite та плагінів, vue-macros, auto-import, layouts, вміст `vite.config`;
5
- * у репозиторії — рекомендацію розширення Vue.volar.
4
+ * Вміст `vite.config`, editor-файли для Vite client types, у репозиторії —
5
+ * рекомендацію розширення Vue.volar.
6
+ *
7
+ * Залежності Vue/Vite (`vite >= 8`, `@vitejs/plugin-vue`, `vue-macros`,
8
+ * `unplugin-auto-import`, `vite-plugin-vue-layouts-next`, заборона `esbuild`) —
9
+ * у policy `vue.package_json`.
6
10
  *
7
11
  * У кожному Vue+Vite-пакеті очікується `src/vite-env.d.ts` з `/// <reference types="vite/client" />`
8
12
  * та `jsconfig.json` у корені пакета (типи для імпортів асетів у `.vue`).
@@ -183,30 +187,6 @@ function ukFilesCountPhrase(n) {
183
187
  return `${n} файлів`
184
188
  }
185
189
 
186
- /**
187
- * Перевіряє наявність залежності в об'єкті deps.
188
- * @param {Record<string,string>} deps об'єкт залежностей
189
- * @param {string} name ім'я пакета
190
- * @param {string} prefix префікс повідомлення
191
- * @param {(msg: string) => void} passFn callback при успішній перевірці
192
- * @param {(msg: string) => void} fail callback при помилці
193
- * @param {string} hint підказка при відсутності
194
- */
195
- function checkRequiredDep(deps, name, prefix, passFn, fail, hint = `${name} відсутній`) {
196
- if (deps[name]) {
197
- passFn(`${prefix}${name}: ${deps[name]}`)
198
- } else {
199
- fail(`${prefix}${hint}`)
200
- }
201
- }
202
-
203
- /**
204
- * Перевіряє версію vite у devDependencies.
205
- * @param {Record<string,string>} devDeps devDependencies з package.json
206
- * @param {string} prefix параметр prefix
207
- * @param {(msg: string) => void} passFn callback при успішній перевірці
208
- * @param {(msg: string) => void} fail callback при помилці
209
- */
210
190
  /**
211
191
  * Перевіряє `src/vite-env.d.ts` і наявність `jsconfig.json` для підтягування типів асетів Vite у IDE.
212
192
  * @param {string} rootDir відносний шлях до кореня пакета
@@ -433,44 +413,7 @@ async function checkVueImportViolations(rootDir, absPackageRoot, ignorePaths, ha
433
413
  */
434
414
  async function checkVuePackage(rootDir, ignorePaths, fail, passFn) {
435
415
  const prefix = `[${packageLabel(rootDir)}] `
436
- const pkg = JSON.parse(await readFile(join(rootDir, 'package.json'), 'utf8'))
437
- const deps = pkg.dependencies || {}
438
- const devDeps = pkg.devDependencies || {}
439
- const allDeps = { ...deps, ...devDeps }
440
-
441
- if (allDeps.esbuild) {
442
- fail(`${prefix}esbuild заборонено (знайдено: ${allDeps.esbuild}). Замінити на rolldown та прибрати esbuild.`)
443
- }
444
-
445
- checkRequiredDep(deps, 'vue', prefix, passFn, fail, 'vue відсутній в dependencies')
446
- // `vite >= 8` перенесено в Rego (`npm/policy/vue/package_json/`); запуск
447
- // через `npx @nitra/cursor check`. Залишені вимоги — present-/missing-deps
448
- // (vue-macros, unplugin-auto-import, тощо) — у самому JS-чекері.
449
- checkRequiredDep(
450
- devDeps,
451
- '@vitejs/plugin-vue',
452
- prefix,
453
- passFn,
454
- fail,
455
- '@vitejs/plugin-vue відсутній в devDependencies'
456
- )
457
- checkRequiredDep(allDeps, 'vue-macros', prefix, passFn, fail, 'vue-macros відсутній — bun add -d vue-macros')
458
- checkRequiredDep(
459
- allDeps,
460
- 'unplugin-auto-import',
461
- prefix,
462
- passFn,
463
- fail,
464
- 'unplugin-auto-import відсутній — bun add -d unplugin-auto-import'
465
- )
466
- checkRequiredDep(
467
- allDeps,
468
- 'vite-plugin-vue-layouts-next',
469
- prefix,
470
- passFn,
471
- fail,
472
- 'vite-plugin-vue-layouts-next відсутній — bun add -d vite-plugin-vue-layouts-next'
473
- )
416
+ passFn(`${prefix}package.json залежності перевіряє npx @nitra/cursor check → vue.package_json`)
474
417
 
475
418
  await checkViteClientEnvAndEditorConfig(rootDir, prefix, passFn, fail)
476
419
 
@@ -5,8 +5,9 @@
5
5
  # conftest test path/to/package.json -p npm/policy/vue \
6
6
  # --namespace vue.package_json
7
7
  #
8
- # Перевіряє: якщо в `dependencies` є `vue`, то у `devDependencies.vite` має бути
9
- # мажорна версія ≥ 8.
8
+ # Перевіряє: якщо в `dependencies` є `vue`, то потрібні канонічні Vue/Vite
9
+ # залежності, `devDependencies.vite` має бути мажорної версії ≥ 8, а `esbuild`
10
+ # у dependencies/devDependencies заборонений (міграція на rolldown).
10
11
  #
11
12
  # AST-сканування коду (заборона явних value-імпортів `from 'vue'`, заборона
12
13
  # Node-нативних модулів у `.vue` SFC, перевірка `vite.config` на
@@ -34,12 +35,54 @@ deny contains msg if {
34
35
  msg := sprintf("Vue-пакет: vite має бути >= 8 (зараз %q) (vue.mdc)", [vite_range])
35
36
  }
36
37
 
38
+ deny contains msg if {
39
+ uses_vue
40
+ not "@vitejs/plugin-vue" in dev_dependency_names
41
+ msg := "Vue-пакет: @vitejs/plugin-vue відсутній у devDependencies (vue.mdc)"
42
+ }
43
+
44
+ deny contains msg if {
45
+ uses_vue
46
+ not "vue-macros" in all_dependency_names
47
+ msg := "Vue-пакет: vue-macros відсутній (vue.mdc)"
48
+ }
49
+
50
+ deny contains msg if {
51
+ uses_vue
52
+ not "unplugin-auto-import" in all_dependency_names
53
+ msg := "Vue-пакет: unplugin-auto-import відсутній (vue.mdc)"
54
+ }
55
+
56
+ deny contains msg if {
57
+ uses_vue
58
+ not "vite-plugin-vue-layouts-next" in all_dependency_names
59
+ msg := "Vue-пакет: vite-plugin-vue-layouts-next відсутній (vue.mdc)"
60
+ }
61
+
62
+ deny contains msg if {
63
+ uses_vue
64
+ "esbuild" in all_dependency_names
65
+ msg := "Vue-пакет: esbuild заборонено — заміни на rolldown і прибери залежність (vue.mdc)"
66
+ }
67
+
37
68
  # ── helpers ────────────────────────────────────────────────────────────────
38
69
 
39
70
  uses_vue if {
40
71
  "vue" in object.keys(object.get(input, "dependencies", {}))
41
72
  }
42
73
 
74
+ dependency_names := {n | some n in object.keys(object.get(input, "dependencies", {}))}
75
+
76
+ dev_dependency_names := {n | some n in object.keys(object.get(input, "devDependencies", {}))}
77
+
78
+ all_dependency_names contains n if {
79
+ some n in dependency_names
80
+ }
81
+
82
+ all_dependency_names contains n if {
83
+ some n in dev_dependency_names
84
+ }
85
+
43
86
  vite_in_dev_dependencies if {
44
87
  "vite" in object.keys(object.get(input, "devDependencies", {}))
45
88
  }
package/rules/vue/vue.mdc CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  description: Vue
3
- version: '1.8'
3
+ version: '1.9'
4
4
  globs: "**/*.vue"
5
5
  alwaysApply: false
6
6
  ---
@@ -104,7 +104,20 @@ const additionalInstructions = `
104
104
 
105
105
  ### Тестування
106
106
 
107
- - **Unit / component:** **Vue Test Utils**; з **Vite** зручно **Vitest**.
107
+ - **Unit:** **Bun Test Runner** (`bun test`) використовуй його замість **Vitest**. API сумісне (`describe` / `it` / `expect` з `bun:test`), моки через `mock` і `mock.module`. У `package.json` тримай `"test": "bun test <шляхи>"`; не додавай `vitest`, `vitest.config.*` і пов’язані залежності.
108
+ - **Component / DOM:** **Vue Test Utils** + **Bun Test Runner** з **happy-dom** як DOM-середовищем. Підключення — через `@happy-dom/global-registrator` у preload-файлі та `bunfig.toml`:
109
+
110
+ ```toml title="bunfig.toml"
111
+ [test]
112
+ preload = ["./test/happy-dom.preload.js"]
113
+ ```
114
+
115
+ ```js title="test/happy-dom.preload.js"
116
+ import { GlobalRegistrator } from '@happy-dom/global-registrator'
117
+ GlobalRegistrator.register()
118
+ ```
119
+
120
+ `jsdom` не використовуй — happy-dom швидший і достатній для типових Vue-компонентних тестів.
108
121
  - **E2E:** **Playwright** змістовні сценарії користувацьких потоків.
109
122
 
110
123
  ### Інструменти (узгоджено з Vite і цим правилом)
@@ -1,10 +1,11 @@
1
1
  /**
2
- * Дописує `\@nitra/cursor` у `devDependencies` кореневого `package.json` проєкту, якщо пакет ще не
3
- * оголошено ні в `devDependencies`, ні в `dependencies`.
2
+ * Дописує `\@nitra/cursor` у `devDependencies` workspace-root `package.json` проєкту, якщо пакет ще
3
+ * не оголошено ні в `devDependencies`, ні в `dependencies`.
4
4
  *
5
5
  * Використовується CLI `n-cursor` при кожному запуску (`npx \@nitra/cursor`, зокрема `check`), щоб
6
6
  * команда `check` і скрипти з `node_modules/\@nitra/cursor/scripts/` були відтворювані після
7
- * `bun install` / `npm install`, а не лише з кешу npx.
7
+ * `bun install` / `npm install`, а не лише з кешу npx. Корінь визначається тільки за наявністю поля
8
+ * `workspaces` у `package.json` поруч із поточною директорією запуску.
8
9
  *
9
10
  * Версія діапазону: `^<version>` з поля `version` установленого пакету `\@nitra/cursor`.
10
11
  */
@@ -37,36 +38,55 @@ export async function readBundledPackageVersion() {
37
38
  }
38
39
 
39
40
  /**
40
- * Якщо в `root/package.json` немає `\@nitra/cursor` у `devDependencies` і `dependencies`, дописує
41
- * `devDependencies["\@nitra/cursor"]` зі значенням `^<bundledVersion>`.
42
- * @param {string} root абсолютний шлях кореня проєкту (зазвичай `process.cwd()`)
43
- * @param {{ bundledVersion?: string | null, silent?: boolean }} [options] `bundledVersion` — для тестів;
44
- * `silent` — не писати в консоль при успішному оновленні
45
- * @returns {Promise<boolean>} `true`, якщо `package.json` змінено на диску
41
+ * Читає JSON-обʼєкт із диска.
42
+ * @param {string} path шлях до JSON-файлу
43
+ * @returns {Promise<Record<string, unknown> | null>} обʼєкт або `null`, якщо файл нечитабельний
46
44
  */
47
- export async function ensureNitraCursorInRootDevDependencies(root, options = {}) {
48
- const pkgPath = join(root, 'package.json')
49
- if (!existsSync(pkgPath)) {
50
- return false
51
- }
52
-
45
+ async function readJsonObject(path) {
53
46
  let raw
54
47
  try {
55
- raw = await readFile(pkgPath, 'utf8')
48
+ raw = await readFile(path, 'utf8')
56
49
  } catch {
57
- return false
50
+ return null
58
51
  }
59
52
 
60
- let pkg
61
53
  try {
62
- pkg = JSON.parse(raw)
54
+ const value = JSON.parse(raw)
55
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null
63
56
  } catch {
64
- return false
57
+ return null
65
58
  }
59
+ }
66
60
 
67
- if (pkg === null || typeof pkg !== 'object' || Array.isArray(pkg)) {
61
+ /**
62
+ * Читає `package.json` поруч зі стартовою директорією, якщо це workspace-root.
63
+ * @param {string} startDir директорія, з якої запущено CLI
64
+ * @returns {Promise<{ path: string, pkg: Record<string, unknown> } | null>} workspace-root package або `null`
65
+ */
66
+ async function readAdjacentWorkspaceRootPackageJson(startDir) {
67
+ const pkgPath = join(startDir, 'package.json')
68
+ if (!existsSync(pkgPath)) {
69
+ return null
70
+ }
71
+
72
+ const pkg = await readJsonObject(pkgPath)
73
+ return pkg && Object.hasOwn(pkg, 'workspaces') ? { path: pkgPath, pkg } : null
74
+ }
75
+
76
+ /**
77
+ * Якщо у workspace-root `package.json` немає `\@nitra/cursor` у `devDependencies` і `dependencies`,
78
+ * дописує `devDependencies["\@nitra/cursor"]` зі значенням `^<bundledVersion>`.
79
+ * @param {string} root стартова директорія проєкту (зазвичай `process.cwd()`)
80
+ * @param {{ bundledVersion?: string | null, silent?: boolean }} [options] `bundledVersion` — для тестів;
81
+ * `silent` — не писати в консоль при успішному оновленні
82
+ * @returns {Promise<boolean>} `true`, якщо `package.json` змінено на диску
83
+ */
84
+ export async function ensureNitraCursorInRootDevDependencies(root, options = {}) {
85
+ const workspaceRoot = await readAdjacentWorkspaceRootPackageJson(root)
86
+ if (!workspaceRoot) {
68
87
  return false
69
88
  }
89
+ const { path: pkgPath, pkg } = workspaceRoot
70
90
 
71
91
  const devDeps = pkg.devDependencies
72
92
  const deps = pkg.dependencies
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Returns list of template/ files that are NOT referenced in <id>.mdc as
3
+ * markdown link targets. Paths returned are relative to ruleDir.
4
+ *
5
+ * @param {string} ruleDir absolute path to npm/rules/<id>/
6
+ * @param {string} ruleId basename (e.g. "security")
7
+ * @returns {Promise<string[]>}
8
+ */
9
+ import { existsSync } from 'node:fs'
10
+ import { readdir, readFile, stat } from 'node:fs/promises'
11
+ import { join, relative } from 'node:path'
12
+
13
+ async function walkTemplateDirs(ruleDir) {
14
+ const out = []
15
+ for (const kind of ['fix', 'policy']) {
16
+ const kindDir = join(ruleDir, kind)
17
+ if (!existsSync(kindDir)) continue
18
+ for (const concern of await readdir(kindDir)) {
19
+ const tpl = join(kindDir, concern, 'template')
20
+ if (!existsSync(tpl)) continue
21
+ if (!(await stat(tpl)).isDirectory()) continue
22
+ out.push(...(await collectFiles(tpl)))
23
+ }
24
+ }
25
+ return out.map(p => relative(ruleDir, p))
26
+ }
27
+
28
+ async function collectFiles(dir) {
29
+ const out = []
30
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
31
+ const full = join(dir, entry.name)
32
+ if (entry.isDirectory()) out.push(...(await collectFiles(full)))
33
+ else out.push(full)
34
+ }
35
+ return out
36
+ }
37
+
38
+ export async function findMissingMdcRefs(ruleDir, ruleId) {
39
+ const mdcPath = join(ruleDir, `${ruleId}.mdc`)
40
+ if (!existsSync(mdcPath)) return []
41
+ const mdc = await readFile(mdcPath, 'utf8')
42
+ const allFiles = await walkTemplateDirs(ruleDir)
43
+ return allFiles.filter(rel => {
44
+ // Match markdown link to ./<rel> or (<rel>) anywhere in the .mdc
45
+ return !mdc.includes(`./${rel}`) && !mdc.includes(`(${rel})`)
46
+ })
47
+ }