@nitra/cursor 1.17.0 → 1.17.2

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 (53) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/bin/n-cursor.js +16 -16
  3. package/package.json +2 -1
  4. package/rules/abie/fix.mjs +3 -3
  5. package/rules/adr/fix.mjs +3 -3
  6. package/rules/adr/js/hooks.mjs +6 -6
  7. package/rules/bun/fix.mjs +3 -3
  8. package/rules/capacitor/fix.mjs +3 -3
  9. package/rules/changelog/fix.mjs +3 -3
  10. package/rules/changelog/js/consistency.mjs +15 -15
  11. package/rules/ci4/fix.mjs +3 -3
  12. package/rules/docker/fix.mjs +3 -3
  13. package/rules/efes/fix.mjs +3 -3
  14. package/rules/feedback/fix.mjs +3 -3
  15. package/rules/ga/fix.mjs +3 -3
  16. package/rules/graphql/fix.mjs +3 -3
  17. package/rules/hasura/fix.mjs +3 -3
  18. package/rules/image-avif/fix.mjs +3 -3
  19. package/rules/image-compress/fix.mjs +3 -3
  20. package/rules/js-bun-db/fix.mjs +3 -3
  21. package/rules/js-bun-redis/fix.mjs +3 -3
  22. package/rules/js-lint/coverage/coverage.mjs +26 -36
  23. package/rules/js-lint/fix.mjs +3 -3
  24. package/rules/js-mssql/fix.mjs +3 -3
  25. package/rules/js-run/fix.mjs +3 -3
  26. package/rules/k8s/fix.mjs +3 -3
  27. package/rules/nginx-default-tpl/fix.mjs +3 -3
  28. package/rules/npm-module/fix.mjs +3 -3
  29. package/rules/php/fix.mjs +3 -3
  30. package/rules/rego/fix.mjs +3 -3
  31. package/rules/rust/coverage/coverage.mjs +22 -43
  32. package/rules/rust/fix.mjs +3 -3
  33. package/rules/rust/lib/has-cargo-toml.mjs +1 -3
  34. package/rules/security/fix.mjs +3 -3
  35. package/rules/style-lint/fix.mjs +3 -3
  36. package/rules/style-lint/js/tooling.mjs +1 -1
  37. package/rules/tauri/fix.mjs +3 -3
  38. package/rules/test/coverage/coverage.mjs +27 -25
  39. package/rules/test/fix.mjs +3 -3
  40. package/rules/test/js/cargo_mutants_config.mjs +65 -0
  41. package/rules/test/js/data/cargo_mutants_config/mutants.toml.baseline +4 -0
  42. package/rules/test/js/data/stryker_config/stryker.config.baseline.mjs +12 -0
  43. package/rules/test/js/location.mjs +1 -1
  44. package/rules/test/js/stryker_config.mjs +61 -0
  45. package/rules/test/test.mdc +16 -3
  46. package/rules/text/fix.mjs +3 -3
  47. package/rules/vue/fix.mjs +3 -3
  48. package/scripts/lib/run-rule-cli.mjs +11 -0
  49. package/scripts/lib/run-standard-rule.mjs +1 -1
  50. package/scripts/utils/resolve-cargo-manifest.mjs +62 -0
  51. package/scripts/utils/resolve-js-root.mjs +46 -0
  52. package/scripts/utils/with-lock.mjs +27 -16
  53. package/scripts/utils/worktree-fingerprint.mjs +10 -7
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Концерн `cargo_mutants_config` правила test (test.mdc): якщо `rust` присутнє
3
+ * в `.n-cursor.json#rules` і не у `disable-rules` — резолвить ВСІ Cargo.toml
4
+ * (cwd і всі workspaces, з підтримкою Tauri-патерну) і копіює canonical
5
+ * baseline `.cargo/mutants.toml` у каталог кожного manifest'а, якщо файлу немає.
6
+ *
7
+ * Self-gating: концерн silently skips коли `rust` не enabled.
8
+ * Якщо `rust` enabled, але жодного Cargo.toml не знайдено — теж silently skip
9
+ * (manifest може з'явитися пізніше; це не помилка).
10
+ *
11
+ * Baseline — порожній файл з коментом; cargo-mutants має робочі defaults.
12
+ */
13
+ import { existsSync } from 'node:fs'
14
+ import { copyFile, mkdir } from 'node:fs/promises'
15
+ import { dirname, join, relative } from 'node:path'
16
+ import { fileURLToPath } from 'node:url'
17
+
18
+ import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
19
+ import { readNCursorConfigLite } from '../../../scripts/lib/read-n-cursor-config-lite.mjs'
20
+ import { resolveAllCargoManifests } from '../../../scripts/utils/resolve-cargo-manifest.mjs'
21
+
22
+ const HERE = dirname(fileURLToPath(import.meta.url))
23
+ const BASELINE_PATH = join(HERE, 'data', 'cargo_mutants_config', 'mutants.toml.baseline')
24
+
25
+ /**
26
+ * @returns {Promise<number>} 0 — OK або silently skipped, 1 — порушення
27
+ */
28
+ export async function check() {
29
+ const reporter = createCheckReporter()
30
+ const cwd = process.cwd()
31
+ const config = await readNCursorConfigLite(cwd)
32
+
33
+ // Self-gate: rust має бути enabled
34
+ if (!config.rules.includes('rust') || config.disableRules.includes('rust')) {
35
+ return reporter.getExitCode()
36
+ }
37
+
38
+ const manifests = await resolveAllCargoManifests(cwd)
39
+ if (manifests.length === 0) {
40
+ // rust enabled, але Cargo.toml ще немає — silently skip (manifest може з'явитися пізніше)
41
+ return reporter.getExitCode()
42
+ }
43
+
44
+ if (!existsSync(BASELINE_PATH)) {
45
+ reporter.fail(
46
+ `.cargo/mutants.toml canonical baseline не знайдено (${BASELINE_PATH}) — перевстанови @nitra/cursor`
47
+ )
48
+ return reporter.getExitCode()
49
+ }
50
+
51
+ for (const manifestPath of manifests) {
52
+ const cargoDir = dirname(manifestPath)
53
+ const target = join(cargoDir, '.cargo', 'mutants.toml')
54
+
55
+ if (existsSync(target)) {
56
+ reporter.pass(`.cargo/mutants.toml існує (${relative(cwd, target)})`)
57
+ continue
58
+ }
59
+
60
+ await mkdir(dirname(target), { recursive: true })
61
+ await copyFile(BASELINE_PATH, target)
62
+ reporter.pass(`.cargo/mutants.toml створено з canonical baseline (${relative(cwd, target)}) (test.mdc)`)
63
+ }
64
+ return reporter.getExitCode()
65
+ }
@@ -0,0 +1,4 @@
1
+ # .cargo/mutants.toml — конфігурація cargo-mutants (опційно).
2
+ # cargo-mutants має робочі defaults; цей файл — стартова точка для customization.
3
+ # Документація: https://mutants.rs/
4
+ # Канон постачає правило `test` (@nitra/cursor).
@@ -0,0 +1,12 @@
1
+ /** @type {import('@stryker-mutator/core').PartialStrykerOptions} */
2
+ export default {
3
+ testRunner: 'command',
4
+ commandRunner: { command: 'bun test' },
5
+ // inPlace: уникає hoisted-node_modules issues у Bun monorepo (sandbox-копія втрачає resolution).
6
+ // Також тести, що читають git/fs-state (integration checks), працюють тільки in-place.
7
+ inPlace: true,
8
+ tempDirName: 'reports/stryker/.tmp',
9
+ reporters: ['json', 'clear-text'],
10
+ jsonReporter: { fileName: 'reports/stryker/mutation.json' },
11
+ coverageAnalysis: 'off'
12
+ }
@@ -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,61 @@
1
+ /**
2
+ * Концерн `stryker_config` правила test (test.mdc): якщо `js-lint` присутнє в
3
+ * `.n-cursor.json#rules` і не у `disable-rules` — резолвить ВСІ JS-roots
4
+ * (всі workspaces з package.json, або cwd у single-package) і копіює canonical
5
+ * baseline `stryker.config.mjs` у кожен root, де файлу немає.
6
+ *
7
+ * Self-gating: концерн silently skips коли `js-lint` не enabled — це навмисно,
8
+ * щоб не шуміти у single-language проєктах без JS coverage tooling.
9
+ *
10
+ * Baseline — мінімум для запуску Stryker з bun test runner; mutate-патерни
11
+ * лишаються на Stryker defaults (`src/**\/*.{js,mjs,ts,jsx,tsx,cjs}`).
12
+ */
13
+ import { existsSync } from 'node:fs'
14
+ import { copyFile } from 'node:fs/promises'
15
+ import { dirname, join, relative } from 'node:path'
16
+ import { fileURLToPath } from 'node:url'
17
+
18
+ import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
19
+ import { readNCursorConfigLite } from '../../../scripts/lib/read-n-cursor-config-lite.mjs'
20
+ import { resolveAllJsRoots } from '../../../scripts/utils/resolve-js-root.mjs'
21
+
22
+ const HERE = dirname(fileURLToPath(import.meta.url))
23
+ const BASELINE_PATH = join(HERE, 'data', 'stryker_config', 'stryker.config.baseline.mjs')
24
+
25
+ /**
26
+ * @returns {Promise<number>} 0 — OK або silently skipped, 1 — порушення
27
+ */
28
+ export async function check() {
29
+ const reporter = createCheckReporter()
30
+ const cwd = process.cwd()
31
+ const config = await readNCursorConfigLite(cwd)
32
+
33
+ // Self-gate: js-lint має бути enabled
34
+ if (!config.rules.includes('js-lint') || config.disableRules.includes('js-lint')) {
35
+ return reporter.getExitCode()
36
+ }
37
+
38
+ const jsRoots = await resolveAllJsRoots(cwd)
39
+ if (jsRoots.length === 0) {
40
+ reporter.fail('test: js-lint enabled, але кореневий package.json не знайдено (test.mdc)')
41
+ return reporter.getExitCode()
42
+ }
43
+
44
+ if (!existsSync(BASELINE_PATH)) {
45
+ reporter.fail(
46
+ `stryker.config.mjs canonical baseline не знайдено (${BASELINE_PATH}) — перевстанови @nitra/cursor`
47
+ )
48
+ return reporter.getExitCode()
49
+ }
50
+
51
+ for (const jsRoot of jsRoots) {
52
+ const target = join(jsRoot, 'stryker.config.mjs')
53
+ if (existsSync(target)) {
54
+ reporter.pass(`stryker.config.mjs існує (${relative(cwd, target)})`)
55
+ continue
56
+ }
57
+ await copyFile(BASELINE_PATH, target)
58
+ reporter.pass(`stryker.config.mjs створено з canonical baseline (${relative(cwd, target)}) (test.mdc)`)
59
+ }
60
+ return reporter.getExitCode()
61
+ }
@@ -1,7 +1,8 @@
1
1
  ---
2
- description: JS-тести (*.test.mjs) живуть у каталозі tests/ поряд із джерельним файлом, а не безпосередньо в тій же директорії
3
- version: '1.2'
4
- alwaysApply: true
2
+ description: JS-тести (*.test.mjs) живуть у tests/. Правило `test` керує stryker.config.mjs (якщо js-lint enabled) і .cargo/mutants.toml (якщо rust enabled).
3
+ version: '2.0'
4
+ globs: "**/{.n-cursor.json,package.json,Cargo.toml,stryker.config.mjs,.cargo/mutants.toml},**/*.test.mjs"
5
+ alwaysApply: false
5
6
  ---
6
7
 
7
8
  ## Конвенція розміщення тестів
@@ -68,3 +69,15 @@ Recursive globs ловлять файли всередині `tests/` так с
68
69
  У `package.json` (корінь) має бути `scripts.coverage` із викликом `n-cursor coverage`:
69
70
 
70
71
  Канон `scripts.coverage` (substring requirement): [package.json.contains.json](./policy/package_json/template/package.json.contains.json)
72
+
73
+ ## Налаштування mutation-testing
74
+
75
+ Якщо у `.n-cursor.json#rules` присутнє правило `js-lint` — правило `test` створює canonical baseline `stryker.config.mjs` у **кожному** JS-root проєкту: у кожному workspace з власним `package.json` (або в корені для single-package). У monorepo з `workspaces: ['app', 'scripts']` отримаєте `app/stryker.config.mjs` і `scripts/stryker.config.mjs`.
76
+
77
+ Канон Stryker config (мінімум для роботи з `bun test`): [stryker.config.baseline.mjs](./js/data/stryker_config/stryker.config.baseline.mjs)
78
+
79
+ Аналогічно, якщо `rust` присутнє в `rules` — створюється `.cargo/mutants.toml` у каталозі **кожного** Cargo.toml-маніфесту: кореневий `Cargo.toml`, `<workspace>/src-tauri/Cargo.toml` (Tauri-патерн) і `<workspace>/Cargo.toml` (flat workspace).
80
+
81
+ Канон cargo-mutants config: [mutants.toml.baseline](./js/data/cargo_mutants_config/mutants.toml.baseline)
82
+
83
+ Customization (mutate patterns, exclude rules, timeout) — відповідальність проєкту-споживача; концерни лише забезпечують наявність файлу як стартового baseline в кожному з виявлених workspace-каталогів.
@@ -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 () => {
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Резолвить шлях до Cargo.toml у проєкті: cwd/Cargo.toml або в одному з
3
+ * workspace-підкаталогів (з підтримкою Tauri-патерну `<workspace>/src-tauri/`).
4
+ * Спільна утиліта для coverage-провайдера rust і test-концерну cargo_mutants_config.
5
+ * Повертає null (а не throw) щоб callsite-и могли gracefully skip-нути.
6
+ */
7
+ import { existsSync } from 'node:fs'
8
+ import { readFile } from 'node:fs/promises'
9
+ import { join } from 'node:path'
10
+
11
+ /**
12
+ * @param {string} cwd корінь проєкту
13
+ * @returns {Promise<string|null>} абсолютний шлях до Cargo.toml або null
14
+ */
15
+ export async function resolveCargoManifest(cwd) {
16
+ const rootManifest = join(cwd, 'Cargo.toml')
17
+ if (existsSync(rootManifest)) return rootManifest
18
+
19
+ const rootPkgPath = join(cwd, 'package.json')
20
+ if (existsSync(rootPkgPath)) {
21
+ const rootPkg = JSON.parse(await readFile(rootPkgPath, 'utf8'))
22
+ const workspaces = Array.isArray(rootPkg.workspaces) ? rootPkg.workspaces : []
23
+ for (const ws of workspaces) {
24
+ const tauri = join(cwd, ws, 'src-tauri', 'Cargo.toml')
25
+ if (existsSync(tauri)) return tauri
26
+ const flat = join(cwd, ws, 'Cargo.toml')
27
+ if (existsSync(flat)) return flat
28
+ }
29
+ }
30
+ return null
31
+ }
32
+
33
+ /**
34
+ * Plural-варіант: повертає всі Cargo.toml-маніфести в проєкті — корінь
35
+ * (`cwd/Cargo.toml`) і у workspace-підкаталогах (`<ws>/src-tauri/Cargo.toml`
36
+ * пріоритетніше за `<ws>/Cargo.toml`). Порожній масив якщо нічого не знайдено.
37
+ * Використовується test-концерном `cargo_mutants_config` для per-manifest
38
+ * baseline-копіювання.
39
+ * @param {string} cwd корінь проєкту
40
+ * @returns {Promise<string[]>} абсолютні шляхи до знайдених Cargo.toml
41
+ */
42
+ export async function resolveAllCargoManifests(cwd) {
43
+ const manifests = []
44
+ const rootManifest = join(cwd, 'Cargo.toml')
45
+ if (existsSync(rootManifest)) manifests.push(rootManifest)
46
+
47
+ const rootPkgPath = join(cwd, 'package.json')
48
+ if (existsSync(rootPkgPath)) {
49
+ const rootPkg = JSON.parse(await readFile(rootPkgPath, 'utf8'))
50
+ const workspaces = Array.isArray(rootPkg.workspaces) ? rootPkg.workspaces : []
51
+ for (const ws of workspaces) {
52
+ const tauri = join(cwd, ws, 'src-tauri', 'Cargo.toml')
53
+ if (existsSync(tauri)) {
54
+ manifests.push(tauri)
55
+ continue
56
+ }
57
+ const flat = join(cwd, ws, 'Cargo.toml')
58
+ if (existsSync(flat)) manifests.push(flat)
59
+ }
60
+ }
61
+ return manifests
62
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Резолвить корінь JS-коду в проєкті: для workspace-projects — перший workspace
3
+ * (наприклад `app/` у mlmail), для single-package — корінь cwd. Спільна утиліта
4
+ * для coverage-провайдера js-lint і test-концерну stryker_config (DRY).
5
+ */
6
+ import { existsSync } from 'node:fs'
7
+ import { readFile } from 'node:fs/promises'
8
+ import { join } from 'node:path'
9
+
10
+ /**
11
+ * @param {string} cwd корінь проєкту (де `.n-cursor.json` і кореневий package.json)
12
+ * @returns {Promise<string|null>} абсолютний шлях до JS-root або null без кореневого package.json
13
+ */
14
+ export async function resolveJsRoot(cwd) {
15
+ const rootPkgPath = join(cwd, 'package.json')
16
+ if (!existsSync(rootPkgPath)) return null
17
+ const rootPkg = JSON.parse(await readFile(rootPkgPath, 'utf8'))
18
+ const workspaces = Array.isArray(rootPkg.workspaces) ? rootPkg.workspaces : []
19
+ if (workspaces.length > 0) {
20
+ const wsPath = join(cwd, workspaces[0])
21
+ if (existsSync(join(wsPath, 'package.json'))) return wsPath
22
+ }
23
+ return cwd
24
+ }
25
+
26
+ /**
27
+ * Plural-варіант: повертає всі JS-roots проєкту. Для workspace-projects — кожен
28
+ * workspace з власним `package.json`; для single-package — `[cwd]`. Порожній
29
+ * масив без кореневого package.json. Використовується test-концерном
30
+ * `stryker_config` для per-workspace baseline-копіювання.
31
+ * @param {string} cwd корінь проєкту
32
+ * @returns {Promise<string[]>} абсолютні шляхи до всіх JS-roots
33
+ */
34
+ export async function resolveAllJsRoots(cwd) {
35
+ const rootPkgPath = join(cwd, 'package.json')
36
+ if (!existsSync(rootPkgPath)) return []
37
+ const rootPkg = JSON.parse(await readFile(rootPkgPath, 'utf8'))
38
+ const workspaces = Array.isArray(rootPkg.workspaces) ? rootPkg.workspaces : []
39
+ if (workspaces.length === 0) return [cwd]
40
+ const roots = []
41
+ for (const ws of workspaces) {
42
+ const wsPath = join(cwd, ws)
43
+ if (existsSync(join(wsPath, 'package.json'))) roots.push(wsPath)
44
+ }
45
+ return roots.length > 0 ? roots : [cwd]
46
+ }
@@ -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,7 +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.map(f => `${f}:${git(['hash-object', f]).trim()}`).sort()
27
+ const pairs = untrackedFiles.map(f => `${f}:${git(['hash-object', f]).trim()}`).toSorted()
25
28
  const raw = [commitHash, diffText, ...pairs].join('\n')
26
29
  return createHash('sha256').update(raw).digest('hex')
27
30
  } catch {