@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 +6 -0
- 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,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
|
@@ -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
|
+
}
|