@nitra/cursor 3.14.0 → 3.14.2

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.14.2] - 2026-06-02
4
+
5
+ ### Fixed
6
+
7
+ - Додано pull-requests: read до clean-merged-branch.yml, щоб cleanup action міг перевіряти PR-и комітів без 403.
8
+ - detectLevel: ASCII L0-дієслова (fix/typo/bump/rename/hotfix) матчаться цілим словом, а не підрядком — опис на кшталт 'add prefix validation' більше не дає хибний L0 (раніше 'fix' ловився в 'prefix'/'fixture'/'suffix'). Кириличні L0-ключі лишаються підрядком (стемінг).
9
+
10
+ ## [3.14.1] - 2026-06-02
11
+
12
+ ### Changed
13
+
14
+ - flow review: рецензент верифікує cross-file твердження читанням (Read) — промпт дозволяє/зобов'язує дочитувати referenced-файли/spec точково, репортувати лише те, що вносить diff (не преіснуючі баги сусідів), і не видавати нефальсифіковних findings «з diff не видно». Зменшує хибні findings.
15
+
16
+ ### Fixed
17
+
18
+ - coverage-gate: запускати Stryker із локально встановленого @stryker-mutator/core (резолв через package.json у node_modules пакета, bin запускається напряму через node-shebang), а не через npx/bunx — ті тягнуть core у власний кеш без vitest-runner, тож plugin-discovery падав 'Cannot find TestRunner plugin vitest' і flow verify coverage-gate червонів. Працює й з worktree без власного node_modules.
19
+
3
20
  ## [3.14.0] - 2026-06-02
4
21
 
5
22
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "3.14.0",
3
+ "version": "3.14.2",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -49,8 +49,9 @@ deny contains msg if {
49
49
  }
50
50
 
51
51
  deny contains msg if {
52
- input.jobs.cleanup_old_branches.permissions.contents != expected_perms.contents
53
- msg := sprintf("clean-merged-branch.yml: permissions.contents має бути %s (ga.mdc)", [expected_perms.contents])
52
+ some permission, expected in expected_perms
53
+ object.get(input.jobs.cleanup_old_branches.permissions, permission, null) != expected
54
+ msg := sprintf("clean-merged-branch.yml: permissions.%s має бути %s (ga.mdc)", [permission, expected])
54
55
  }
55
56
 
56
57
  deny contains msg if {
@@ -17,6 +17,7 @@ jobs:
17
17
  runs-on: ubuntu-latest
18
18
  permissions:
19
19
  contents: write
20
+ pull-requests: read
20
21
  steps:
21
22
  - id: delete_stuff
22
23
  name: Delete those pesky dead branches
@@ -9,8 +9,9 @@
9
9
  import { spawnSync } from 'node:child_process'
10
10
  import { existsSync, readFileSync } from 'node:fs'
11
11
  import { mkdtemp, readFile, rm } from 'node:fs/promises'
12
+ import { createRequire } from 'node:module'
12
13
  import { tmpdir } from 'node:os'
13
- import { isAbsolute, join, relative } from 'node:path'
14
+ import { dirname, isAbsolute, join, relative } from 'node:path'
14
15
 
15
16
  import { resolveAllJsRoots } from '../../../scripts/utils/resolve-js-root.mjs'
16
17
  import { addCoverage, addMutation } from '../../test/coverage/coverage.mjs'
@@ -239,6 +240,32 @@ export function parseStrykerReport(report, jsRoot) {
239
240
  * (типовий патерн monorepo, де тести зосереджені в одному пакеті); пустий lcov
240
241
  * у такому випадку сигналізує "no tests" → collectOneRoot пропускає workspace.
241
242
  */
243
+ /**
244
+ * Шлях до локально встановленого Stryker core-bin (поряд із плагінами на кшталт
245
+ * `@stryker-mutator/vitest-runner`). Запуск саме його через `node` — не `npx`/`bunx` —
246
+ * дає Stryker побачити локальні плагіни при plugin-discovery.
247
+ * @returns {string | null} абсолютний шлях `bin/stryker.js` або `null`, якщо не встановлено
248
+ */
249
+ /** Мемо: `undefined` — ще не обчислено; `string`/`null` — результат. */
250
+ let strykerBinCache
251
+
252
+ function resolveLocalStrykerBin() {
253
+ if (strykerBinCache !== undefined) return strykerBinCache
254
+ try {
255
+ // `exports` у core НЕ відкриває `./bin/stryker.js`, тож резолвимо package.json
256
+ // (доступний) і беремо шлях bin звідти. Ключ bin зазвичай `stryker`; як запас —
257
+ // перше значення map'и.
258
+ const require = createRequire(import.meta.url)
259
+ const pkgJsonPath = require.resolve('@stryker-mutator/core/package.json')
260
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'))
261
+ const binRel = typeof pkg.bin === 'string' ? pkg.bin : (pkg.bin?.stryker ?? Object.values(pkg.bin ?? {})[0])
262
+ strykerBinCache = binRel ? join(dirname(pkgJsonPath), binRel) : null
263
+ } catch {
264
+ strykerBinCache = null
265
+ }
266
+ return strykerBinCache
267
+ }
268
+
242
269
  const defaultRunner = {
243
270
  runJsCoverage({ cwd, lcovDir, base }) {
244
271
  // base !== undefined ⇔ --changed-режим: vitest сам рахує зачеплені змінами тести
@@ -261,20 +288,21 @@ const defaultRunner = {
261
288
  return r.status ?? 1
262
289
  },
263
290
  runStryker({ cwd, mutate }) {
264
- // `npx`, не `bunx`: bunx завжди ставить пакет у `T/bunx-<uid>-<pkg>@latest` і запускає
265
- // Stryker звідти. Плагін-discovery у Stryker (`@stryker-mutator/*`) globится відносно
266
- // CORE-install-каталогу (`core/dist/src/di/plugin-loader.js` `../../../../../@stryker-mutator/*`),
267
- // тож у bunx-temp бачить лише `core/api/instrumenter/util` (усі в IGNORED_PACKAGES) — а локально
268
- // встановлений `@stryker-mutator/vitest-runner` залишається невидимим, і workers падають з
269
- // `Cannot find TestRunner plugin "vitest"`. `npx` ходить угору по `node_modules/.bin/` і
270
- // запускає Stryker з локального hoisted-install, де поряд лежить vitest-runner.
291
+ // Plugin-discovery Stryker (`@stryker-mutator/*`) globиться відносно CORE-install-каталогу
292
+ // (`core/dist/src/di/plugin-loader.js` `../../../../../@stryker-mutator/*`). Тож core
293
+ // МАЄ вантажитись із проєктного `node_modules`, де поряд лежить `@stryker-mutator/vitest-runner`.
294
+ // `npx`/`bunx` тягнуть core у власний кеш (`_npx/<hash>`, `bunx-temp`) БЕЗ плагінів воркери
295
+ // падають `Cannot find TestRunner plugin "vitest"`. Тому резолвимо локальний core-bin через
296
+ // `import.meta.url` (модуль у `npm/` кореневий `node_modules` пакета; працює й з worktree без
297
+ // власного node_modules) і запускаємо його через `node`. Fallback на `npx`, якщо не встановлено.
271
298
  // mutate (непорожній) ⇔ --changed-режим: мутуємо лише змінені production-файли цього root.
272
299
  const mutateArgs = mutate && mutate.length > 0 ? ['--mutate', mutate.join(',')] : []
273
- const r = spawnSync('npx', ['@stryker-mutator/core', 'run', ...mutateArgs], {
274
- cwd,
275
- stdio: 'inherit',
276
- env: process.env
277
- })
300
+ const strykerBin = resolveLocalStrykerBin()
301
+ // Запускаємо bin НАПРЯМУ (його shebang `#!/usr/bin/env node` → завжди node, навіть якщо
302
+ // coverage.mjs стартував під bun, де `process.execPath` вказував би на bun). Fallback на npx.
303
+ const r = strykerBin
304
+ ? spawnSync(strykerBin, ['run', ...mutateArgs], { cwd, stdio: 'inherit', env: process.env })
305
+ : spawnSync('npx', ['@stryker-mutator/core', 'run', ...mutateArgs], { cwd, stdio: 'inherit', env: process.env })
278
306
  return r.status ?? 1
279
307
  }
280
308
  }
@@ -10,11 +10,38 @@
10
10
 
11
11
  /** L3 — велике/архітектурне. */
12
12
  const L3_KEYS = ['platform', 'migration', 'rewrite', 'architecture', 'enterprise', 'редизайн', 'міграц', 'переписат']
13
- /** L0 — тривіальне. */
14
- const L0_KEYS = ['fix', 'typo', 'bump', 'rename', 'hotfix', 'опечат', 'перейменув']
13
+ /** L0 — тривіальне. ASCII-дієслова: матч цілим словом (щоб `fix` не ловило `prefix`/`fixture`). */
14
+ const L0_WORD_KEYS = ['fix', 'typo', 'bump', 'rename', 'hotfix']
15
+ /** L0 — кириличні ключі: підрядком (стемінг: `перейменув` ловить `перейменування`). */
16
+ const L0_SUBSTR_KEYS = ['опечат', 'перейменув']
15
17
  /** L2 — багатофайлова фіча/рефактор. */
16
18
  const L2_KEYS = ['feature', 'epic', 'refactor', 'рефактор', 'фіча']
17
19
 
20
+ /**
21
+ * Чи символ — ASCII-літера/цифра (межа слова). `undefined` (край рядка) — не alnum.
22
+ * @param {string | undefined} ch символ
23
+ * @returns {boolean} результат
24
+ */
25
+ function isAsciiAlnum(ch) {
26
+ return ch !== undefined && ((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9'))
27
+ }
28
+
29
+ /**
30
+ * Чи містить `text` слово `word` із межами, що не є ASCII-alnum (без regex —
31
+ * конвенція файлу). Для ASCII L0-дієслів: `fix` у `prefix`/`fixture` не рахується.
32
+ * @param {string} text текст (lowercase)
33
+ * @param {string} word шукане ASCII-слово (lowercase)
34
+ * @returns {boolean} результат
35
+ */
36
+ function hasWord(text, word) {
37
+ let i = text.indexOf(word)
38
+ while (i !== -1) {
39
+ if (!isAsciiAlnum(text[i - 1]) && !isAsciiAlnum(text[i + word.length])) return true
40
+ i = text.indexOf(word, i + 1)
41
+ }
42
+ return false
43
+ }
44
+
18
45
  /**
19
46
  * Рівень складності задачі за описом: 0 (тривіальне) … 3 (архітектурне).
20
47
  * Пріоритет: L3 > L0 > L2 > дефолт L1.
@@ -24,8 +51,9 @@ const L2_KEYS = ['feature', 'epic', 'refactor', 'рефактор', 'фіча']
24
51
  export function detectLevel(desc) {
25
52
  const d = String(desc ?? '').toLowerCase()
26
53
  const has = keys => keys.some(k => d.includes(k))
54
+ const isL0 = L0_WORD_KEYS.some(k => hasWord(d, k)) || L0_SUBSTR_KEYS.some(k => d.includes(k))
27
55
  if (has(L3_KEYS)) return 3
28
- if (has(L0_KEYS)) return 0
56
+ if (isL0) return 0
29
57
  if (has(L2_KEYS)) return 2
30
58
  return 1
31
59
  }
@@ -33,8 +33,9 @@ export function diffFromBase(base, run, cwd) {
33
33
  }
34
34
 
35
35
  /**
36
- * Промпт adversarial-рецензента (читає ЛИШЕ diff). Для high-risk додає
37
- * безпекову лінзу.
36
+ * Промпт adversarial-рецензента. Фокус diff, але рецензент працює у робочій теці
37
+ * репо й має інструмент `Read`, тож cross-file твердження мусить верифікувати читанням.
38
+ * Для high-risk додає безпекову лінзу.
38
39
  * @param {string} diff текст diff
39
40
  * @param {string} [risk] low|med|high — фокус перевірки
40
41
  * @returns {string} промпт
@@ -45,11 +46,19 @@ export function reviewerPrompt(diff, risk) {
45
46
  ? 'ОСОБЛИВА УВАГА БЕЗПЕЦІ: auth/доступи, секрети/токени, ін\'єкції, валідація входу, незворотні операції.'
46
47
  : ''
47
48
  return [
48
- 'Ти — прискіпливий adversarial-рецензент. Знайди баги, ризики й smells ЛИШЕ в цьому diff.',
49
+ 'Ти — прискіпливий adversarial-рецензент. Знайди баги, ризики й smells, які ВНОСИТЬ або зачіпає цей diff.',
50
+ 'Якщо тобі доступний інструмент Read — ти в робочій теці репо: читай ТОЧКОВО потрібні referenced-файли' +
51
+ ' (викликану функцію, інший модуль, spec/plan, конфіг), щоб ПЕРЕВІРИТИ cross-file твердження перед репортом.' +
52
+ ' Якщо Read недоступний — рецензуй лише diff.',
53
+ 'Сусідні файли читай ДЛЯ КОНТЕКСТУ й верифікації, а не щоб шукати в них окремі преіснуючі баги:' +
54
+ ' репортуй лише те, що вносить/ламає цей diff.',
55
+ 'НЕ видавай нефальсифіковних findings виду «з diff не видно / не показано / можливо» —' +
56
+ ' або підтверди читанням файлу, або відкинь. Кожен finding має бути перевірним фактом.',
49
57
  lens,
50
58
  'Поверни ЛИШЕ JSON-масив: [{ "severity": "high|med|low", "file": "...", "issue": "...", "suggestion": "..." }].',
51
59
  'Якщо проблем нема — поверни [].',
52
60
  '',
61
+ 'DIFF (фокус рецензування):',
53
62
  diff.slice(0, DIFF_LIMIT)
54
63
  ]
55
64
  .filter(Boolean)