@nitra/cursor 3.21.0 → 3.22.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 +13 -0
- package/bin/n-cursor.js +112 -52
- package/package.json +1 -1
- package/rules/js-lint/js/lint-findings.mjs +110 -0
- package/rules/js-lint/js/lint.mjs +86 -15
- package/scripts/lib/diff-added-lines.mjs +85 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [3.22.0] - 2026-06-04
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- js-lint quick-режим класифікує lint-findings на introduced (рядок у diff від HEAD) vs pre-existing (борг файлу) і позначає у виводі (🆕/🗄) — щоб дотик до файлу не плутав «моє чи старий борг». Захоплює oxlint/eslint --format=json, парсить added-lines git-diff, групує. Блокування без змін (#6/A). Краш інструмента не пропускається тихо.
|
|
8
|
+
|
|
9
|
+
## [3.21.1] - 2026-06-04
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- CLI sync: ховати блоки правил / 🧩 Skills / ⌨️ Commands / 🥧 Pi skills (рядки ⬇ і підсумок) за успішного прогону — друкувати лише коли fail > 0 (helper captureOutput)
|
|
14
|
+
- CLI sync: ховати post-sync рядки (📝 setup-bun-deps action / 📝 AGENTS.md / 📝 CLAUDE.md / 🤖 Claude-конфіг) за успішного прогону — через captureOutput, друк лише при помилці
|
|
15
|
+
|
|
3
16
|
## [3.21.0] - 2026-06-04
|
|
4
17
|
|
|
5
18
|
### Added
|
package/bin/n-cursor.js
CHANGED
|
@@ -1132,6 +1132,48 @@ async function runSyncStep(prefix, action) {
|
|
|
1132
1132
|
}
|
|
1133
1133
|
}
|
|
1134
1134
|
|
|
1135
|
+
/**
|
|
1136
|
+
* Виконує `action`, буферизуючи весь його stdout/console-вивід.
|
|
1137
|
+
*
|
|
1138
|
+
* Мотивація: за успішного прогону sync-блоку рядки `⬇ … ✅` і підсумок
|
|
1139
|
+
* (`🧩 Skills: N скопійовано, 0 з помилками`) не несуть користі й лише
|
|
1140
|
+
* захаращують термінал. Тому буфер скидається в реальний stdout **лише**
|
|
1141
|
+
* коли крок повернув `fail > 0` (або кинув виняток); за `fail === 0` —
|
|
1142
|
+
* відкидається мовчки.
|
|
1143
|
+
*
|
|
1144
|
+
* @template T
|
|
1145
|
+
* @param {() => Promise<T>} action крок синку, що повертає обʼєкт із лічильником помилок `fail`
|
|
1146
|
+
* @returns {Promise<T>} результат `action` без змін
|
|
1147
|
+
*/
|
|
1148
|
+
async function captureOutput(action) {
|
|
1149
|
+
const buffer = []
|
|
1150
|
+
const realStdoutWrite = process.stdout.write
|
|
1151
|
+
const realLog = console.log
|
|
1152
|
+
const realError = console.error
|
|
1153
|
+
const flush = () => realStdoutWrite.call(process.stdout, buffer.join(''))
|
|
1154
|
+
process.stdout.write = (...args) => {
|
|
1155
|
+
const [chunk] = args
|
|
1156
|
+
buffer.push(typeof chunk === 'string' ? chunk : chunk.toString())
|
|
1157
|
+
const cb = args.find((arg) => typeof arg === 'function')
|
|
1158
|
+
if (cb) cb()
|
|
1159
|
+
return true
|
|
1160
|
+
}
|
|
1161
|
+
console.log = (...args) => buffer.push(`${args.map(String).join(' ')}\n`)
|
|
1162
|
+
console.error = (...args) => buffer.push(`${args.map(String).join(' ')}\n`)
|
|
1163
|
+
try {
|
|
1164
|
+
const result = await action()
|
|
1165
|
+
if (result?.fail > 0) flush()
|
|
1166
|
+
return result
|
|
1167
|
+
} catch (error) {
|
|
1168
|
+
flush()
|
|
1169
|
+
throw error
|
|
1170
|
+
} finally {
|
|
1171
|
+
process.stdout.write = realStdoutWrite
|
|
1172
|
+
console.log = realLog
|
|
1173
|
+
console.error = realError
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1135
1177
|
/**
|
|
1136
1178
|
* Копіює керовані `.mdc` файли з пакету до `.cursor/rules`.
|
|
1137
1179
|
* @param {string[]} rules список rules з конфігу
|
|
@@ -1357,14 +1399,19 @@ async function runSync() {
|
|
|
1357
1399
|
console.log(`📋 Правил до завантаження: ${rules.length}`)
|
|
1358
1400
|
console.log(`📋 Skills до синхронізації: ${skills.length}`)
|
|
1359
1401
|
|
|
1360
|
-
await runSyncStep('❌ Не вдалося записати setup-bun-deps action: ',
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1402
|
+
await runSyncStep('❌ Не вдалося записати setup-bun-deps action: ', () =>
|
|
1403
|
+
captureOutput(async () => {
|
|
1404
|
+
const { destPath } = await syncSetupBunDepsAction(cwd(), effectivePackageRoot)
|
|
1405
|
+
console.log(`📝 Оновлено ${destPath} (composite setup-bun-deps з пакету)\n`)
|
|
1406
|
+
})
|
|
1407
|
+
)
|
|
1364
1408
|
|
|
1365
1409
|
const rulesDir = join(cwd(), RULES_DIR)
|
|
1366
1410
|
await mkdir(rulesDir, { recursive: true })
|
|
1367
|
-
const { successCount, failCount } = await
|
|
1411
|
+
const { successCount, failCount } = await captureOutput(async () => {
|
|
1412
|
+
const stats = await syncManagedRuleFiles(rules, bundledRulesDir, rulesDir)
|
|
1413
|
+
return { ...stats, fail: stats.failCount }
|
|
1414
|
+
})
|
|
1368
1415
|
|
|
1369
1416
|
await runSyncStep(`❌ Не вдалося прибрати зайві файли в ${RULES_DIR}: `, async () => {
|
|
1370
1417
|
const removed = await removeOrphanManagedRuleFiles(rulesDir, rules)
|
|
@@ -1372,10 +1419,13 @@ async function runSync() {
|
|
|
1372
1419
|
})
|
|
1373
1420
|
|
|
1374
1421
|
await runSyncStep('❌ Skills: ', async () => {
|
|
1375
|
-
const {
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1422
|
+
const { fail: skillFail } = await captureOutput(async () => {
|
|
1423
|
+
const { success: skillOk, fail } = await syncSkills(skills, bundledSkillsDir)
|
|
1424
|
+
if (skills.length > 0) {
|
|
1425
|
+
console.log(`\n🧩 Skills: ${skillOk} скопійовано, ${fail} з помилками`)
|
|
1426
|
+
}
|
|
1427
|
+
return { fail }
|
|
1428
|
+
})
|
|
1379
1429
|
const removedSkills = await removeOrphanManagedSkillDirs(join(cwd(), SKILLS_DIR), skills)
|
|
1380
1430
|
logRemovedManagedItems('skills', SKILLS_DIR, removedSkills)
|
|
1381
1431
|
if (skillFail > 0) {
|
|
@@ -1384,13 +1434,16 @@ async function runSync() {
|
|
|
1384
1434
|
})
|
|
1385
1435
|
|
|
1386
1436
|
await runSyncStep('❌ Commands: ', async () => {
|
|
1387
|
-
const {
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1437
|
+
const { fail: totalFail } = await captureOutput(async () => {
|
|
1438
|
+
const { success: cmdOk, fail: cmdFail } = await syncCommands(skills, bundledSkillsDir)
|
|
1439
|
+
const { success: localOk, fail: localFail } = await syncLocalOnlySkillCommands(skills)
|
|
1440
|
+
const totalOk = cmdOk + localOk
|
|
1441
|
+
const fail = cmdFail + localFail
|
|
1442
|
+
if (totalOk + fail > 0) {
|
|
1443
|
+
console.log(`\n⌨️ Commands: ${totalOk} скопійовано, ${fail} з помилками`)
|
|
1444
|
+
}
|
|
1445
|
+
return { fail }
|
|
1446
|
+
})
|
|
1394
1447
|
const commandsDir = join(cwd(), COMMANDS_DIR)
|
|
1395
1448
|
const removedCmds = await removeOrphanManagedCommandFiles(commandsDir, skills)
|
|
1396
1449
|
logRemovedManagedItems('commands', COMMANDS_DIR, removedCmds)
|
|
@@ -1402,13 +1455,16 @@ async function runSync() {
|
|
|
1402
1455
|
})
|
|
1403
1456
|
|
|
1404
1457
|
await runSyncStep('❌ Pi skills: ', async () => {
|
|
1405
|
-
const {
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1458
|
+
const { fail: totalFail } = await captureOutput(async () => {
|
|
1459
|
+
const { success: piOk, fail: piFail } = await syncPiSkills(skills, bundledSkillsDir)
|
|
1460
|
+
const { success: piLocalOk, fail: piLocalFail } = await syncLocalOnlyPiSkills(skills)
|
|
1461
|
+
const totalOk = piOk + piLocalOk
|
|
1462
|
+
const fail = piFail + piLocalFail
|
|
1463
|
+
if (totalOk + fail > 0) {
|
|
1464
|
+
console.log(`\n🥧 Pi skills: ${totalOk} скопійовано, ${fail} з помилками`)
|
|
1465
|
+
}
|
|
1466
|
+
return { fail }
|
|
1467
|
+
})
|
|
1412
1468
|
const piSkillsDir = join(cwd(), PI_SKILLS_DIR)
|
|
1413
1469
|
const removedPi = await removeOrphanManagedPiSkillDirs(piSkillsDir, skills)
|
|
1414
1470
|
logRemovedManagedItems('pi skills', PI_SKILLS_DIR, removedPi)
|
|
@@ -1419,39 +1475,43 @@ async function runSync() {
|
|
|
1419
1475
|
}
|
|
1420
1476
|
})
|
|
1421
1477
|
|
|
1422
|
-
await runSyncStep(`❌ Не вдалося оновити ${AGENTS_FILE}: `, () =>
|
|
1478
|
+
await runSyncStep(`❌ Не вдалося оновити ${AGENTS_FILE}: `, () =>
|
|
1479
|
+
captureOutput(() => syncAgentsMd(bundledAgentsTemplatePath))
|
|
1480
|
+
)
|
|
1423
1481
|
await runSyncStep('❌ Не вдалося оновити CLAUDE.md: ', () =>
|
|
1424
|
-
syncClaudeMd(/** @type {string[] | undefined} */ (ignore))
|
|
1482
|
+
captureOutput(() => syncClaudeMd(/** @type {string[] | undefined} */ (ignore)))
|
|
1425
1483
|
)
|
|
1426
1484
|
|
|
1427
|
-
await runSyncStep('❌ Не вдалося синхронізувати Claude-конфіг: ',
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
const parts = []
|
|
1439
|
-
if (result.settings) parts.push('.claude/settings.json')
|
|
1440
|
-
if (result.cursorHooks) parts.push('.cursor/hooks.json')
|
|
1441
|
-
if (result.commands.length > 0) parts.push(`${result.commands.length} slash-commands`)
|
|
1442
|
-
if (result.adrHook) parts.push('.claude/hooks/capture-decisions.sh')
|
|
1443
|
-
if (result.adrNormalizeHook) parts.push('.claude/hooks/normalize-decisions.sh')
|
|
1444
|
-
if (result.adrHookLib?.length > 0) {
|
|
1445
|
-
for (const libPath of result.adrHookLib) {
|
|
1446
|
-
parts.push(libPath)
|
|
1485
|
+
await runSyncStep('❌ Не вдалося синхронізувати Claude-конфіг: ', () =>
|
|
1486
|
+
captureOutput(async () => {
|
|
1487
|
+
const result = await syncClaudeConfig({
|
|
1488
|
+
projectRoot: cwd(),
|
|
1489
|
+
bundledPackageRoot: effectivePackageRoot,
|
|
1490
|
+
enabled: claudeConfigEnabled,
|
|
1491
|
+
rules
|
|
1492
|
+
})
|
|
1493
|
+
if (!claudeConfigEnabled) {
|
|
1494
|
+
console.log('🤖 Claude-конфіг: пропущено (claude-config: false у .n-cursor.json)')
|
|
1495
|
+
return
|
|
1447
1496
|
}
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1497
|
+
const parts = []
|
|
1498
|
+
if (result.settings) parts.push('.claude/settings.json')
|
|
1499
|
+
if (result.cursorHooks) parts.push('.cursor/hooks.json')
|
|
1500
|
+
if (result.commands.length > 0) parts.push(`${result.commands.length} slash-commands`)
|
|
1501
|
+
if (result.adrHook) parts.push('.claude/hooks/capture-decisions.sh')
|
|
1502
|
+
if (result.adrNormalizeHook) parts.push('.claude/hooks/normalize-decisions.sh')
|
|
1503
|
+
if (result.adrHookLib?.length > 0) {
|
|
1504
|
+
for (const libPath of result.adrHookLib) {
|
|
1505
|
+
parts.push(libPath)
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
if (result.gitignoreAdr) parts.push('.gitignore (adr fragment)')
|
|
1509
|
+
if (result.piExtension) parts.push('.pi/extensions/n-cursor-adr/')
|
|
1510
|
+
if (parts.length > 0) {
|
|
1511
|
+
console.log(`🤖 Claude-конфіг: ${parts.join(', ')}`)
|
|
1512
|
+
}
|
|
1513
|
+
})
|
|
1514
|
+
)
|
|
1455
1515
|
|
|
1456
1516
|
await runSyncStep('❌ Не вдалося оновити .gitignore (worktree): ', async () => {
|
|
1457
1517
|
const { written } = await syncGitignoreWorktree(cwd())
|
package/package.json
CHANGED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Нормалізація й класифікація lint-findings (oxlint + eslint) на introduced
|
|
3
|
+
* (рядок у diff від HEAD) vs pre-existing (борг файлу) — беклог #6, варіант A.
|
|
4
|
+
*
|
|
5
|
+
* Формати: oxlint `--format=json` → `{ diagnostics:[{ filename, code, labels:[{span:{line}}] }] }`;
|
|
6
|
+
* eslint `--format=json` → `[{ filePath, messages:[{ ruleId, line, message }] }]`. Шляхи абсолютні.
|
|
7
|
+
*/
|
|
8
|
+
import { isAbsolute, relative } from 'node:path'
|
|
9
|
+
|
|
10
|
+
import { isIntroducedLine } from '../../../scripts/lib/diff-added-lines.mjs'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {string} jsonText вивід `oxlint --format=json`
|
|
14
|
+
* @returns {{ file: string, line: number, rule: string, message: string, tool: string }[] | null} findings,
|
|
15
|
+
* або `null` якщо json непарсабельний (краш/обрізаний вивід інструмента — НЕ «чисто»)
|
|
16
|
+
*/
|
|
17
|
+
export function parseOxlint(jsonText) {
|
|
18
|
+
let data
|
|
19
|
+
try {
|
|
20
|
+
data = JSON.parse(jsonText)
|
|
21
|
+
} catch {
|
|
22
|
+
return null
|
|
23
|
+
}
|
|
24
|
+
const diags = Array.isArray(data?.diagnostics) ? data.diagnostics : []
|
|
25
|
+
return diags
|
|
26
|
+
.filter(d => d?.filename)
|
|
27
|
+
.map(d => ({
|
|
28
|
+
file: d.filename,
|
|
29
|
+
line: d.labels?.[0]?.span?.line ?? 0,
|
|
30
|
+
rule: d.code ?? '',
|
|
31
|
+
message: d.message ?? '',
|
|
32
|
+
tool: 'oxlint'
|
|
33
|
+
}))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {string} jsonText вивід `eslint --format=json`
|
|
38
|
+
* @returns {{ file: string, line: number, rule: string, message: string, tool: string }[] | null} findings,
|
|
39
|
+
* або `null` якщо json непарсабельний (краш/обрізаний вивід інструмента — НЕ «чисто»)
|
|
40
|
+
*/
|
|
41
|
+
export function parseEslint(jsonText) {
|
|
42
|
+
let data
|
|
43
|
+
try {
|
|
44
|
+
data = JSON.parse(jsonText)
|
|
45
|
+
} catch {
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
const results = Array.isArray(data) ? data : []
|
|
49
|
+
const out = []
|
|
50
|
+
for (const r of results) {
|
|
51
|
+
for (const m of r?.messages ?? []) {
|
|
52
|
+
out.push({
|
|
53
|
+
file: r.filePath,
|
|
54
|
+
line: m.line ?? 0,
|
|
55
|
+
rule: m.ruleId ?? '(syntax)',
|
|
56
|
+
message: m.message ?? '',
|
|
57
|
+
tool: 'eslint'
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return out.filter(f => f.file)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Розділяє findings на introduced / pre-existing за доданими рядками.
|
|
66
|
+
* @param {{ file: string, line: number }[]} findings нормалізовані findings
|
|
67
|
+
* @param {Map<string, Set<number> | string>} addedLines з `addedLinesByFile`
|
|
68
|
+
* @param {string} [cwd] корінь (для нормалізації абсолютних шляхів у relative)
|
|
69
|
+
* @returns {{ introduced: object[], preExisting: object[] }} класифікація
|
|
70
|
+
*/
|
|
71
|
+
export function classifyFindings(findings, addedLines, cwd = process.cwd()) {
|
|
72
|
+
const introduced = []
|
|
73
|
+
const preExisting = []
|
|
74
|
+
for (const f of findings) {
|
|
75
|
+
const rel = isAbsolute(f.file) ? relative(cwd, f.file) : f.file
|
|
76
|
+
if (isIntroducedLine(addedLines, rel, f.line)) introduced.push(f)
|
|
77
|
+
else preExisting.push(f)
|
|
78
|
+
}
|
|
79
|
+
return { introduced, preExisting }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Рядок одного finding: `<rel>:<line> <rule> <message>`.
|
|
84
|
+
* @param {{ file: string, line: number, rule: string, message: string }} f finding
|
|
85
|
+
* @param {string} cwd корінь
|
|
86
|
+
* @returns {string} рядок
|
|
87
|
+
*/
|
|
88
|
+
function formatFinding(f, cwd) {
|
|
89
|
+
const rel = isAbsolute(f.file) ? relative(cwd, f.file) : f.file
|
|
90
|
+
return ` ${rel}:${f.line} ${f.rule} ${f.message}`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Згрупований звіт: 🆕 introduced (виправ) + 🗄 pre-existing (борг файлу).
|
|
95
|
+
* @param {{ introduced: object[], preExisting: object[] }} classified результат `classifyFindings`
|
|
96
|
+
* @param {string} [cwd] корінь
|
|
97
|
+
* @returns {string} текст звіту
|
|
98
|
+
*/
|
|
99
|
+
export function renderFindings({ introduced, preExisting }, cwd = process.cwd()) {
|
|
100
|
+
const lines = []
|
|
101
|
+
if (introduced.length > 0) {
|
|
102
|
+
lines.push(` 🆕 introduced (${introduced.length}) — внесено цією зміною, виправ:`)
|
|
103
|
+
for (const f of introduced) lines.push(formatFinding(f, cwd))
|
|
104
|
+
}
|
|
105
|
+
if (preExisting.length > 0) {
|
|
106
|
+
lines.push(` 🗄 pre-existing (${preExisting.length}) — борг файлу, не з цієї зміни:`)
|
|
107
|
+
for (const f of preExisting) lines.push(formatFinding(f, cwd))
|
|
108
|
+
}
|
|
109
|
+
return lines.join('\n')
|
|
110
|
+
}
|
|
@@ -2,12 +2,17 @@
|
|
|
2
2
|
* Quick-крок lint правила js-lint: oxlint + eslint (з автофіксом).
|
|
3
3
|
*
|
|
4
4
|
* Викликається lint-оркестратором (`n-cursor lint` / `lint-ci`):
|
|
5
|
-
* - `files` = масив змінених файлів (quick) → лінтимо лише js-подібні з
|
|
6
|
-
*
|
|
5
|
+
* - `files` = масив змінених файлів (quick) → лінтимо лише js-подібні з них і
|
|
6
|
+
* КЛАСИФІКУЄМО лишені findings на introduced (рядок у diff від HEAD) vs
|
|
7
|
+
* pre-existing (борг файлу) — беклог #6, варіант A (видимість; блокування без змін);
|
|
8
|
+
* - `files` = undefined (ci) → лінтимо весь проєкт (стрімінг, без класифікації).
|
|
7
9
|
* Крос-файлові jscpd/knip — окреме правило js-lint-ci (фаза ci).
|
|
8
10
|
*/
|
|
9
11
|
import { spawnSync } from 'node:child_process'
|
|
10
12
|
|
|
13
|
+
import { addedLinesByFile } from '../../../scripts/lib/diff-added-lines.mjs'
|
|
14
|
+
import { classifyFindings, parseEslint, parseOxlint, renderFindings } from './lint-findings.mjs'
|
|
15
|
+
|
|
11
16
|
const JS_EXT_RE = /\.(?:mjs|cjs|js|jsx|ts|tsx|vue)$/u
|
|
12
17
|
|
|
13
18
|
/**
|
|
@@ -20,15 +25,88 @@ export function filterJsFiles(files) {
|
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
/**
|
|
23
|
-
*
|
|
28
|
+
* Запуск інструмента (через bunx) зі стрімінгом у термінал.
|
|
29
|
+
* @param {string[]} args аргументи
|
|
24
30
|
* @param {string} cwd корінь
|
|
25
31
|
* @returns {number} exit code
|
|
26
32
|
*/
|
|
27
|
-
function
|
|
33
|
+
function runInherit(args, cwd) {
|
|
28
34
|
const r = spawnSync('bunx', args, { cwd, stdio: 'inherit' })
|
|
29
35
|
return typeof r.status === 'number' ? r.status : 1
|
|
30
36
|
}
|
|
31
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Авто-фікс-пас: застосовує `--fix`, stdout приглушено (findings перерендеримо
|
|
40
|
+
* класифіковано), stderr — назовні (краші інструмента видимі).
|
|
41
|
+
* @param {string[]} args аргументи
|
|
42
|
+
* @param {string} cwd корінь
|
|
43
|
+
* @returns {number} exit code
|
|
44
|
+
*/
|
|
45
|
+
function runFix(args, cwd) {
|
|
46
|
+
const r = spawnSync('bunx', args, { cwd, stdio: ['ignore', 'ignore', 'inherit'] })
|
|
47
|
+
return typeof r.status === 'number' ? r.status : 1
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Запас буфера для json-виводу лінтерів (великі changeset-и > дефолтного ~1MB). */
|
|
51
|
+
const JSON_MAX_BUFFER = 64 * 1024 * 1024
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Репорт-пас: `--format=json`. Повертає exit-код і stdout (щоб відрізнити
|
|
55
|
+
* «чисто/є-порушення» від краху інструмента).
|
|
56
|
+
* @param {string[]} args аргументи
|
|
57
|
+
* @param {string} cwd корінь
|
|
58
|
+
* @returns {{ status: number, stdout: string }} результат
|
|
59
|
+
*/
|
|
60
|
+
function runJson(args, cwd) {
|
|
61
|
+
const r = spawnSync('bunx', args, { cwd, encoding: 'utf8', maxBuffer: JSON_MAX_BUFFER })
|
|
62
|
+
return { status: typeof r.status === 'number' ? r.status : 1, stdout: r.stdout ?? '' }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Full-режим (ci): лінт усього проєкту зі стрімінгом і fail-fast (без класифікації).
|
|
67
|
+
* @param {string} cwd корінь
|
|
68
|
+
* @returns {number} exit code
|
|
69
|
+
*/
|
|
70
|
+
function lintFullProject(cwd) {
|
|
71
|
+
const ox = runInherit(['oxlint', '--fix'], cwd)
|
|
72
|
+
if (ox !== 0) return ox
|
|
73
|
+
return runInherit(['eslint', '--fix', '.'], cwd)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Quick-режим: авто-фікс змінених файлів, тоді класифікація лишених findings
|
|
78
|
+
* на introduced / pre-existing (беклог #6/A). Блокування на будь-якому finding.
|
|
79
|
+
* @param {string[]} js js-подібні змінені файли
|
|
80
|
+
* @param {string} cwd корінь
|
|
81
|
+
* @returns {number} exit code (0 — чисто; 1 — лишились findings)
|
|
82
|
+
*/
|
|
83
|
+
function lintChangedClassified(js, cwd) {
|
|
84
|
+
// Фікс-пас обох інструментів (послідовно; обидва — щоб репорт показав повну картину).
|
|
85
|
+
runFix(['oxlint', '--fix', ...js], cwd)
|
|
86
|
+
runFix(['eslint', '--fix', ...js], cwd)
|
|
87
|
+
|
|
88
|
+
// Репорт-пас по ФІНАЛЬНОМУ (пост-фікс) файлу — рядки findings і diff узгоджені.
|
|
89
|
+
const oxRes = runJson(['oxlint', '--format=json', ...js], cwd)
|
|
90
|
+
const esRes = runJson(['eslint', '--format=json', ...js], cwd)
|
|
91
|
+
const ox = parseOxlint(oxRes.stdout)
|
|
92
|
+
const es = parseEslint(esRes.stdout)
|
|
93
|
+
|
|
94
|
+
// Краш інструмента (ненульовий exit + непарсабельний json) НЕ можна тихо пропустити
|
|
95
|
+
// як «чисто» — це регресія проти старого fail-fast. Фейлимо явно.
|
|
96
|
+
if ((ox === null && oxRes.status !== 0) || (es === null && esRes.status !== 0)) {
|
|
97
|
+
process.stderr.write('❌ js-lint: інструмент завершився з помилкою (не lint-порушення) — json не розпарсено\n')
|
|
98
|
+
return 1
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const findings = [...(ox ?? []), ...(es ?? [])]
|
|
102
|
+
if (findings.length === 0) return 0
|
|
103
|
+
|
|
104
|
+
const classified = classifyFindings(findings, addedLinesByFile(js, cwd), cwd)
|
|
105
|
+
const header = `❌ js-lint: ${findings.length} порушень (introduced ${classified.introduced.length}, pre-existing ${classified.preExisting.length})`
|
|
106
|
+
process.stdout.write(`${header}\n${renderFindings(classified, cwd)}\n`)
|
|
107
|
+
return 1
|
|
108
|
+
}
|
|
109
|
+
|
|
32
110
|
/**
|
|
33
111
|
* Запускає oxlint+eslint з автофіксом.
|
|
34
112
|
* @param {string[] | undefined} files quick: лише ці файли; undefined: весь проєкт
|
|
@@ -36,17 +114,10 @@ function run(args, cwd) {
|
|
|
36
114
|
* @returns {Promise<number>} 0 — OK, ≠0 — порушення
|
|
37
115
|
*/
|
|
38
116
|
export function lint(files, cwd = process.cwd()) {
|
|
39
|
-
let oxArgs = ['oxlint', '--fix']
|
|
40
|
-
let esArgs = ['eslint', '--fix']
|
|
41
117
|
if (files === undefined) {
|
|
42
|
-
|
|
43
|
-
} else {
|
|
44
|
-
const js = filterJsFiles(files)
|
|
45
|
-
if (js.length === 0) return Promise.resolve(0)
|
|
46
|
-
oxArgs = ['oxlint', '--fix', ...js]
|
|
47
|
-
esArgs = ['eslint', '--fix', ...js]
|
|
118
|
+
return Promise.resolve(lintFullProject(cwd))
|
|
48
119
|
}
|
|
49
|
-
const
|
|
50
|
-
if (
|
|
51
|
-
return Promise.resolve(
|
|
120
|
+
const js = filterJsFiles(files)
|
|
121
|
+
if (js.length === 0) return Promise.resolve(0)
|
|
122
|
+
return Promise.resolve(lintChangedClassified(js, cwd))
|
|
52
123
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Додані/змінені рядки на файл (vs HEAD) — для класифікації lint-findings на
|
|
3
|
+
* introduced (рядок у diff) vs pre-existing (поза diff), беклог #6.
|
|
4
|
+
*
|
|
5
|
+
* Парсимо `git diff --unified=0 HEAD -- <files>`: hunk-заголовок `@@ -a,b +c,d @@`
|
|
6
|
+
* дає додані рядки c..c+d-1. Untracked (нові, поза HEAD) — усі рядки introduced (маркер `ALL`).
|
|
7
|
+
*/
|
|
8
|
+
import { spawnSync } from 'node:child_process'
|
|
9
|
+
|
|
10
|
+
/** Маркер «усі рядки файлу introduced» (новий untracked-файл). */
|
|
11
|
+
export const ALL_LINES = 'ALL'
|
|
12
|
+
|
|
13
|
+
/** Шлях цільового файлу у рядку `+++ b/path` (або `/dev/null`). */
|
|
14
|
+
const PLUS_FILE_RE = /^\+\+\+ (?:b\/)?(.*)$/u
|
|
15
|
+
/** Діапазон доданих рядків у hunk-заголовку `@@ -a,b +c,d @@`. */
|
|
16
|
+
const HUNK_ADD_RE = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/u
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Парсить вивід `git diff --unified=0` у мапу доданих рядків на файл.
|
|
20
|
+
* @param {string} diffText сирий вивід git diff
|
|
21
|
+
* @returns {Map<string, Set<number>>} файл → множина доданих рядків
|
|
22
|
+
*/
|
|
23
|
+
export function parseAddedLines(diffText) {
|
|
24
|
+
const byFile = new Map()
|
|
25
|
+
let current = null
|
|
26
|
+
for (const line of String(diffText).split('\n')) {
|
|
27
|
+
const fileMatch = PLUS_FILE_RE.exec(line)
|
|
28
|
+
if (fileMatch) {
|
|
29
|
+
current = fileMatch[1] === '/dev/null' ? null : fileMatch[1]
|
|
30
|
+
if (current && !byFile.has(current)) byFile.set(current, new Set())
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
const hunk = current && HUNK_ADD_RE.exec(line)
|
|
34
|
+
if (hunk) {
|
|
35
|
+
const start = Number(hunk[1])
|
|
36
|
+
const count = hunk[2] === undefined ? 1 : Number(hunk[2])
|
|
37
|
+
for (let i = 0; i < count; i++) byFile.get(current).add(start + i)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return byFile
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Тихий git → stdout або `''`.
|
|
45
|
+
* @param {string[]} args аргументи git
|
|
46
|
+
* @param {string} cwd робочий каталог
|
|
47
|
+
* @returns {string} stdout
|
|
48
|
+
*/
|
|
49
|
+
function git(args, cwd) {
|
|
50
|
+
const r = spawnSync('git', args, { cwd, encoding: 'utf8' })
|
|
51
|
+
return r.status === 0 ? (r.stdout ?? '') : ''
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Додані рядки на файл (vs HEAD) для заданих файлів. Tracked → з diff;
|
|
56
|
+
* untracked (нові) → маркер `ALL_LINES`.
|
|
57
|
+
* @param {string[]} files відносні шляхи (від cwd)
|
|
58
|
+
* @param {string} [cwd] корінь репо
|
|
59
|
+
* @param {{ git?: (args: string[], cwd: string) => string }} [deps] ін'єкція git (тести)
|
|
60
|
+
* @returns {Map<string, Set<number> | typeof ALL_LINES>} файл → додані рядки / `ALL`
|
|
61
|
+
*/
|
|
62
|
+
export function addedLinesByFile(files, cwd = process.cwd(), deps = {}) {
|
|
63
|
+
if (!files || files.length === 0) return new Map()
|
|
64
|
+
const run = deps.git ?? git
|
|
65
|
+
const map = parseAddedLines(run(['diff', '--unified=0', 'HEAD', '--', ...files], cwd))
|
|
66
|
+
const untracked = run(['ls-files', '--others', '--exclude-standard', '--', ...files], cwd)
|
|
67
|
+
for (const f of untracked.split('\n').filter(Boolean)) {
|
|
68
|
+
map.set(f, ALL_LINES)
|
|
69
|
+
}
|
|
70
|
+
return map
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Чи рядок `line` у файлі `file` — доданий (introduced).
|
|
75
|
+
* @param {Map<string, Set<number> | typeof ALL_LINES>} addedLines результат `addedLinesByFile`
|
|
76
|
+
* @param {string} file відносний шлях
|
|
77
|
+
* @param {number} line номер рядка
|
|
78
|
+
* @returns {boolean} результат
|
|
79
|
+
*/
|
|
80
|
+
export function isIntroducedLine(addedLines, file, line) {
|
|
81
|
+
const entry = addedLines.get(file)
|
|
82
|
+
if (entry === undefined) return false
|
|
83
|
+
if (entry === ALL_LINES) return true
|
|
84
|
+
return entry.has(line)
|
|
85
|
+
}
|