@nitra/cursor 1.16.1 → 1.17.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +27 -1
  2. package/README.md +2 -2
  3. package/bin/n-cursor.js +28 -18
  4. package/package.json +2 -1
  5. package/rules/abie/fix.mjs +3 -3
  6. package/rules/adr/fix.mjs +3 -3
  7. package/rules/adr/js/hooks.mjs +6 -6
  8. package/rules/bun/fix.mjs +3 -3
  9. package/rules/capacitor/fix.mjs +3 -3
  10. package/rules/changelog/fix.mjs +3 -3
  11. package/rules/changelog/js/consistency.mjs +15 -15
  12. package/rules/ci4/fix.mjs +3 -3
  13. package/rules/docker/fix.mjs +3 -3
  14. package/rules/efes/fix.mjs +3 -3
  15. package/rules/feedback/fix.mjs +3 -3
  16. package/rules/ga/fix.mjs +3 -3
  17. package/rules/graphql/fix.mjs +3 -3
  18. package/rules/hasura/fix.mjs +3 -3
  19. package/rules/image-avif/fix.mjs +3 -3
  20. package/rules/image-compress/fix.mjs +3 -3
  21. package/rules/js-bun-db/fix.mjs +3 -3
  22. package/rules/js-bun-redis/fix.mjs +3 -3
  23. package/rules/js-bun-redis/js/imports.mjs +1 -5
  24. package/rules/js-lint/coverage/coverage.mjs +148 -0
  25. package/rules/js-lint/fix.mjs +3 -3
  26. package/rules/js-lint/js-lint.mdc +5 -1
  27. package/rules/js-mssql/fix.mjs +3 -3
  28. package/rules/js-run/fix.mjs +3 -3
  29. package/rules/js-run/js/runtime.mjs +2 -9
  30. package/rules/k8s/fix.mjs +3 -3
  31. package/rules/nginx-default-tpl/fix.mjs +3 -3
  32. package/rules/npm-module/fix.mjs +3 -3
  33. package/rules/php/fix.mjs +3 -3
  34. package/rules/rego/fix.mjs +3 -3
  35. package/rules/rust/coverage/coverage.mjs +111 -0
  36. package/rules/rust/fix.mjs +3 -3
  37. package/rules/rust/lib/has-cargo-toml.mjs +1 -3
  38. package/rules/rust/rust.mdc +5 -1
  39. package/rules/security/fix.mjs +3 -3
  40. package/rules/style-lint/fix.mjs +3 -3
  41. package/rules/style-lint/js/tooling.mjs +1 -1
  42. package/rules/tauri/fix.mjs +3 -3
  43. package/rules/test/coverage/coverage.mjs +165 -0
  44. package/rules/test/fix.mjs +3 -3
  45. package/rules/test/js/location.mjs +1 -1
  46. package/rules/test/policy/package_json/package_json.rego +17 -0
  47. package/rules/test/policy/package_json/target.json +5 -0
  48. package/rules/test/policy/package_json/template/package.json.contains.json +5 -0
  49. package/rules/test/test.mdc +11 -1
  50. package/rules/text/fix.mjs +3 -3
  51. package/rules/vue/fix.mjs +3 -3
  52. package/scripts/lib/run-rule-cli.mjs +11 -0
  53. package/scripts/lib/run-standard-rule.mjs +1 -1
  54. package/scripts/utils/with-lock.mjs +27 -16
  55. package/scripts/utils/worktree-fingerprint.mjs +10 -9
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Канонічна команда `n-cursor coverage`: збирає метрики покриття + мутаційного
3
+ * тестування з усіх провайдерів, чиє правило активне в `.n-cursor.json#rules`,
4
+ * агрегує та записує COVERAGE.md у корінь проєкту.
5
+ *
6
+ * Discovery провайдерів — за `.n-cursor.json#rules`: для кожного `ruleId` зі
7
+ * списку шукаємо `npm/rules/<ruleId>/coverage/coverage.mjs` і динамічно
8
+ * імпортуємо. Якщо файлу немає — провайдер для цього правила відсутній (skip
9
+ * silently, не помилка).
10
+ *
11
+ * Лок — прямий виклик `withLock('coverage', steps)`. Один CLI-консумер, один
12
+ * callsite — спільна точка входу не виноситься (YAGNI, див. C4 у
13
+ * specs/2026-05-24-coverage-rule-design.md).
14
+ */
15
+ import { existsSync } from 'node:fs'
16
+ import { writeFile } from 'node:fs/promises'
17
+ import { dirname, join } from 'node:path'
18
+ import { fileURLToPath, pathToFileURL } from 'node:url'
19
+
20
+ import { readNCursorConfigLite } from '../../../scripts/lib/read-n-cursor-config-lite.mjs'
21
+ import { withLock } from '../../../scripts/utils/with-lock.mjs'
22
+
23
+ /** Корінь `npm/rules/` — `<rules>/test/coverage` → `<rules>` */
24
+ const RULES_DIR = dirname(dirname(dirname(fileURLToPath(import.meta.url))))
25
+
26
+ /**
27
+ * Сума двох coverage-totals.
28
+ * @param {{lines:{covered:number,total:number}, functions:{covered:number,total:number}}} a перший subtotal
29
+ * @param {{lines:{covered:number,total:number}, functions:{covered:number,total:number}}} b другий subtotal
30
+ * @returns {{lines:{covered:number,total:number}, functions:{covered:number,total:number}}} сумарні lines/functions
31
+ */
32
+ export function addCoverage(a, b) {
33
+ return {
34
+ lines: { covered: a.lines.covered + b.lines.covered, total: a.lines.total + b.lines.total },
35
+ functions: {
36
+ covered: a.functions.covered + b.functions.covered,
37
+ total: a.functions.total + b.functions.total
38
+ }
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Сума двох mutation-counts.
44
+ * @param {{caught:number,total:number}} a перший subtotal
45
+ * @param {{caught:number,total:number}} b другий subtotal
46
+ * @returns {{caught:number,total:number}} сумарні caught/total
47
+ */
48
+ export function addMutation(a, b) {
49
+ return { caught: a.caught + b.caught, total: a.total + b.total }
50
+ }
51
+
52
+ /**
53
+ * Форматує covered/total як `XX.XX% (covered/total)`.
54
+ * @param {{covered:number,total:number}} metric метрика lines або functions
55
+ * @returns {string} відформатований рядок для таблиці COVERAGE.md
56
+ */
57
+ export function formatCoverage({ covered, total }) {
58
+ const percent = total === 0 ? '—' : `${((covered / total) * 100).toFixed(2)}%`
59
+ return `${percent} (${covered}/${total})`
60
+ }
61
+
62
+ /**
63
+ * Форматує мутаційний score як `XX.XX%`.
64
+ * @param {{caught:number,total:number}} metric агрегований mutation score
65
+ * @returns {string} відформатований score або прочерк
66
+ */
67
+ export function formatScore({ caught, total }) {
68
+ return total === 0 ? '—' : `${((caught / total) * 100).toFixed(2)}%`
69
+ }
70
+
71
+ /**
72
+ * Рендерить таблицю покриття + мутаційного тестування як Markdown.
73
+ * Без timestamp, щоб git diff рухався лише при зміні метрик.
74
+ * @param {Array<{area:string, coverage:{lines:{covered:number,total:number},functions:{covered:number,total:number}}, mutation:{caught:number,total:number}}>} rows рядки провайдерів
75
+ * @returns {string} Markdown-таблиця з заголовком `# Coverage`
76
+ */
77
+ export function renderMarkdown(rows) {
78
+ const lines = [
79
+ '# Coverage',
80
+ '',
81
+ '| Область | Рядки | Функції | Вбито мутацій | Score |',
82
+ '| --- | --- | --- | --- | --- |'
83
+ ]
84
+ for (const row of rows) {
85
+ lines.push(
86
+ `| ${row.area} | ${formatCoverage(row.coverage.lines)} | ${formatCoverage(row.coverage.functions)} | ` +
87
+ `${row.mutation.caught}/${row.mutation.total} | ${formatScore(row.mutation)} |`
88
+ )
89
+ }
90
+ return `${lines.join('\n')}\n`
91
+ }
92
+
93
+ /**
94
+ * Завантажує provider-модуль з `<rulesDir>/<ruleId>/coverage/coverage.mjs`.
95
+ * Повертає null коли:
96
+ * - файлу немає (rule без coverage-провайдера),
97
+ * - файл існує, але не експортує `detect` + `collect` як функції (наприклад,
98
+ * `rules/test/coverage/coverage.mjs` — сам оркестратор, не провайдер).
99
+ * @param {string} rulesDir корінь `npm/rules/`
100
+ * @param {string} ruleId id правила з `.n-cursor.json#rules`
101
+ * @returns {Promise<{detect:(cwd:string)=>Promise<boolean>, collect:(cwd:string)=>Promise<Array<object>>}|null>} provider-модуль або null
102
+ */
103
+ async function loadProvider(rulesDir, ruleId) {
104
+ const providerPath = join(rulesDir, ruleId, 'coverage', 'coverage.mjs')
105
+ if (!existsSync(providerPath)) return null
106
+ // eslint-disable-next-line no-unsanitized/method -- providerPath з join(rulesDir, ruleId, …), ruleId з конфігу
107
+ const mod = await import(pathToFileURL(providerPath).href)
108
+ if (typeof mod.detect !== 'function' || typeof mod.collect !== 'function') return null
109
+ return mod
110
+ }
111
+
112
+ /**
113
+ * Будує підсумковий рядок «Разом» через сумування всіх coverage/mutation.
114
+ * @param {Array<{area:string, coverage:object, mutation:object}>} rows рядки провайдерів без totals
115
+ * @returns {{area:string, coverage:object, mutation:{caught:number,total:number}}} агрегований рядок «Разом»
116
+ */
117
+ function buildTotalsRow(rows) {
118
+ let totalCoverage = { lines: { covered: 0, total: 0 }, functions: { covered: 0, total: 0 } }
119
+ let totalMutation = { caught: 0, total: 0 }
120
+ for (const row of rows) {
121
+ totalCoverage = addCoverage(totalCoverage, row.coverage)
122
+ totalMutation = addMutation(totalMutation, row.mutation)
123
+ }
124
+ return { area: '**Разом**', coverage: totalCoverage, mutation: totalMutation }
125
+ }
126
+
127
+ /**
128
+ * Виконує coverage-pipeline: discovery провайдерів за `.n-cursor.json#rules`,
129
+ * detect+collect для кожного, агрегація, запис COVERAGE.md.
130
+ * @param {{cwd?:string, rulesDir?:string}} [opts] ін'єкція cwd/rulesDir для тестів
131
+ * @returns {Promise<number>} exit code (0 OK, 1 коли жоден провайдер не дав даних)
132
+ */
133
+ export async function runCoverageSteps(opts = {}) {
134
+ const cwd = opts.cwd ?? process.cwd()
135
+ const rulesDir = opts.rulesDir ?? RULES_DIR
136
+ const config = await readNCursorConfigLite(cwd)
137
+ const rows = []
138
+
139
+ for (const ruleId of config.rules) {
140
+ if (config.disableRules.includes(ruleId)) continue
141
+ const provider = await loadProvider(rulesDir, ruleId)
142
+ if (!provider) continue
143
+ if (!(await provider.detect(cwd))) continue
144
+ console.log(`→ ${ruleId} coverage…`)
145
+ rows.push(...(await provider.collect(cwd)))
146
+ }
147
+
148
+ if (rows.length === 0) {
149
+ console.error('✗ Жодного провайдера покриття не знайдено для активних правил у .n-cursor.json#rules')
150
+ return 1
151
+ }
152
+
153
+ rows.push(buildTotalsRow(rows))
154
+ const md = renderMarkdown(rows)
155
+ await writeFile(join(cwd, 'COVERAGE.md'), md, 'utf8')
156
+ console.log('✓ COVERAGE.md')
157
+ return 0
158
+ }
159
+
160
+ // Один оркестратор, один callsite — `withLock` викликається напряму, без спільної
161
+ // точки входу. Канонічне обмеження «не імпортуй withLock у lint.mjs/fix.mjs напряму»
162
+ // (scripts.mdc § withLock) націлене на дедуплікацію preamble серед багатьох файлів —
163
+ // для одного coverage-консумера не релевантне (див. C4 у
164
+ // specs/2026-05-24-coverage-rule-design.md).
165
+ export const runCoverageCli = () => withLock('coverage', runCoverageSteps)
@@ -1,3 +1,4 @@
1
+ import { isRunAsCli, runRuleCli } from '../../scripts/lib/run-rule-cli.mjs'
1
2
  import { runStandardRule } from '../../scripts/lib/run-standard-rule.mjs'
2
3
 
3
4
  /**
@@ -10,10 +11,9 @@ export function run(ctx) {
10
11
  return runStandardRule(import.meta.dirname, ctx)
11
12
  }
12
13
 
13
- if (import.meta.main) {
14
+ if (isRunAsCli()) {
14
15
  // Standalone: bun rules/<id>/fix.mjs — повний еквівалент `npx @nitra/cursor fix <id>`
15
16
  // (config-loading + whitelist + summary). Дві ролі fix.mjs: library (run) + standalone (main).
16
- const { runRuleCli } = await import('../../scripts/lib/run-rule-cli.mjs')
17
- // eslint-disable-next-line unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE
17
+ // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE
18
18
  process.exit(await runRuleCli(import.meta.dirname))
19
19
  }
@@ -18,7 +18,7 @@ const TESTS_DIR_NAME = 'tests'
18
18
  /**
19
19
  * Чи файл є JS-тестом (`*.test.mjs`).
20
20
  * @param {string} absPath абсолютний шлях
21
- * @returns {boolean}
21
+ * @returns {boolean} true для шляхів із суфіксом `.test.mjs`
22
22
  */
23
23
  function isTestFile(absPath) {
24
24
  return basename(absPath).endsWith('.test.mjs')
@@ -0,0 +1,17 @@
1
+ # Перевірка `package.json` для правила test (test.mdc).
2
+ #
3
+ # Канон надходить через --data: { "template": { "contains": ... } }
4
+ # Структура --data сформована з template/package.json.contains.json.
5
+ # Перевіряємо substring-вимоги до scripts.coverage:
6
+ # рядок має містити "n-cursor coverage" (локальні розширення дозволені).
7
+ package test.package_json
8
+
9
+ import rego.v1
10
+
11
+ deny contains msg if {
12
+ some script_name, needles in data.template.contains.scripts
13
+ actual := object.get(object.get(input, "scripts", {}), script_name, "")
14
+ some needle in needles
15
+ not contains(actual, needle)
16
+ msg := sprintf("package.json: scripts.%s має містити %q (test.mdc)", [script_name, needle])
17
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "$schema": "https://unpkg.com/@nitra/cursor/schemas/target.json",
3
+ "files": { "single": "package.json", "required": true },
4
+ "missingMessage": "package.json не існує — створи зі scripts.coverage (test.mdc)"
5
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "scripts": {
3
+ "coverage": ["n-cursor coverage"]
4
+ }
5
+ }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  description: JS-тести (*.test.mjs) живуть у каталозі tests/ поряд із джерельним файлом, а не безпосередньо в тій же директорії
3
- version: '1.1'
3
+ version: '1.2'
4
4
  alwaysApply: true
5
5
  ---
6
6
 
@@ -58,3 +58,13 @@ Recursive globs ловлять файли всередині `tests/` так с
58
58
  `*_test.rego` перевіркою **не охоплюються** — вони не переміщуються.
59
59
 
60
60
  Пропускаються: `node_modules`, `.git`, `dist`, `build`, `.venv`, `venv`, шляхи з `.n-cursor.json:ignore`.
61
+
62
+ ## Покриття + мутаційне тестування
63
+
64
+ Канонічна команда — `n-cursor coverage`: збирає метрики покриття (`bun test --coverage`, `cargo llvm-cov` тощо) і мутаційного тестування (Stryker, `cargo-mutants`) з усіх активних провайдерів у `.n-cursor.json#rules` і пише `COVERAGE.md` у корінь проєкту. Лок і дедуп — `withLock('coverage', ...)`.
65
+
66
+ Провайдери живуть у `npm/rules/<rule>/coverage/coverage.mjs` (постачаються правилами мови/рантайму: `js-lint`, `rust`, у майбутньому `python` тощо). Оркестратор — у `npm/rules/test/coverage/coverage.mjs`.
67
+
68
+ У `package.json` (корінь) має бути `scripts.coverage` із викликом `n-cursor coverage`:
69
+
70
+ Канон `scripts.coverage` (substring requirement): [package.json.contains.json](./policy/package_json/template/package.json.contains.json)
@@ -1,3 +1,4 @@
1
+ import { isRunAsCli, runRuleCli } from '../../scripts/lib/run-rule-cli.mjs'
1
2
  import { runStandardRule } from '../../scripts/lib/run-standard-rule.mjs'
2
3
 
3
4
  /**
@@ -10,10 +11,9 @@ export function run(ctx) {
10
11
  return runStandardRule(import.meta.dirname, ctx)
11
12
  }
12
13
 
13
- if (import.meta.main) {
14
+ if (isRunAsCli()) {
14
15
  // Standalone: bun rules/<id>/fix.mjs — повний еквівалент `npx @nitra/cursor fix <id>`
15
16
  // (config-loading + whitelist + summary). Дві ролі fix.mjs: library (run) + standalone (main).
16
- const { runRuleCli } = await import('../../scripts/lib/run-rule-cli.mjs')
17
- // eslint-disable-next-line unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE
17
+ // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE
18
18
  process.exit(await runRuleCli(import.meta.dirname))
19
19
  }
package/rules/vue/fix.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { isRunAsCli, runRuleCli } from '../../scripts/lib/run-rule-cli.mjs'
1
2
  import { runStandardRule } from '../../scripts/lib/run-standard-rule.mjs'
2
3
 
3
4
  /**
@@ -10,10 +11,9 @@ export function run(ctx) {
10
11
  return runStandardRule(import.meta.dirname, ctx)
11
12
  }
12
13
 
13
- if (import.meta.main) {
14
+ if (isRunAsCli()) {
14
15
  // Standalone: bun rules/<id>/fix.mjs — повний еквівалент `npx @nitra/cursor fix <id>`
15
16
  // (config-loading + whitelist + summary). Дві ролі fix.mjs: library (run) + standalone (main).
16
- const { runRuleCli } = await import('../../scripts/lib/run-rule-cli.mjs')
17
- // eslint-disable-next-line unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE
17
+ // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE
18
18
  process.exit(await runRuleCli(import.meta.dirname))
19
19
  }
@@ -7,6 +7,7 @@
7
7
  * Library-mode виклик з CLI orchestration — інше: див. `runStandardRule` + `fix.mjs::run(ctx)`.
8
8
  */
9
9
  import { basename } from 'node:path'
10
+ import { pathToFileURL } from 'node:url'
10
11
 
11
12
  import { isRuleEnabled, readNCursorConfigLite } from './read-n-cursor-config-lite.mjs'
12
13
  import { runStandardRule } from './run-standard-rule.mjs'
@@ -14,6 +15,16 @@ import { getOrCreateWalkCache } from '../utils/walk-cache.mjs'
14
15
 
15
16
  const PACKAGE_NAME = '@nitra/cursor'
16
17
 
18
+ /**
19
+ * Чи поточний модуль запущено як CLI entry-point (`bun rules/<id>/fix.mjs`).
20
+ * @returns {boolean} true, коли `import.meta.url` збігається з `process.argv[1]`
21
+ */
22
+ export function isRunAsCli() {
23
+ const entry = process.argv[1]
24
+ if (!entry) return false
25
+ return import.meta.url === pathToFileURL(entry).href
26
+ }
27
+
17
28
  /**
18
29
  * @param {string} ruleDir абсолютний шлях до `rules/<id>/`
19
30
  * @returns {Promise<number>} 0 — OK або правило не enabled; 1 — порушення
@@ -32,7 +32,7 @@ import { withLock } from '../utils/with-lock.mjs'
32
32
  * @param {RuleContext} [ctx] контекст прогону (walkCache тощо)
33
33
  * @returns {Promise<number>} 0 OK, 1 violations
34
34
  */
35
- export async function runStandardRule(ruleDir, ctx = {}) {
35
+ export function runStandardRule(ruleDir, ctx = {}) {
36
36
  const ruleId = basename(ruleDir)
37
37
  const bundledRulesDir = dirname(ruleDir)
38
38
  return withLock(`fix-${ruleId}`, async () => {
@@ -3,7 +3,7 @@
3
3
  * Алгоритм: mkdirSync-based lock, перевірка живості PID, sha256-dedup з TTL.
4
4
  */
5
5
  import * as fs from 'node:fs'
6
- import * as path from 'node:path'
6
+ import { join } from 'node:path'
7
7
  import * as os from 'node:os'
8
8
  import { setTimeout as sleep } from 'node:timers/promises'
9
9
  import { worktreeFingerprint } from './worktree-fingerprint.mjs'
@@ -12,9 +12,14 @@ const DEFAULTS = {
12
12
  ttl: 600_000,
13
13
  staleThreshold: 1_800_000,
14
14
  waitTimeout: 1_200_000,
15
- pollInterval: 1_500
15
+ pollInterval: 1500
16
16
  }
17
17
 
18
+ /**
19
+ * Чи процес із заданим PID ще живий на поточному host.
20
+ * @param {number} pid ідентифікатор процесу з owner.json
21
+ * @returns {boolean} true, якщо process.kill(pid, 0) не кинув помилку
22
+ */
18
23
  function isAlive(pid) {
19
24
  try {
20
25
  process.kill(pid, 0)
@@ -24,14 +29,21 @@ function isAlive(pid) {
24
29
  }
25
30
  }
26
31
 
32
+ /**
33
+ * Повертає функцію, що знімає lock-директорію.
34
+ * @param {string} lockDir абсолютний шлях до lock-директорії
35
+ * @returns {() => void} release-колбек для finally/signal handler
36
+ */
27
37
  function makeRelease(lockDir) {
28
38
  return () => fs.rmSync(lockDir, { recursive: true, force: true })
29
39
  }
30
40
 
31
41
  /**
32
- * @param {{exitCode:number, fingerprint:string|null, finishedAt:number}} result
33
- * @param {string|null} fingerprint
34
- * @param {number} ttl
42
+ * Чи можна пропустити повторний прогін за кешованим result.json.
43
+ * @param {{exitCode:number, fingerprint:string|null, finishedAt:number}} result попередній результат з result.json
44
+ * @param {string|null} fingerprint поточний fingerprint робочого дерева
45
+ * @param {number} ttl TTL дедуплікації в мілісекундах
46
+ * @returns {boolean} true, якщо попередній успішний прогін можна повторно використати
35
47
  */
36
48
  export function shouldDedup(result, fingerprint, ttl) {
37
49
  if (result.exitCode !== 0) return false
@@ -41,27 +53,26 @@ export function shouldDedup(result, fingerprint, ttl) {
41
53
  }
42
54
 
43
55
  /**
44
- * @param {string} key
45
- * @param {() => number | Promise<number>} runFn
46
- * @param {{ttl?:number, staleThreshold?:number, waitTimeout?:number, pollInterval?:number, cacheDir?:string, getFingerprint?:() => string | null}} [opts]
47
- * @returns {Promise<number>}
56
+ * Серіалізує важку команду через атомарний lock і dedup за fingerprint.
57
+ * @param {string} key ключ локу (наприклад `lint-ga`, `fix-bun`)
58
+ * @param {() => number | Promise<number>} runFn основна робота; повертає exit code
59
+ * @param {{ttl?:number, staleThreshold?:number, waitTimeout?:number, pollInterval?:number, cacheDir?:string, getFingerprint?:() => string | null}} [opts] TTL, шлях кешу та override fingerprint
60
+ * @returns {Promise<number>} exit code виконаної або дедуплікованої команди
48
61
  */
49
62
  export async function withLock(key, runFn, opts = {}) {
50
63
  const { ttl, staleThreshold, waitTimeout, pollInterval } = { ...DEFAULTS, ...opts }
51
64
  const getFingerprint = opts.getFingerprint ?? worktreeFingerprint
52
- const cacheDir = opts.cacheDir ?? path.join(process.cwd(), 'node_modules/.cache/n-cursor', key)
53
- const lockDir = path.join(cacheDir, 'lock')
54
- const ownerFile = path.join(lockDir, 'owner.json')
55
- const resultFile = path.join(cacheDir, 'result.json')
65
+ const cacheDir = opts.cacheDir ?? join(process.cwd(), 'node_modules/.cache/n-cursor', key)
66
+ const lockDir = join(cacheDir, 'lock')
67
+ const ownerFile = join(lockDir, 'owner.json')
68
+ const resultFile = join(cacheDir, 'result.json')
56
69
  const release = makeRelease(lockDir)
57
70
 
58
71
  const fingerprint = getFingerprint()
59
72
  fs.mkdirSync(cacheDir, { recursive: true })
60
73
 
61
74
  const loopStart = Date.now()
62
- let locked = false
63
75
 
64
- // eslint-disable-next-line no-constant-condition
65
76
  while (true) {
66
77
  if (Date.now() - loopStart >= waitTimeout) {
67
78
  console.error(`⚠️ ${key}: чекав ${waitTimeout / 60_000} хв — запускаю без локу`)
@@ -73,7 +84,6 @@ export async function withLock(key, runFn, opts = {}) {
73
84
  ownerFile,
74
85
  JSON.stringify({ pid: process.pid, host: os.hostname(), startedAt: Date.now(), fingerprint })
75
86
  )
76
- locked = true
77
87
  break
78
88
  } catch (error) {
79
89
  if (error.code !== 'EEXIST') throw error
@@ -113,6 +123,7 @@ export async function withLock(key, runFn, opts = {}) {
113
123
 
114
124
  const onSignal = () => {
115
125
  release()
126
+ // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit -- SIGINT/SIGTERM мають завершити процес із кодом 130
116
127
  process.exit(130)
117
128
  }
118
129
  process.once('SIGINT', onSignal)
@@ -1,13 +1,16 @@
1
- /**
2
- * Fingerprint поточного стану git-робочого дерева.
3
- * Повертає sha256-hex (64 символи) або null, якщо не в git-репо.
4
- * @param {typeof import('child_process').spawnSync} spawn
5
- */
6
1
  import { spawnSync } from 'node:child_process'
7
2
  import { createHash } from 'node:crypto'
8
3
 
4
+ /**
5
+ * Fingerprint поточного стану git-робочого дерева.
6
+ * @param {typeof import('child_process').spawnSync} [spawn] sync-виклик git (ін'єкція для тестів)
7
+ * @returns {string|null} sha256-hex (64 символи) або null, якщо не в git-репо
8
+ */
9
9
  export function worktreeFingerprint(spawn = spawnSync) {
10
- /** @param {string[]} args */
10
+ /**
11
+ * @param {string[]} args аргументи підкоманди git
12
+ * @returns {string} stdout git-команди
13
+ */
11
14
  function git(args) {
12
15
  const r = spawn('git', args, { encoding: 'utf8' })
13
16
  if (r.status !== 0 || r.error) throw new Error(`git ${args[0]} failed`)
@@ -21,9 +24,7 @@ export function worktreeFingerprint(spawn = spawnSync) {
21
24
  // повертаються у `"..."` формі, і `git hash-object` не знаходить файл → throw → fingerprint=null.
22
25
  const untrackedRaw = git(['ls-files', '-z', '--others', '--exclude-standard'])
23
26
  const untrackedFiles = untrackedRaw.split('\0').filter(Boolean)
24
- const pairs = untrackedFiles
25
- .map(f => `${f}:${git(['hash-object', f]).trim()}`)
26
- .sort()
27
+ const pairs = untrackedFiles.map(f => `${f}:${git(['hash-object', f]).trim()}`).toSorted()
27
28
  const raw = [commitHash, diffText, ...pairs].join('\n')
28
29
  return createHash('sha256').update(raw).digest('hex')
29
30
  } catch {