@nitra/cursor 5.2.1 → 5.3.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 +13 -0
  2. package/bin/n-cursor.js +72 -50
  3. package/lib/llm.mjs +60 -47
  4. package/lib/models.mjs +1 -1
  5. package/lib/omlx-trace.mjs +158 -0
  6. package/lib/omlx.mjs +49 -11
  7. package/package.json +1 -1
  8. package/rules/js-bun-db/js-bun-db.mdc +7 -7
  9. package/rules/js-lint/js-lint.mdc +14 -1
  10. package/rules/js-run/js-run.mdc +16 -16
  11. package/rules/k8s/js/manifests.mjs +144 -82
  12. package/rules/npm-module/js/header_doc_pointer.mjs +72 -27
  13. package/rules/npm-module/js/rule_meta.mjs +72 -36
  14. package/rules/npm-module/js/skill_meta.mjs +59 -35
  15. package/rules/style-lint/js/tooling.mjs +13 -4
  16. package/rules/style-lint/style-lint.mdc +1 -1
  17. package/rules/test/js/data/stryker_config/stryker.config.baseline.mjs +1 -1
  18. package/rules/test/js/data/stryker_config/stryker.config.vue.baseline.mjs +1 -1
  19. package/rules/test/js/stryker_config.mjs +33 -5
  20. package/rules/test/js/vitest-config-pool-forks.mjs +11 -7
  21. package/rules/test/test.mdc +9 -9
  22. package/rules/vue/vue.mdc +6 -6
  23. package/scripts/coverage-classify/index.mjs +5 -17
  24. package/scripts/coverage-classify/verdict-schema.mjs +1 -1
  25. package/scripts/lib/assert-project-root.mjs +1 -1
  26. package/scripts/lib/discover-check-rules-from-cursor.mjs +1 -1
  27. package/scripts/lib/rule-predicates.mjs +30 -18
  28. package/scripts/lib/run-rule-cli.mjs +1 -1
  29. package/scripts/lib/run-standard-rule.mjs +1 -1
  30. package/scripts/post-tool-use-fix.mjs +3 -3
  31. package/scripts/skills-cli.mjs +5 -5
  32. package/scripts/worktree-cli.mjs +5 -5
  33. package/skills/doc-files/js/docgen-extract.mjs +1 -1
  34. package/skills/doc-files/js/docgen-files-batch.mjs +65 -34
  35. package/skills/doc-files/js/docgen-gen.mjs +121 -36
  36. package/skills/doc-files/js/docgen-prompts.mjs +20 -5
  37. package/skills/fix/js/llm-worker.mjs +10 -22
  38. package/skills/fix/js/orchestrator.mjs +64 -35
  39. package/skills/fix/js/t0.mjs +44 -32
  40. package/skills/start-check/js/check.mjs +1 -1
@@ -5,6 +5,64 @@ import { join } from 'node:path'
5
5
  import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
6
6
  import { parseSkillAutoSpec, readSkillMetaRaw } from '../../../scripts/lib/skill-meta.mjs'
7
7
 
8
+ /**
9
+ * Перевіряє поля сирого meta.json одного скіла (без auto.md / відсутності файлу).
10
+ * @param {string} id ідентифікатор скіла
11
+ * @param {Record<string, unknown>} raw сирий meta.json
12
+ * @param {ReturnType<typeof createCheckReporter>} reporter репортер
13
+ * @returns {boolean} true, якщо всі поля валідні
14
+ */
15
+ function checkSkillFields(id, raw, reporter) {
16
+ let ok = true
17
+ if (typeof raw.worktree !== 'boolean') {
18
+ reporter.fail(`skills/${id}: meta.json.worktree має бути boolean`)
19
+ ok = false
20
+ }
21
+ if (raw.auto !== undefined && parseSkillAutoSpec(raw.auto) === null) {
22
+ reporter.fail(`skills/${id}: meta.json.auto нерозпізнане — очікується "завжди" або непорожній масив правил`)
23
+ ok = false
24
+ }
25
+ if (raw.requireRoot !== undefined && typeof raw.requireRoot !== 'boolean') {
26
+ reporter.fail(`skills/${id}: meta.json.requireRoot має бути boolean`)
27
+ ok = false
28
+ }
29
+ if (raw.worktree === true && raw.requireRoot === false) {
30
+ reporter.fail(
31
+ `skills/${id}: requireRoot:false суперечить worktree:true (worktree вже вимагає кореня — прибери поле)`
32
+ )
33
+ ok = false
34
+ }
35
+ return ok
36
+ }
37
+
38
+ /**
39
+ * Валідує meta.json одного скіла.
40
+ * @param {string} id ідентифікатор скіла
41
+ * @param {string} skillDir каталог скіла
42
+ * @param {ReturnType<typeof createCheckReporter>} reporter репортер
43
+ * @returns {void}
44
+ */
45
+ function checkSkill(id, skillDir, reporter) {
46
+ let skillOk = true
47
+
48
+ if (existsSync(join(skillDir, 'auto.md'))) {
49
+ reporter.fail(`skills/${id}: залишковий auto.md — видали (метадані тепер у meta.json)`)
50
+ skillOk = false
51
+ }
52
+
53
+ const raw = readSkillMetaRaw(skillDir)
54
+ if (!raw) {
55
+ reporter.fail(`skills/${id}: відсутній або невалідний meta.json (очікується {"auto"?, "worktree": bool})`)
56
+ return
57
+ }
58
+
59
+ if (!checkSkillFields(id, raw, reporter)) skillOk = false
60
+
61
+ if (skillOk) {
62
+ reporter.pass(`skills/${id}: meta.json валідний`)
63
+ }
64
+ }
65
+
8
66
  /**
9
67
  * Валідує всі `npm/skills/<id>/meta.json`.
10
68
  * @param {string} [cwd] корінь репозиторію
@@ -20,41 +78,7 @@ export function check(cwd = process.cwd()) {
20
78
 
21
79
  for (const entry of readdirSync(skillsDir, { withFileTypes: true })) {
22
80
  if (!entry.isDirectory() || entry.name.startsWith('.')) continue
23
- const id = entry.name
24
- const skillDir = join(skillsDir, id)
25
- let skillOk = true
26
-
27
- if (existsSync(join(skillDir, 'auto.md'))) {
28
- reporter.fail(`skills/${id}: залишковий auto.md — видали (метадані тепер у meta.json)`)
29
- skillOk = false
30
- }
31
-
32
- const raw = readSkillMetaRaw(skillDir)
33
- if (!raw) {
34
- reporter.fail(`skills/${id}: відсутній або невалідний meta.json (очікується {"auto"?, "worktree": bool})`)
35
- continue
36
- }
37
- if (typeof raw.worktree !== 'boolean') {
38
- reporter.fail(`skills/${id}: meta.json.worktree має бути boolean`)
39
- skillOk = false
40
- }
41
- if (raw.auto !== undefined && parseSkillAutoSpec(raw.auto) === null) {
42
- reporter.fail(`skills/${id}: meta.json.auto нерозпізнане — очікується "завжди" або непорожній масив правил`)
43
- skillOk = false
44
- }
45
- if (raw.requireRoot !== undefined && typeof raw.requireRoot !== 'boolean') {
46
- reporter.fail(`skills/${id}: meta.json.requireRoot має бути boolean`)
47
- skillOk = false
48
- }
49
- if (raw.worktree === true && raw.requireRoot === false) {
50
- reporter.fail(
51
- `skills/${id}: requireRoot:false суперечить worktree:true (worktree вже вимагає кореня — прибери поле)`
52
- )
53
- skillOk = false
54
- }
55
- if (skillOk) {
56
- reporter.pass(`skills/${id}: meta.json валідний`)
57
- }
81
+ checkSkill(entry.name, join(skillsDir, entry.name), reporter)
58
82
  }
59
83
 
60
84
  return Promise.resolve(reporter.getExitCode())
@@ -5,6 +5,18 @@ import { join } from 'node:path'
5
5
 
6
6
  import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
7
7
 
8
+ // Зовнішні файли конфігу stylelint, які підхоплює cosmiconfig. Канон нових
9
+ // JS-конфігів — `.mjs`/`.cjs` (js-lint.mdc), legacy `.js` лишається валідним.
10
+ const STYLELINT_CONFIG_FILES = [
11
+ '.stylelintrc.json',
12
+ '.stylelintrc.js',
13
+ '.stylelintrc.cjs',
14
+ '.stylelintrc.mjs',
15
+ 'stylelint.config.js',
16
+ 'stylelint.config.cjs',
17
+ 'stylelint.config.mjs'
18
+ ]
19
+
8
20
  /**
9
21
  * Альтернатива полю `stylelint` у `package.json` — зовнішній файл конфігу. Якщо
10
22
  * поля немає і файлу немає, фейлимося; якщо є хоч щось — пропускаємо. Поле
@@ -18,10 +30,7 @@ async function checkStylelintConfigPresence(reporter, cwd) {
18
30
  if (!existsSync(pkgPath)) return
19
31
  const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
20
32
  const hasField = pkg.stylelint && typeof pkg.stylelint === 'object'
21
- const hasExternalCfg =
22
- existsSync(join(cwd, '.stylelintrc.json')) ||
23
- existsSync(join(cwd, '.stylelintrc.js')) ||
24
- existsSync(join(cwd, 'stylelint.config.js'))
33
+ const hasExternalCfg = STYLELINT_CONFIG_FILES.some(name => existsSync(join(cwd, name)))
25
34
  if (hasField || hasExternalCfg) {
26
35
  pass('Конфіг stylelint є — у package.json або окремим файлом')
27
36
  } else {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  description: Правила стилів CSS та SCSS
3
- version: '1.4'
3
+ version: '1.5'
4
4
  globs: "**/*.{css,scss,vue}"
5
5
  alwaysApply: false
6
6
  ---
@@ -1,7 +1,7 @@
1
1
  /** @type {import('@stryker-mutator/core').PartialStrykerOptions} */
2
2
  export default {
3
3
  testRunner: 'vitest',
4
- vitest: { configFile: 'vitest.config.js' },
4
+ vitest: { configFile: 'vitest.config.mjs' },
5
5
  // perTest: Stryker запускає лише тести, що покривають мутовану лінію — головний приріст
6
6
  // швидкості проти command runner (де треба було б ганяти ввесь test-suite на кожен мутант).
7
7
  coverageAnalysis: 'perTest',
@@ -1,7 +1,7 @@
1
1
  /** @type {import('@stryker-mutator/core').PartialStrykerOptions} */
2
2
  export default {
3
3
  testRunner: 'vitest',
4
- vitest: { configFile: 'vitest.config.js' },
4
+ vitest: { configFile: 'vitest.config.mjs' },
5
5
  // perTest: Stryker запускає лише тести, що покривають мутовану лінію — головний приріст
6
6
  // швидкості проти command runner (де треба було б ганяти ввесь test-suite на кожен мутант).
7
7
  coverageAnalysis: 'perTest',
@@ -18,6 +18,24 @@ const STRYKER_VUE_PLUGIN_PATH = join(HERE, 'data', 'stryker_config', 'stryker-vu
18
18
  const STRYKER_VUE_PLUGIN_FILENAME = 'stryker-vue-macros-ignorer.mjs'
19
19
  const VITEST_BASELINE_PATH = join(HERE, 'data', 'vitest_config', 'vitest.config.baseline.js')
20
20
 
21
+ // Канонічна назва vitest-конфіга — `.mjs` (нові файли, js-lint.mdc); legacy
22
+ // `.js` лишається валідним. Перший знайдений виграє (.mjs пріоритетніший).
23
+ const VITEST_CONFIG_NAMES = ['vitest.config.mjs', 'vitest.config.js']
24
+ // Заміна literal `configFile` у скопійованому stryker-baseline на фактичне
25
+ // ім'я vitest-конфіга jsRoot-а (узгодження Stryker ↔ vitest).
26
+ const STRYKER_CONFIG_FILE_RE = /configFile: 'vitest\.config\.[cm]?js'/u
27
+
28
+ /**
29
+ * Визначає ім'я vitest-конфіга для jsRoot: існуючий `.mjs`/`.js` (якщо є),
30
+ * інакше дефолт `vitest.config.mjs` (нові файли — `.mjs`). Існуючий
31
+ * `vitest.config.js` лишається валідним (backward-compat), новий не плодиться.
32
+ * @param {string} jsRoot абсолютний шлях до workspace-каталогу
33
+ * @returns {string} ім'я vitest-конфіга
34
+ */
35
+ function resolveVitestConfigName(jsRoot) {
36
+ return VITEST_CONFIG_NAMES.find(name => existsSync(join(jsRoot, name))) ?? 'vitest.config.mjs'
37
+ }
38
+
21
39
  // Канонічні entries, які vue-варіант baseline тримає у `plugins`/`ignorers`.
22
40
  // Augment-крок (augmentVueStrykerConfig) дбає, щоб саме вони були присутні в
23
41
  // уже-існуючому `stryker.config.mjs` Vue-root-а. Нову property пишемо у
@@ -64,15 +82,20 @@ async function hasVueFiles(jsRoot) {
64
82
  * @param {string} cwd корінь проєкту (для relative-шляхів у логах)
65
83
  * @param {string} baselinePath абсолютний шлях до canonical baseline
66
84
  * @param {string} target абсолютний шлях, куди копіювати
67
- * @param {string} label зрозуміла для людини мітка ("stryker.config.mjs" / "vitest.config.js")
85
+ * @param {string} label зрозуміла для людини мітка ("stryker.config.mjs" / "vitest.config.mjs")
86
+ * @param {(content: string) => string} [transform] опційне перетворення тексту baseline перед записом
68
87
  * @returns {Promise<void>}
69
88
  */
70
- async function ensureBaselineFile(reporter, cwd, baselinePath, target, label) {
89
+ async function ensureBaselineFile(reporter, cwd, baselinePath, target, label, transform) {
71
90
  if (existsSync(target)) {
72
91
  reporter.pass(`${label} існує (${relative(cwd, target)})`)
73
92
  return
74
93
  }
75
- await copyFile(baselinePath, target)
94
+ if (transform) {
95
+ await writeFile(target, transform(await readFile(baselinePath, 'utf8')), 'utf8')
96
+ } else {
97
+ await copyFile(baselinePath, target)
98
+ }
76
99
  reporter.pass(`${label} створено з canonical baseline (${relative(cwd, target)}) (test.mdc)`)
77
100
  }
78
101
 
@@ -341,7 +364,12 @@ export async function check(cwd = process.cwd()) {
341
364
  // і саме тут augment закриває drift-hole.
342
365
  const wasMissing = !existsSync(strykerTarget)
343
366
  const strykerBaseline = isVueRoot ? STRYKER_VUE_BASELINE_PATH : STRYKER_BASELINE_PATH
344
- await ensureBaselineFile(reporter, cwd, strykerBaseline, strykerTarget, 'stryker.config.mjs')
367
+ // configFile у новоствореному baseline має вказувати на фактичний vitest-конфіг
368
+ // jsRoot-а (existing `.js`/`.mjs` або дефолтний `.mjs`).
369
+ const vitestName = resolveVitestConfigName(jsRoot)
370
+ await ensureBaselineFile(reporter, cwd, strykerBaseline, strykerTarget, 'stryker.config.mjs', content =>
371
+ content.replace(STRYKER_CONFIG_FILE_RE, `configFile: '${vitestName}'`)
372
+ )
345
373
  if (isVueRoot) {
346
374
  if (!wasMissing) {
347
375
  await augmentVueStrykerConfig(reporter, cwd, jsRoot)
@@ -354,7 +382,7 @@ export async function check(cwd = process.cwd()) {
354
382
  STRYKER_VUE_PLUGIN_FILENAME
355
383
  )
356
384
  }
357
- await ensureBaselineFile(reporter, cwd, VITEST_BASELINE_PATH, join(jsRoot, 'vitest.config.js'), 'vitest.config.js')
385
+ await ensureBaselineFile(reporter, cwd, VITEST_BASELINE_PATH, join(jsRoot, vitestName), vitestName)
358
386
  }
359
387
 
360
388
  // Гарантуємо що тест-артефакти (Stryker output, lcov HTML-звіт) ніколи не
@@ -8,8 +8,12 @@ import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
8
8
  /** Subтring-pattern: `pool: 'forks'` або `pool: "forks"` (з опційним whitespace). */
9
9
  const POOL_FORKS_RE = /pool\s*:\s*['"]forks['"]/u
10
10
 
11
+ // Канонічна назва — `.mjs` (нові файли, js-lint.mdc), але legacy `.js` лишається
12
+ // валідним. Перший знайдений виграє: `.mjs` пріоритетніший.
13
+ const VITEST_CONFIG_NAMES = ['vitest.config.mjs', 'vitest.config.js']
14
+
11
15
  /**
12
- * Перевіряє, що `vitest.config.js` (якщо існує) містить `pool: 'forks'`.
16
+ * Перевіряє, що `vitest.config.{mjs,js}` (якщо існує) містить `pool: 'forks'`.
13
17
  * @param {string} [cwdParam] корінь репозиторію
14
18
  * @returns {Promise<number>} 0 — OK або skip, 1 — config без `pool: 'forks'`
15
19
  */
@@ -17,18 +21,18 @@ export async function check(cwdParam = process.cwd()) {
17
21
  const reporter = createCheckReporter()
18
22
  const { pass, fail } = reporter
19
23
 
20
- const configPath = join(cwdParam, 'vitest.config.js')
21
- if (!existsSync(configPath)) {
22
- pass('vitest.config.js відсутній — pool-перевірку пропущено')
24
+ const configName = VITEST_CONFIG_NAMES.find(name => existsSync(join(cwdParam, name)))
25
+ if (!configName) {
26
+ pass('vitest.config.mjs/.js відсутній — pool-перевірку пропущено')
23
27
  return reporter.getExitCode()
24
28
  }
25
29
 
26
- const body = await readFile(configPath, 'utf8')
30
+ const body = await readFile(join(cwdParam, configName), 'utf8')
27
31
  if (POOL_FORKS_RE.test(body)) {
28
- pass("vitest.config.js містить pool: 'forks' (test.mdc)")
32
+ pass(`${configName} містить pool: 'forks' (test.mdc)`)
29
33
  } else {
30
34
  fail(
31
- "vitest.config.js має містити pool: 'forks' — defense-in-depth для race у process.cwd() між паралельними test files (test.mdc)"
35
+ `${configName} має містити pool: 'forks' — defense-in-depth для race у process.cwd() між паралельними test files (test.mdc)`
32
36
  )
33
37
  }
34
38
 
@@ -1,7 +1,7 @@
1
1
  ---
2
- description: JS-тести (*.test.mjs) живуть у tests/. Правило `test` керує stryker.config.mjs + vitest.config.js (якщо js-lint enabled) і .cargo/mutants.toml (якщо rust enabled).
3
- version: '2.7'
4
- globs: "**/{.n-cursor.json,package.json,Cargo.toml,stryker.config.mjs,vitest.config.js,.cargo/mutants.toml},**/*.test.mjs"
2
+ description: JS-тести (*.test.mjs) живуть у tests/. Правило `test` керує stryker.config.mjs + vitest.config.mjs (якщо js-lint enabled) і .cargo/mutants.toml (якщо rust enabled).
3
+ version: '2.8'
4
+ globs: "**/{.n-cursor.json,package.json,Cargo.toml,stryker.config.mjs,vitest.config.mjs,vitest.config.js,.cargo/mutants.toml},**/*.test.mjs"
5
5
  alwaysApply: false
6
6
  ---
7
7
 
@@ -71,15 +71,15 @@ Recursive globs ловлять файли всередині `tests/` так с
71
71
  - Усі FS-операції у тесті — через `join(dir, …)` і `writeJson(join(dir, …), …)` / `ensureDir(join(dir, …))` (хелпери валідують `isAbsolute`).
72
72
  - Усі child-процеси — `execFile(bin, args, { cwd: dir })`, `spawnSync(bin, args, { cwd: dir })`.
73
73
  - Concern-функції правил — `await check(dir)`, `await applies(dir)`, `await fix(dir)`; усі production функції приймають перший параметр `cwd = process.cwd()` (default зберігає CLI-сумісність).
74
- - `vitest.config.js` додатково ставить `pool: 'forks'` як defense-in-depth: навіть якщо хтось пропустить правило вище, fork-ізоляція не дасть race у production tree.
74
+ - `vitest.config.mjs` (або legacy `vitest.config.js`) додатково ставить `pool: 'forks'` як defense-in-depth: навіть якщо хтось пропустить правило вище, fork-ізоляція не дасть race у production tree.
75
75
 
76
76
  Це **обов'язково** і для тестів пакета `@nitra/cursor`, і для кожного проєкту-споживача. Триплет перевірок:
77
77
 
78
78
  - **`no-process-chdir`** (`rules/test/js/no-process-chdir.mjs`) — сканує `**/*.test.{js,mjs}` і падає з ❌ на будь-яке вживання `process.chdir(`.
79
79
  - **`no-relative-fs-path`** (`rules/test/js/no-relative-fs-path.mjs`) — AST-сканер (`oxc-parser`): знаходить виклики FS-функцій із `node:fs`/`node:fs/promises` (`writeFile`, `copyFile`, `mkdir`, `readFile`, `existsSync`, `rename`, `symlink`, `cp`, … включно з `*Sync`-варіантами та `writeJson`/`ensureDir`-хелперами), де path-аргумент — це **string literal** без префікса `/`, `\`, `file:`, `http(s):`, `data:`, чи Windows-disk-letter `C:\`. Виклики `copyFile`/`rename`/`symlink`/`link`/`cp` перевіряють обидва path-аргументи. Виклики з обчисленим path (`join(dir, …)`, змінна, template-literal з виразом) пропускаються. Виловив би інцидент v1.28.0 у `tests/check-rule-fixtures.test.mjs` (`copyFile(src, 'default.conf.template')` → файл у production tree).
80
- - **`vitest-config-pool-forks`** (`rules/test/js/vitest-config-pool-forks.mjs`) — substring-перевірка `pool: 'forks'` у `vitest.config.js`. Defense-in-depth.
80
+ - **`vitest-config-pool-forks`** (`rules/test/js/vitest-config-pool-forks.mjs`) — substring-перевірка `pool: 'forks'` у `vitest.config.mjs` (або legacy `vitest.config.js`; `.mjs` пріоритетніший). Defense-in-depth.
81
81
 
82
- Canonical `vitest.config.js` (для довідки — `pool: 'forks'` + `include` + `coverage`) — у `rules/test/js/data/vitest_config/vitest.config.baseline.js` (концерн `stryker_config` копіює його у кожен JS-root).
82
+ Canonical `vitest.config.mjs` (для довідки — `pool: 'forks'` + `include` + `coverage`) — у `rules/test/js/data/vitest_config/vitest.config.baseline.js` (концерн `stryker_config` копіює його у кожен JS-root; нові файли — `.mjs`, наявний `vitest.config.js` лишається валідним і не дублюється).
83
83
 
84
84
  ## Console mocking у тестах
85
85
 
@@ -144,7 +144,7 @@ test.skipIf(env.STRYKER_MUTATOR_WORKER)('узгоджені з поточним
144
144
 
145
145
  ## Налаштування mutation-testing
146
146
 
147
- Якщо у `.n-cursor.json#rules` присутнє правило `js-lint` — правило `test` створює canonical baseline `stryker.config.mjs` + `vitest.config.js` у **кожному** JS-root проєкту: у кожному workspace з власним `package.json` (або в корені для single-package). У monorepo з `workspaces: ['app', 'scripts']` отримаєте `app/stryker.config.mjs` + `app/vitest.config.js` і `scripts/stryker.config.mjs` + `scripts/vitest.config.js`.
147
+ Якщо у `.n-cursor.json#rules` присутнє правило `js-lint` — правило `test` створює canonical baseline `stryker.config.mjs` + `vitest.config.mjs` у **кожному** JS-root проєкту: у кожному workspace з власним `package.json` (або в корені для single-package). У monorepo з `workspaces: ['app', 'scripts']` отримаєте `app/stryker.config.mjs` + `app/vitest.config.mjs` і `scripts/stryker.config.mjs` + `scripts/vitest.config.mjs`. Якщо у JS-root уже лежить legacy `vitest.config.js` — він лишається валідним, новий `.mjs` поряд не створюється, а `vitest.configFile` у скопійованому `stryker.config.mjs` приводиться до фактичного імені.
148
148
 
149
149
  Канон Stryker config (Vitest runner + perTest): [stryker.config.baseline.mjs](./js/data/stryker_config/stryker.config.baseline.mjs)
150
150
 
@@ -160,7 +160,7 @@ JS-root без `.vue` отримує дефолтний baseline без `plugins
160
160
 
161
161
  ### Vitest baseline та `package.json#scripts`
162
162
 
163
- Поряд зі Stryker концерн `stryker_config` без дублювання копіює `vitest.config.js` (теж тільки якщо файлу немає). Canonical: [vitest.config.baseline.js](./js/data/vitest_config/vitest.config.baseline.js) — `environment: 'node'`, `coverage.provider: 'v8'` з lcov+text-summary репортами, `include: ['**/*.test.{js,mjs}', 'tests/**/*.test.{js,mjs}']` (підхоплює обидві розкладки — тести у `tests/`-піддиректоріях і top-level integration suites у `<root>/tests/`).
163
+ Поряд зі Stryker концерн `stryker_config` без дублювання копіює `vitest.config.mjs` (тільки якщо немає ні `.mjs`, ні legacy `.js`). Canonical: [vitest.config.baseline.js](./js/data/vitest_config/vitest.config.baseline.js) — `environment: 'node'`, `coverage.provider: 'v8'` з lcov+text-summary репортами, `include: ['**/*.test.{js,mjs}', 'tests/**/*.test.{js,mjs}']` (підхоплює обидві розкладки — тести у `tests/`-піддиректоріях і top-level integration suites у `<root>/tests/`).
164
164
 
165
165
  У `package.json#scripts` має бути `"test": "vitest run"` (canonical contains-substring `vitest` — допустимо `vitest run` та інші локальні розширення); опційно — `"test:watch": "vitest"`.
166
166
 
@@ -168,7 +168,7 @@ JS-root без `.vue` отримує дефолтний baseline без `plugins
168
168
 
169
169
  ### Frontend-варіант (Vue/Vite + happy-dom)
170
170
 
171
- Для проєктів зі своїм `vite.config.js` `vitest.config.js` має повторно використовувати vite-плагіни та aliases і перемкнути `environment` на `'happy-dom'` (або `'jsdom'`):
171
+ Для проєктів зі своїм `vite.config.js` `vitest.config.mjs` має повторно використовувати vite-плагіни та aliases і перемкнути `environment` на `'happy-dom'` (або `'jsdom'`):
172
172
 
173
173
  ```js
174
174
  import { defineConfig, mergeConfig } from 'vitest/config'
package/rules/vue/vue.mdc CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  description: Vue
3
- version: '2.1'
3
+ version: '2.2'
4
4
  globs: "**/*.vue"
5
5
  alwaysApply: false
6
6
  ---
@@ -44,14 +44,14 @@ const folderStructure = `
44
44
  assets/
45
45
  public/
46
46
  App.vue
47
- main.js
47
+ main.mjs
48
48
  `
49
49
  ```
50
50
 
51
51
  ### Найменування файлів
52
52
 
53
53
  - **SFC:** імена файлів компонентів у **PascalCase** починаючи з букви N(`NMyWidget.vue`).
54
- - **Інші JS-модулі:** узгоджено **kebab-case** (`date-utils.js`).
54
+ - **Інші JS-модулі:** узгоджено **kebab-case** (`date-utils.mjs`).
55
55
 
56
56
  ### Модулі та архітектура
57
57
 
@@ -116,9 +116,9 @@ const additionalInstructions = `
116
116
 
117
117
  ### Тестування
118
118
 
119
- - **Unit + Component / DOM:** **Vitest** (`vitest`) + **Vue Test Utils** з **happy-dom** як DOM-середовищем. Це канон, узгоджений з `test.mdc` (Stryker з vitest-runner + `perTest`-аналіз покриття). `vitest.config.js` повторно використовує `vite.config.js` через `mergeConfig` і перемикає `environment` на `'happy-dom'`:
119
+ - **Unit + Component / DOM:** **Vitest** (`vitest`) + **Vue Test Utils** з **happy-dom** як DOM-середовищем. Це канон, узгоджений з `test.mdc` (Stryker з vitest-runner + `perTest`-аналіз покриття). `vitest.config.mjs` повторно використовує `vite.config.js` через `mergeConfig` і перемикає `environment` на `'happy-dom'`:
120
120
 
121
- ```js title="vitest.config.js"
121
+ ```js title="vitest.config.mjs"
122
122
  import { defineConfig, mergeConfig } from 'vitest/config'
123
123
  import viteConfig from './vite.config.js'
124
124
 
@@ -590,7 +590,7 @@ function getTr() {
590
590
  Називай store за назвою сторінки або компонента — `customerPageStore`, `routePageStore` тощо. На сторінці звертайся до нього через змінну `pageStore`.
591
591
 
592
592
  ```javascript
593
- // store/customerPage.js
593
+ // store/customerPage.mjs
594
594
  export const useCustomerPageStore = defineStore('customerPage', {
595
595
  state: () => ({
596
596
  filterName: '',
@@ -11,11 +11,10 @@
11
11
  * решта → pi CLI. Якщо omlx-Tier 1 недоступний, помилка падає в той самий catch
12
12
  * і класифікація відкочується на хмарний Tier 2 через pi.
13
13
  */
14
- import { spawnSync } from 'node:child_process'
15
14
  import { join } from 'node:path'
16
15
 
17
16
  import { CLOUD_MIN, resolveModel } from '../../lib/models.mjs'
18
- import { callOmlx, isOmlxModel } from '../../lib/omlx.mjs'
17
+ import { callLlm } from '../../lib/llm.mjs'
19
18
  import { deriveCacheKey, readCache, writeCache } from './cache.mjs'
20
19
  import { buildUserPrompt, SYSTEM_PROMPT } from './prompt.mjs'
21
20
  import { parseVerdict } from './verdict-schema.mjs'
@@ -27,25 +26,14 @@ const FALLBACK_VERDICT = {
27
26
  }
28
27
 
29
28
  /**
30
- * Викликає LLM за model-id і повертає raw текст відповіді.
31
- * `omlx/...` → прямий HTTP до omlx (text-only); решта → pi CLI.
29
+ * Викликає LLM через спільний `callLlm` (маршрут за префіксом model-id; wire-trace).
32
30
  * @param {string} prompt текст промпта
33
31
  * @param {string} model provider/model-id, `omlx/...` або '' для pi-дефолту
34
32
  * @returns {string} текст відповіді моделі
35
- * @throws якщо backend недоступний або повертає помилку
33
+ * @throws {Error} якщо backend недоступний або повертає помилку
36
34
  */
37
35
  function callModel(prompt, model) {
38
- if (isOmlxModel(model)) {
39
- return callOmlx([{ role: 'user', content: prompt }], model, { timeoutMs: 60_000 })
40
- }
41
- const modelArgs = model ? ['--model', model] : []
42
- const r = spawnSync('pi', ['-p', prompt, ...modelArgs, '--no-session', '--mode', 'text', '--no-tools'], {
43
- encoding: 'utf8',
44
- timeout: 60_000
45
- })
46
- if (r.error) throw new Error(`pi error: ${r.error.message}`)
47
- if (r.status !== 0) throw new Error(`pi exit ${r.status}: ${r.stderr?.slice(0, 200) ?? ''}`)
48
- return r.stdout?.trim() ?? ''
36
+ return callLlm([{ role: 'user', content: prompt }], model, { timeoutMs: 60_000, caller: 'coverage' })
49
37
  }
50
38
 
51
39
  /**
@@ -80,7 +68,7 @@ function classifyOne(group, mutant, cwd, callModelFn) {
80
68
  * Класифікує survived мутантів (resolveModel('min') → CLOUD_MIN → fallback).
81
69
  * @param {Array<{file: string, mutants: object[], exampleTest?: object|null, recommendationText?: string|null}>} survived список вцілілих мутантів
82
70
  * @param {string} cwd корінь проєкту
83
- * @param {{cachePath?: string, callModel?: Function}} [opts] ін'єкції для тестів
71
+ * @param {{cachePath?: string, callModel?: (prompt: string, model: string) => string}} [opts] ін'єкції для тестів
84
72
  * @returns {Promise<Array<{key: string, verdict: object}>>} verdicts
85
73
  */
86
74
  export function classify(survived, cwd, opts = {}) {
@@ -22,7 +22,7 @@ export const VerdictSchema = z.object({
22
22
  * Витягує JSON-об'єкт з raw-text LLM-відповіді і валідує через VerdictSchema.
23
23
  * @param {string} rawText raw-text відповідь LLM
24
24
  * @returns {{verdict: string, confidence: number, reason: string, suggestedTest?: string}} verdict
25
- * @throws якщо JSON не знайдено, не парситься, або не відповідає схемі
25
+ * @throws {Error} якщо JSON не знайдено, не парситься, або не відповідає схемі
26
26
  */
27
27
  export function parseVerdict(rawText) {
28
28
  const jsonStart = rawText.indexOf('{')
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Guard для дефолтної синхронізації `npx @nitra/cursor` (гілка без підкоманди).
2
+ * Guard для дефолтної синхронізації `npx \@nitra/cursor` (гілка без підкоманди).
3
3
  *
4
4
  * Дефолтний sync (`runSync` у `bin/n-cursor.js`) скаффолдить у `cwd()` керовані
5
5
  * артефакти — `.cursor/rules/`, `.cursor/skills/`, `.claude/`, `AGENTS.md`,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Визначає список id правил для `npx @nitra/cursor fix` без аргументів:
2
+ * Визначає список id правил для `npx \@nitra/cursor fix` без аргументів:
3
3
  * зчитує базові імена `*.mdc` у `.cursor/rules/` і залишає лише ті id,
4
4
  * для яких у пакеті є programmatic перевірка (JS-концерн або policy з target.json).
5
5
  */
@@ -23,35 +23,44 @@ const IGNORED_DIR_NAMES = new Set(['node_modules', '.git', '.next', '.turbo'])
23
23
  */
24
24
  async function anyDepInTree(root, keys) {
25
25
  const wanted = new Set(keys)
26
- let found = false
27
- /** @param {string} dir каталог обходу @returns {Promise<void>} */
26
+ /**
27
+ * Чи package.json за `abs` оголошує будь-який пакет із `wanted` у `dependencies`.
28
+ * @param {string} abs шлях до package.json
29
+ * @returns {Promise<boolean>} true, якщо знайдено хоч один
30
+ */
31
+ async function pkgDeclaresWanted(abs) {
32
+ try {
33
+ const deps = JSON.parse(await readFile(abs, 'utf8'))?.dependencies
34
+ if (deps && typeof deps === 'object' && !Array.isArray(deps)) {
35
+ for (const k of wanted) if (Object.hasOwn(deps, k)) return true
36
+ }
37
+ } catch {
38
+ /* ігноруємо пошкоджені package.json */
39
+ }
40
+ return false
41
+ }
42
+ /**
43
+ * @param {string} dir каталог обходу
44
+ * @returns {Promise<boolean>} true, якщо знайдено хоч один пакет
45
+ */
28
46
  async function walk(dir) {
29
- if (found) return
30
47
  let entries
31
48
  try {
32
49
  entries = await readdir(dir, { withFileTypes: true })
33
50
  } catch {
34
- return
51
+ return false
35
52
  }
36
53
  for (const entry of entries) {
37
- if (found) return
38
54
  const abs = join(dir, entry.name)
39
55
  if (entry.isDirectory()) {
40
- if (!IGNORED_DIR_NAMES.has(entry.name)) await walk(abs)
41
- } else if (entry.isFile() && entry.name === 'package.json') {
42
- try {
43
- const deps = JSON.parse(await readFile(abs, 'utf8'))?.dependencies
44
- if (deps && typeof deps === 'object' && !Array.isArray(deps)) {
45
- for (const k of wanted) if (Object.hasOwn(deps, k)) found = true
46
- }
47
- } catch {
48
- /* ігноруємо пошкоджені package.json */
49
- }
56
+ if (!IGNORED_DIR_NAMES.has(entry.name) && (await walk(abs))) return true
57
+ } else if (entry.isFile() && entry.name === 'package.json' && (await pkgDeclaresWanted(abs))) {
58
+ return true
50
59
  }
51
60
  }
61
+ return false
52
62
  }
53
- await walk(root)
54
- return found
63
+ return walk(root)
55
64
  }
56
65
 
57
66
  /**
@@ -62,7 +71,10 @@ async function anyDepInTree(root, keys) {
62
71
  async function nestedWithoutVite(root) {
63
72
  const rootPkg = join(root, 'package.json')
64
73
  let result = false
65
- /** @param {string} dir каталог @returns {Promise<void>} */
74
+ /**
75
+ * @param {string} dir каталог
76
+ * @returns {Promise<void>}
77
+ */
66
78
  async function walk(dir) {
67
79
  if (result) return
68
80
  let entries
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Standalone CLI runner для одного правила. Викликається з `rules/<id>/fix.mjs`
3
3
  * у блоці `if (import.meta.main)` — це робить `bun rules/<id>/fix.mjs` повним
4
- * еквівалентом старого `npx @nitra/cursor fix <id>`: читає `.n-cursor.json`,
4
+ * еквівалентом старого `npx \@nitra/cursor fix <id>`: читає `.n-cursor.json`,
5
5
  * перевіряє whitelist, друкує summary, повертає aggregated exit-code.
6
6
  *
7
7
  * Library-mode виклик з CLI orchestration — інше: див. `runStandardRule` + `fix.mjs::run(ctx)`.
@@ -5,7 +5,7 @@
5
5
  * Локальна логіка в правилах заборонена; розширення поведінки — через `ctx`-опції.
6
6
  *
7
7
  * Серіалізація: загортає виконання у `withLock('fix-<ruleId>')` — паралельні запуски
8
- * того самого правила (через `npx @nitra/cursor fix`, прямий `bun rules/<id>/fix.mjs`
8
+ * того самого правила (через `npx \@nitra/cursor fix`, прямий `bun rules/<id>/fix.mjs`
9
9
  * чи `run(ctx)`-композицію) дедупляться за станом git-дерева; різні правила можуть
10
10
  * виконуватись паралельно. Точка інтеграції — тут, щоб не дублювати лок у кожному
11
11
  * `fix.mjs`.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * PostToolUse hook для Claude Code: точкова маршрутизація `npx @nitra/cursor fix`
2
+ * PostToolUse hook для Claude Code: точкова маршрутизація `npx \@nitra/cursor fix`
3
3
  * за типом зміненого файла. Запускається після кожного `Edit` / `Write` / `MultiEdit`;
4
4
  * замінює дорогий синхронний `Stop`-хук, що ганяв повний `fix` усіх правил на кожному
5
5
  * turn-і.
@@ -8,7 +8,7 @@
8
8
  * - stdin Claude Code: JSON із `tool_input.file_path` (відносний шлях зміненого файла);
9
9
  * - exit 0, якщо файл не має маршрут (PostToolUse не блокує turn у будь-якому випадку,
10
10
  * але ми лишаємо exit-код прозорим — для діагностики);
11
- * - інакше spawn `npx --no @nitra/cursor fix <rules…>` із передаванням exit-коду.
11
+ * - інакше spawn `npx --no \@nitra/cursor fix <rules…>` із передаванням exit-коду.
12
12
  *
13
13
  * Маршрути впорядковані від найбільш специфічного до загального; перший збіг — переможець.
14
14
  * `docs/adr/**\/*.md` свідомо повертає `[]`: ADR-нормалізація вже покривається async
@@ -46,7 +46,7 @@ const ROUTES = Object.freeze([
46
46
  * Повертає список правил, які слід прогнати для зміненого `filePath`.
47
47
  * Перший збіг із `ROUTES` — переможець; невідомі шляхи / некоректні входи → `[]`.
48
48
  * @param {unknown} filePath відносний шлях зміненого файла зі stdin Claude Code
49
- * @returns {string[]} ID правил для `npx @nitra/cursor fix`
49
+ * @returns {string[]} ID правил для `npx \@nitra/cursor fix`
50
50
  */
51
51
  export function routeFilePathToRules(filePath) {
52
52
  if (typeof filePath !== 'string' || filePath === '') {
@@ -6,11 +6,11 @@
6
6
  * `.n-cursor.json`) — далі stdout або делегування в `cursor-agent` / `claude`.
7
7
  *
8
8
  * Підтримувані формати:
9
- * `npx @nitra/cursor skill list`
10
- * `npx @nitra/cursor skill taze`
11
- * `npx @nitra/cursor skill cursor taze`
12
- * `npx @nitra/cursor skill cursor taze "онови залежності"`
13
- * `npx @nitra/cursor skill claude taze` — те саме через Claude Code CLI
9
+ * `npx \@nitra/cursor skill list`
10
+ * `npx \@nitra/cursor skill taze`
11
+ * `npx \@nitra/cursor skill cursor taze`
12
+ * `npx \@nitra/cursor skill cursor taze "онови залежності"`
13
+ * `npx \@nitra/cursor skill claude taze` — те саме через Claude Code CLI
14
14
  */
15
15
 
16
16
  import { spawnSync } from 'node:child_process'