@nitra/cursor 5.3.0 → 5.3.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.
@@ -6,6 +6,77 @@ import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
6
6
  import { parseRuleAutoSpec, parseRuleLintPhase, readRuleMetaRaw } from '../../../scripts/lib/rule-meta.mjs'
7
7
  import { RULE_PREDICATES } from '../../../scripts/lib/rule-predicates.mjs'
8
8
 
9
+ /**
10
+ * Перевіряє поле `auto` у meta.json одного правила.
11
+ * @param {string} id ідентифікатор правила
12
+ * @param {Record<string, unknown>} raw сирий meta.json
13
+ * @param {ReturnType<typeof createCheckReporter>} reporter репортер
14
+ * @returns {boolean} true, якщо поле валідне (або відсутнє)
15
+ */
16
+ function checkAutoField(id, raw, reporter) {
17
+ if (raw.auto === undefined) return true
18
+ const spec = parseRuleAutoSpec(raw.auto)
19
+ if (spec === null) {
20
+ reporter.fail(`rules/${id}: meta.json.auto нерозпізнане (очікується "завжди" / масив / {glob} / {predicate})`)
21
+ return false
22
+ }
23
+ if ('predicate' in spec && !Object.hasOwn(RULE_PREDICATES, spec.predicate)) {
24
+ reporter.fail(`rules/${id}: невідомий predicate "${spec.predicate}" (немає в RULE_PREDICATES)`)
25
+ return false
26
+ }
27
+ return true
28
+ }
29
+
30
+ /**
31
+ * Перевіряє поле `lint` у meta.json одного правила.
32
+ * @param {string} id ідентифікатор правила
33
+ * @param {string} ruleDir каталог правила
34
+ * @param {Record<string, unknown>} raw сирий meta.json
35
+ * @param {ReturnType<typeof createCheckReporter>} reporter репортер
36
+ * @returns {boolean} true, якщо поле валідне (або відсутнє)
37
+ */
38
+ function checkLintField(id, ruleDir, raw, reporter) {
39
+ if (raw.lint === undefined) return true
40
+ if (parseRuleLintPhase(raw.lint) === null) {
41
+ reporter.fail(`rules/${id}: meta.json.lint нерозпізнане (очікується "quick"|"ci")`)
42
+ return false
43
+ }
44
+ if (!existsSync(join(ruleDir, 'js', 'lint.mjs'))) {
45
+ reporter.fail(`rules/${id}: lint:"${raw.lint}" але немає js/lint.mjs`)
46
+ return false
47
+ }
48
+ return true
49
+ }
50
+
51
+ /**
52
+ * Валідує meta.json одного правила.
53
+ * @param {string} id ідентифікатор правила
54
+ * @param {string} ruleDir каталог правила
55
+ * @param {ReturnType<typeof createCheckReporter>} reporter репортер
56
+ * @returns {void}
57
+ */
58
+ function checkRule(id, ruleDir, reporter) {
59
+ let ruleOk = true
60
+
61
+ if (existsSync(join(ruleDir, 'auto.md'))) {
62
+ reporter.fail(`rules/${id}: залишковий auto.md — видали (метадані тепер у meta.json)`)
63
+ ruleOk = false
64
+ }
65
+
66
+ const raw = readRuleMetaRaw(ruleDir)
67
+ if (!raw) {
68
+ reporter.fail(`rules/${id}: відсутній або невалідний meta.json`)
69
+ return
70
+ }
71
+
72
+ if (!checkAutoField(id, raw, reporter)) ruleOk = false
73
+ if (!checkLintField(id, ruleDir, raw, reporter)) ruleOk = false
74
+
75
+ if (ruleOk) {
76
+ reporter.pass(`rules/${id}: meta.json валідний`)
77
+ }
78
+ }
79
+
9
80
  /**
10
81
  * Валідує всі `npm/rules/<id>/meta.json`.
11
82
  * @param {string} [cwd] корінь репозиторію
@@ -21,42 +92,7 @@ export function check(cwd = process.cwd()) {
21
92
 
22
93
  for (const entry of readdirSync(rulesDir, { withFileTypes: true })) {
23
94
  if (!entry.isDirectory() || entry.name.startsWith('.')) continue
24
- const id = entry.name
25
- const ruleDir = join(rulesDir, id)
26
- let ruleOk = true
27
-
28
- if (existsSync(join(ruleDir, 'auto.md'))) {
29
- reporter.fail(`rules/${id}: залишковий auto.md — видали (метадані тепер у meta.json)`)
30
- ruleOk = false
31
- }
32
-
33
- const raw = readRuleMetaRaw(ruleDir)
34
- if (!raw) {
35
- reporter.fail(`rules/${id}: відсутній або невалідний meta.json`)
36
- continue
37
- }
38
- if (raw.auto !== undefined) {
39
- const spec = parseRuleAutoSpec(raw.auto)
40
- if (spec === null) {
41
- reporter.fail(`rules/${id}: meta.json.auto нерозпізнане (очікується "завжди" / масив / {glob} / {predicate})`)
42
- ruleOk = false
43
- } else if ('predicate' in spec && !Object.hasOwn(RULE_PREDICATES, spec.predicate)) {
44
- reporter.fail(`rules/${id}: невідомий predicate "${spec.predicate}" (немає в RULE_PREDICATES)`)
45
- ruleOk = false
46
- }
47
- }
48
- if (raw.lint !== undefined) {
49
- if (parseRuleLintPhase(raw.lint) === null) {
50
- reporter.fail(`rules/${id}: meta.json.lint нерозпізнане (очікується "quick"|"ci")`)
51
- ruleOk = false
52
- } else if (!existsSync(join(ruleDir, 'js', 'lint.mjs'))) {
53
- reporter.fail(`rules/${id}: lint:"${raw.lint}" але немає js/lint.mjs`)
54
- ruleOk = false
55
- }
56
- }
57
- if (ruleOk) {
58
- reporter.pass(`rules/${id}: meta.json валідний`)
59
- }
95
+ checkRule(entry.name, join(rulesDir, entry.name), reporter)
60
96
  }
61
97
 
62
98
  return Promise.resolve(reporter.getExitCode())
@@ -5,6 +5,64 @@ import { join } from 'node:path'
5
5
  import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
6
6
  import { parseSkillAutoSpec, readSkillMetaRaw } from '../../../scripts/lib/skill-meta.mjs'
7
7
 
8
+ /**
9
+ * Перевіряє поля сирого meta.json одного скіла (без auto.md / відсутності файлу).
10
+ * @param {string} id ідентифікатор скіла
11
+ * @param {Record<string, unknown>} raw сирий meta.json
12
+ * @param {ReturnType<typeof createCheckReporter>} reporter репортер
13
+ * @returns {boolean} true, якщо всі поля валідні
14
+ */
15
+ function checkSkillFields(id, raw, reporter) {
16
+ let ok = true
17
+ if (typeof raw.worktree !== 'boolean') {
18
+ reporter.fail(`skills/${id}: meta.json.worktree має бути boolean`)
19
+ ok = false
20
+ }
21
+ if (raw.auto !== undefined && parseSkillAutoSpec(raw.auto) === null) {
22
+ reporter.fail(`skills/${id}: meta.json.auto нерозпізнане — очікується "завжди" або непорожній масив правил`)
23
+ ok = false
24
+ }
25
+ if (raw.requireRoot !== undefined && typeof raw.requireRoot !== 'boolean') {
26
+ reporter.fail(`skills/${id}: meta.json.requireRoot має бути boolean`)
27
+ ok = false
28
+ }
29
+ if (raw.worktree === true && raw.requireRoot === false) {
30
+ reporter.fail(
31
+ `skills/${id}: requireRoot:false суперечить worktree:true (worktree вже вимагає кореня — прибери поле)`
32
+ )
33
+ ok = false
34
+ }
35
+ return ok
36
+ }
37
+
38
+ /**
39
+ * Валідує meta.json одного скіла.
40
+ * @param {string} id ідентифікатор скіла
41
+ * @param {string} skillDir каталог скіла
42
+ * @param {ReturnType<typeof createCheckReporter>} reporter репортер
43
+ * @returns {void}
44
+ */
45
+ function checkSkill(id, skillDir, reporter) {
46
+ let skillOk = true
47
+
48
+ if (existsSync(join(skillDir, 'auto.md'))) {
49
+ reporter.fail(`skills/${id}: залишковий auto.md — видали (метадані тепер у meta.json)`)
50
+ skillOk = false
51
+ }
52
+
53
+ const raw = readSkillMetaRaw(skillDir)
54
+ if (!raw) {
55
+ reporter.fail(`skills/${id}: відсутній або невалідний meta.json (очікується {"auto"?, "worktree": bool})`)
56
+ return
57
+ }
58
+
59
+ if (!checkSkillFields(id, raw, reporter)) skillOk = false
60
+
61
+ if (skillOk) {
62
+ reporter.pass(`skills/${id}: meta.json валідний`)
63
+ }
64
+ }
65
+
8
66
  /**
9
67
  * Валідує всі `npm/skills/<id>/meta.json`.
10
68
  * @param {string} [cwd] корінь репозиторію
@@ -20,41 +78,7 @@ export function check(cwd = process.cwd()) {
20
78
 
21
79
  for (const entry of readdirSync(skillsDir, { withFileTypes: true })) {
22
80
  if (!entry.isDirectory() || entry.name.startsWith('.')) continue
23
- const id = entry.name
24
- const skillDir = join(skillsDir, id)
25
- let skillOk = true
26
-
27
- if (existsSync(join(skillDir, 'auto.md'))) {
28
- reporter.fail(`skills/${id}: залишковий auto.md — видали (метадані тепер у meta.json)`)
29
- skillOk = false
30
- }
31
-
32
- const raw = readSkillMetaRaw(skillDir)
33
- if (!raw) {
34
- reporter.fail(`skills/${id}: відсутній або невалідний meta.json (очікується {"auto"?, "worktree": bool})`)
35
- continue
36
- }
37
- if (typeof raw.worktree !== 'boolean') {
38
- reporter.fail(`skills/${id}: meta.json.worktree має бути boolean`)
39
- skillOk = false
40
- }
41
- if (raw.auto !== undefined && parseSkillAutoSpec(raw.auto) === null) {
42
- reporter.fail(`skills/${id}: meta.json.auto нерозпізнане — очікується "завжди" або непорожній масив правил`)
43
- skillOk = false
44
- }
45
- if (raw.requireRoot !== undefined && typeof raw.requireRoot !== 'boolean') {
46
- reporter.fail(`skills/${id}: meta.json.requireRoot має бути boolean`)
47
- skillOk = false
48
- }
49
- if (raw.worktree === true && raw.requireRoot === false) {
50
- reporter.fail(
51
- `skills/${id}: requireRoot:false суперечить worktree:true (worktree вже вимагає кореня — прибери поле)`
52
- )
53
- skillOk = false
54
- }
55
- if (skillOk) {
56
- reporter.pass(`skills/${id}: meta.json валідний`)
57
- }
81
+ checkSkill(entry.name, join(skillsDir, entry.name), reporter)
58
82
  }
59
83
 
60
84
  return Promise.resolve(reporter.getExitCode())
@@ -30,7 +30,7 @@ const FALLBACK_VERDICT = {
30
30
  * @param {string} prompt текст промпта
31
31
  * @param {string} model provider/model-id, `omlx/...` або '' для pi-дефолту
32
32
  * @returns {string} текст відповіді моделі
33
- * @throws якщо backend недоступний або повертає помилку
33
+ * @throws {Error} якщо backend недоступний або повертає помилку
34
34
  */
35
35
  function callModel(prompt, model) {
36
36
  return callLlm([{ role: 'user', content: prompt }], model, { timeoutMs: 60_000, caller: 'coverage' })
@@ -68,7 +68,7 @@ function classifyOne(group, mutant, cwd, callModelFn) {
68
68
  * Класифікує survived мутантів (resolveModel('min') → CLOUD_MIN → fallback).
69
69
  * @param {Array<{file: string, mutants: object[], exampleTest?: object|null, recommendationText?: string|null}>} survived список вцілілих мутантів
70
70
  * @param {string} cwd корінь проєкту
71
- * @param {{cachePath?: string, callModel?: Function}} [opts] ін'єкції для тестів
71
+ * @param {{cachePath?: string, callModel?: (prompt: string, model: string) => string}} [opts] ін'єкції для тестів
72
72
  * @returns {Promise<Array<{key: string, verdict: object}>>} verdicts
73
73
  */
74
74
  export function classify(survived, cwd, opts = {}) {
@@ -22,7 +22,7 @@ export const VerdictSchema = z.object({
22
22
  * Витягує JSON-об'єкт з raw-text LLM-відповіді і валідує через VerdictSchema.
23
23
  * @param {string} rawText raw-text відповідь LLM
24
24
  * @returns {{verdict: string, confidence: number, reason: string, suggestedTest?: string}} verdict
25
- * @throws якщо JSON не знайдено, не парситься, або не відповідає схемі
25
+ * @throws {Error} якщо JSON не знайдено, не парситься, або не відповідає схемі
26
26
  */
27
27
  export function parseVerdict(rawText) {
28
28
  const jsonStart = rawText.indexOf('{')
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Guard для дефолтної синхронізації `npx @nitra/cursor` (гілка без підкоманди).
2
+ * Guard для дефолтної синхронізації `npx \@nitra/cursor` (гілка без підкоманди).
3
3
  *
4
4
  * Дефолтний sync (`runSync` у `bin/n-cursor.js`) скаффолдить у `cwd()` керовані
5
5
  * артефакти — `.cursor/rules/`, `.cursor/skills/`, `.claude/`, `AGENTS.md`,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Визначає список id правил для `npx @nitra/cursor fix` без аргументів:
2
+ * Визначає список id правил для `npx \@nitra/cursor fix` без аргументів:
3
3
  * зчитує базові імена `*.mdc` у `.cursor/rules/` і залишає лише ті id,
4
4
  * для яких у пакеті є programmatic перевірка (JS-концерн або policy з target.json).
5
5
  */
@@ -23,35 +23,44 @@ const IGNORED_DIR_NAMES = new Set(['node_modules', '.git', '.next', '.turbo'])
23
23
  */
24
24
  async function anyDepInTree(root, keys) {
25
25
  const wanted = new Set(keys)
26
- let found = false
27
- /** @param {string} dir каталог обходу @returns {Promise<void>} */
26
+ /**
27
+ * Чи package.json за `abs` оголошує будь-який пакет із `wanted` у `dependencies`.
28
+ * @param {string} abs шлях до package.json
29
+ * @returns {Promise<boolean>} true, якщо знайдено хоч один
30
+ */
31
+ async function pkgDeclaresWanted(abs) {
32
+ try {
33
+ const deps = JSON.parse(await readFile(abs, 'utf8'))?.dependencies
34
+ if (deps && typeof deps === 'object' && !Array.isArray(deps)) {
35
+ for (const k of wanted) if (Object.hasOwn(deps, k)) return true
36
+ }
37
+ } catch {
38
+ /* ігноруємо пошкоджені package.json */
39
+ }
40
+ return false
41
+ }
42
+ /**
43
+ * @param {string} dir каталог обходу
44
+ * @returns {Promise<boolean>} true, якщо знайдено хоч один пакет
45
+ */
28
46
  async function walk(dir) {
29
- if (found) return
30
47
  let entries
31
48
  try {
32
49
  entries = await readdir(dir, { withFileTypes: true })
33
50
  } catch {
34
- return
51
+ return false
35
52
  }
36
53
  for (const entry of entries) {
37
- if (found) return
38
54
  const abs = join(dir, entry.name)
39
55
  if (entry.isDirectory()) {
40
- if (!IGNORED_DIR_NAMES.has(entry.name)) await walk(abs)
41
- } else if (entry.isFile() && entry.name === 'package.json') {
42
- try {
43
- const deps = JSON.parse(await readFile(abs, 'utf8'))?.dependencies
44
- if (deps && typeof deps === 'object' && !Array.isArray(deps)) {
45
- for (const k of wanted) if (Object.hasOwn(deps, k)) found = true
46
- }
47
- } catch {
48
- /* ігноруємо пошкоджені package.json */
49
- }
56
+ if (!IGNORED_DIR_NAMES.has(entry.name) && (await walk(abs))) return true
57
+ } else if (entry.isFile() && entry.name === 'package.json' && (await pkgDeclaresWanted(abs))) {
58
+ return true
50
59
  }
51
60
  }
61
+ return false
52
62
  }
53
- await walk(root)
54
- return found
63
+ return walk(root)
55
64
  }
56
65
 
57
66
  /**
@@ -62,7 +71,10 @@ async function anyDepInTree(root, keys) {
62
71
  async function nestedWithoutVite(root) {
63
72
  const rootPkg = join(root, 'package.json')
64
73
  let result = false
65
- /** @param {string} dir каталог @returns {Promise<void>} */
74
+ /**
75
+ * @param {string} dir каталог
76
+ * @returns {Promise<void>}
77
+ */
66
78
  async function walk(dir) {
67
79
  if (result) return
68
80
  let entries
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Standalone CLI runner для одного правила. Викликається з `rules/<id>/fix.mjs`
3
3
  * у блоці `if (import.meta.main)` — це робить `bun rules/<id>/fix.mjs` повним
4
- * еквівалентом старого `npx @nitra/cursor fix <id>`: читає `.n-cursor.json`,
4
+ * еквівалентом старого `npx \@nitra/cursor fix <id>`: читає `.n-cursor.json`,
5
5
  * перевіряє whitelist, друкує summary, повертає aggregated exit-code.
6
6
  *
7
7
  * Library-mode виклик з CLI orchestration — інше: див. `runStandardRule` + `fix.mjs::run(ctx)`.
@@ -5,7 +5,7 @@
5
5
  * Локальна логіка в правилах заборонена; розширення поведінки — через `ctx`-опції.
6
6
  *
7
7
  * Серіалізація: загортає виконання у `withLock('fix-<ruleId>')` — паралельні запуски
8
- * того самого правила (через `npx @nitra/cursor fix`, прямий `bun rules/<id>/fix.mjs`
8
+ * того самого правила (через `npx \@nitra/cursor fix`, прямий `bun rules/<id>/fix.mjs`
9
9
  * чи `run(ctx)`-композицію) дедупляться за станом git-дерева; різні правила можуть
10
10
  * виконуватись паралельно. Точка інтеграції — тут, щоб не дублювати лок у кожному
11
11
  * `fix.mjs`.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * PostToolUse hook для Claude Code: точкова маршрутизація `npx @nitra/cursor fix`
2
+ * PostToolUse hook для Claude Code: точкова маршрутизація `npx \@nitra/cursor fix`
3
3
  * за типом зміненого файла. Запускається після кожного `Edit` / `Write` / `MultiEdit`;
4
4
  * замінює дорогий синхронний `Stop`-хук, що ганяв повний `fix` усіх правил на кожному
5
5
  * turn-і.
@@ -8,7 +8,7 @@
8
8
  * - stdin Claude Code: JSON із `tool_input.file_path` (відносний шлях зміненого файла);
9
9
  * - exit 0, якщо файл не має маршрут (PostToolUse не блокує turn у будь-якому випадку,
10
10
  * але ми лишаємо exit-код прозорим — для діагностики);
11
- * - інакше spawn `npx --no @nitra/cursor fix <rules…>` із передаванням exit-коду.
11
+ * - інакше spawn `npx --no \@nitra/cursor fix <rules…>` із передаванням exit-коду.
12
12
  *
13
13
  * Маршрути впорядковані від найбільш специфічного до загального; перший збіг — переможець.
14
14
  * `docs/adr/**\/*.md` свідомо повертає `[]`: ADR-нормалізація вже покривається async
@@ -46,7 +46,7 @@ const ROUTES = Object.freeze([
46
46
  * Повертає список правил, які слід прогнати для зміненого `filePath`.
47
47
  * Перший збіг із `ROUTES` — переможець; невідомі шляхи / некоректні входи → `[]`.
48
48
  * @param {unknown} filePath відносний шлях зміненого файла зі stdin Claude Code
49
- * @returns {string[]} ID правил для `npx @nitra/cursor fix`
49
+ * @returns {string[]} ID правил для `npx \@nitra/cursor fix`
50
50
  */
51
51
  export function routeFilePathToRules(filePath) {
52
52
  if (typeof filePath !== 'string' || filePath === '') {
@@ -6,11 +6,11 @@
6
6
  * `.n-cursor.json`) — далі stdout або делегування в `cursor-agent` / `claude`.
7
7
  *
8
8
  * Підтримувані формати:
9
- * `npx @nitra/cursor skill list`
10
- * `npx @nitra/cursor skill taze`
11
- * `npx @nitra/cursor skill cursor taze`
12
- * `npx @nitra/cursor skill cursor taze "онови залежності"`
13
- * `npx @nitra/cursor skill claude taze` — те саме через Claude Code CLI
9
+ * `npx \@nitra/cursor skill list`
10
+ * `npx \@nitra/cursor skill taze`
11
+ * `npx \@nitra/cursor skill cursor taze`
12
+ * `npx \@nitra/cursor skill cursor taze "онови залежності"`
13
+ * `npx \@nitra/cursor skill claude taze` — те саме через Claude Code CLI
14
14
  */
15
15
 
16
16
  import { spawnSync } from 'node:child_process'
@@ -79,7 +79,7 @@ function listDescFiles(cwd) {
79
79
  /**
80
80
  * add: створити worktree від HEAD + .md-опис.
81
81
  * @param {string[]} rest [branch, ...descParts]
82
- * @param {{ cwd: string, log: Function, logError: Function, now: () => Date }} ctx контекст
82
+ * @param {{ cwd: string, log: (line: string) => void, logError: (line: string) => void, now: () => Date }} ctx контекст
83
83
  * @returns {number} exit code
84
84
  */
85
85
  function cmdAdd(rest, ctx) {
@@ -135,7 +135,7 @@ function cmdAdd(rest, ctx) {
135
135
  /**
136
136
  * remove: прибрати checkout + .md (гілку лишає).
137
137
  * @param {string[]} rest [branch, ...flags]
138
- * @param {{ cwd: string, log: Function, logError: Function }} ctx контекст
138
+ * @param {{ cwd: string, log: (line: string) => void, logError: (line: string) => void }} ctx контекст
139
139
  * @returns {number} exit code
140
140
  */
141
141
  function cmdRemove(rest, ctx) {
@@ -167,7 +167,7 @@ function cmdRemove(rest, ctx) {
167
167
 
168
168
  /**
169
169
  * list: git worktree list + вміст .md-описів.
170
- * @param {{ cwd: string, log: Function }} ctx контекст
170
+ * @param {{ cwd: string, log: (line: string) => void }} ctx контекст
171
171
  * @returns {number} exit code
172
172
  */
173
173
  function cmdList(ctx) {
@@ -181,7 +181,7 @@ function cmdList(ctx) {
181
181
 
182
182
  /**
183
183
  * prune: git worktree prune + видалити осиротілі .md.
184
- * @param {{ cwd: string, log: Function }} ctx контекст
184
+ * @param {{ cwd: string, log: (line: string) => void }} ctx контекст
185
185
  * @returns {number} exit code
186
186
  */
187
187
  function cmdPrune(ctx) {
@@ -198,7 +198,7 @@ function cmdPrune(ctx) {
198
198
  /**
199
199
  * Точка входу підкоманди worktree.
200
200
  * @param {string[]} argv аргументи після `worktree`
201
- * @param {{ cwd?: string, log?: Function, logError?: Function, now?: () => Date }} [options] ін'єкція для тестів
201
+ * @param {{ cwd?: string, log?: (line: string) => void, logError?: (line: string) => void, now?: () => Date }} [options] ін'єкція для тестів
202
202
  * @returns {Promise<number>} exit code
203
203
  */
204
204
  export function runWorktreeCli(argv, options = {}) {
@@ -65,7 +65,7 @@ function cleanJsDoc(raw) {
65
65
  }
66
66
 
67
67
  /**
68
- * Опис (без @-тегів) + параметри з @param як «name — опис».
68
+ * Опис (без @-тегів) + параметри з `@param` як «name — опис».
69
69
  * @param {string} raw сирий JSDoc-блок
70
70
  * @returns {{desc:string, params:Array<{name:string, desc:string}>, ret:string}} розпарсений опис, параметри й опис повернення
71
71
  */
@@ -79,6 +79,68 @@ function preflightProblem() {
79
79
  return `omlx помилка: ${hc.detail}`
80
80
  }
81
81
 
82
+ /**
83
+ * Текст-суфікс режиму для прогрес-рядка.
84
+ * @param {{ overwrite: boolean, retryDegraded: boolean }} mode режими
85
+ * @returns {string} ` (--overwrite)` / ` (--retry-degraded)` / порожній рядок
86
+ */
87
+ function modeSuffix({ overwrite, retryDegraded }) {
88
+ if (overwrite) return ' (--overwrite)'
89
+ if (retryDegraded) return ' (--retry-degraded)'
90
+ return ''
91
+ }
92
+
93
+ /**
94
+ * Генерує й штампує доку для одного файлу, оновлюючи лічильники й прогрес.
95
+ * @param {object} file елемент scanForDocFiles
96
+ * @param {string} root абсолютний корінь
97
+ * @param {{ done: number, total: number }} progress позиція у прогресі
98
+ * @param {{ ok: number, degraded: number, err: number, errors: string[] }} stats акумулятор статистики
99
+ * @returns {Promise<void>}
100
+ */
101
+ async function generateOne(file, root, progress, stats) {
102
+ const sourceAbs = join(root, file.sourcePath)
103
+ process.stdout.write(` [${progress.done}/${progress.total}] ${file.sourcePath} … `)
104
+ try {
105
+ const docAbs = join(root, file.docPath)
106
+ // Варіант B: передаємо наявну доку, щоб зберегти захищену секцію «Призначення»
107
+ const existingMd = existsSync(docAbs) ? readFileSync(docAbs, 'utf8') : null
108
+ const result = await generateDoc(sourceAbs, { existingMd })
109
+ const crc = crc32(readFileSync(sourceAbs))
110
+ mkdirSync(dirname(docAbs), { recursive: true })
111
+ const quality =
112
+ result.score === null ? null : { score: result.score, issues: result.degraded ? result.issues : [] }
113
+ writeFileSync(docAbs, stampDoc(result.md, file.sourcePath, crc, quality))
114
+ stats.ok++
115
+ if (result.degraded) {
116
+ stats.degraded++
117
+ process.stdout.write(`⚠ degraded score=${result.score} crc=${crc}\n`)
118
+ } else {
119
+ process.stdout.write(`✓ score=${result.score ?? '—'} crc=${crc}\n`)
120
+ }
121
+ } catch (error) {
122
+ stats.err++
123
+ stats.errors.push(file.sourcePath)
124
+ process.stdout.write(`✗ ${error.message}\n`)
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Підсумковий звіт прогону у stdout.
130
+ * @param {{ ok: number, degraded: number, err: number, errors: string[] }} stats статистика
131
+ * @returns {void}
132
+ */
133
+ function reportStats(stats) {
134
+ console.log(`\n${'─'.repeat(50)}\n✓ OK: ${stats.ok} ⚠ degraded: ${stats.degraded} ✗ Err: ${stats.err}`)
135
+ if (stats.errors.length > 0) {
136
+ console.log('Помилки:')
137
+ for (const e of stats.errors) console.log(` - ${e}`)
138
+ }
139
+ if (stats.degraded > 0) {
140
+ console.log(`Degraded-доки перегенеровуються пізніше: npx @nitra/cursor doc-files gen --retry-degraded`)
141
+ }
142
+ }
143
+
82
144
  /**
83
145
  * `doc-files gen` — згенерувати документацію для застарілих/відсутніх док.
84
146
  * @param {string[]} argv аргументи після назви субкоманди
@@ -106,47 +168,16 @@ export async function runDocFilesGenCli(argv) {
106
168
  return 1
107
169
  }
108
170
 
109
- let modeTxt = ''
110
- if (overwrite) modeTxt = ' (--overwrite)'
111
- else if (retryDegraded) modeTxt = ' (--retry-degraded)'
112
- console.log(`📋 doc-files: до генерації ${targets.length} файл(ів)${modeTxt}`)
171
+ console.log(`📋 doc-files: до генерації ${targets.length} файл(ів)${modeSuffix({ overwrite, retryDegraded })}`)
113
172
  const stats = { ok: 0, degraded: 0, err: 0, errors: [] }
114
173
 
115
174
  let done = 0
116
175
  for (const file of targets) {
117
176
  done++
118
- const sourceAbs = join(root, file.sourcePath)
119
- process.stdout.write(` [${done}/${targets.length}] ${file.sourcePath} … `)
120
- try {
121
- const result = await generateDoc(sourceAbs)
122
- const crc = crc32(readFileSync(sourceAbs))
123
- const docAbs = join(root, file.docPath)
124
- mkdirSync(dirname(docAbs), { recursive: true })
125
- const quality =
126
- result.score === null ? null : { score: result.score, issues: result.degraded ? result.issues : [] }
127
- writeFileSync(docAbs, stampDoc(result.md, file.sourcePath, crc, quality))
128
- stats.ok++
129
- if (result.degraded) {
130
- stats.degraded++
131
- process.stdout.write(`⚠ degraded score=${result.score} crc=${crc}\n`)
132
- } else {
133
- process.stdout.write(`✓ score=${result.score ?? '—'} crc=${crc}\n`)
134
- }
135
- } catch (error) {
136
- stats.err++
137
- stats.errors.push(file.sourcePath)
138
- process.stdout.write(`✗ ${error.message}\n`)
139
- }
177
+ await generateOne(file, root, { done, total: targets.length }, stats)
140
178
  }
141
179
 
142
- console.log(`\n${'─'.repeat(50)}\n✓ OK: ${stats.ok} ⚠ degraded: ${stats.degraded} ✗ Err: ${stats.err}`)
143
- if (stats.errors.length > 0) {
144
- console.log('Помилки:')
145
- for (const e of stats.errors) console.log(` - ${e}`)
146
- }
147
- if (stats.degraded > 0) {
148
- console.log(`Degraded-доки перегенеровуються пізніше: npx @nitra/cursor doc-files gen --retry-degraded`)
149
- }
180
+ reportStats(stats)
150
181
  return stats.err > 0 ? 1 : 0
151
182
  }
152
183