@nitra/cursor 3.21.1 → 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,11 @@
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
+
3
9
  ## [3.21.1] - 2026-06-04
4
10
 
5
11
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "3.21.1",
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
+ }