@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.
- package/CHANGELOG.md +7 -0
- package/README.md +3 -0
- package/bin/n.js +4 -0
- package/package.json +49 -0
- package/src/assess-need.mjs +76 -0
- package/src/coverage/coverage.mjs +315 -0
- package/src/coverage-classify/apply.mjs +67 -0
- package/src/coverage-classify/cache.mjs +77 -0
- package/src/coverage-classify/index.mjs +112 -0
- package/src/coverage-classify/prompt.mjs +126 -0
- package/src/coverage-classify/verdict-schema.mjs +35 -0
- package/src/coverage-fix-extract.mjs +122 -0
- package/src/coverage-fix.mjs +117 -0
- package/src/coverage-per-file.mjs +85 -0
- package/src/gen-tests.mjs +143 -0
- package/src/index.js +21 -0
- package/src/lib/pi-client.mjs +61 -0
- package/src/run.mjs +88 -0
- package/src/scripts/lib/changed-files.mjs +77 -0
- package/src/scripts/lib/read-n-cursor-config-lite.mjs +59 -0
- package/src/scripts/utils/lock-cache-dir.mjs +37 -0
- package/src/scripts/utils/with-lock.mjs +149 -0
- package/src/scripts/utils/worktree-fingerprint.mjs +33 -0
|
@@ -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
|
+
}
|