@nitra/cursor 5.4.0 → 6.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 (37) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/bin/n-cursor.js +4 -2
  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 +28 -20
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [6.0.0] - 2026-06-14
4
+
5
+ ### Added
6
+
7
+ - fix-doc-files: пер-файловий таймінг у виводі — `<total>s (llm <llmS>/<N> calls, orch <orchS>)`: видно, скільки часу зайняла модель (і кількість LLM-викликів) проти JS-оркестрації. `generateDoc` повертає `llmMs`/`llmCalls`; облік через прозору обгортку `callLlm` (синхронні spawnSync-виклики, послідовна генерація — без гонок).
8
+
9
+ ### Changed
10
+
11
+ - 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})
12
+
3
13
  ## [5.4.0] - 2026-06-14
4
14
 
5
15
  ### Added
package/bin/n-cursor.js CHANGED
@@ -1686,12 +1686,14 @@ try {
1686
1686
  break
1687
1687
  }
1688
1688
  case 'lint': {
1689
- process.exitCode = await runLint({ ci: false })
1689
+ // Дві ортогональні осі: --full (scope: весь репо vs дельта vs origin) × --read-only (behavior).
1690
+ process.exitCode = await runLint({ full: args.includes('--full'), readOnly: args.includes('--read-only') })
1690
1691
 
1691
1692
  break
1692
1693
  }
1693
1694
  case 'lint-ci': {
1694
- process.exitCode = await runLint({ ci: true })
1695
+ // CI = весь репо в read-only (нуль мутацій, нуль LLM) — еквівалент `lint --read-only --full`.
1696
+ process.exitCode = await runLint({ full: true, readOnly: true })
1695
1697
 
1696
1698
  break
1697
1699
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "5.4.0",
3
+ "version": "6.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,38 @@
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'
13
17
  import { cwd as processCwd } from 'node:process'
14
18
 
15
- import { parseRuleLintPhase, readRuleMetaRaw } from './lib/rule-meta.mjs'
16
- import { collectChangedFiles } from './lib/changed-files.mjs'
19
+ import { parseRuleLintSpec, readRuleMetaRaw } from './lib/rule-meta.mjs'
20
+ import { collectChangedFilesSince, resolveChangedBase } from './lib/changed-files.mjs'
17
21
 
18
22
  const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
19
23
  const RULES_DIR = join(PACKAGE_ROOT, 'rules')
20
24
 
21
25
  /**
22
- * Вибирає id правил для фази, алфавітно.
26
+ * Вибирає id правил для контексту, алфавітно.
23
27
  * @param {Record<string, {lint?: unknown}>} metaById мапа id → meta-обʼєкт
24
- * @param {'quick'|'ci'} phase цільова фаза (quick → лише quick; ciquick+ci)
28
+ * @param {boolean} full `false` → лише `per-file` правила; `true` усі (`per-file` ∪ `full`)
25
29
  * @returns {string[]} відсортовані id
26
30
  */
27
- export function selectLintRules(metaById, phase) {
31
+ export function selectLintRules(metaById, full) {
28
32
  const out = []
29
33
  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)
34
+ const scope = parseRuleLintSpec(raw?.lint)
35
+ if (scope === 'per-file' || (full && scope === 'full')) out.push(id)
32
36
  }
33
37
  return out.toSorted((a, b) => a.localeCompare(b))
34
38
  }
@@ -52,22 +56,26 @@ function readAllMeta(rulesDir) {
52
56
 
53
57
  /**
54
58
  * Запускає lint-оркестрацію.
55
- * @param {{ ci?: boolean, cwd?: string, rulesDir?: string, log?: (s: string) => void }} [opts] параметри
59
+ * @param {{ full?: boolean, readOnly?: boolean, cwd?: string, rulesDir?: string, log?: (s: string) => void }} [opts] параметри
60
+ * - `full` — весь репо (`true`) проти дельти vs origin (`false`, default);
61
+ * - `readOnly` — лише детект без мутацій (`true`) проти fix (`false`, default).
56
62
  * @returns {Promise<number>} exit code
57
63
  */
58
64
  export async function runLint(opts = {}) {
59
- const ci = opts.ci === true
65
+ const full = opts.full === true
66
+ const readOnly = opts.readOnly === true
60
67
  const cwd = opts.cwd ?? processCwd()
61
68
  const rulesDir = opts.rulesDir ?? RULES_DIR
62
69
  const log = opts.log ?? (s => process.stdout.write(s))
63
70
 
64
- const changed = ci ? undefined : collectChangedFiles(cwd)
65
- if (!ci && changed.length === 0) {
66
- log('\nℹ️ lint: немає змінених файлів нічого перевіряти.\n')
71
+ // Default scope дельта vs origin (merge-base main/origin/main); `--full` — весь репо.
72
+ const changed = full ? undefined : collectChangedFilesSince(resolveChangedBase(cwd), cwd)
73
+ if (!full && changed.length === 0) {
74
+ log('\nℹ️ lint: немає змінених файлів відносно origin — нічого перевіряти.\n')
67
75
  return 0
68
76
  }
69
77
 
70
- const ids = selectLintRules(readAllMeta(rulesDir), ci ? 'ci' : 'quick')
78
+ const ids = selectLintRules(readAllMeta(rulesDir), full)
71
79
  for (const id of ids) {
72
80
  const lintPath = join(rulesDir, id, 'js', 'lint.mjs')
73
81
  if (!existsSync(lintPath)) {
@@ -75,7 +83,7 @@ export async function runLint(opts = {}) {
75
83
  continue
76
84
  }
77
85
  const mod = await import(lintPath)
78
- const code = await mod.lint(changed, cwd)
86
+ const code = await mod.lint(changed, cwd, { readOnly })
79
87
  if (code !== 0) return code
80
88
  }
81
89
  return 0