@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 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: ', async () => {
1361
- const { destPath } = await syncSetupBunDepsAction(cwd(), effectivePackageRoot)
1362
- console.log(`📝 Оновлено ${destPath} (composite setup-bun-deps з пакету)\n`)
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 syncManagedRuleFiles(rules, bundledRulesDir, rulesDir)
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 { success: skillOk, fail: skillFail } = await syncSkills(skills, bundledSkillsDir)
1376
- if (skills.length > 0) {
1377
- console.log(`\n🧩 Skills: ${skillOk} скопійовано, ${skillFail} з помилками`)
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 { success: cmdOk, fail: cmdFail } = await syncCommands(skills, bundledSkillsDir)
1388
- const { success: localOk, fail: localFail } = await syncLocalOnlySkillCommands(skills)
1389
- const totalOk = cmdOk + localOk
1390
- const totalFail = cmdFail + localFail
1391
- if (totalOk + totalFail > 0) {
1392
- console.log(`\n⌨️ Commands: ${totalOk} скопійовано, ${totalFail} з помилками`)
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 { success: piOk, fail: piFail } = await syncPiSkills(skills, bundledSkillsDir)
1406
- const { success: piLocalOk, fail: piLocalFail } = await syncLocalOnlyPiSkills(skills)
1407
- const totalOk = piOk + piLocalOk
1408
- const totalFail = piFail + piLocalFail
1409
- if (totalOk + totalFail > 0) {
1410
- console.log(`\n🥧 Pi skills: ${totalOk} скопійовано, ${totalFail} з помилками`)
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}: `, () => syncAgentsMd(bundledAgentsTemplatePath))
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-конфіг: ', async () => {
1428
- const result = await syncClaudeConfig({
1429
- projectRoot: cwd(),
1430
- bundledPackageRoot: effectivePackageRoot,
1431
- enabled: claudeConfigEnabled,
1432
- rules
1433
- })
1434
- if (!claudeConfigEnabled) {
1435
- console.log('🤖 Claude-конфіг: пропущено (claude-config: false у .n-cursor.json)')
1436
- return
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
- if (result.gitignoreAdr) parts.push('.gitignore (adr fragment)')
1450
- if (result.piExtension) parts.push('.pi/extensions/n-cursor-adr/')
1451
- if (parts.length > 0) {
1452
- console.log(`🤖 Claude-конфіг: ${parts.join(', ')}`)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "3.21.0",
3
+ "version": "3.22.0",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -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
- * - `files` = undefined (ci) лінтимо весь проєкт.
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
- * @param {string[]} args аргументи інструмента (бінар через bunx)
28
+ * Запуск інструмента (через bunx) зі стрімінгом у термінал.
29
+ * @param {string[]} args аргументи
24
30
  * @param {string} cwd корінь
25
31
  * @returns {number} exit code
26
32
  */
27
- function run(args, cwd) {
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
- esArgs.push('.')
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 ox = run(oxArgs, cwd)
50
- if (ox !== 0) return Promise.resolve(ox)
51
- return Promise.resolve(run(esArgs, cwd))
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
+ }