@nitra/cursor 5.4.0 → 7.0.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/bin/n-cursor.js +25 -42
  3. package/package.json +1 -1
  4. package/rules/doc-files/js/docgen-files-batch.mjs +18 -5
  5. package/rules/doc-files/js/docgen-gen.mjs +46 -5
  6. package/rules/doc-files/js/docgen-scan.mjs +2 -2
  7. package/rules/doc-files/js/docs/docgen-files-batch.md +1 -1
  8. package/rules/doc-files/js/docs/docgen-gen.md +1 -1
  9. package/rules/doc-files/js/docs/docgen-scan.md +1 -1
  10. package/rules/doc-files/meta.json +1 -1
  11. package/rules/ga/meta.json +1 -1
  12. package/rules/js-lint/js/docs/lint.md +1 -1
  13. package/rules/js-lint/js/lint.mjs +19 -12
  14. package/rules/js-lint/js-lint.mdc +1 -1
  15. package/rules/js-lint/meta.json +1 -1
  16. package/rules/js-lint-ci/js-lint-ci.mdc +1 -1
  17. package/rules/js-lint-ci/meta.json +1 -1
  18. package/rules/npm-module/js/docs/rule_meta.md +1 -1
  19. package/rules/npm-module/js/rule_meta.mjs +3 -3
  20. package/rules/rego/meta.json +1 -1
  21. package/rules/security/meta.json +1 -1
  22. package/rules/style-lint/js/docs/lint.md +1 -1
  23. package/rules/style-lint/js/lint.mjs +4 -3
  24. package/rules/style-lint/meta.json +1 -1
  25. package/rules/text/js/docs/lint.md +1 -1
  26. package/rules/text/js/lint.mjs +5 -3
  27. package/rules/text/lint/docs/lint.md +1 -1
  28. package/rules/text/lint/docs/run-dotenv-linter.md +1 -1
  29. package/rules/text/lint/docs/run-shellcheck.md +1 -1
  30. package/rules/text/lint/lint.mjs +13 -9
  31. package/rules/text/lint/run-dotenv-linter.mjs +13 -10
  32. package/rules/text/lint/run-shellcheck.mjs +10 -6
  33. package/rules/text/meta.json +1 -1
  34. package/scripts/docs/lint-cli.md +1 -1
  35. package/scripts/lib/docs/rule-meta.md +1 -1
  36. package/scripts/lib/rule-meta.mjs +10 -6
  37. package/scripts/lint-cli.mjs +78 -20
  38. package/scripts/post-tool-use-fix.mjs +17 -65
  39. package/skills/fix/SKILL.md +13 -14
  40. package/skills/lint/SKILL.md +7 -8
  41. /package/{skills/fix/js → scripts/lib/fix}/docs/llm-worker.md +0 -0
  42. /package/{skills/fix/js → scripts/lib/fix}/docs/orchestrator.md +0 -0
  43. /package/{skills/fix/js → scripts/lib/fix}/docs/t0.md +0 -0
  44. /package/{skills/fix/js → scripts/lib/fix}/llm-worker.mjs +0 -0
  45. /package/{skills/fix/js → scripts/lib/fix}/orchestrator.mjs +0 -0
  46. /package/{skills/fix/js → scripts/lib/fix}/t0.mjs +0 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## [7.0.0] - 2026-06-14
4
+
5
+ ### Changed
6
+
7
+ - lint --full поглинає конформність (колишній fix): read-only → детект через per-rule fix.mjs run(); fix → convergence-движок. Конформність як whole-repo фаза лише у --full
8
+ - lint приймає фільтр правил (lint <rule>) → конформність лише цих правил (мапить колишній fix <rule>); hk pre-commit changelog → lint changelog
9
+ - governance: знято заборону паралельного eslint — паралельно по диз'юнктних файлах дозволено; серіалізувати лише whole-tree прогони того самого корпусу (CLAUDE.md-секція + n-lint SKILL)
10
+
11
+ ### Removed
12
+
13
+ - Видалено команди fix/check/fix-run; рух-движок конформності переміщено skills/fix/js→scripts/lib/fix і поглинуто в lint. PostToolUse-хук → read-only детект усіх правил (без роутингу). /n-fix → делегат на /n-lint. fix-t0/_fix-check лишаються внутрішніми фазами движка
14
+
15
+ ## [6.0.0] - 2026-06-14
16
+
17
+ ### Added
18
+
19
+ - fix-doc-files: пер-файловий таймінг у виводі — `<total>s (llm <llmS>/<N> calls, orch <orchS>)`: видно, скільки часу зайняла модель (і кількість LLM-викликів) проти JS-оркестрації. `generateDoc` повертає `llmMs`/`llmCalls`; облік через прозору обгортку `callLlm` (синхронні spawnSync-виклики, послідовна генерація — без гонок).
20
+
21
+ ### Changed
22
+
23
+ - lint: вісь scope per-file|full (база-origin) + вісь behavior fix-by-default/--read-only; meta.json:lint hard-rename quick|ci→per-file|full; lint-ci=--read-only --full; контракт lint(files,cwd,{readOnly})
24
+
3
25
  ## [5.4.0] - 2026-06-14
4
26
 
5
27
  ### Added
package/bin/n-cursor.js CHANGED
@@ -637,15 +637,16 @@ async function removeOrphanManagedSkillDirs(skillsRoot, configSkills) {
637
637
  }
638
638
 
639
639
  /**
640
- * Рендерить коротку секцію для CLAUDE.md: не розпаралелювати лінт (ESLint) між shells/субагентами.
640
+ * Рендерить коротку секцію для CLAUDE.md: паралелізм лінту по диз'юнктних файлах дозволено,
641
+ * серіалізувати лише whole-tree прогони того самого корпусу.
641
642
  * @returns {string[]} рядки для вставки (з порожнім рядком на початку)
642
643
  */
643
644
  function buildClaudeLintParallelismSectionLines() {
644
645
  return [
645
646
  '',
646
- '## Лінт і ESLint (без паралельних запусків)',
647
+ '## Лінт і ESLint (паралелізм)',
647
648
  '',
648
- 'Щоб не запускати **кілька** одночасних **`eslint`** не перевантажувати диск/CPU), **заборонено** стартувати `bun run lint` / `lint-js` / `eslint` **паралельно** в різних Bash-задачах, **фонових** shells чи **субагентах** (Task тощо). Має бути **один** послідовний прогон на сесію; команда **`/n-lint`****не** ділити на паралельні підзадачі. Деталі: `.cursor/skills/n-lint/SKILL.md`.',
649
+ 'Паралельний лінт по **різних** файлах **дозволено**: диз\'юнктні набори (per-file `lint` на змінених vs origin) не конфліктують і не перевантажують диск/CPU. Серіалізувати треба лише **whole-tree** прогони того самого корпусу (`bun run lint`, `n-cursor lint --full` по всьому репо) щоб не дублювати важкий full-scan. Деталі: `.cursor/skills/n-lint/SKILL.md`.',
649
650
  ''
650
651
  ]
651
652
  }
@@ -1597,7 +1598,7 @@ async function runSync() {
1597
1598
  * `--root`-команди `lint-doc-files`/`fix-doc-files`/`doc-files`/`doc-aggregate`/`rename-yaml-extensions`, `worktree`,
1598
1599
  * sub-лінтери) гард не зачіпає.
1599
1600
  */
1600
- const ROOT_GUARDED_COMMANDS = new Set([undefined, '', 'fix', 'check', 'lint', 'coverage', 'change', 'release'])
1601
+ const ROOT_GUARDED_COMMANDS = new Set([undefined, '', 'lint', 'coverage', 'change', 'release'])
1601
1602
 
1602
1603
  /**
1603
1604
  * Короткий опис дії для тексту root-guard помилки за іменем команди.
@@ -1610,12 +1611,8 @@ function describeRootGuardedAction(cmd) {
1610
1611
  case '': {
1611
1612
  return 'Дефолтна синхронізація скаффолдить .cursor/, .claude/, CLAUDE.md, .n-cursor.json і робить bun install у поточному каталозі'
1612
1613
  }
1613
- case 'fix':
1614
- case 'check': {
1615
- return '`fix` запускає programmatic-перевірки правил, що переписують конфіги в поточному каталозі'
1616
- }
1617
1614
  case 'lint': {
1618
- return '`lint` запускає авто-fix лінтерів (oxfmt/eslint --fix/stylelint --fix) у поточному каталозі'
1615
+ return '`lint` за замовчуванням авто-fix лінтерів (oxfmt/eslint --fix/stylelint --fix) і конформності (--full) у поточному каталозі'
1619
1616
  }
1620
1617
  case 'coverage': {
1621
1618
  return '`coverage` генерує COVERAGE.md і Stryker-артефакти в поточному каталозі'
@@ -1646,29 +1643,13 @@ try {
1646
1643
  }
1647
1644
  await ensureNitraCursorInRootDevDependencies(cwd())
1648
1645
  switch (command) {
1649
- case 'fix': {
1650
- const { runOrchestratorCli } = await import('../skills/fix/js/orchestrator.mjs')
1651
- process.exitCode = await runOrchestratorCli(args, cwd())
1652
- break
1653
- }
1654
1646
  case '_fix-check': {
1655
- // Внутрішня команда оркестратора не є публічним API.
1656
- // Повертає JSON {total, failed, rules:[{ruleId, ok, output}]} у stdout.
1647
+ // Внутрішня команда движка конформності (не публічний API): per-rule fix.mjs run() = детект.
1648
+ // Повертає JSON {total, failed, rules:[{ruleId, ok, output}]} у stdout. Викликається
1649
+ // конформність-фазою `lint` (read-only) і движком orchestrator/t0.
1657
1650
  await runFixCommand(args, { json: true })
1658
1651
  break
1659
1652
  }
1660
- case 'check': {
1661
- // Backward-compatibility alias. Перейменовано на `fix` у 1.13.84 (узгоджено з ім'ям файла `rules/<id>/fix.mjs`).
1662
- console.warn(
1663
- `⚠️ Команда \`check\` deprecated — використовуйте \`fix\` (\`npx ${PACKAGE_NAME} fix [<rule>...]\`)`
1664
- )
1665
- await runFixCommand(
1666
- args.filter(a => a !== '--json'),
1667
- { json: args.includes('--json') }
1668
- )
1669
-
1670
- break
1671
- }
1672
1653
  case 'rename-yaml-extensions': {
1673
1654
  const code = await runRenameYamlExtensionsCli(args)
1674
1655
  if (code !== 0) {
@@ -1686,12 +1667,21 @@ try {
1686
1667
  break
1687
1668
  }
1688
1669
  case 'lint': {
1689
- process.exitCode = await runLint({ ci: false })
1670
+ // Дві ортогональні осі: --full (scope: весь репо vs дельта vs origin) × --read-only (behavior).
1671
+ // Позиційні (не-флаг) аргументи — фільтр правил конформності (напр. `lint changelog`):
1672
+ // прогнати лише конформність цих правил, без лінтер-скану (мапить колишній `fix <rule>`).
1673
+ const rules = args.filter(a => !a.startsWith('-'))
1674
+ process.exitCode = await runLint({
1675
+ full: args.includes('--full'),
1676
+ readOnly: args.includes('--read-only'),
1677
+ rules
1678
+ })
1690
1679
 
1691
1680
  break
1692
1681
  }
1693
1682
  case 'lint-ci': {
1694
- process.exitCode = await runLint({ ci: true })
1683
+ // CI = весь репо в read-only (нуль мутацій, нуль LLM) — еквівалент `lint --read-only --full`.
1684
+ process.exitCode = await runLint({ full: true, readOnly: true })
1695
1685
 
1696
1686
  break
1697
1687
  }
@@ -1762,19 +1752,12 @@ try {
1762
1752
 
1763
1753
  break
1764
1754
  }
1765
- case 'fix-run': {
1766
- // Backward-compatibility alias → перенаправляємо на `fix`.
1767
- console.warn(`⚠️ \`fix-run\` deprecated — використовуйте \`fix\``)
1768
- const { runOrchestratorCli } = await import('../skills/fix/js/orchestrator.mjs')
1769
- process.exitCode = await runOrchestratorCli(args, cwd())
1770
- break
1771
- }
1772
1755
  case 'fix-t0': {
1773
- // n-cursor fix-t0 [rule...] T0-auto рівень n-fix оркестратора.
1774
- // Запускає fix --json, знаходить violation-output із детермінованим паттерном
1756
+ // Внутрішня фаза движка конформності (не публічний API): T0-auto рівень.
1757
+ // Запускає _fix-check, знаходить violation-output із детермінованим паттерном
1775
1758
  // (vscode-ext-add, rm-forbidden-file тощо), застосовує програмний фікс (0 LLM),
1776
- // перевіряє check-gate. Exit 0 = усі T0-паттерни закриті; 1 = є решта для LLM.
1777
- const { runT0AutoCli } = await import('../skills/fix/js/t0.mjs')
1759
+ // перевіряє check-gate. Викликається orchestrator.mjs (fix-режим конформності lint).
1760
+ const { runT0AutoCli } = await import('../scripts/lib/fix/t0.mjs')
1778
1761
  process.exitCode = await runT0AutoCli(args, cwd())
1779
1762
 
1780
1763
  break
@@ -1890,7 +1873,7 @@ try {
1890
1873
  default: {
1891
1874
  console.error(`❌ Невідома команда: ${command}`)
1892
1875
  console.error(
1893
- ` Очікується: (без аргументів) синхронізація правил, fix, check, rename-yaml-extensions, post-tool-use-fix, lint, lint-ga, lint-rego, lint-k8s, lint-docker, lint-text, lint-doc-files, fix-doc-files, coverage, coverage-fix, taze, start-check, fix-t0, change, release, skill, worktree, lint-ci, trace, doc-files, doc-aggregate`
1876
+ ` Очікується: (без аргументів) синхронізація правил, rename-yaml-extensions, post-tool-use-fix, lint, lint-ga, lint-rego, lint-k8s, lint-docker, lint-text, lint-doc-files, fix-doc-files, coverage, coverage-fix, taze, start-check, change, release, skill, worktree, lint-ci, trace, doc-files, doc-aggregate`
1894
1877
  )
1895
1878
  process.exitCode = 1
1896
1879
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "5.4.0",
3
+ "version": "7.0.0",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -90,6 +90,19 @@ function modeSuffix({ overwrite, retryDegraded }) {
90
90
  return ''
91
91
  }
92
92
 
93
+ /**
94
+ * Рядок таймінгу одного файлу: загальний час, час у LLM (і кількість викликів)
95
+ * та залишок — оркестрація (екстракт фактів, скоринг, парсинг, IO). Дає зрозуміти,
96
+ * скільки коштує сама модель проти JS-оркестрації.
97
+ * @param {{ ms: number, llmMs?: number, llmCalls?: number }} r результат generateDoc
98
+ * @returns {string} напр. `12.3s (llm 11.8s/7 calls, orch 0.5s)`
99
+ */
100
+ function fmtTiming(r) {
101
+ const s = ms => `${(ms / 1000).toFixed(1)}s`
102
+ const llmMs = r.llmMs ?? 0
103
+ return `${s(r.ms)} (llm ${s(llmMs)}/${r.llmCalls ?? 0} calls, orch ${s(r.ms - llmMs)})`
104
+ }
105
+
93
106
  /**
94
107
  * Генерує й штампує доку для одного файлу, оновлюючи лічильники й прогрес.
95
108
  * @param {object} file елемент scanForDocFiles
@@ -114,9 +127,9 @@ async function generateOne(file, root, progress, stats) {
114
127
  stats.ok++
115
128
  if (result.degraded) {
116
129
  stats.degraded++
117
- process.stdout.write(`⚠ degraded score=${result.score} crc=${crc}\n`)
130
+ process.stdout.write(`⚠ degraded score=${result.score} crc=${crc} ${fmtTiming(result)}\n`)
118
131
  } else {
119
- process.stdout.write(`✓ score=${result.score ?? '—'} crc=${crc}\n`)
132
+ process.stdout.write(`✓ score=${result.score ?? '—'} crc=${crc} ${fmtTiming(result)}\n`)
120
133
  }
121
134
  } catch (error) {
122
135
  stats.err++
@@ -137,7 +150,7 @@ function reportStats(stats) {
137
150
  for (const e of stats.errors) console.log(` - ${e}`)
138
151
  }
139
152
  if (stats.degraded > 0) {
140
- console.log(`Degraded-доки перегенеровуються пізніше: npx @nitra/cursor doc-files gen --retry-degraded`)
153
+ console.log(`Degraded-доки перегенеровуються пізніше: npx @nitra/cursor fix-doc-files --retry-degraded`)
141
154
  }
142
155
  }
143
156
 
@@ -164,7 +177,7 @@ export async function runDocFilesGenCli(argv) {
164
177
 
165
178
  const problem = preflightProblem()
166
179
  if (problem) {
167
- console.error(`✗ doc-files gen: ${problem}`)
180
+ console.error(`✗ fix-doc-files: ${problem}`)
168
181
  return 1
169
182
  }
170
183
 
@@ -201,7 +214,7 @@ export function runDocFilesStampCli(argv) {
201
214
  writeFileSync(docAbs, stampDoc(md, file.sourcePath, crc, score === null ? null : { score, issues }))
202
215
  stamped++
203
216
  }
204
- console.log(`✓ doc-files stamp: оновлено frontmatter у ${stamped} доці(ах).`)
217
+ console.log(`✓ fix-doc-files --stamp: оновлено frontmatter у ${stamped} доці(ах).`)
205
218
  return 0
206
219
  }
207
220
 
@@ -4,7 +4,7 @@ import { basename } from 'node:path'
4
4
  import { env } from 'node:process'
5
5
  import { resolveModel } from '../../../lib/models.mjs'
6
6
  import { DEFAULT_OMLX_MODEL } from '../../../lib/omlx.mjs'
7
- import { callLlm } from '../../../lib/llm.mjs'
7
+ import { callLlm as callLlmRaw } from '../../../lib/llm.mjs'
8
8
  import { isRunAsCli } from '../../../scripts/cli-entry.mjs'
9
9
  import { docPathForSource } from './docgen-scan.mjs'
10
10
  import { extractFacts } from './docgen-extract.mjs'
@@ -19,6 +19,26 @@ import {
19
19
  guaranteesFromMarkers
20
20
  } from './docgen-prompts.mjs'
21
21
 
22
+ /** Облік LLM-викликів і часу в них у межах однієї генерації (скидається на старті generateDoc). */
23
+ let llmMeter = { calls: 0, ms: 0 }
24
+
25
+ /**
26
+ * Обгортка callLlm з обліком: лічить кількість викликів і сумарний час у них.
27
+ * callLlm синхронний (spawnSync/curl), генерація одного файлу послідовна — лічильник без гонок.
28
+ * Усі виклики `callLlm(...)` у цьому модулі йдуть через неї автоматично (імпорт як callLlmRaw).
29
+ * @param {...any} args ті самі аргументи, що й у callLlm з lib/llm.mjs
30
+ * @returns {string} відповідь моделі
31
+ */
32
+ function callLlm(...args) {
33
+ const started = Date.now()
34
+ try {
35
+ return callLlmRaw(...args)
36
+ } finally {
37
+ llmMeter.calls += 1
38
+ llmMeter.ms += Date.now() - started
39
+ }
40
+ }
41
+
22
42
  const FENCE_OPEN_RE = /^```[a-z]*\n?/
23
43
  const FENCE_CLOSE_RE = /\n?```\s*$/
24
44
  const LEADING_HEADING_RE = /^#{1,6}[ \t]{1,8}[^\n]{0,400}\n{1,8}/
@@ -360,12 +380,13 @@ export const DEFAULT_LOCAL_MODEL = env.N_CURSOR_DOCGEN_MODEL ?? (resolveModel('m
360
380
  * позначається `degraded`, рішення про перегенерацію приймає batch/користувач.
361
381
  * @param {string} file абсолютний шлях джерела
362
382
  * @param {{ model?: string, threshold?: number, existingMd?: string|null }} [opts] model-id, поріг degraded, наявна дока (для збереження захищеної секції)
363
- * @returns {{ md: string, ms: number, score: number|null, issues: string[], degraded: boolean, model: string }} документ і метадані генерації
383
+ * @returns {{ md: string, ms: number, llmMs: number, llmCalls: number, score: number|null, issues: string[], degraded: boolean, model: string }} документ і метадані генерації (ms — увесь файл; llmMs/llmCalls — лише LLM; решта ms — оркестрація)
364
384
  */
365
385
  export function generateDoc(file, { model = DEFAULT_LOCAL_MODEL, threshold = QUALITY_THRESHOLD, existingMd = null } = {}) {
366
386
  const src = readFileSync(file, 'utf8')
367
387
  const facts = extractFacts(src, file)
368
388
  const t0 = Date.now()
389
+ llmMeter = { calls: 0, ms: 0 }
369
390
 
370
391
  // Варіант B: захищена секція «Призначення» з наявної доки — зберегти й подати як контекст
371
392
  const intent = existingMd ? splitProtected(existingMd).body : null
@@ -376,7 +397,16 @@ export function generateDoc(file, { model = DEFAULT_LOCAL_MODEL, threshold = QUA
376
397
 
377
398
  // unsupported (vue/py до юніт-шару): скорер не застосовний — score=null, не degraded
378
399
  if (facts.unsupported) {
379
- return { ...r, ms: Date.now() - t0, score: null, issues: [], degraded: false, model }
400
+ return {
401
+ ...r,
402
+ ms: Date.now() - t0,
403
+ llmMs: llmMeter.ms,
404
+ llmCalls: llmMeter.calls,
405
+ score: null,
406
+ issues: [],
407
+ degraded: false,
408
+ model
409
+ }
380
410
  }
381
411
 
382
412
  // Stage 2.5: детермінований скоринг (0 токенів)
@@ -399,7 +429,16 @@ export function generateDoc(file, { model = DEFAULT_LOCAL_MODEL, threshold = QUA
399
429
  }
400
430
  }
401
431
 
402
- return { ...r, ms: Date.now() - t0, score, issues, degraded: score < threshold, model }
432
+ return {
433
+ ...r,
434
+ ms: Date.now() - t0,
435
+ llmMs: llmMeter.ms,
436
+ llmCalls: llmMeter.calls,
437
+ score,
438
+ issues,
439
+ degraded: score < threshold,
440
+ model
441
+ }
403
442
  }
404
443
 
405
444
  // CLI: node docgen-gen.mjs <file> [--model <m>]
@@ -416,6 +455,8 @@ if (isRunAsCli(import.meta.url)) {
416
455
  const existingMd = existsSync(docPath) ? readFileSync(docPath, 'utf8') : null
417
456
  const r = generateDoc(file, { model, existingMd })
418
457
  const issuesTxt = r.issues?.length ? ` issues=${r.issues.join(',')}` : ''
419
- process.stderr.write(`[local ${r.model}] ${r.ms}ms / score=${r.score}${r.degraded ? ' DEGRADED' : ''}${issuesTxt}\n`)
458
+ process.stderr.write(
459
+ `[local ${r.model}] ${r.ms}ms (llm ${r.llmMs}ms/${r.llmCalls} calls, orch ${r.ms - r.llmMs}ms) / score=${r.score}${r.degraded ? ' DEGRADED' : ''}${issuesTxt}\n`
460
+ )
420
461
  process.stdout.write(r.md)
421
462
  }
@@ -229,7 +229,7 @@ function runDegradedReport(root) {
229
229
  })
230
230
  .join('\n')
231
231
  console.log(
232
- `⚠ doc-files: degraded-док ${degraded.length} (score < ${QUALITY_THRESHOLD}):\n${list}\n→ перегенеруй: npx @nitra/cursor doc-files gen --retry-degraded`
232
+ `⚠ doc-files: degraded-док ${degraded.length} (score < ${QUALITY_THRESHOLD}):\n${list}\n→ перегенеруй: npx @nitra/cursor fix-doc-files --retry-degraded`
233
233
  )
234
234
  return 0
235
235
  }
@@ -285,7 +285,7 @@ export async function runDocFilesCheckCli(argv) {
285
285
  // Великий прогін: Stop-гейт не блокує, лише попереджає (захист від нескінченного блоку).
286
286
  if (gitMode && stale.length > gateMax) {
287
287
  console.error(
288
- `⚠ doc-files: застарілих док ${stale.length} (> ${gateMax}) — гейт не блокує. Запусти масовий прогін:\n npx @nitra/cursor doc-files gen`
288
+ `⚠ doc-files: застарілих док ${stale.length} (> ${gateMax}) — гейт не блокує. Запусти масовий прогін:\n npx @nitra/cursor fix-doc-files`
289
289
  )
290
290
  return 0
291
291
  }
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  docgen:
3
3
  source: npm/rules/doc-files/js/docgen-files-batch.mjs
4
- crc: 5c9b8d72
4
+ crc: 6f01f8b9
5
5
  score: 95
6
6
  ---
7
7
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  docgen:
3
3
  source: npm/rules/doc-files/js/docgen-gen.mjs
4
- crc: e2af04d6
4
+ crc: 70215974
5
5
  score: 100
6
6
  ---
7
7
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  docgen:
3
3
  source: npm/rules/doc-files/js/docgen-scan.mjs
4
- crc: 46f11827
4
+ crc: dcc90d44
5
5
  score: 100
6
6
  ---
7
7
 
@@ -1 +1 @@
1
- { "auto": "завжди", "lint": "quick" }
1
+ { "auto": "завжди", "lint": "per-file" }
@@ -1 +1 @@
1
- { "auto": { "glob": ".github/workflows/**" }, "lint": "ci" }
1
+ { "auto": { "glob": ".github/workflows/**" }, "lint": "full" }
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  docgen:
3
3
  source: npm/rules/js-lint/js/lint.mjs
4
- crc: c90c15eb
4
+ crc: 1f38613e
5
5
  score: 100
6
6
  ---
7
7
 
@@ -54,14 +54,15 @@ function runJson(args, cwd) {
54
54
  }
55
55
 
56
56
  /**
57
- * Full-режим (ci): лінт усього проєкту зі стрімінгом і fail-fast (без класифікації).
57
+ * Full-режим (--full): лінт усього проєкту зі стрімінгом і fail-fast (без класифікації).
58
58
  * @param {string} cwd корінь
59
+ * @param {boolean} readOnly true → без `--fix` (детект, нуль мутацій — CI)
59
60
  * @returns {number} exit code
60
61
  */
61
- function lintFullProject(cwd) {
62
- const ox = runInherit(['oxlint', '--fix'], cwd)
62
+ function lintFullProject(cwd, readOnly) {
63
+ const ox = runInherit(readOnly ? ['oxlint'] : ['oxlint', '--fix'], cwd)
63
64
  if (ox !== 0) return ox
64
- return runInherit(['eslint', '--fix', '.'], cwd)
65
+ return runInherit(readOnly ? ['eslint', '.'] : ['eslint', '--fix', '.'], cwd)
65
66
  }
66
67
 
67
68
  /**
@@ -69,12 +70,16 @@ function lintFullProject(cwd) {
69
70
  * на introduced / pre-existing (беклог #6/A). Блокування на будь-якому finding.
70
71
  * @param {string[]} js js-подібні змінені файли
71
72
  * @param {string} cwd корінь
73
+ * @param {boolean} readOnly true → пропустити фікс-пас (детект, нуль мутацій)
72
74
  * @returns {number} exit code (0 — чисто; 1 — лишились findings)
73
75
  */
74
- function lintChangedClassified(js, cwd) {
76
+ function lintChangedClassified(js, cwd, readOnly) {
75
77
  // Фікс-пас обох інструментів (послідовно; обидва — щоб репорт показав повну картину).
76
- runFix(['oxlint', '--fix', ...js], cwd)
77
- runFix(['eslint', '--fix', ...js], cwd)
78
+ // У read-only пропускаємо — лише детект без мутацій (CI / pre-commit).
79
+ if (!readOnly) {
80
+ runFix(['oxlint', '--fix', ...js], cwd)
81
+ runFix(['eslint', '--fix', ...js], cwd)
82
+ }
78
83
 
79
84
  // Репорт-пас по ФІНАЛЬНОМУ (пост-фікс) файлу — рядки findings і diff узгоджені.
80
85
  const oxRes = runJson(['oxlint', '--format=json', ...js], cwd)
@@ -99,16 +104,18 @@ function lintChangedClassified(js, cwd) {
99
104
  }
100
105
 
101
106
  /**
102
- * Запускає oxlint+eslint з автофіксом.
103
- * @param {string[] | undefined} files quick: лише ці файли; undefined: весь проєкт
107
+ * Запускає oxlint+eslint. За замовчуванням — з автофіксом; `opts.readOnly` — лише детект.
108
+ * @param {string[] | undefined} files per-file: лише ці файли; undefined: весь проєкт (--full)
104
109
  * @param {string} [cwd] корінь репо
110
+ * @param {{ readOnly?: boolean }} [opts] readOnly → без `--fix` (нуль мутацій)
105
111
  * @returns {Promise<number>} 0 — OK, ≠0 — порушення
106
112
  */
107
- export function lint(files, cwd = process.cwd()) {
113
+ export function lint(files, cwd = process.cwd(), opts = {}) {
114
+ const readOnly = opts.readOnly === true
108
115
  if (files === undefined) {
109
- return Promise.resolve(lintFullProject(cwd))
116
+ return Promise.resolve(lintFullProject(cwd, readOnly))
110
117
  }
111
118
  const js = filterJsFiles(files)
112
119
  if (js.length === 0) return Promise.resolve(0)
113
- return Promise.resolve(lintChangedClassified(js, cwd))
120
+ return Promise.resolve(lintChangedClassified(js, cwd, readOnly))
114
121
  }
@@ -75,7 +75,7 @@ version: '1.30'
75
75
 
76
76
  ## knip
77
77
 
78
- Залежнісний аналіз (knip — невикористані залежності/експорти, `knip.json` канон) крос-файловий, тож винесений у правило `js-lint-ci` (`lint: ci`). Див. `js-lint-ci`.
78
+ Залежнісний аналіз (knip — невикористані залежності/експорти, `knip.json` канон) крос-файловий, тож винесений у правило `js-lint-ci` (`lint: full`). Див. `js-lint-ci`.
79
79
 
80
80
  ## jscpd: рефакторинг і структура
81
81
 
@@ -1 +1 @@
1
- { "auto": { "glob": ["**/*.mjs", "**/*.cjs", "**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"] }, "lint": "quick" }
1
+ { "auto": { "glob": ["**/*.mjs", "**/*.cjs", "**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"] }, "lint": "per-file" }
@@ -10,7 +10,7 @@ version: '1.0'
10
10
  `jscpd` і `knip` аналізують увесь граф проєкту, тож мають сенс лише у повному прогоні
11
11
  `npx @nitra/cursor lint-ci` (не у швидкому `lint` по змінених файлах). Per-file режиму нема.
12
12
 
13
- Швидкий етап js-lint (oxlint/eslint) — у правилі `js-lint` (`lint: quick`).
13
+ Швидкий етап js-lint (oxlint/eslint) — у правилі `js-lint` (`lint: per-file`).
14
14
 
15
15
  ## Залежнісна політика (що не додавати)
16
16
 
@@ -1 +1 @@
1
- { "auto": { "glob": ["**/*.mjs", "**/*.cjs", "**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"] }, "lint": "ci" }
1
+ { "auto": { "glob": ["**/*.mjs", "**/*.cjs", "**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"] }, "lint": "full" }
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  docgen:
3
3
  source: npm/rules/npm-module/js/rule_meta.mjs
4
- crc: fa29bd00
4
+ crc: 8262678c
5
5
  score: 100
6
6
  ---
7
7
 
@@ -3,7 +3,7 @@ import { existsSync, readdirSync } from 'node:fs'
3
3
  import { join } from 'node:path'
4
4
 
5
5
  import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
6
- import { parseRuleAutoSpec, parseRuleLintPhase, readRuleMetaRaw } from '../../../scripts/lib/rule-meta.mjs'
6
+ import { parseRuleAutoSpec, parseRuleLintSpec, readRuleMetaRaw } from '../../../scripts/lib/rule-meta.mjs'
7
7
  import { RULE_PREDICATES } from '../../../scripts/lib/rule-predicates.mjs'
8
8
 
9
9
  /**
@@ -37,8 +37,8 @@ function checkAutoField(id, raw, reporter) {
37
37
  */
38
38
  function checkLintField(id, ruleDir, raw, reporter) {
39
39
  if (raw.lint === undefined) return true
40
- if (parseRuleLintPhase(raw.lint) === null) {
41
- reporter.fail(`rules/${id}: meta.json.lint нерозпізнане (очікується "quick"|"ci")`)
40
+ if (parseRuleLintSpec(raw.lint) === null) {
41
+ reporter.fail(`rules/${id}: meta.json.lint нерозпізнане (очікується "per-file"|"full")`)
42
42
  return false
43
43
  }
44
44
  if (!existsSync(join(ruleDir, 'js', 'lint.mjs'))) {
@@ -1 +1 @@
1
- { "auto": { "glob": "**/*.rego" }, "lint": "ci" }
1
+ { "auto": { "glob": "**/*.rego" }, "lint": "full" }
@@ -1 +1 @@
1
- { "auto": "завжди", "lint": "ci" }
1
+ { "auto": "завжди", "lint": "per-file" }
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  docgen:
3
3
  source: npm/rules/style-lint/js/lint.mjs
4
- crc: 94e067b3
4
+ crc: 2013a66b
5
5
  score: 100
6
6
  ---
7
7
 
@@ -12,12 +12,13 @@ export function filterStyleFiles(files) {
12
12
  }
13
13
 
14
14
  /**
15
- * @param {string[] | undefined} files quick: ці файли; undefined: весь проєкт
15
+ * @param {string[] | undefined} files per-file: ці файли; undefined: весь проєкт (--full)
16
16
  * @param {string} [cwd] корінь
17
+ * @param {{ readOnly?: boolean }} [opts] readOnly → без `--fix` (детект, нуль мутацій)
17
18
  * @returns {Promise<number>} exit code
18
19
  */
19
- export function lint(files, cwd = process.cwd()) {
20
- const args = ['stylelint', '--fix']
20
+ export function lint(files, cwd = process.cwd(), opts = {}) {
21
+ const args = opts.readOnly === true ? ['stylelint'] : ['stylelint', '--fix']
21
22
  if (files === undefined) {
22
23
  args.push('**/*.{css,scss,vue}')
23
24
  } else {
@@ -1 +1 @@
1
- { "auto": { "glob": ["**/*.css", "**/*.vue"] }, "lint": "quick" }
1
+ { "auto": { "glob": ["**/*.css", "**/*.vue"] }, "lint": "per-file" }
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  docgen:
3
3
  source: npm/rules/text/js/lint.mjs
4
- crc: 4ee054a0
4
+ crc: 49aab7ce
5
5
  score: 100
6
6
  ---
7
7
 
@@ -1,12 +1,14 @@
1
1
  /**
2
- * Ci-крок text: делегує у наявний CLI правила (per-file режиму немає — `files` ігнорується).
2
+ * Крок text: делегує у наявний CLI правила (per-file режиму немає — `files` ігнорується).
3
3
  */
4
4
  import { runLintTextCli } from '../lint/lint.mjs'
5
5
 
6
6
  /**
7
7
  * @param {string[] | undefined} _files ігнорується (whole-repo аналіз)
8
+ * @param {string} [_cwd] корінь (ігнорується — CLI працює від process.cwd())
9
+ * @param {{ readOnly?: boolean }} [opts] readOnly → детект без авто-фіксу (нуль мутацій)
8
10
  * @returns {Promise<number>} exit code
9
11
  */
10
- export function lint(_files) {
11
- return runLintTextCli()
12
+ export function lint(_files, _cwd, opts = {}) {
13
+ return runLintTextCli({ readOnly: opts.readOnly === true })
12
14
  }
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  docgen:
3
3
  source: npm/rules/text/lint/lint.mjs
4
- crc: 05f3f108
4
+ crc: bdaef0f8
5
5
  ---
6
6
 
7
7
  # `lint.mjs` — CLI-обгортка `lint-text`
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  docgen:
3
3
  source: npm/rules/text/lint/run-dotenv-linter.mjs
4
- crc: 8bb94af4
4
+ crc: 4719ac66
5
5
  ---
6
6
 
7
7
  # run-dotenv-linter.mjs
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  docgen:
3
3
  source: npm/rules/text/lint/run-shellcheck.mjs
4
- crc: e6fa8c23
4
+ crc: 6b2daaa8
5
5
  ---
6
6
 
7
7
  # run-shellcheck.mjs
@@ -95,28 +95,30 @@ function preflight(dep) {
95
95
 
96
96
  /**
97
97
  * Внутрішні кроки `lint-text` без локу.
98
+ * @param {boolean} [readOnly] true → лише детект без авто-фіксу (нуль мутацій — CI/pre-commit)
98
99
  * @returns {number} 0 — все OK, інакше — код першого кроку, що впав
99
100
  */
100
- function runLintTextSteps() {
101
+ function runLintTextSteps(readOnly = false) {
101
102
  // Auto-install: throws on failure → propagates as exit 1 from runStandardLint
102
103
  ensureTool('shellcheck')
103
104
  ensureTool('dotenv-linter')
104
105
 
105
- // patch is hint-only (system tool)
106
- if (!preflight(PATCH_PREFLIGHT)) return 1
106
+ // patch потрібен лише для авто-фіксу shellcheck; у read-only пропускаємо preflight.
107
+ if (!readOnly && !preflight(PATCH_PREFLIGHT)) return 1
107
108
 
108
109
  const cspellCode = runLintStep('cspell', 'npx', ['cspell', '.'])
109
110
  if (cspellCode !== 0) return cspellCode
110
111
 
111
- console.log('\n▶ shellcheck (авто-фікс + фінальна перевірка *.sh)')
112
- const shellcheckCode = runShellcheckText()
112
+ console.log(`\n▶ shellcheck (${readOnly ? 'перевірка' : 'авто-фікс + фінальна перевірка'} *.sh)`)
113
+ const shellcheckCode = runShellcheckText(process.cwd(), readOnly)
113
114
  if (shellcheckCode !== 0) return shellcheckCode
114
115
 
115
- console.log('\n▶ dotenv-linter (авто-фікс + фінальна перевірка .env*)')
116
- const dotenvCode = runDotenvLinter()
116
+ console.log(`\n▶ dotenv-linter (${readOnly ? 'перевірка' : 'авто-фікс + фінальна перевірка'} .env*)`)
117
+ const dotenvCode = runDotenvLinter(process.cwd(), readOnly)
117
118
  if (dotenvCode !== 0) return dotenvCode
118
119
 
119
- const markdownlintCode = runLintStep('markdownlint', 'bunx', ['markdownlint-cli2', '--fix', '**/*.md', '**/*.mdc'])
120
+ const mdArgs = readOnly ? ['markdownlint-cli2', '**/*.md', '**/*.mdc'] : ['markdownlint-cli2', '--fix', '**/*.md', '**/*.mdc']
121
+ const markdownlintCode = runLintStep('markdownlint', 'bunx', mdArgs)
120
122
  if (markdownlintCode !== 0) return markdownlintCode
121
123
 
122
124
  console.log('\n▶ v8r (schema-валідація json/json5/yaml/yml/toml)')
@@ -125,6 +127,8 @@ function runLintTextSteps() {
125
127
 
126
128
  /**
127
129
  * Публічна CLI-форма: серіалізує через `withLock('lint-text')` + дедуп за станом git-дерева.
130
+ * @param {{ readOnly?: boolean }} [opts] readOnly → детект без авто-фіксу
128
131
  * @returns {Promise<number>} код виходу
129
132
  */
130
- export const runLintTextCli = () => runStandardLint(import.meta.dirname, () => runLintTextSteps())
133
+ export const runLintTextCli = (opts = {}) =>
134
+ runStandardLint(import.meta.dirname, () => runLintTextSteps(opts.readOnly === true))
@@ -52,9 +52,10 @@ function buildExcludeArgs() {
52
52
  /**
53
53
  * Запускає dotenv-linter з авто-фіксом і фінальною перевіркою.
54
54
  * @param {string} [cwd] робочий каталог (за замовчуванням `process.cwd()`)
55
+ * @param {boolean} [readOnly] true → пропустити авто-фікс (`fix`), лише `check` (нуль мутацій)
55
56
  * @returns {number} 0 — OK; 1 — інструмент відсутній або є залишкові порушення
56
57
  */
57
- export function runDotenvLinter(cwd = process.cwd()) {
58
+ export function runDotenvLinter(cwd = process.cwd(), readOnly = false) {
58
59
  const root = resolve(cwd)
59
60
  const bin = resolveCmd('dotenv-linter')
60
61
  if (!bin) {
@@ -63,15 +64,17 @@ export function runDotenvLinter(cwd = process.cwd()) {
63
64
  }
64
65
 
65
66
  const exclude = buildExcludeArgs()
66
- const fixRun = spawnSync(bin, ['fix', '-r', '--no-backup', '--quiet', ...exclude, '.'], {
67
- cwd: root,
68
- encoding: 'utf8',
69
- env: process.env,
70
- stdio: ['ignore', 'pipe', 'pipe']
71
- })
72
- if (fixRun.error) {
73
- process.stderr.write(`${fixRun.error.message}\n`)
74
- return 1
67
+ if (!readOnly) {
68
+ const fixRun = spawnSync(bin, ['fix', '-r', '--no-backup', '--quiet', ...exclude, '.'], {
69
+ cwd: root,
70
+ encoding: 'utf8',
71
+ env: process.env,
72
+ stdio: ['ignore', 'pipe', 'pipe']
73
+ })
74
+ if (fixRun.error) {
75
+ process.stderr.write(`${fixRun.error.message}\n`)
76
+ return 1
77
+ }
75
78
  }
76
79
 
77
80
  const checkRun = spawnSync(bin, ['check', '-r', '--quiet', ...exclude, '.'], {
@@ -96,17 +96,19 @@ export function listShellScriptPaths(cwd) {
96
96
  /**
97
97
  * Запускає shellcheck із авто-виправленнями і фінальною перевіркою.
98
98
  * @param {string} [cwd] робочий каталог (за замовчуванням `process.cwd()`)
99
+ * @param {boolean} [readOnly] true → пропустити авто-фікс (diff+patch), лише фінальна перевірка
99
100
  * @returns {number} 0 — OK; 1 — помилка середовища або залишкові зауваження shellcheck
100
101
  */
101
- export function runShellcheckText(cwd = process.cwd()) {
102
+ export function runShellcheckText(cwd = process.cwd(), readOnly = false) {
102
103
  const root = resolve(cwd)
103
104
  const shellcheck = resolveCmd('shellcheck')
104
105
  if (!shellcheck) {
105
106
  printShellcheckInstallHints()
106
107
  return 1
107
108
  }
108
- const patchBin = resolveCmd('patch')
109
- if (!patchBin) {
109
+ // patch потрібен лише для авто-фіксу (diff+patch); у read-only його відсутність не блокує детект.
110
+ const patchBin = readOnly ? null : resolveCmd('patch')
111
+ if (!readOnly && !patchBin) {
110
112
  printPatchInstallHints()
111
113
  return 1
112
114
  }
@@ -116,9 +118,11 @@ export function runShellcheckText(cwd = process.cwd()) {
116
118
  return 0
117
119
  }
118
120
 
119
- for (const rel of files) {
120
- const fixCode = autofixOneFile(shellcheck, patchBin, root, rel)
121
- if (fixCode !== 0) return fixCode
121
+ if (!readOnly) {
122
+ for (const rel of files) {
123
+ const fixCode = autofixOneFile(shellcheck, /** @type {string} */ (patchBin), root, rel)
124
+ if (fixCode !== 0) return fixCode
125
+ }
122
126
  }
123
127
 
124
128
  return runFinalShellcheck(shellcheck, files, root)
@@ -1 +1 @@
1
- { "auto": "завжди", "lint": "ci" }
1
+ { "auto": "завжди", "lint": "per-file" }
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  docgen:
3
3
  source: npm/scripts/lint-cli.mjs
4
- crc: d4a7562d
4
+ crc: 9e0a12b9
5
5
  score: 100
6
6
  ---
7
7
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  docgen:
3
3
  source: npm/scripts/lib/rule-meta.mjs
4
- crc: 4475d5ff
4
+ crc: fa5ca866
5
5
  ---
6
6
 
7
7
  # rule-meta.mjs
@@ -48,16 +48,20 @@ export function parseRuleAutoSpec(value) {
48
48
  return null
49
49
  }
50
50
 
51
- /** Допустимі фази lint. */
52
- const LINT_PHASES = new Set(['quick', 'ci'])
51
+ /** Допустимі значення `meta.json.lint` (вісь scope: чи детектор дробиться на changed-set). */
52
+ const LINT_SCOPES = new Set(['per-file', 'full'])
53
53
 
54
54
  /**
55
- * Нормалізує значення `meta.json.lint` у фазу lint.
55
+ * Нормалізує значення `meta.json.lint` у scope детектора.
56
+ * - `"per-file"` — детектор декомпозується на змінені файли (дельта vs origin);
57
+ * - `"full"` — нероздільно крос-файловий (лише `--full` / CI).
58
+ * Об'єктна форма `{scope, ci}` скасована: CI=`--read-only --full` ганяє все повністю,
59
+ * тож per-rule CI-override не потрібен (spec 2026-06-14-lint-rule-consolidation §3-А).
56
60
  * @param {unknown} value значення поля `lint`
57
- * @returns {'quick' | 'ci' | null} фаза або `null` (відсутнє/невалідне = не lint-крок)
61
+ * @returns {'per-file' | 'full' | null} scope або `null` (відсутнє/невалідне = не lint-крок)
58
62
  */
59
- export function parseRuleLintPhase(value) {
60
- return typeof value === 'string' && LINT_PHASES.has(value) ? /** @type {'quick'|'ci'} */ (value) : null
63
+ export function parseRuleLintSpec(value) {
64
+ return typeof value === 'string' && LINT_SCOPES.has(value) ? /** @type {'per-file'|'full'} */ (value) : null
61
65
  }
62
66
 
63
67
  /**
@@ -1,34 +1,74 @@
1
1
  /**
2
- * Оркестратор `n-cursor lint` (quick) / `n-cursor lint-ci` (full).
2
+ * Оркестратор `n-cursor lint` — дві ортогональні осі (spec 2026-06-14-lint-rule-consolidation
3
+ * + компаньйон 2026-06-14-lint-orchestrator-fix-readonly-unification):
4
+ * - **scope** (`--full`): default = дельта vs origin (лише `per-file` правила);
5
+ * `--full` = весь репо (`per-file` ∪ `full` правила);
6
+ * - **behavior** (`--read-only`): default = fix; `--read-only` = лише детект без мутацій.
3
7
  *
4
- * Data-driven: сканує `rules/<id>/meta.json` за полем `lint` (`quick`|`ci`),
5
- * послідовно (заборона паралельного eslint) викликає `rules/<id>/js/lint.mjs`:
6
- * - quick: `lint(changedFiles)` лише змінені файли (git diff HEAD + untracked);
7
- * - ci: `lint(undefined)` — весь проєкт.
8
- * Порядок правил — алфавітний; ci-набір = quick ∪ ci. Fail-fast: перший ненульовий код спиняє.
8
+ * Data-driven: сканує `rules/<id>/meta.json` за полем `lint` (`per-file`|`full`),
9
+ * викликає `rules/<id>/js/lint.mjs` → `lint(files, cwd, { readOnly })`:
10
+ * - default scope: `files` = змінені відносно origin (`collectChangedFilesSince`);
11
+ * - `--full`: `files = undefined` — весь проєкт.
12
+ * Порядок правил — алфавітний. Fail-fast: перший ненульовий код спиняє.
9
13
  */
10
14
  import { existsSync, readdirSync } from 'node:fs'
11
15
  import { dirname, join } from 'node:path'
12
16
  import { fileURLToPath } from 'node:url'
17
+ import { spawnSync } from 'node:child_process'
13
18
  import { cwd as processCwd } from 'node:process'
14
19
 
15
- import { parseRuleLintPhase, readRuleMetaRaw } from './lib/rule-meta.mjs'
16
- import { collectChangedFiles } from './lib/changed-files.mjs'
20
+ import { parseRuleLintSpec, readRuleMetaRaw } from './lib/rule-meta.mjs'
21
+ import { collectChangedFilesSince, resolveChangedBase } from './lib/changed-files.mjs'
17
22
 
18
23
  const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
19
24
  const RULES_DIR = join(PACKAGE_ROOT, 'rules')
25
+ const N_CURSOR_BIN = join(PACKAGE_ROOT, 'bin', 'n-cursor.js')
20
26
 
21
27
  /**
22
- * Вибирає id правил для фази, алфавітно.
28
+ * Конформність-фаза lint (whole-repo: config/file/workflow conformance — те, що раніше робив `fix`).
29
+ * Per-file декомпозиції немає, тож виконується лише у `--full`.
30
+ * - read-only: детект через `_fix-check` (per-rule `fix.mjs run()` = перевірка, без мутацій);
31
+ * - fix: convergence-движок (check → Tier0 → omlx) через orchestrator.
32
+ * @param {string} cwd корінь
33
+ * @param {boolean} readOnly true → лише детект (нуль мутацій)
34
+ * @param {(s: string) => void} log логер
35
+ * @param {string[]} [filter] фільтр правил (порожній — усі)
36
+ * @returns {Promise<number>} 0 — чисто, 1 — порушення/помилка
37
+ */
38
+ async function runConformance(cwd, readOnly, log, filter = []) {
39
+ if (!readOnly) {
40
+ const { runOrchestratorCli } = await import('./lib/fix/orchestrator.mjs')
41
+ return runOrchestratorCli(filter, cwd)
42
+ }
43
+ const r = spawnSync('bun', [N_CURSOR_BIN, '_fix-check', ...filter], { cwd, encoding: 'utf8', timeout: 600_000 })
44
+ let parsed = null
45
+ try {
46
+ parsed = JSON.parse((r.stdout ?? '').trim())
47
+ } catch {
48
+ parsed = null
49
+ }
50
+ if (!parsed) {
51
+ log('❌ lint: конформність — помилка перевірки (_fix-check не повернув JSON)\n')
52
+ return 1
53
+ }
54
+ const failed = parsed.rules.filter(/** @param {{ok:boolean}} x */ x => !x.ok)
55
+ if (failed.length === 0) return 0
56
+ log(`❌ lint: конформність — ${failed.length} порушень: ${failed.map(/** @param {{ruleId:string}} x */ x => x.ruleId).join(', ')}\n`)
57
+ for (const f of failed) if (f.output) log(`${f.output}\n`)
58
+ return 1
59
+ }
60
+
61
+ /**
62
+ * Вибирає id правил для контексту, алфавітно.
23
63
  * @param {Record<string, {lint?: unknown}>} metaById мапа id → meta-обʼєкт
24
- * @param {'quick'|'ci'} phase цільова фаза (quick → лише quick; ciquick+ci)
64
+ * @param {boolean} full `false` → лише `per-file` правила; `true` усі (`per-file` ∪ `full`)
25
65
  * @returns {string[]} відсортовані id
26
66
  */
27
- export function selectLintRules(metaById, phase) {
67
+ export function selectLintRules(metaById, full) {
28
68
  const out = []
29
69
  for (const [id, raw] of Object.entries(metaById)) {
30
- const p = parseRuleLintPhase(raw?.lint)
31
- if (p === 'quick' || (phase === 'ci' && p === 'ci')) out.push(id)
70
+ const scope = parseRuleLintSpec(raw?.lint)
71
+ if (scope === 'per-file' || (full && scope === 'full')) out.push(id)
32
72
  }
33
73
  return out.toSorted((a, b) => a.localeCompare(b))
34
74
  }
@@ -52,22 +92,33 @@ function readAllMeta(rulesDir) {
52
92
 
53
93
  /**
54
94
  * Запускає lint-оркестрацію.
55
- * @param {{ ci?: boolean, cwd?: string, rulesDir?: string, log?: (s: string) => void }} [opts] параметри
95
+ * @param {{ full?: boolean, readOnly?: boolean, rules?: string[], cwd?: string, rulesDir?: string, log?: (s: string) => void }} [opts] параметри
96
+ * - `full` — весь репо (`true`) проти дельти vs origin (`false`, default);
97
+ * - `readOnly` — лише детект без мутацій (`true`) проти fix (`false`, default);
98
+ * - `rules` — непорожній фільтр → лише конформність цих правил (без лінтер-скану; мапить `fix <rule>`).
56
99
  * @returns {Promise<number>} exit code
57
100
  */
58
101
  export async function runLint(opts = {}) {
59
- const ci = opts.ci === true
102
+ const full = opts.full === true
103
+ const readOnly = opts.readOnly === true
104
+ const rules = Array.isArray(opts.rules) ? opts.rules : []
60
105
  const cwd = opts.cwd ?? processCwd()
61
106
  const rulesDir = opts.rulesDir ?? RULES_DIR
62
107
  const log = opts.log ?? (s => process.stdout.write(s))
63
108
 
64
- const changed = ci ? undefined : collectChangedFiles(cwd)
65
- if (!ci && changed.length === 0) {
66
- log('\nℹ️ lint: немає змінених файлів — нічого перевіряти.\n')
109
+ // Rule-filter режим (напр. `lint changelog` із hk): лише конформність указаних правил, без лінтерів.
110
+ if (rules.length > 0) {
111
+ return runConformance(cwd, readOnly, log, rules)
112
+ }
113
+
114
+ // Default scope — дельта vs origin (merge-base main/origin/main); `--full` — весь репо.
115
+ const changed = full ? undefined : collectChangedFilesSince(resolveChangedBase(cwd), cwd)
116
+ if (!full && changed.length === 0) {
117
+ log('\nℹ️ lint: немає змінених файлів відносно origin — нічого перевіряти.\n')
67
118
  return 0
68
119
  }
69
120
 
70
- const ids = selectLintRules(readAllMeta(rulesDir), ci ? 'ci' : 'quick')
121
+ const ids = selectLintRules(readAllMeta(rulesDir), full)
71
122
  for (const id of ids) {
72
123
  const lintPath = join(rulesDir, id, 'js', 'lint.mjs')
73
124
  if (!existsSync(lintPath)) {
@@ -75,8 +126,15 @@ export async function runLint(opts = {}) {
75
126
  continue
76
127
  }
77
128
  const mod = await import(lintPath)
78
- const code = await mod.lint(changed, cwd)
129
+ const code = await mod.lint(changed, cwd, { readOnly })
79
130
  if (code !== 0) return code
80
131
  }
132
+
133
+ // Конформність-фаза (поглинула `fix`): whole-repo, лише у `--full`. Кастомний rulesDir
134
+ // (юніт-тести селектора) — реальний пакет недоступний, тож пропускаємо.
135
+ if (full && opts.rulesDir === undefined) {
136
+ const conformanceCode = await runConformance(cwd, readOnly, log)
137
+ if (conformanceCode !== 0) return conformanceCode
138
+ }
81
139
  return 0
82
140
  }
@@ -1,65 +1,19 @@
1
1
  /**
2
- * PostToolUse hook для Claude Code: точкова маршрутизація `npx \@nitra/cursor fix`
3
- * за типом зміненого файла. Запускається після кожного `Edit` / `Write` / `MultiEdit`;
4
- * замінює дорогий синхронний `Stop`-хук, що ганяв повний `fix` усіх правил на кожному
5
- * turn-і.
2
+ * PostToolUse hook для Claude Code: read-only детект конформності **всіх** активованих правил
3
+ * після редагування файлу. Запускається після кожного `Edit` / `Write` / `MultiEdit`.
6
4
  *
7
- * Контракт:
8
- * - stdin Claude Code: JSON із `tool_input.file_path` (відносний шлях зміненого файла);
9
- * - exit 0, якщо файл не має маршрут (PostToolUse не блокує turn у будь-якому випадку,
10
- * але ми лишаємо exit-код прозорим — для діагностики);
11
- * - інакше spawn `npx --no \@nitra/cursor fix <rules…>` із передаванням exit-коду.
5
+ * Раніше хук маршрутизував змінений файл у релевантні правила й ганяв повний `fix` (автофікс
6
+ * + LLM) дорого, тож звужували. Тепер хук — **детект** (нуль мутацій, нуль LLM), тож роутинг
7
+ * зайвий: один виклик `_fix-check` (per-rule `fix.mjs run()` = перевірка) по всіх правилах.
12
8
  *
13
- * Маршрути впорядковані від найбільш специфічного до загального; перший збіг — переможець.
14
- * `docs/adr/**\/*.md` свідомо повертає `[]`: ADR-нормалізація вже покривається async
15
- * Stop-hook'ом `normalize-decisions.sh` повторний `fix adr` тут лише сповільнював би turn.
9
+ * Контракт:
10
+ * - stdin Claude Code: JSON із `tool_input.file_path`; якщо файлу немає (напр. Bash) — exit 0 (skip);
11
+ * - інакше spawn `_fix-check` (детект усіх правил), exit-код прозоро пробрасуємо (PostToolUse
12
+ * не блокує turn, але код лишаємо інформативним: 1 — є порушення конформності).
16
13
  */
17
14
  import { spawn } from 'node:child_process'
18
15
  import { once } from 'node:events'
19
16
 
20
- import picomatch from 'picomatch'
21
-
22
- /**
23
- * @typedef {object} Route
24
- * @property {string} pattern picomatch glob (з підтримкою `**` і `{a,b}`)
25
- * @property {string[]} rules ID правил `npm/rules/<id>` (бо `fix.mjs` обов'язковий)
26
- */
27
-
28
- /** Порядок важливий: специфічні маршрути (`.github/workflows/*`, `**\/k8s/**`) — перед загальними. */
29
- /** @type {readonly Route[]} */
30
- const ROUTES = Object.freeze([
31
- { pattern: 'docs/adr/**/*.md', rules: [] },
32
- { pattern: '.github/workflows/*.{yml,yaml}', rules: ['ga'] },
33
- { pattern: '**/k8s/**/*.{yaml,yml}', rules: ['k8s'] },
34
- { pattern: '**/*.vue', rules: ['js-lint', 'style-lint', 'vue'] },
35
- { pattern: '**/*.{mjs,js,cjs,ts,tsx,jsx}', rules: ['js-lint'] },
36
- { pattern: '**/*.{css,scss,sass}', rules: ['style-lint'] },
37
- { pattern: '**/*.rego', rules: ['rego'] },
38
- { pattern: '{**/,}Dockerfile', rules: ['docker'] },
39
- { pattern: '**/*.Dockerfile', rules: ['docker'] },
40
- { pattern: '**/*.sh', rules: ['security'] },
41
- { pattern: '{**/,}package.json', rules: ['npm-module', 'bun'] },
42
- { pattern: '**/*.md', rules: ['text'] }
43
- ])
44
-
45
- /**
46
- * Повертає список правил, які слід прогнати для зміненого `filePath`.
47
- * Перший збіг із `ROUTES` — переможець; невідомі шляхи / некоректні входи → `[]`.
48
- * @param {unknown} filePath відносний шлях зміненого файла зі stdin Claude Code
49
- * @returns {string[]} ID правил для `npx \@nitra/cursor fix`
50
- */
51
- export function routeFilePathToRules(filePath) {
52
- if (typeof filePath !== 'string' || filePath === '') {
53
- return []
54
- }
55
- for (const { pattern, rules } of ROUTES) {
56
- if (picomatch.isMatch(filePath, pattern, { dot: true })) {
57
- return [...rules]
58
- }
59
- }
60
- return []
61
- }
62
-
63
17
  /**
64
18
  * Зчитує stdin до EOF як utf8 рядок. На TTY — повертає `''` одразу.
65
19
  * @returns {Promise<string>} вміст stdin
@@ -87,7 +41,7 @@ async function readStdin() {
87
41
  * @param {string} stdinJson сирий вміст stdin
88
42
  * @returns {string | null} відносний шлях або `null`
89
43
  */
90
- function extractFilePath(stdinJson) {
44
+ export function extractFilePath(stdinJson) {
91
45
  if (!stdinJson) {
92
46
  return null
93
47
  }
@@ -103,27 +57,25 @@ function extractFilePath(stdinJson) {
103
57
  /**
104
58
  * Точка входу. Викликається з `bin/n-cursor.js` коли argv[0] === `post-tool-use-fix`.
105
59
  * Параметри доступні для інʼєкції для тестів: `stdinJson` обходить read від `process.stdin`,
106
- * `spawnFn` — заміна `node:child_process.spawn` (повертає EventEmitter-сумісний об'єкт).
107
- * @param {{ stdinJson?: string, spawnFn?: typeof spawn }} [options] параметри для тестів (ін'єкція stdin/spawn)
108
- * @returns {Promise<number>} exit code (0 — пропущено / fix ОК; інше — exit-код `fix`)
60
+ * `spawnFn` — заміна `node:child_process.spawn`.
61
+ * @param {{ stdinJson?: string, spawnFn?: typeof spawn }} [options] параметри для тестів
62
+ * @returns {Promise<number>} exit code (0 — пропущено / конформність ОК; інше — є порушення)
109
63
  */
110
64
  export async function runPostToolUseFixCli(options = {}) {
111
65
  const stdinJson = options.stdinJson ?? (await readStdin())
112
66
  const filePath = extractFilePath(stdinJson)
67
+ // Тільки після редагування файлу (Edit/Write/MultiEdit мають file_path); Bash тощо — skip.
113
68
  if (filePath === null) {
114
69
  return 0
115
70
  }
116
- const rules = routeFilePathToRules(filePath)
117
- if (rules.length === 0) {
118
- return 0
119
- }
120
71
  const spawnFn = options.spawnFn ?? spawn
121
- const child = spawnFn('npx', ['--no', '@nitra/cursor', 'fix', ...rules], { stdio: 'inherit' })
72
+ // Один read-only виклик: детект конформності всіх активованих правил, без роутингу.
73
+ const child = spawnFn('npx', ['--no', '@nitra/cursor', '_fix-check'], { stdio: 'inherit' })
122
74
  try {
123
75
  const [code] = await once(child, 'exit')
124
76
  return code ?? 1
125
77
  } catch (error) {
126
- process.stderr.write(`post-tool-use-fix: не вдалося запустити npx @nitra/cursor fix — ${error.message}\n`)
78
+ process.stderr.write(`post-tool-use-fix: не вдалося запустити детект конформності — ${error.message}\n`)
127
79
  return 1
128
80
  }
129
81
  }
@@ -1,23 +1,22 @@
1
1
  ---
2
2
  name: n-fix
3
3
  description: >-
4
- Виправити проєкт відповідно до всіх правил в .cursor/rules/
4
+ DEPRECATED використовуй /n-lint. fix злито в lint: `n-cursor lint` тепер і
5
+ детектить, і виправляє (конформність + лінтери) за один прохід.
5
6
  ---
6
7
 
7
- # n-fix — автоматичне виправлення проєкту
8
+ # n-fix — DEPRECATED (делегат на /n-lint)
8
9
 
9
- ## Scope
10
+ Команду `n-cursor fix` **видалено**: рух-движок конформності (convergence-loop /
11
+ check-gate / Tier0 / LLM) поглинуто в `lint` (спека
12
+ `docs/specs/2026-06-14-lint-orchestrator-fix-readonly-unification-design.md`).
10
13
 
11
- Цей скіл відповідає **лише за структуру** проєкту: щоб `.cursor/rules/` + `npx @nitra/cursor fix` були задоволені (наявність конфігів, залежностей, скриптів, GitHub workflows, відсутність заборонених файлів). **Лінт-порушення у самому коді** (ESLint, oxlint, jscpd, cspell, knip, sonarjs, stylelint тощо) — **поза скоупом**; їх діагностує й виправляє **`/n-lint`** (`bun run lint`).
14
+ **Використовуй `/n-lint`** замість цього скіла:
12
15
 
13
- ## Workflow
16
+ - `n-cursor lint` — дельта vs origin, **fix за замовчуванням** (лінтери на змінених файлах);
17
+ - `n-cursor lint --full` — весь репо + **конформність** (колишній `fix`: конфіги/файли/воркфлоу
18
+ через convergence-движок);
19
+ - `n-cursor lint --read-only [--full]` — лише детект, нуль мутацій (CI / pre-commit);
20
+ - `n-cursor lint <rule>` — конформність одного правила (колишній `fix <rule>`).
14
21
 
15
- ```bash
16
- n_cursor_npx fix
17
- ```
18
-
19
- Exit 0 = чисто, 1 = є unresolved (перевір вивід — буде список правил що не закрились після 3 ітерацій).
20
-
21
- Якщо змінились залежності — `bun i`. Якщо змінились JS/TS файли — `oxfmt .`.
22
-
23
- Для конкретних правил: `n_cursor_npx fix bun ga`.
22
+ Цей скіл лишено як тонкий делегат до наступного major; уся логіка — у `/n-lint`.
@@ -91,18 +91,17 @@ bun run lint
91
91
  - Якщо тестів **немає** або вони **не покривають** блок, який змінюєш — **спочатку** додай/розшир тести, переконайся, що вони стабільно проходять, **потім** роби рефакторинг, **потім** знову прогони тести й **`bun run lint`**, щоб підтвердити, що функціональність коректна й лінт чистий.
92
92
  - Якщо після рефакторингу тести або лінт падають — **не** залишай «половинчастий» рефакторинг: відкотись або доведи зміни до зеленого стану.
93
93
 
94
- ## Навантаження на macOS (багато процесів `eslint` / лаги)
94
+ ## Паралелізм і навантаження на macOS
95
95
 
96
- Часто це **не** «один ESLint розпаралелив себе всередині», а **кілька паралельних запусків** одного й того ж ланцюжка (**`bun run lint`**, **`bun run lint-js`**) у різних Bash-задачах агента (кілька shells). Кожен запуск = окремий процес **`eslint`** плюс **`oxlint`**, **`jscpd`** тощо диск і CPU перегружаються.
96
+ **Паралельно по різних файлах дозволено.** Диз'юнктні набори (per-file `n-cursor lint` на змінених vs origin) не конфліктують: кожен процес обробляє свій підмножину файлів, тож гонки за тим самим корпусом немає.
97
97
 
98
- **Чому взагалі кілька `eslint`:** оркестратор (Claude Code, Cursor тощо) може **розпаралелити** роботу: кілька **субагентів** / **паралельних Bash-задач** / **фонових shell**, і кожен **сам** виконує **`/n-lint`** або запускає **`eslint`**. Тоді навантаження **множиться**: не один прогон лінту, а **N прогонів** одночасно.
98
+ Проблема не паралелізм як такий, а **кілька одночасних whole-tree прогонів того самого корпусу** (**`bun run lint`**, **`n-cursor lint --full`**) у різних Bash-задачах/shells: кожен повторно сканує **весь** репо (eslint + oxlint + jscpd + knip), і диск/CPU перевантажуються дублюванням важкого full-scan.
99
99
 
100
- ### Що робити агенту під час виконання цього скілу (обов’язково)
100
+ ### Що робити агенту під час виконання цього скілу
101
101
 
102
- 1. **Один** запуск **`bun run lint`** (або всі кроки **`lint`**, як у **`package.json`**) — у **одному** foreground shell, **без** `run_in_background` / фонових копій тієї ж команди.
103
- 2. **Не** викликати **паралельні субагенти** (subagent, Task, «розбий на N паралельних завдань») лише заради лінту в одному репозиторії. Лінт не потребує шардінгу: один процес, послідовно.
104
- 3. Якщо ти **батьок-сесія** й уже делегував дочірнім задачам **інші** кроки **не** доручай дочірнім і **`bun run lint`**, залиш **лише** собі один **послідовний** прогон **після** змін у файлах, або **один** виклик цілого скілу на сесію.
105
- 4. Якщо сесія/користувач уже запускає лінт — **не** дублювати; зачекати завершення або **не** стартувати лінт повторно в іншому shell.
102
+ 1. **Whole-tree** прогін (**`bun run lint`** / **`lint --full`**) — **один** за раз, у одному foreground shell; **не** запускати другий full-прогін того самого корпусу паралельно.
103
+ 2. **Per-file** лінт по диз'юнктних файлах (різні субагенти на різні файли) **дозволено** й не потребує серіалізації.
104
+ 3. Якщо сесія/користувач уже запускає **whole-tree** лінтне дублювати його; зачекати завершення.
106
105
 
107
106
  **Що можна змінити у проєкті (локально або в `package.json`)**
108
107
 
File without changes
File without changes