@nitra/cursor 12.11.0 → 12.11.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## [12.11.1] - 2026-06-25
4
+
5
+ ### Changed
6
+
7
+ - ♻️ refactor(scripts): T0-autofix контракт fix-<concern>.mjs — нова конвенція в scripts.mdc
8
+
3
9
  ## [12.11.0] - 2026-06-25
4
10
 
5
11
  ### Changed
package/bin/n-cursor.js CHANGED
@@ -64,7 +64,7 @@
64
64
  import { spawnSync } from 'node:child_process'
65
65
  import { existsSync } from 'node:fs'
66
66
  import { mkdir, readdir, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises'
67
- import { basename, dirname, join } from 'node:path'
67
+ import { basename, dirname, join, resolve } from 'node:path'
68
68
  import { cwd, env } from 'node:process'
69
69
  import { fileURLToPath } from 'node:url'
70
70
 
@@ -1457,7 +1457,7 @@ async function runSync() {
1457
1457
  * `--root`-команди `doc-aggregate`/`rename-yaml-extensions`,
1458
1458
  * sub-лінтери) гард не зачіпає.
1459
1459
  */
1460
- const ROOT_GUARDED_COMMANDS = new Set([undefined, '', 'lint', 'coverage', 'change', 'release'])
1460
+ const ROOT_GUARDED_COMMANDS = new Set([undefined, '', 'lint', 'change', 'release'])
1461
1461
 
1462
1462
  /**
1463
1463
  * Короткий опис дії для тексту root-guard помилки за іменем команди.
@@ -1473,9 +1473,6 @@ function describeRootGuardedAction(cmd) {
1473
1473
  case 'lint': {
1474
1474
  return '`lint` за замовчуванням авто-fix лінтерів (oxfmt/eslint --fix/stylelint --fix) і конформності (--full) у поточному каталозі'
1475
1475
  }
1476
- case 'coverage': {
1477
- return '`coverage` генерує COVERAGE.md і Stryker-артефакти в поточному каталозі'
1478
- }
1479
1476
  case 'change': {
1480
1477
  return '`change` пише change-файл у .changes/ поточного каталогу'
1481
1478
  }
@@ -1493,7 +1490,7 @@ const [command, ...args] = process.argv.slice(2)
1493
1490
 
1494
1491
  try {
1495
1492
  // Root-guard до перших мутацій: дефолтний sync скаффолдить .cursor/.claude/CLAUDE.md/
1496
- // .n-cursor.json + bun install, а fix/lint/coverage/change/release переписують файли в CWD —
1493
+ // .n-cursor.json + bun install, а lint/change/release переписують файли в CWD —
1497
1494
  // усе це ключиться на cwd(). Запуск із піддиректорії git-репо (типово прямий
1498
1495
  // `bun npm/bin/n-cursor.js` не з кореня) зачепив би не той каталог → STOP. Read-only та
1499
1496
  // `--root`-команди (trace, graph, doc-aggregate, rename-yaml-extensions) не зачіпаємо.
@@ -1523,33 +1520,18 @@ try {
1523
1520
  // Дві ортогональні осі: --full (scope: весь репо vs дельта vs origin) × --read-only (behavior).
1524
1521
  // Позиційні (не-флаг) аргументи — фільтр правил конформності (напр. `lint changelog`):
1525
1522
  // прогнати лише конформність цих правил, без лінтер-скану (мапить колишній `fix <rule>`).
1526
- const rules = args.filter(a => !a.startsWith('-'))
1523
+ const cwdIdx = args.indexOf('--cwd')
1524
+ const cwdArg = cwdIdx !== -1 ? resolve(args[cwdIdx + 1]) : undefined
1525
+ const rules = args.filter((a, i) => !a.startsWith('-') && i !== cwdIdx + 1)
1527
1526
  process.exitCode = await runLint({
1528
1527
  full: args.includes('--full'),
1529
1528
  readOnly: args.includes('--read-only'),
1530
- rules
1529
+ rules,
1530
+ cwd: cwdArg
1531
1531
  })
1532
1532
 
1533
1533
  break
1534
1534
  }
1535
- case 'coverage': {
1536
- // n-cursor coverage — оркестратор покриття + мутаційного тестування з discovery
1537
- // провайдерів через .n-cursor.json#rules (test.mdc). --changed звужує scope до
1538
- // змінених від base файлів (flow-турнікет: лише vitest/Stryker по diff).
1539
- const { runCoverageCli } = await import('../rules/test/coverage/coverage.mjs')
1540
- process.exitCode = await runCoverageCli({ fix: args.includes('--fix'), changed: args.includes('--changed') })
1541
-
1542
- break
1543
- }
1544
- case 'coverage-fix': {
1545
- // n-cursor coverage-fix index|slice — read-only витяг вцілілих мутантів із
1546
- // COVERAGE.md для скілу n-coverage-fix. Важкий парсинг несе скрипт (0 LLM-
1547
- // токенів); агент отримує лише компактний index або зріз під один файл.
1548
- const { runCoverageFixCli } = await import('../scripts/coverage-fix-extract.mjs')
1549
- process.exitCode = await runCoverageFixCli(args)
1550
-
1551
- break
1552
- }
1553
1535
  case 'analyze-escalation': {
1554
1536
  // n-cursor analyze-escalation — читає весь escalation-лог (.n-cursor/fix-escalation.jsonl),
1555
1537
  // чанкує й просить хмарну avg-модель запропонувати, як зменшити LLM-залежність fix-
@@ -1634,7 +1616,7 @@ try {
1634
1616
  default: {
1635
1617
  console.error(`❌ Невідома команда: ${command}`)
1636
1618
  console.error(
1637
- ` Очікується: (без аргументів) синхронізація правил, rename-yaml-extensions, hook, adr-normalize-local, lint (включно зі scope: lint ga|rego|k8s|docker|text), coverage, coverage-fix, analyze-escalation, taze, start-check, release, skill, trace, doc-aggregate`
1619
+ ` Очікується: (без аргументів) синхронізація правил, rename-yaml-extensions, hook, adr-normalize-local, lint (включно зі scope: lint ga|rego|k8s|docker|text), analyze-escalation, taze, start-check, release, skill, trace, doc-aggregate`
1638
1620
  )
1639
1621
  process.exitCode = 1
1640
1622
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "12.11.0",
3
+ "version": "12.11.1",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -8,8 +8,6 @@ docgen:
8
8
  score: 95
9
9
  ---
10
10
 
11
- ## Огляд
12
-
13
11
  Модуль сканує монорепозиторій, ідентифікує проєкти та порівнює їхні версії з даними, отриманими з https://pypi.org/pypi/, для верифікації відповідності версій. Для проєктів, що не призначені для публікації, виконуються локальні перевірки на відповідність файлу `CHANGELOG.md` конфігурації, визначеній у res.json.
14
12
 
15
13
  ## Поведінка
@@ -19,18 +17,18 @@ docgen:
19
17
  3. `check` знаходить усі кореневі каталоги проєктів у монорепозиторії.
20
18
  4. `check` класифікує знайдені проєкти на ті, що можуть бути опубліковані в реєстрі, та ті, що є локальними.
21
19
  5. Для кожного проєкту, що може бути опублікованим, `check` перевіряє його відповідність вимогам:
22
- а. Перевіряє наявність файлу `CHANGELOG.md`.
23
- б. Перевіряє базовий формат `CHANGELOG.md` на наявність заголовка `# Changelog`.
24
- в. Якщо проєкт має поле `version` у маніфесті, `check` порівнює його з опублікованою версією, отриманою через мережевий запит до https://pypi.org/pypi/ або `npm view`.
25
- г. Якщо версія у проєкті випереджає опубліковану, `check` повідомляє про заборонений ручний bump.
26
- д. Якщо версія у проєкті відстає від опублікованої, `check` повідомляє про відставання локальної копії від реєстру.
27
- е. Якщо версії збігаються, `check` перевіряє, чи є незрелі зміни у проєкті відносно базового релізу.
28
- ж. Якщо проєкт має незрелі зміни, `check` перевіряє наявність change-файлу. Якщо його немає, `check` або повідомляє про необхідність створити його (якщо не в режимі autofix), або автоматично створює його та додає до індексу.
20
+ а. Перевіряє наявність файлу `CHANGELOG.md`.
21
+ б. Перевіряє базовий формат `CHANGELOG.md` на наявність заголовка `# Changelog`.
22
+ в. Якщо проєкт має поле `version` у маніфесті, `check` порівнює його з опублікованою версією, отриманою через мережевий запит до https://pypi.org/pypi/ або `npm view`.
23
+ г. Якщо версія у проєкті випереджає опубліковану, `check` повідомляє про заборонений ручний bump.
24
+ д. Якщо версія у проєкті відстає від опублікованої, `check` повідомляє про відставання локальної копії від реєстру.
25
+ е. Якщо версії збігаються, `check` перевіряє, чи є незрелі зміни у проєкті відносно базового релізу.
26
+ ж. Якщо проєкт має незрелі зміни, `check` перевіряє наявність change-файлу. Якщо його немає, `check` або повідомляє про необхідність створити його (якщо не в режимі autofix), або автоматично створює його та додає до індексу.
29
27
  6. `check` виконує локальні перевірки для проєктів, які не призначені для публікації:
30
- а. Для кожного локального проєкту `check` перевіряє наявність файлу `CHANGELOG.md` та його формат.
31
- б. `check` визначає точку порівняння (базу) на основі поточної гілки.
32
- в. `check` перевіряє, чи є релевантні зміни у проєкті відносно цієї бази.
33
- г. Якщо зміни є, `check` перевіряє наявність change-файлу. Якщо його немає, `check` або повідомляє про необхідність створити його (якщо не в режимі autofix), або автоматично створює його та додає до індексу.
28
+ а. Для кожного локального проєкту `check` перевіряє наявність файлу `CHANGELOG.md` та його формат.
29
+ б. `check` визначає точку порівняння (базу) на основі поточної гілки.
30
+ в. `check` перевіряє, чи є релевантні зміни у проєкті відносно цієї бази.
31
+ г. Якщо зміни є, `check` перевіряє наявність change-файлу. Якщо його немає, `check` або повідомляє про необхідність створити його (якщо не в режимі autofix), або автоматично створює його та додає до індексу.
34
32
  7. `check` повертає кінцевий код завершення, що відображає результати всіх перевірок.
35
33
 
36
34
  ## Гарантії поведінки
@@ -127,6 +127,21 @@ deny contains msg if {
127
127
  msg := sprintf(setup_bun_no_checkout_template, [job_id])
128
128
  }
129
129
 
130
+ # ── deny: actions/checkout без persist-credentials: false ─────────────────
131
+ #
132
+ # `persist-credentials: false` запобігає кешуванню git-токена після checkout,
133
+ # що є обов'язковою вимогою безпеки (ga.mdc).
134
+
135
+ deny contains msg if {
136
+ some _job_id, job in jobs
137
+ some step in object.get(job, "steps", [])
138
+ uses := object.get(step, "uses", "")
139
+ startswith(uses, "actions/checkout@")
140
+ creds := object.get(object.get(step, "with", {}), "persist-credentials", true)
141
+ creds != false
142
+ msg := sprintf("jobs.%s: actions/checkout@v6 потребує `with: persist-credentials: false` (ga.mdc)", [_job_id])
143
+ }
144
+
130
145
  # ── deny: concurrency блок ─────────────────────────────────────────────────
131
146
  #
132
147
  # Дублює окремі per-workflow перевірки для clean-ga-workflows / clean-merged-branch /
@@ -0,0 +1,57 @@
1
+ # Перевірка `.github/workflows/lint-k8s.yml` (k8s.mdc).
2
+ #
3
+ # Канон надходить через --data: { "template": { "snippet": ... } }
4
+ # Структура --data сформована з template/lint-k8s.yml.snippet.yml.
5
+ # Перевіряємо (drift-safe — усе ведеться з template, без inline-літералів):
6
+ # - кожен `uses` з template: actions/checkout@v6, setup-bun-deps;
7
+ # - кожен `run` з template (як substring): install kubeconform, kubescape,
8
+ # n-cursor lint k8s --read-only.
9
+ # Універсальні workflow-перевірки (name, concurrency, branches,
10
+ # persist-credentials) — у `ga.workflow_common`.
11
+ package k8s.lint_k8s_yml
12
+
13
+ import rego.v1
14
+
15
+ # Усі `uses` з канону workflow (по всіх job'ах template).
16
+ expected_uses contains u if {
17
+ some job in data.template.snippet.jobs
18
+ some step in job.steps
19
+ u := object.get(step, "uses", "")
20
+ u != ""
21
+ }
22
+
23
+ # Усі `uses` з input workflow.
24
+ actual_uses contains u if {
25
+ some job in object.get(input, "jobs", {})
26
+ some step in object.get(job, "steps", [])
27
+ u := object.get(step, "uses", "")
28
+ u != ""
29
+ }
30
+
31
+ # Конкатенація всіх `run`-кроків з input workflow.
32
+ all_run_text := concat("\n", [run_text |
33
+ some job in object.get(input, "jobs", {})
34
+ some step in object.get(job, "steps", [])
35
+ run_text := step_run_to_text(step)
36
+ ])
37
+
38
+ deny contains msg if {
39
+ some required_use in expected_uses
40
+ not required_use in actual_uses
41
+ msg := sprintf("lint-k8s.yml: відсутній step з `uses: %s` (k8s.mdc)", [required_use])
42
+ }
43
+
44
+ deny contains msg if {
45
+ some job in data.template.snippet.jobs
46
+ some step in job.steps
47
+ expected_run := object.get(step, "run", "")
48
+ expected_run != ""
49
+ not contains(all_run_text, expected_run)
50
+ msg := sprintf("lint-k8s.yml: жоден крок run не містить %q (k8s.mdc)", [expected_run])
51
+ }
52
+
53
+ step_run_to_text(step) := step.run if is_string(step.run)
54
+
55
+ else := concat("\n", [s | some s in step.run]) if is_array(step.run)
56
+
57
+ else := ""
@@ -0,0 +1,4 @@
1
+ {
2
+ "$schema": "https://unpkg.com/@nitra/cursor/schemas/target.json",
3
+ "files": { "single": ".github/workflows/lint-k8s.yml" }
4
+ }
@@ -0,0 +1,43 @@
1
+ name: Lint K8s
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - dev
7
+ - main
8
+ paths:
9
+ - '**/k8s/**/*.yaml'
10
+
11
+ pull_request:
12
+ branches:
13
+ - dev
14
+ - main
15
+
16
+ concurrency:
17
+ group: ${{ github.ref }}-${{ github.workflow }}
18
+ cancel-in-progress: true
19
+
20
+ jobs:
21
+ lint-k8s:
22
+ runs-on: ubuntu-latest
23
+ permissions:
24
+ contents: read
25
+ steps:
26
+ - uses: actions/checkout@v6
27
+ with:
28
+ persist-credentials: false
29
+
30
+ - uses: ./.github/actions/setup-bun-deps
31
+
32
+ - name: Install kubeconform
33
+ run: |
34
+ curl -sSL "https://github.com/yannh/kubeconform/releases/download/v0.7.0/kubeconform-linux-amd64.tar.gz" | tar xz
35
+ sudo mv kubeconform /usr/local/bin/
36
+
37
+ - name: Install kubescape
38
+ run: |
39
+ curl -s https://raw.githubusercontent.com/kubescape/kubescape/master/install.sh | /bin/bash
40
+ echo "$HOME/.kubescape/bin" >> $GITHUB_PATH
41
+
42
+ - name: Lint K8s
43
+ run: n-cursor lint k8s --read-only
@@ -3,8 +3,9 @@
3
3
  * під фікс і застосування змін. Використовують і `llm-worker.mjs` (конформність), і
4
4
  * `llm-lint-fix.mjs` (per-tool лінтер-фіксери) — щоб не дублювати парс/apply (knip/jscpd).
5
5
  */
6
+ import { execSync } from 'node:child_process'
6
7
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
7
- import { dirname, join } from 'node:path'
8
+ import { basename, dirname, join } from 'node:path'
8
9
 
9
10
  const JSON_CODE_BLOCK_RE = /```(?:json)?[ \t]{0,8}\n?([\s\S]*?)```/
10
11
 
@@ -39,8 +40,30 @@ export function parseChangesResponse(text) {
39
40
  return null
40
41
  }
41
42
 
43
+ /**
44
+ * Шукає файл за basename у дереві проєкту (fallback коли прямий шлях не існує).
45
+ * Повертає відносний шлях якщо знайдено рівно один матч, інакше `null` (ambiguous/not found).
46
+ * @param {string} name basename файлу
47
+ * @param {string} projectRoot абсолютний корінь
48
+ * @returns {string|null} відносний шлях або null
49
+ */
50
+ function findByBasename(name, projectRoot) {
51
+ try {
52
+ const raw = execSync(
53
+ `find . -maxdepth 7 -name '${name.replace(/'/g, "'\\''")}' -not -path '*/node_modules/*' -not -path '*/.git/*' -not -path '*/.worktrees/*'`,
54
+ { cwd: projectRoot, encoding: 'utf8', timeout: 3000 }
55
+ ).trim()
56
+ const hits = raw.split('\n').filter(Boolean)
57
+ return hits.length === 1 ? hits[0].replace(/^\.\//, '') : null
58
+ } catch {
59
+ return null
60
+ }
61
+ }
62
+
42
63
  /**
43
64
  * Читає існуючі файли за відносними шляхами у форму `{path, content}` (для prompt).
65
+ * Якщо файл не знайдений за прямим шляхом — намагається знайти за basename через `find`.
66
+ * Повертає resolved path (може відрізнятись від вхідного коли `find` знайшов реальне місце).
44
67
  * @param {string[]} filePaths відносні шляхи від кореня
45
68
  * @param {string} projectRoot абсолютний корінь
46
69
  * @returns {Array<{path:string, content:string}>} наявні файли з вмістом
@@ -48,10 +71,18 @@ export function parseChangesResponse(text) {
48
71
  export function readFilesForFix(filePaths, projectRoot) {
49
72
  return filePaths
50
73
  .map(p => {
51
- const abs = join(projectRoot, p)
74
+ let abs = join(projectRoot, p)
75
+ let resolvedPath = p
76
+ if (!existsSync(abs)) {
77
+ const found = findByBasename(basename(p), projectRoot)
78
+ if (found) {
79
+ resolvedPath = found
80
+ abs = join(projectRoot, found)
81
+ }
82
+ }
52
83
  if (!existsSync(abs)) return null
53
84
  try {
54
- return { path: p, content: readFileSync(abs, 'utf8') }
85
+ return { path: resolvedPath, content: readFileSync(abs, 'utf8') }
55
86
  } catch {
56
87
  return null
57
88
  }
@@ -18,35 +18,44 @@ const DEFAULT_THINKING_BUDGET = Number(env.N_CURSOR_OMLX_THINKING_BUDGET ?? 4096
18
18
 
19
19
  const API_KEY_RE = /api key/i
20
20
 
21
+ const FILE_EXTS = 'json|js|mjs|ts|vue|yml|yaml|toml|mdc|md|sh|py'
22
+
21
23
  /**
22
24
  * Витягує відносні шляхи файлів із violation output.
23
25
  * Розуміє workspace-prefix: `[npm] skills/foo.mjs` → `npm/skills/foo.mjs`.
26
+ * Спочатку явно парсить рядки ❌ (найвищий сигнал — файл потребує фіксу),
27
+ * потім підхоплює решту файлів generic-regex (контекст для читання).
24
28
  * @param {string} output violation output з fix check
25
29
  * @returns {string[]} унікальні відносні шляхи (від кореня проєкту)
26
30
  */
27
- function extractFilePaths(output) {
31
+ export function extractFilePaths(output) {
28
32
  const seen = new Set()
29
33
  const results = []
30
-
31
- // Патерн з workspace: [npm] skills/foo.mjs або [demo] src/bar.ts
32
- const wsRe = /\[([\w-]+)\]\s+([\w./][\w./-]*\.(?:json|js|mjs|ts|vue|yml|yaml|toml|mdc|md|sh|py))(?::\d+)?/gm
33
- for (const m of output.matchAll(wsRe)) {
34
- const p = `${m[1]}/${m[2]}`
34
+ const add = p => {
35
35
  if (!seen.has(p)) {
36
36
  seen.add(p)
37
37
  results.push(p)
38
38
  }
39
39
  }
40
40
 
41
- // Патерн без workspace: просто path/to/file.ext або ./file.ext
42
- const re = /(?:^|\s)(\.?\w[\w./-]*\.(?:json|js|mjs|ts|vue|yml|yaml|toml|mdc|md|sh|py))(?::\d+)?/gm
43
- for (const m of output.matchAll(re)) {
44
- const p = m[1]
45
- if (!seen.has(p)) {
46
- seen.add(p)
47
- results.push(p)
48
- }
49
- }
41
+ // 1. Явні рядки найвищий сигнал: саме ці файли потребують фіксу.
42
+ // Формати: `❌ [ws] path/file.ext:line — msg` та `❌ path/file.ext: msg`
43
+ // Роздільник після шляху: `:` (з пробілом або цифрою), `—` (em-dash), або кінець рядка.
44
+ const failSep = `(?::\\d+)?(?::\\s|[\\s—]|$)`
45
+ const failWsRe = new RegExp(`^\\s*❌\\s+\\[([\\w-]+)\\]\\s+([\\w./][\\w./-]*\\.(?:${FILE_EXTS}))${failSep}`, 'gm')
46
+ for (const m of output.matchAll(failWsRe)) add(`${m[1]}/${m[2]}`)
47
+
48
+ const failRe = new RegExp(`^\\s*❌\\s+(\\.?[\\w][\\w./-]*\\.(?:${FILE_EXTS}))${failSep}`, 'gm')
49
+ for (const m of output.matchAll(failRe)) add(m[1])
50
+
51
+ // 2. Generic-regex: підхоплює файли з ✅-рядків та описів (контекст для читання).
52
+ // Workspace: [npm] skills/foo.mjs
53
+ const wsRe = new RegExp(`\\[([\\w-]+)\\]\\s+([\\w./][\\w./-]*\\.(?:${FILE_EXTS}))(?::\\d+)?`, 'gm')
54
+ for (const m of output.matchAll(wsRe)) add(`${m[1]}/${m[2]}`)
55
+
56
+ // Без workspace: path/to/file.ext або ./file.ext
57
+ const re = new RegExp(`(?:^|\\s)(\\.?\\w[\\w./-]*\\.(?:${FILE_EXTS}))(?::\\d+)?`, 'gm')
58
+ for (const m of output.matchAll(re)) add(m[1])
50
59
 
51
60
  return results
52
61
  }
@@ -1,6 +1,6 @@
1
1
  /** @see ./docs/t0.md */
2
2
  import { spawnSync } from 'node:child_process'
3
- import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { appendFileSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
4
4
  import { join } from 'node:path'
5
5
 
6
6
  import { runConformanceCheck } from './run-conformance-check.mjs'
@@ -116,6 +116,121 @@ const PATTERNS = [
116
116
  }
117
117
  return { ok: true, action: `створено change-файл (${CHANGE_BUMP}/${CHANGE_SECTION}): ${created.join(', ')}` }
118
118
  }
119
+ },
120
+
121
+ // ── bun-bunfig-create ────────────────────────────────────────────────────────
122
+ // Violation: «Відсутній bunfig.toml — створи з [install] linker = "hoisted"»
123
+ // Fix: створити bunfig.toml з канонічним вмістом (bun.mdc)
124
+ {
125
+ id: 'bun-bunfig-create',
126
+ test: out => /Відсутній bunfig\.toml/.test(out),
127
+ apply: (_out, cwd) => {
128
+ const target = join(cwd, 'bunfig.toml')
129
+ if (existsSync(target)) return { ok: false, action: 'bunfig.toml вже існує' }
130
+ writeFileSync(target, '[install]\nlinker = "hoisted"\n', 'utf8')
131
+ return { ok: true, action: 'створено bunfig.toml' }
132
+ }
133
+ },
134
+
135
+ // ── bun-yarn-dir-remove ──────────────────────────────────────────────────────
136
+ // Violation: «Знайдено директорію .yarn — видали її»
137
+ // Fix: рекурсивно видалити .yarn/
138
+ {
139
+ id: 'bun-yarn-dir-remove',
140
+ test: out => /Знайдено директорію \.yarn/.test(out),
141
+ apply: (_out, cwd) => {
142
+ const target = join(cwd, '.yarn')
143
+ if (!existsSync(target)) return { ok: false, action: '.yarn не знайдено' }
144
+ rmSync(target, { recursive: true, force: true })
145
+ return { ok: true, action: 'видалено .yarn/' }
146
+ }
147
+ },
148
+
149
+ // ── style-stylelintignore-create ─────────────────────────────────────────────
150
+ // Violation: «.stylelintignore не існує — створи з вмістом: dist/»
151
+ // Fix: створити .stylelintignore з рядком dist/
152
+ {
153
+ id: 'style-stylelintignore-create',
154
+ test: out => /\.stylelintignore не існує/.test(out),
155
+ apply: (_out, cwd) => {
156
+ writeFileSync(join(cwd, '.stylelintignore'), 'dist/\n', 'utf8')
157
+ return { ok: true, action: 'створено .stylelintignore' }
158
+ }
159
+ },
160
+
161
+ // ── style-stylelintignore-dist-add ───────────────────────────────────────────
162
+ // Violation: «.stylelintignore не містить рядка dist/»
163
+ // Fix: дописати dist/ до існуючого .stylelintignore
164
+ {
165
+ id: 'style-stylelintignore-dist-add',
166
+ test: out => /\.stylelintignore не містить рядка dist\//.test(out),
167
+ apply: (_out, cwd) => {
168
+ const target = join(cwd, '.stylelintignore')
169
+ appendFileSync(target, '\ndist/\n', 'utf8')
170
+ return { ok: true, action: 'додано dist/ до .stylelintignore' }
171
+ }
172
+ },
173
+
174
+ // ── style-pkg-stylelint-add ──────────────────────────────────────────────────
175
+ // Violation: «Немає конфігу stylelint — додай "stylelint": {...} до package.json»
176
+ // Fix: додати поле stylelint до кореневого package.json
177
+ {
178
+ id: 'style-pkg-stylelint-add',
179
+ test: out => /Немає конфігу stylelint/.test(out),
180
+ apply: (_out, cwd) => {
181
+ const pkgPath = join(cwd, 'package.json')
182
+ if (!existsSync(pkgPath)) return { ok: false, action: 'package.json не знайдено' }
183
+ let pkg
184
+ try {
185
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
186
+ } catch {
187
+ return { ok: false, action: 'package.json: невалідний JSON' }
188
+ }
189
+ if (pkg.stylelint) return { ok: false, action: 'stylelint вже є в package.json' }
190
+ pkg.stylelint = { extends: '@nitra/stylelint-config' }
191
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8')
192
+ return { ok: true, action: 'додано stylelint до package.json' }
193
+ }
194
+ },
195
+
196
+ // ── js-run-jsconfig-create ───────────────────────────────────────────────────
197
+ // Violation: «[packages/api] є каталог src/, але немає jsconfig.json»
198
+ // Fix: для кожного воркспейсу з violation створити канонічний jsconfig.json
199
+ // (NodeNext + include: src/**/*; шаблон: js-run/policy/jsconfig/template/)
200
+ {
201
+ id: 'js-run-jsconfig-create',
202
+ test: out => /є каталог src\/, але немає jsconfig\.json/.test(out),
203
+ apply: (out, cwd) => {
204
+ const RE = /\[([^\]]+)\] є каталог src\/, але немає jsconfig\.json/gu
205
+ const matches = [...out.matchAll(RE)]
206
+ if (matches.length === 0) return { ok: false, action: 'no match' }
207
+ const canonical =
208
+ JSON.stringify(
209
+ {
210
+ compilerOptions: {
211
+ lib: ['esnext'],
212
+ module: 'NodeNext',
213
+ moduleResolution: 'NodeNext',
214
+ target: 'esnext',
215
+ checkJs: false
216
+ },
217
+ include: ['src/**/*']
218
+ },
219
+ null,
220
+ 2
221
+ ) + '\n'
222
+ const created = []
223
+ for (const m of matches) {
224
+ const ws = m[1]
225
+ const target = join(cwd, ws, 'jsconfig.json')
226
+ if (!existsSync(target)) {
227
+ writeFileSync(target, canonical, 'utf8')
228
+ created.push(ws)
229
+ }
230
+ }
231
+ if (created.length === 0) return { ok: false, action: 'jsconfig.json вже існує в усіх воркспейсах' }
232
+ return { ok: true, action: `створено jsconfig.json: ${created.join(', ')}` }
233
+ }
119
234
  }
120
235
  ]
121
236