@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.
- package/CHANGELOG.md +27 -1
- package/README.md +2 -2
- package/bin/n-cursor.js +28 -18
- package/package.json +2 -1
- package/rules/abie/fix.mjs +3 -3
- package/rules/adr/fix.mjs +3 -3
- package/rules/adr/js/hooks.mjs +6 -6
- package/rules/bun/fix.mjs +3 -3
- package/rules/capacitor/fix.mjs +3 -3
- package/rules/changelog/fix.mjs +3 -3
- package/rules/changelog/js/consistency.mjs +15 -15
- package/rules/ci4/fix.mjs +3 -3
- package/rules/docker/fix.mjs +3 -3
- package/rules/efes/fix.mjs +3 -3
- package/rules/feedback/fix.mjs +3 -3
- package/rules/ga/fix.mjs +3 -3
- package/rules/graphql/fix.mjs +3 -3
- package/rules/hasura/fix.mjs +3 -3
- package/rules/image-avif/fix.mjs +3 -3
- package/rules/image-compress/fix.mjs +3 -3
- package/rules/js-bun-db/fix.mjs +3 -3
- package/rules/js-bun-redis/fix.mjs +3 -3
- package/rules/js-bun-redis/js/imports.mjs +1 -5
- package/rules/js-lint/coverage/coverage.mjs +148 -0
- package/rules/js-lint/fix.mjs +3 -3
- package/rules/js-lint/js-lint.mdc +5 -1
- package/rules/js-mssql/fix.mjs +3 -3
- package/rules/js-run/fix.mjs +3 -3
- package/rules/js-run/js/runtime.mjs +2 -9
- package/rules/k8s/fix.mjs +3 -3
- package/rules/nginx-default-tpl/fix.mjs +3 -3
- package/rules/npm-module/fix.mjs +3 -3
- package/rules/php/fix.mjs +3 -3
- package/rules/rego/fix.mjs +3 -3
- package/rules/rust/coverage/coverage.mjs +111 -0
- package/rules/rust/fix.mjs +3 -3
- package/rules/rust/lib/has-cargo-toml.mjs +1 -3
- package/rules/rust/rust.mdc +5 -1
- package/rules/security/fix.mjs +3 -3
- package/rules/style-lint/fix.mjs +3 -3
- package/rules/style-lint/js/tooling.mjs +1 -1
- package/rules/tauri/fix.mjs +3 -3
- package/rules/test/coverage/coverage.mjs +165 -0
- package/rules/test/fix.mjs +3 -3
- package/rules/test/js/location.mjs +1 -1
- package/rules/test/policy/package_json/package_json.rego +17 -0
- package/rules/test/policy/package_json/target.json +5 -0
- package/rules/test/policy/package_json/template/package.json.contains.json +5 -0
- package/rules/test/test.mdc +11 -1
- package/rules/text/fix.mjs +3 -3
- package/rules/vue/fix.mjs +3 -3
- package/scripts/lib/run-rule-cli.mjs +11 -0
- package/scripts/lib/run-standard-rule.mjs +1 -1
- package/scripts/utils/with-lock.mjs +27 -16
- 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)
|
package/rules/test/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 (
|
|
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
|
-
|
|
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
|
+
}
|
package/rules/test/test.mdc
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: JS-тести (*.test.mjs) живуть у каталозі tests/ поряд із джерельним файлом, а не безпосередньо в тій же директорії
|
|
3
|
-
version: '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)
|
package/rules/text/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 (
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
*
|
|
33
|
-
* @param {string|null}
|
|
34
|
-
* @param {
|
|
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
|
-
*
|
|
45
|
-
* @param {
|
|
46
|
-
* @param {
|
|
47
|
-
* @
|
|
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 ??
|
|
53
|
-
const lockDir =
|
|
54
|
-
const ownerFile =
|
|
55
|
-
const resultFile =
|
|
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
|
-
/**
|
|
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 {
|