@nitra/cursor 3.14.1 → 3.15.0

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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.15.0] - 2026-06-02
4
+
5
+ ### Added
6
+
7
+ - parity-гард дзеркала правил: тест перевіряє, що .cursor/rules/n-<id>.mdc == канонічний npm/rules/<id>/<id>.mdc з inlined-шаблонами (хелпер mirror-parity.mjs), ловлячи дрейф рано. Разово регенеровано наявний дрейф (changelog/flow/ga/npm-module/test).
8
+
9
+ ## [3.14.2] - 2026-06-02
10
+
11
+ ### Fixed
12
+
13
+ - Додано pull-requests: read до clean-merged-branch.yml, щоб cleanup action міг перевіряти PR-и комітів без 403.
14
+ - detectLevel: ASCII L0-дієслова (fix/typo/bump/rename/hotfix) матчаться цілим словом, а не підрядком — опис на кшталт 'add prefix validation' більше не дає хибний L0 (раніше 'fix' ловився в 'prefix'/'fixture'/'suffix'). Кириличні L0-ключі лишаються підрядком (стемінг).
15
+
3
16
  ## [3.14.1] - 2026-06-02
4
17
 
5
18
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "3.14.1",
3
+ "version": "3.15.0",
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
@@ -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
  }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Parity дзеркала правил: `.cursor/rules/n-<id>.mdc` має дорівнювати канонічному
3
+ * `npm/rules/<id>/<id>.mdc` з inlined-шаблонами — тим самим трансформом, що його
4
+ * застосовує синк (`readBundledRuleContent` → `inlineTemplateLinks`). Дрейф виникає,
5
+ * коли канонічний `.mdc` змінюють, не регенерувавши дзеркало (беклог адаптації flow #10).
6
+ *
7
+ * Використовується і тестом-гардом (drift === []), і разовою регенерацією.
8
+ */
9
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
10
+ import { dirname, join } from 'node:path'
11
+
12
+ import { inlineTemplateLinks } from './inline-template-links.mjs'
13
+
14
+ const MIRROR_PREFIX = 'n-'
15
+ const MDC_EXT = '.mdc'
16
+
17
+ /**
18
+ * Керовані дзеркала `.cursor/rules/n-<id>.mdc`, що мають канонічне джерело
19
+ * `npm/rules/<id>/<id>.mdc`. Дзеркала без канону (зовнішні) пропускаються.
20
+ * @param {string} repoRoot корінь репо
21
+ * @returns {{ id: string, mirrorPath: string, canonicalPath: string }[]} список
22
+ */
23
+ export function listManagedMirrors(repoRoot) {
24
+ const rulesDir = join(repoRoot, '.cursor/rules')
25
+ if (!existsSync(rulesDir)) return []
26
+ return readdirSync(rulesDir)
27
+ .filter(f => f.startsWith(MIRROR_PREFIX) && f.endsWith(MDC_EXT))
28
+ .map(f => {
29
+ const id = f.slice(MIRROR_PREFIX.length, -MDC_EXT.length)
30
+ return {
31
+ id,
32
+ mirrorPath: join(rulesDir, f),
33
+ canonicalPath: join(repoRoot, 'npm/rules', id, `${id}${MDC_EXT}`)
34
+ }
35
+ })
36
+ .filter(m => existsSync(m.canonicalPath))
37
+ }
38
+
39
+ /**
40
+ * Очікуваний вміст дзеркала = канон з inlined-шаблонами (трансформ синку).
41
+ * @param {string} canonicalPath абсолютний шлях `npm/rules/<id>/<id>.mdc`
42
+ * @returns {Promise<string>} очікуваний текст дзеркала
43
+ */
44
+ export function expectedMirrorContent(canonicalPath) {
45
+ return inlineTemplateLinks(readFileSync(canonicalPath, 'utf8'), dirname(canonicalPath))
46
+ }
47
+
48
+ /**
49
+ * Id дзеркал, що розійшлися з каноном (actual ≠ expected).
50
+ * @param {string} repoRoot корінь репо
51
+ * @returns {Promise<string[]>} відсортовані id дрейфу
52
+ */
53
+ export async function findMirrorDrift(repoRoot) {
54
+ const drift = []
55
+ for (const m of listManagedMirrors(repoRoot)) {
56
+ const expected = await expectedMirrorContent(m.canonicalPath)
57
+ if (readFileSync(m.mirrorPath, 'utf8') !== expected) drift.push(m.id)
58
+ }
59
+ return drift.sort()
60
+ }