@7n/test 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Test generation via pi SDK (one file at a time).
3
+ *
4
+ * Uses callText() for text-mode generation: gets raw code back,
5
+ * extracts the ```js block, writes the file directly.
6
+ * No subprocess, no confirmation prompts.
7
+ */
8
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
9
+ import { join, relative } from 'node:path'
10
+ import { callText } from './lib/pi-client.mjs'
11
+
12
+ const MAX_SRC_BYTES = 6000
13
+
14
+ /** Test file candidates relative to source file. */
15
+ function testCandidates(file) {
16
+ const base = file.replace(/\.[^.]+$/, '')
17
+ const lastSlash = base.lastIndexOf('/')
18
+ const name = lastSlash === -1 ? base : base.slice(lastSlash + 1)
19
+ const dir = lastSlash === -1 ? '' : base.slice(0, lastSlash)
20
+ return [`${base}.test.mjs`, `${base}.test.js`, ...(dir ? [`${dir}/tests/${name}.test.mjs`] : [])]
21
+ }
22
+
23
+ /**
24
+ * Extracts the first ```js...``` block from LLM text output.
25
+ * @param {string} text
26
+ * @returns {string}
27
+ */
28
+ function extractCode(text) {
29
+ const match = text.match(/```(?:js|javascript|mjs|ts)?\n([\s\S]*?)```/)
30
+ return match ? match[1].trim() : ''
31
+ }
32
+
33
+ /**
34
+ * Builds a display-only summary prompt (used in tests).
35
+ * @param {Array<{file: string, pct: number, reason: string}>} files
36
+ * @param {string} dir
37
+ * @returns {string}
38
+ */
39
+ export function buildGenTestsPrompt(files, dir) {
40
+ return files
41
+ .map(({ file, pct, reason }) => {
42
+ const absPath = join(dir, file)
43
+ let content = ''
44
+ if (existsSync(absPath)) {
45
+ content = readFileSync(absPath, 'utf8')
46
+ if (content.length > MAX_SRC_BYTES) content = content.slice(0, MAX_SRC_BYTES) + '\n...(truncated)'
47
+ }
48
+ return (
49
+ `### \`${file}\` (покриття: ${pct.toFixed(1)}%)\n` +
50
+ (reason ? `Причина: ${reason}\n\n` : '') +
51
+ (content ? `\`\`\`js\n${content}\n\`\`\`` : '(вміст недоступний)')
52
+ )
53
+ })
54
+ .join('\n\n')
55
+ }
56
+
57
+ function buildSingleFilePrompt(fileInfo, dir) {
58
+ const { file, pct, reason } = fileInfo
59
+ const absPath = join(dir, file)
60
+ let content = ''
61
+ if (existsSync(absPath)) {
62
+ content = readFileSync(absPath, 'utf8')
63
+ if (content.length > MAX_SRC_BYTES) content = content.slice(0, MAX_SRC_BYTES) + '\n...(truncated)'
64
+ }
65
+
66
+ const existingTestFile = testCandidates(file).find(c => existsSync(join(dir, c)))
67
+ let existingSection = ''
68
+ if (existingTestFile) {
69
+ const tc = readFileSync(join(dir, existingTestFile), 'utf8')
70
+ existingSection = `\n\nІснуючий тест (доповни):\n\`\`\`js\n${tc.slice(0, 3000)}\n\`\`\``
71
+ }
72
+
73
+ return [
74
+ `Напиши повний unit-тест для файлу \`${file}\` (покриття зараз ${pct.toFixed(1)}%).`,
75
+ reason ? `Причина: ${reason}` : '',
76
+ '',
77
+ 'Правила (СУВОРО):',
78
+ '- Перший рядок: `import { vi, describe, it, expect, beforeEach } from "vitest"`',
79
+ '- Мокуй залежності: `vi.mock("module", () => ({ fn: vi.fn() }))` + `vi.mocked(fn)`',
80
+ '- НІКОЛИ `jest.*`, НІКОЛИ `require()`',
81
+ '- Лише поведінкові тести — НЕ тестуй приватні деталі реалізації',
82
+ '- Поверни ЛИШЕ код тесту у блоці ```js ... ``` — без пояснень',
83
+ '',
84
+ `Джерело (\`${file}\`):`,
85
+ '```js',
86
+ content || '(недоступно)',
87
+ '```',
88
+ existingSection
89
+ ]
90
+ .filter(Boolean)
91
+ .join('\n')
92
+ }
93
+
94
+ /**
95
+ * Generates a test for one file via pi text mode.
96
+ * @param {{file: string, pct: number, reason: string}} fileInfo
97
+ * @param {string} dir
98
+ * @param {Function} callTextFn
99
+ * @returns {Promise<string|null>} written test path or null
100
+ */
101
+ async function generateOneTest(fileInfo, dir, callTextFn) {
102
+ const prompt = buildSingleFilePrompt(fileInfo, dir)
103
+
104
+ let response
105
+ try {
106
+ response = await callTextFn(prompt, { cwd: dir })
107
+ } catch (err) {
108
+ console.error(` ✗ pi помилка для ${fileInfo.file}: ${err.message}`)
109
+ return null
110
+ }
111
+
112
+ const code = extractCode(response)
113
+ if (!code) {
114
+ console.error(` ✗ pi не повернула код для ${fileInfo.file}`)
115
+ return null
116
+ }
117
+
118
+ const testPath = join(dir, testCandidates(fileInfo.file)[0])
119
+ writeFileSync(testPath, code + '\n', 'utf8')
120
+ console.log(` ✓ Записано: ${relative(dir, testPath)}`)
121
+ return testPath
122
+ }
123
+
124
+ /**
125
+ * Generates tests for all given files (one pi call per file).
126
+ * @param {Array<{file: string, pct: number, reason: string}>} files
127
+ * @param {string} dir project root
128
+ * @param {{ callText?: Function, generateOne?: Function }} [opts]
129
+ * @returns {Promise<void>}
130
+ */
131
+ export async function generateTests(files, dir, opts = {}) {
132
+ if (files.length === 0) return
133
+
134
+ const callTextFn = opts.callText ?? callText
135
+ const generateOneFn = opts.generateOne ?? ((f, d) => generateOneTest(f, d, callTextFn))
136
+
137
+ console.log(`\n🤖 Генерую тести для ${files.length} файлів (pi SDK, по одному)...\n`)
138
+
139
+ for (const fileInfo of files) {
140
+ console.log(` → ${fileInfo.file} (${fileInfo.pct.toFixed(1)}%)`)
141
+ await generateOneFn(fileInfo, dir)
142
+ }
143
+ }
package/src/index.js ADDED
@@ -0,0 +1,21 @@
1
+ import { resolve } from 'node:path'
2
+ import { cwd as getCwd } from 'node:process'
3
+
4
+ export async function run(args) {
5
+ const flags = (args ?? []).filter(a => a.startsWith('--'))
6
+ const positional = (args ?? []).filter(a => !a.startsWith('--'))
7
+ const [first] = positional
8
+
9
+ if (flags.includes('--help') || flags.includes('-h') || first === '--help' || first === '-h') {
10
+ console.log('Usage: n [directory] [--no-mutation]')
11
+ console.log(' Runs coverage analysis, generates missing tests, then mutation testing.')
12
+ console.log(' --no-mutation Skip mutation testing phase.')
13
+ console.log(' Defaults to current directory when no argument is given.')
14
+ return 0
15
+ }
16
+
17
+ const dir = first ? resolve(first) : getCwd()
18
+ const noMutation = flags.includes('--no-mutation')
19
+ const { runAutoTest } = await import('./run.mjs')
20
+ return runAutoTest(dir, { noMutation })
21
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Programmatic pi client via @earendil-works/pi-coding-agent SDK.
3
+ * Replaces spawnSync('pi', ...) with direct in-process calls.
4
+ *
5
+ * Two modes:
6
+ * callText(prompt, model?) — text-only, no tools, returns string
7
+ * callAgent(prompt, cwd) — coding tools enabled, writes files directly
8
+ */
9
+ import { createAgentSession, SessionManager } from '@earendil-works/pi-coding-agent'
10
+
11
+ /**
12
+ * Sends a single prompt to pi in text mode (no tools) and returns the response.
13
+ * Reads auth/model config from ~/.pi/ same as the CLI.
14
+ *
15
+ * @param {string} prompt
16
+ * @param {object} [opts]
17
+ * @param {string} [opts.cwd]
18
+ * @param {string} [opts.model] provider/model-id passed to pi (e.g. "openai/gpt-4o"); omit for pi default
19
+ * @returns {Promise<string>}
20
+ */
21
+ export async function callText(prompt, opts = {}) {
22
+ const cwd = opts.cwd ?? process.cwd()
23
+ const sessionOpts = {
24
+ tools: [],
25
+ sessionManager: SessionManager.inMemory(cwd),
26
+ cwd,
27
+ ...(opts.model ? { model: opts.model } : {})
28
+ }
29
+ const { session } = await createAgentSession(sessionOpts)
30
+
31
+ await session.prompt(prompt)
32
+
33
+ const state = session.state
34
+ const last = state.messages[state.messages.length - 1]
35
+ if (!last || last.role !== 'assistant') return ''
36
+ if (last.stopReason === 'error' || last.stopReason === 'aborted') {
37
+ throw new Error(`pi error: ${last.errorMessage ?? last.stopReason}`)
38
+ }
39
+ return last.content
40
+ .filter(c => c.type === 'text')
41
+ .map(c => c.text)
42
+ .join('')
43
+ }
44
+
45
+ /**
46
+ * Sends a prompt to pi in agent mode with full coding tools (read/write/bash/edit).
47
+ * The agent writes test files directly — no need to parse output.
48
+ *
49
+ * @param {string} prompt
50
+ * @param {string} cwd project root where files should be written
51
+ * @returns {Promise<void>}
52
+ */
53
+ export async function callAgent(prompt, cwd) {
54
+ const { session } = await createAgentSession({
55
+ tools: ['read', 'write', 'edit', 'bash', 'grep', 'find', 'ls'],
56
+ sessionManager: SessionManager.inMemory(cwd),
57
+ cwd
58
+ })
59
+
60
+ await session.prompt(prompt)
61
+ }
package/src/run.mjs ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Main auto-test loop: coverage → assess → gen-tests → loop until max → mutation + fix.
3
+ *
4
+ * Phase 1 (loop, max 5 iterations):
5
+ * 1. Measure per-file line coverage via vitest+lcov.
6
+ * 2. pi CLI assesses which uncovered files actually need tests.
7
+ * 3. pi agent generates tests for those files.
8
+ * 4. Repeat until coverage maxes out or no improvement.
9
+ *
10
+ * Phase 2:
11
+ * 5. Mutation testing via existing coverage providers (requires .n-cursor.json#rules).
12
+ * 6. Auto-fix survived mutants via pi agent.
13
+ */
14
+ import { measureCoveragePerFile, getUncoveredFiles } from './coverage-per-file.mjs'
15
+ import { assessNeed } from './assess-need.mjs'
16
+ import { generateTests } from './gen-tests.mjs'
17
+ import { runCoverageSteps } from './coverage/coverage.mjs'
18
+ import { withLock } from './scripts/utils/with-lock.mjs'
19
+
20
+ const MAX_ITERATIONS = 5
21
+ const COVERAGE_THRESHOLD = 80
22
+
23
+ /**
24
+ * @param {string} dir absolute path to project root
25
+ * @param {{ noMutation?: boolean }} [opts]
26
+ * @returns {Promise<number>} exit code
27
+ */
28
+ export async function runAutoTest(dir, opts = {}) {
29
+ console.log(`\n📁 ${dir}\n`)
30
+
31
+ let prevUncoveredCount = Infinity
32
+
33
+ for (let i = 1; i <= MAX_ITERATIONS; i++) {
34
+ console.log(`\n── Ітерація ${i}/${MAX_ITERATIONS}: coverage ──\n`)
35
+
36
+ const allFiles = await measureCoveragePerFile(dir)
37
+ if (allFiles.length === 0) {
38
+ console.log('⚠ Vitest coverage не повернула даних — перевір налаштування vitest.')
39
+ break
40
+ }
41
+
42
+ const uncovered = getUncoveredFiles(allFiles, COVERAGE_THRESHOLD)
43
+ const covered = allFiles.length - uncovered.length
44
+ const pct = ((covered / allFiles.length) * 100).toFixed(1)
45
+
46
+ console.log(`\n✓ Покриття: ${pct}% файлів (${covered}/${allFiles.length})`)
47
+
48
+ if (uncovered.length === 0) {
49
+ console.log('✓ Всі файли мають достатнє покриття — переходжу до мутантів.')
50
+ break
51
+ }
52
+
53
+ if (uncovered.length >= prevUncoveredCount) {
54
+ console.log('⚠ Покриття не покращилось після генерації тестів — зупиняю цикл.')
55
+ break
56
+ }
57
+ prevUncoveredCount = uncovered.length
58
+
59
+ console.log(`\n── Оцінюю ${uncovered.length} непокритих файлів (LLM) ──\n`)
60
+ const assessed = await assessNeed(uncovered, dir)
61
+ const needsTests = assessed.filter(f => f.needsTests)
62
+
63
+ if (needsTests.length === 0) {
64
+ console.log('✓ LLM вирішила: жоден непокритий файл не потребує unit-тестів.')
65
+ break
66
+ }
67
+
68
+ console.log(`\n→ Потребують тестів (${needsTests.length}):`)
69
+ for (const f of needsTests) {
70
+ console.log(` • ${f.file} (${f.pct.toFixed(1)}%) — ${f.reason}`)
71
+ }
72
+
73
+ await generateTests(needsTests, dir)
74
+ }
75
+
76
+ if (opts.noMutation) {
77
+ console.log('\n── Мутаційне тестування пропущено (--no-mutation) ──\n')
78
+ return 0
79
+ }
80
+
81
+ console.log('\n── Мутаційне тестування + автофікс ──\n')
82
+ const code = await withLock('coverage', () => runCoverageSteps({ fix: true, cwd: dir }))
83
+ if (code === 0) {
84
+ console.log('\n♻️ Повторний coverage після агента...\n')
85
+ return withLock('coverage', () => runCoverageSteps({ fix: false, cwd: dir }))
86
+ }
87
+ return code
88
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Збір змінених файлів для quick-режиму lint-оркестратора.
3
+ *
4
+ * Quick лінтить лише те, що змінено в робочому дереві: tracked-modified + staged
5
+ * (`git diff HEAD`) і нові untracked (`git ls-files --others --exclude-standard`).
6
+ * Видалені файли не повертаються. Поза git-репо або при помилці git — порожній список.
7
+ */
8
+ import { spawnSync } from 'node:child_process'
9
+
10
+ /**
11
+ * @param {string[]} args аргументи git
12
+ * @param {string} cwd корінь
13
+ * @returns {string[]} непорожні рядки stdout або [] при помилці
14
+ */
15
+ function gitLines(args, cwd) {
16
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8' })
17
+ if (r.status !== 0 || r.error) return []
18
+ return r.stdout
19
+ .split('\n')
20
+ .map(s => s.trim())
21
+ .filter(Boolean)
22
+ }
23
+
24
+ /**
25
+ * Relative-posix список змінених + untracked файлів робочого дерева.
26
+ * @param {string} [cwd] корінь репо
27
+ * @returns {string[]} унікальні шляхи (без видалених)
28
+ */
29
+ export function collectChangedFiles(cwd = process.cwd()) {
30
+ const modified = gitLines(['diff', 'HEAD', '--name-only', '--diff-filter=ACMR'], cwd)
31
+ const untracked = gitLines(['ls-files', '--others', '--exclude-standard'], cwd)
32
+ return [...new Set([...modified, ...untracked])]
33
+ }
34
+
35
+ /**
36
+ * Визначає git base для scoped-перевірок без зовнішнього runtime-стану.
37
+ * Пріоритет: локальна `main`, потім `origin/main`; якщо обидві відсутні,
38
+ * повертає null і caller порівнює лише робоче дерево з HEAD.
39
+ * @param {string} [cwd] корінь репо
40
+ * @returns {string|null} merge-base commit або null
41
+ */
42
+ export function resolveChangedBase(cwd = process.cwd()) {
43
+ for (const ref of ['main', 'origin/main']) {
44
+ const result = spawnSync('git', ['merge-base', 'HEAD', ref], { cwd, encoding: 'utf8' })
45
+ const base = result.status === 0 && !result.error ? result.stdout.trim() : ''
46
+ if (base) return base
47
+ }
48
+ return null
49
+ }
50
+
51
+ /**
52
+ * Список змінених + untracked файлів **відносно базового комміту**.
53
+ *
54
+ * `git diff <base>` (без `..`/`...`, без `HEAD`) порівнює base-комміт із поточним
55
+ * **робочим деревом** — тобто однаково ловить і закомічене від base, і staged, і
56
+ * незакомічені модифікації. Це гарантує однакову поведінку незалежно від того, чи
57
+ * зміни вже закомічені у worktree. Без `base` — fallback на `collectChangedFiles`
58
+ * (робоче дерево vs HEAD).
59
+ * @param {string|null} [base] базовий комміт
60
+ * @param {string} [cwd] корінь репо
61
+ * @returns {string[]} унікальні шляхи (без видалених)
62
+ */
63
+ export function collectChangedFilesSince(base, cwd = process.cwd()) {
64
+ if (!base) return collectChangedFiles(cwd)
65
+ // Fail-closed: недосяжний base (rebase/force-update/shallow prune) інакше дав би `git diff`
66
+ // exit 128 → порожній список → gate мовчки пройшов би без перевірки. Краще явна помилка.
67
+ const verify = spawnSync('git', ['rev-parse', '--verify', '--quiet', `${base}^{commit}`], { cwd, encoding: 'utf8' })
68
+ if (verify.status !== 0 || verify.error) {
69
+ throw new Error(
70
+ `collectChangedFilesSince: base-комміт «${base}» недосяжний у ${cwd} ` +
71
+ '(rebase/force-update?) — coverage --changed не може визначити scope'
72
+ )
73
+ }
74
+ const changed = gitLines(['diff', base, '--name-only', '--diff-filter=ACMR'], cwd)
75
+ const untracked = gitLines(['ls-files', '--others', '--exclude-standard'], cwd)
76
+ return [...new Set([...changed, ...untracked])]
77
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Light read-only `.n-cursor.json` reader для standalone `check.mjs` invocation.
3
+ *
4
+ * НЕ робить auto-rules detection, merge, schema sync — це справа повного `readConfig` у CLI.
5
+ * Тут лише: прочитати файл (якщо є), повернути `{ rules: string[], disableRules: string[] }`.
6
+ *
7
+ * Спостереження whitelist:
8
+ * - якщо `.n-cursor.json` НЕМАЄ → правило вважається enabled (поведінка "open by default"),
9
+ * щоб `bun rules/<id>/check.mjs` з будь-якої тимчасової директорії працювало для debug.
10
+ * - якщо файл є з `rules:[…]`, але правила там немає → правило не enabled.
11
+ * - якщо правило в `disable-rules` → не enabled, навіть якщо у `rules:[…]`.
12
+ */
13
+ import { existsSync } from 'node:fs'
14
+ import { readFile } from 'node:fs/promises'
15
+ import { join } from 'node:path'
16
+
17
+ const CONFIG_FILE = '.n-cursor.json'
18
+
19
+ /**
20
+ * @typedef {object} LiteConfig
21
+ * @property {boolean} exists чи існує .n-cursor.json у поточному каталозі
22
+ * @property {string[]} rules id правил з whitelist (порожній якщо файл відсутній)
23
+ * @property {string[]} disableRules id правил, явно вимкнених у `disable-rules`
24
+ */
25
+
26
+ /**
27
+ * @param {string} [cwd] корінь, у якому шукати .n-cursor.json (default — `process.cwd()`)
28
+ * @returns {Promise<LiteConfig>} стан конфігу
29
+ */
30
+ export async function readNCursorConfigLite(cwd = process.cwd()) {
31
+ const configPath = join(cwd, CONFIG_FILE)
32
+ if (!existsSync(configPath)) {
33
+ return { exists: false, rules: [], disableRules: [] }
34
+ }
35
+ const raw = await readFile(configPath, 'utf8')
36
+ /** @type {{ rules?: unknown, ['disable-rules']?: unknown }} */
37
+ const parsed = JSON.parse(raw)
38
+ const rules = Array.isArray(parsed.rules) ? parsed.rules.filter(r => typeof r === 'string') : []
39
+ const disableRules = Array.isArray(parsed['disable-rules'])
40
+ ? parsed['disable-rules'].filter(r => typeof r === 'string')
41
+ : []
42
+ return { exists: true, rules, disableRules }
43
+ }
44
+
45
+ /**
46
+ * Чи активне правило згідно з конфігом.
47
+ * - файл відсутній → true (open by default для debug);
48
+ * - правило явно в `disable-rules` → false;
49
+ * - правило у `rules` → true;
50
+ * - інакше → false.
51
+ * @param {LiteConfig} config розпарсений lite-конфіг
52
+ * @param {string} ruleId id правила (= basename каталогу)
53
+ * @returns {boolean} чи запускати правило
54
+ */
55
+ export function isRuleEnabled(config, ruleId) {
56
+ if (!config.exists) return true
57
+ if (config.disableRules.includes(ruleId)) return false
58
+ return config.rules.includes(ruleId)
59
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Обчислення директорії стану `withLock` (lock + dedup), СПІЛЬНОЇ для всіх git-worktree.
3
+ *
4
+ * Лок-стан (`lock/owner.json`, `result.json`) має бути один на головний checkout
5
+ * і всі linked-worktree. Інакше важкі команди (eslint/oxlint/jscpd, conftest,
6
+ * hadolint, per-rule fix), запущені в різних worktree, НЕ серіалізуються —
7
+ * кожен worktree має власний `node_modules/.cache/`, локи одне одного не бачать,
8
+ * і паралельний eslint перевантажує CPU/диск на macOS.
9
+ *
10
+ * `git rev-parse --git-common-dir` повертає той самий `.git` головного репо з
11
+ * будь-якого worktree, тож стан кладемо під `<git-common-dir>/n-cursor/<key>`
12
+ * (всередині `.git` — спільне, ніколи не трекається, переживає `bun i`).
13
+ * Поза git-репо (git недоступний / каталог не репо) — fallback на per-checkout
14
+ * `node_modules/.cache/n-cursor/<key>`, як було історично.
15
+ */
16
+ import { spawnSync } from 'node:child_process'
17
+ import { join, resolve } from 'node:path'
18
+
19
+ /**
20
+ * @param {string} key ключ локу (`lint-ga`, `fix-bun`, …)
21
+ * @param {{cwd?:string, spawn?:typeof import('child_process').spawnSync}} [opts] робоча директорія та sync-виклик git (ін'єкція для тестів)
22
+ * @returns {string} абсолютний шлях до директорії стану локу для цього ключа
23
+ */
24
+ export function resolveLockCacheDir(key, opts = {}) {
25
+ const cwd = opts.cwd ?? process.cwd()
26
+ const spawn = opts.spawn ?? spawnSync
27
+
28
+ const r = spawn('git', ['rev-parse', '--git-common-dir'], { cwd, encoding: 'utf8' })
29
+ const commonDir = r.status === 0 && !r.error ? r.stdout.trim() : ''
30
+
31
+ if (commonDir === '') {
32
+ return join(cwd, 'node_modules/.cache/n-cursor', key)
33
+ }
34
+ // commonDir буває відносним (`.git` з кореня) або абсолютним (linked-worktree):
35
+ // resolve проти cwd дає однаковий абсолютний `<main>/.git` в обох випадках.
36
+ return join(resolve(cwd, commonDir), 'n-cursor', key)
37
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Guard-механізм: атомарний lock + dedup для важких команд.
3
+ * Алгоритм: mkdirSync-based lock, перевірка живості PID, sha256-dedup з TTL.
4
+ */
5
+ import * as fs from 'node:fs'
6
+ import { join } from 'node:path'
7
+ import * as os from 'node:os'
8
+ import { setTimeout as sleep } from 'node:timers/promises'
9
+ import { resolveLockCacheDir } from './lock-cache-dir.mjs'
10
+ import { worktreeFingerprint } from './worktree-fingerprint.mjs'
11
+
12
+ const DEFAULTS = {
13
+ ttl: 600_000,
14
+ staleThreshold: 1_800_000,
15
+ waitTimeout: 1_200_000,
16
+ pollInterval: 1500,
17
+ onWaitTimeout: 'run-unlocked'
18
+ }
19
+
20
+ /**
21
+ * Чи процес із заданим PID ще живий на поточному host.
22
+ * @param {number} pid ідентифікатор процесу з owner.json
23
+ * @returns {boolean} true, якщо process.kill(pid, 0) не кинув помилку
24
+ */
25
+ function isAlive(pid) {
26
+ try {
27
+ process.kill(pid, 0)
28
+ return true
29
+ } catch {
30
+ return false
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Повертає функцію, що знімає lock-директорію.
36
+ * @param {string} lockDir абсолютний шлях до lock-директорії
37
+ * @returns {() => void} release-колбек для finally/signal handler
38
+ */
39
+ function makeRelease(lockDir) {
40
+ return () => fs.rmSync(lockDir, { recursive: true, force: true })
41
+ }
42
+
43
+ /**
44
+ * Чи можна пропустити повторний прогін за кешованим result.json.
45
+ * @param {{exitCode:number, fingerprint:string|null, finishedAt:number}} result попередній результат з result.json
46
+ * @param {string|null} fingerprint поточний fingerprint робочого дерева
47
+ * @param {number} ttl TTL дедуплікації в мілісекундах
48
+ * @returns {boolean} true, якщо попередній успішний прогін можна повторно використати
49
+ */
50
+ export function shouldDedup(result, fingerprint, ttl) {
51
+ if (result.exitCode !== 0) return false
52
+ if (fingerprint === null || result.fingerprint !== fingerprint) return false
53
+ if (Date.now() - result.finishedAt >= ttl) return false
54
+ return true
55
+ }
56
+
57
+ /**
58
+ * Серіалізує важку команду через атомарний lock і dedup за fingerprint.
59
+ * @param {string} key ключ локу (наприклад `lint-ga`, `fix-bun`)
60
+ * @param {() => number | Promise<number>} runFn основна робота; повертає exit code
61
+ * @param {{ttl?:number, staleThreshold?:number, waitTimeout?:number, pollInterval?:number, onWaitTimeout?:'run-unlocked'|'fail', cacheDir?:string, getFingerprint?:() => string | null}} [opts] TTL, шлях кешу, поведінка на таймаут (default `run-unlocked`; `fail` = fail-closed) та override fingerprint
62
+ * @returns {Promise<number>} exit code виконаної або дедуплікованої команди
63
+ */
64
+ export async function withLock(key, runFn, opts = {}) {
65
+ const { ttl, staleThreshold, waitTimeout, pollInterval, onWaitTimeout } = { ...DEFAULTS, ...opts }
66
+ const getFingerprint = opts.getFingerprint ?? worktreeFingerprint
67
+ const cacheDir = opts.cacheDir ?? resolveLockCacheDir(key)
68
+ const lockDir = join(cacheDir, 'lock')
69
+ const ownerFile = join(lockDir, 'owner.json')
70
+ const resultFile = join(cacheDir, 'result.json')
71
+ const release = makeRelease(lockDir)
72
+
73
+ const fingerprint = getFingerprint()
74
+ fs.mkdirSync(cacheDir, { recursive: true })
75
+
76
+ const loopStart = Date.now()
77
+
78
+ while (true) {
79
+ if (Date.now() - loopStart >= waitTimeout) {
80
+ if (onWaitTimeout === 'fail') {
81
+ throw new Error(`${key}: не вдалося взяти лок за ${waitTimeout / 60_000} хв — fail-closed`)
82
+ }
83
+ console.error(`⚠️ ${key}: чекав ${waitTimeout / 60_000} хв — запускаю без локу`)
84
+ return await runFn()
85
+ }
86
+ try {
87
+ fs.mkdirSync(lockDir)
88
+ fs.writeFileSync(
89
+ ownerFile,
90
+ JSON.stringify({ pid: process.pid, host: os.hostname(), startedAt: Date.now(), fingerprint })
91
+ )
92
+ break
93
+ } catch (error) {
94
+ if (error.code !== 'EEXIST') throw error
95
+ let owner
96
+ try {
97
+ owner = JSON.parse(fs.readFileSync(ownerFile, 'utf8'))
98
+ } catch {
99
+ fs.rmSync(lockDir, { recursive: true, force: true })
100
+ continue
101
+ }
102
+ const stale =
103
+ Date.now() - owner.startedAt > staleThreshold || (os.hostname() === owner.host && !isAlive(owner.pid))
104
+ if (stale) {
105
+ console.error(`🧹 ${key}: знайдено застарілий лок — очищаю`)
106
+ fs.rmSync(lockDir, { recursive: true, force: true })
107
+ continue
108
+ }
109
+ console.error(`⏳ ${key}: чекаю, лок тримає pid ${owner.pid}…`)
110
+ await sleep(pollInterval)
111
+ }
112
+ }
113
+
114
+ console.error(`🔒 ${key}: лок взято`)
115
+
116
+ try {
117
+ const raw = fs.readFileSync(resultFile, 'utf8')
118
+ const result = JSON.parse(raw)
119
+ if (shouldDedup(result, fingerprint, ttl)) {
120
+ const elapsed = Math.round((Date.now() - result.finishedAt) / 1000)
121
+ console.error(`♻️ ${key}: те саме дерево, ${elapsed}с тому — пропускаю`)
122
+ release()
123
+ return 0
124
+ }
125
+ } catch {
126
+ /* result.json не існує або пошкоджений */
127
+ }
128
+
129
+ // Після release лок ре-рейзиться той самий сигнал: `once` вже зняв обробник,
130
+ // тож процес завершується дефолтною дією з коректним кодом (130/143)
131
+ const onSignal = signal => {
132
+ release()
133
+ process.kill(process.pid, signal)
134
+ }
135
+ process.once('SIGINT', onSignal)
136
+ process.once('SIGTERM', onSignal)
137
+
138
+ let code
139
+ try {
140
+ code = await runFn()
141
+ fs.writeFileSync(resultFile, JSON.stringify({ finishedAt: Date.now(), exitCode: code, fingerprint }))
142
+ } finally {
143
+ process.off('SIGINT', onSignal)
144
+ process.off('SIGTERM', onSignal)
145
+ release()
146
+ }
147
+
148
+ return code
149
+ }
@@ -0,0 +1,33 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { createHash } from 'node:crypto'
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
+ export function worktreeFingerprint(spawn = spawnSync) {
10
+ /**
11
+ * @param {string[]} args аргументи підкоманди git
12
+ * @returns {string} stdout git-команди
13
+ */
14
+ function git(args) {
15
+ const r = spawn('git', args, { encoding: 'utf8' })
16
+ if (r.status !== 0 || r.error) throw new Error(`git ${args[0]} failed`)
17
+ return r.stdout
18
+ }
19
+
20
+ try {
21
+ const commitHash = git(['rev-parse', 'HEAD']).trim()
22
+ const diffText = git(['diff', 'HEAD'])
23
+ // -z: NUL-розділення без C-екранування. Без нього імена з не-ASCII символами
24
+ // повертаються у `"..."` формі, і `git hash-object` не знаходить файл → throw → fingerprint=null.
25
+ const untrackedRaw = git(['ls-files', '-z', '--others', '--exclude-standard'])
26
+ const untrackedFiles = untrackedRaw.split('\0').filter(Boolean)
27
+ const pairs = untrackedFiles.map(f => `${f}:${git(['hash-object', f]).trim()}`).toSorted()
28
+ const raw = [commitHash, diffText, ...pairs].join('\n')
29
+ return createHash('sha256').update(raw).digest('hex')
30
+ } catch {
31
+ return null
32
+ }
33
+ }