@nitra/cursor 1.39.1 → 1.40.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/bin/n-cursor.js +8 -5
- package/package.json +1 -1
- package/rules/ga/js/lint.mjs +12 -0
- package/rules/ga/meta.json +1 -1
- package/rules/js-lint/js/lint.mjs +52 -0
- package/rules/js-lint/meta.json +1 -1
- package/rules/js-lint-ci/fix.mjs +19 -0
- package/rules/js-lint-ci/js/lint.mjs +20 -0
- package/rules/js-lint-ci/js-lint-ci.mdc +12 -0
- package/rules/js-lint-ci/meta.json +1 -0
- package/rules/npm-module/js/rule_meta.mjs +10 -1
- package/rules/rego/js/lint.mjs +12 -0
- package/rules/rego/meta.json +1 -1
- package/rules/security/js/lint.mjs +18 -0
- package/rules/security/meta.json +1 -1
- package/rules/style-lint/js/lint.mjs +34 -0
- package/rules/style-lint/meta.json +1 -1
- package/rules/text/js/lint.mjs +12 -0
- package/rules/text/meta.json +1 -1
- package/schemas/rule-meta.json +1 -0
- package/scripts/lib/changed-files.mjs +30 -0
- package/scripts/lib/rule-meta.mjs +12 -0
- package/scripts/lint-cli.mjs +83 -0
- package/scripts/lib/run-lint-cli.mjs +0 -116
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.40.0] - 2026-05-31
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- lint: розділення на `n-cursor lint` (quick, по змінених файлах) і `n-cursor lint-ci` (повний, по всьому репо) — data-driven за полем `meta.json.lint` (quick/ci); виконавець кроку — `js/lint.mjs` правила; jscpd+knip винесено в нове правило `js-lint-ci` (фаза ci)
|
|
8
|
+
|
|
3
9
|
## [1.39.1] - 2026-05-31
|
|
4
10
|
|
|
5
11
|
### Changed
|
package/bin/n-cursor.js
CHANGED
|
@@ -105,7 +105,7 @@ import { runRenameYamlExtensionsCli } from './rename-yaml-extensions.mjs'
|
|
|
105
105
|
import { runSkillsCli } from '../scripts/skills-cli.mjs'
|
|
106
106
|
import { runWorktreeCli } from '../scripts/worktree-cli.mjs'
|
|
107
107
|
import { syncSetupBunDepsAction } from '../scripts/sync-setup-bun-deps-action.mjs'
|
|
108
|
-
import {
|
|
108
|
+
import { runLint } from '../scripts/lint-cli.mjs'
|
|
109
109
|
import { formatTimingSummary } from '../scripts/lib/timing-summary.mjs'
|
|
110
110
|
|
|
111
111
|
const PACKAGE_NAME = '@nitra/cursor'
|
|
@@ -1464,9 +1464,12 @@ try {
|
|
|
1464
1464
|
break
|
|
1465
1465
|
}
|
|
1466
1466
|
case 'lint': {
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1467
|
+
process.exitCode = await runLint({ ci: false })
|
|
1468
|
+
|
|
1469
|
+
break
|
|
1470
|
+
}
|
|
1471
|
+
case 'lint-ci': {
|
|
1472
|
+
process.exitCode = await runLint({ ci: true })
|
|
1470
1473
|
|
|
1471
1474
|
break
|
|
1472
1475
|
}
|
|
@@ -1540,7 +1543,7 @@ try {
|
|
|
1540
1543
|
default: {
|
|
1541
1544
|
console.error(`❌ Невідома команда: ${command}`)
|
|
1542
1545
|
console.error(
|
|
1543
|
-
` Очікується: (без аргументів) синхронізація правил, check, rename-yaml-extensions, post-tool-use-fix, lint, lint-ga, lint-rego, lint-k8s, lint-docker, lint-text, coverage, change, release, skill, worktree`
|
|
1546
|
+
` Очікується: (без аргументів) синхронізація правил, check, rename-yaml-extensions, post-tool-use-fix, lint, lint-ga, lint-rego, lint-k8s, lint-docker, lint-text, coverage, change, release, skill, worktree, lint-ci`
|
|
1544
1547
|
)
|
|
1545
1548
|
process.exitCode = 1
|
|
1546
1549
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ci-крок ga: делегує у наявний CLI правила (per-file режиму немає — `files` ігнорується).
|
|
3
|
+
*/
|
|
4
|
+
import { runLintGaCli } from '../lint/lint.mjs'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {string[] | undefined} _files ігнорується (whole-repo аналіз)
|
|
8
|
+
* @returns {Promise<number>} exit code
|
|
9
|
+
*/
|
|
10
|
+
export async function lint(_files) {
|
|
11
|
+
return runLintGaCli()
|
|
12
|
+
}
|
package/rules/ga/meta.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{ "auto": { "glob": ".github/workflows/**" } }
|
|
1
|
+
{ "auto": { "glob": ".github/workflows/**" }, "lint": "ci" }
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quick-крок lint правила js-lint: oxlint + eslint (з автофіксом).
|
|
3
|
+
*
|
|
4
|
+
* Викликається lint-оркестратором (`n-cursor lint` / `lint-ci`):
|
|
5
|
+
* - `files` = масив змінених файлів (quick) → лінтимо лише js-подібні з них;
|
|
6
|
+
* - `files` = undefined (ci) → лінтимо весь проєкт.
|
|
7
|
+
* Крос-файлові jscpd/knip — окреме правило js-lint-ci (фаза ci).
|
|
8
|
+
*/
|
|
9
|
+
import { spawnSync } from 'node:child_process'
|
|
10
|
+
|
|
11
|
+
const JS_EXT_RE = /\.(?:mjs|cjs|js|jsx|ts|tsx|vue)$/u
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Лишає лише js-подібні файли зі списку.
|
|
15
|
+
* @param {string[]} files список шляхів
|
|
16
|
+
* @returns {string[]} підмножина js-подібних
|
|
17
|
+
*/
|
|
18
|
+
export function filterJsFiles(files) {
|
|
19
|
+
return files.filter(f => JS_EXT_RE.test(f))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {string[]} args аргументи інструмента (бінар через bunx)
|
|
24
|
+
* @param {string} cwd корінь
|
|
25
|
+
* @returns {number} exit code
|
|
26
|
+
*/
|
|
27
|
+
function run(args, cwd) {
|
|
28
|
+
const r = spawnSync('bunx', args, { cwd, stdio: 'inherit' })
|
|
29
|
+
return typeof r.status === 'number' ? r.status : 1
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Запускає oxlint+eslint з автофіксом.
|
|
34
|
+
* @param {string[] | undefined} files quick: лише ці файли; undefined: весь проєкт
|
|
35
|
+
* @param {string} [cwd] корінь репо
|
|
36
|
+
* @returns {Promise<number>} 0 — OK, ≠0 — порушення
|
|
37
|
+
*/
|
|
38
|
+
export function lint(files, cwd = process.cwd()) {
|
|
39
|
+
let oxArgs = ['oxlint', '--fix']
|
|
40
|
+
let esArgs = ['eslint', '--fix']
|
|
41
|
+
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]
|
|
48
|
+
}
|
|
49
|
+
const ox = run(oxArgs, cwd)
|
|
50
|
+
if (ox !== 0) return Promise.resolve(ox)
|
|
51
|
+
return Promise.resolve(run(esArgs, cwd))
|
|
52
|
+
}
|
package/rules/js-lint/meta.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{ "auto": { "glob": ["**/*.mjs", "**/*.cjs", "**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"] } }
|
|
1
|
+
{ "auto": { "glob": ["**/*.mjs", "**/*.cjs", "**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"] }, "lint": "quick" }
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { isRunAsCli, runRuleCli } from '../../scripts/lib/run-rule-cli.mjs'
|
|
2
|
+
import { runStandardRule } from '../../scripts/lib/run-standard-rule.mjs'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Запускає правило: applies → JS-concerns → policy → mdc-refs (через runStandardRule).
|
|
6
|
+
* Library mode: викликається CLI orchestration через `import + run(ctx)`.
|
|
7
|
+
* @param {import('../../scripts/lib/run-standard-rule.mjs').RuleContext} [ctx] контекст прогону (walkCache тощо)
|
|
8
|
+
* @returns {Promise<number>} 0 — OK, 1 — порушення
|
|
9
|
+
*/
|
|
10
|
+
export function run(ctx) {
|
|
11
|
+
return runStandardRule(import.meta.dirname, ctx)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (isRunAsCli(import.meta.url)) {
|
|
15
|
+
// Standalone: bun rules/<id>/fix.mjs — повний еквівалент `npx @nitra/cursor fix <id>`
|
|
16
|
+
// (config-loading + whitelist + summary). Дві ролі fix.mjs: library (run) + standalone (main).
|
|
17
|
+
// eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE
|
|
18
|
+
process.exit(await runRuleCli(import.meta.dirname))
|
|
19
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ci-крок: jscpd (детектор клонів) + knip (невикористані експорти).
|
|
3
|
+
*
|
|
4
|
+
* Крос-файлові аналізатори — працюють лише по всьому репо, тож `files` ігнорується
|
|
5
|
+
* (викликається лише у `lint-ci` з undefined). Per-file режиму ці інструменти не мають.
|
|
6
|
+
*/
|
|
7
|
+
import { spawnSync } from 'node:child_process'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {string[] | undefined} _files ігнорується (крос-файловий аналіз)
|
|
11
|
+
* @param {string} [cwd] корінь репо
|
|
12
|
+
* @returns {Promise<number>} 0 — OK, ≠0 — порушення
|
|
13
|
+
*/
|
|
14
|
+
export function lint(_files, cwd = process.cwd()) {
|
|
15
|
+
const jscpd = spawnSync('bunx', ['jscpd', '.'], { cwd, stdio: 'inherit' })
|
|
16
|
+
const jc = typeof jscpd.status === 'number' ? jscpd.status : 1
|
|
17
|
+
if (jc !== 0) return Promise.resolve(jc)
|
|
18
|
+
const knip = spawnSync('bunx', ['knip', '--no-config-hints'], { cwd, stdio: 'inherit' })
|
|
19
|
+
return Promise.resolve(typeof knip.status === 'number' ? knip.status : 1)
|
|
20
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Крос-файловий ci-етап js-lint — jscpd (детектор клонів) і knip (невикористані експорти). Лише у lint-ci, по всьому репо.
|
|
3
|
+
globs:
|
|
4
|
+
alwaysApply: true
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# js-lint-ci — крос-файловий ci-етап
|
|
8
|
+
|
|
9
|
+
`jscpd` і `knip` аналізують увесь граф проєкту, тож мають сенс лише у повному прогоні
|
|
10
|
+
`npx @nitra/cursor lint-ci` (не у швидкому `lint` по змінених файлах). Per-file режиму нема.
|
|
11
|
+
|
|
12
|
+
Швидкий етап js-lint (oxlint/eslint) — у правилі `js-lint` (`lint: quick`).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "lint": "ci" }
|
|
@@ -12,7 +12,7 @@ import { existsSync, readdirSync } from 'node:fs'
|
|
|
12
12
|
import { join } from 'node:path'
|
|
13
13
|
|
|
14
14
|
import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
|
|
15
|
-
import { parseRuleAutoSpec, readRuleMetaRaw } from '../../../scripts/lib/rule-meta.mjs'
|
|
15
|
+
import { parseRuleAutoSpec, parseRuleLintPhase, readRuleMetaRaw } from '../../../scripts/lib/rule-meta.mjs'
|
|
16
16
|
import { RULE_PREDICATES } from '../../../scripts/lib/rule-predicates.mjs'
|
|
17
17
|
|
|
18
18
|
/**
|
|
@@ -54,6 +54,15 @@ export function check(cwd = process.cwd()) {
|
|
|
54
54
|
ruleOk = false
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
|
+
if (raw.lint !== undefined) {
|
|
58
|
+
if (parseRuleLintPhase(raw.lint) === null) {
|
|
59
|
+
reporter.fail(`rules/${id}: meta.json.lint нерозпізнане (очікується "quick"|"ci")`)
|
|
60
|
+
ruleOk = false
|
|
61
|
+
} else if (!existsSync(join(ruleDir, 'js', 'lint.mjs'))) {
|
|
62
|
+
reporter.fail(`rules/${id}: lint:"${raw.lint}" але немає js/lint.mjs`)
|
|
63
|
+
ruleOk = false
|
|
64
|
+
}
|
|
65
|
+
}
|
|
57
66
|
if (ruleOk) {
|
|
58
67
|
reporter.pass(`rules/${id}: meta.json валідний`)
|
|
59
68
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ci-крок rego: делегує у наявний CLI правила (per-file режиму немає — `files` ігнорується).
|
|
3
|
+
*/
|
|
4
|
+
import { runLintRego } from '../lint/lint.mjs'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {string[] | undefined} _files ігнорується (whole-repo аналіз)
|
|
8
|
+
* @returns {Promise<number>} exit code
|
|
9
|
+
*/
|
|
10
|
+
export async function lint(_files) {
|
|
11
|
+
return runLintRego()
|
|
12
|
+
}
|
package/rules/rego/meta.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{ "auto": { "glob": "**/*.rego" } }
|
|
1
|
+
{ "auto": { "glob": "**/*.rego" }, "lint": "ci" }
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ci-крок security: trufflehog filesystem скан усього репо (per-file немає).
|
|
3
|
+
*/
|
|
4
|
+
import { spawnSync } from 'node:child_process'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {string[] | undefined} _files ігнорується
|
|
8
|
+
* @param {string} [cwd] корінь
|
|
9
|
+
* @returns {Promise<number>} exit code
|
|
10
|
+
*/
|
|
11
|
+
export function lint(_files, cwd = process.cwd()) {
|
|
12
|
+
const r = spawnSync(
|
|
13
|
+
'trufflehog',
|
|
14
|
+
['filesystem', '.', '--no-update', '--exclude-paths', '.trufflehog-exclude', '--results=verified,unknown', '--fail'],
|
|
15
|
+
{ cwd, stdio: 'inherit' }
|
|
16
|
+
)
|
|
17
|
+
return Promise.resolve(typeof r.status === 'number' ? r.status : 1)
|
|
18
|
+
}
|
package/rules/security/meta.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{ "auto": "завжди" }
|
|
1
|
+
{ "auto": "завжди", "lint": "ci" }
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quick-крок lint правила style-lint: stylelint --fix по css/scss/vue.
|
|
3
|
+
*
|
|
4
|
+
* `files` (quick) → лише style-файли з них; undefined (ci) → весь glob `**\/*.{css,scss,vue}`.
|
|
5
|
+
*/
|
|
6
|
+
import { spawnSync } from 'node:child_process'
|
|
7
|
+
|
|
8
|
+
const STYLE_EXT_RE = /\.(?:css|scss|vue)$/u
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {string[]} files список шляхів
|
|
12
|
+
* @returns {string[]} лише css/scss/vue
|
|
13
|
+
*/
|
|
14
|
+
export function filterStyleFiles(files) {
|
|
15
|
+
return files.filter(f => STYLE_EXT_RE.test(f))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {string[] | undefined} files quick: ці файли; undefined: весь проєкт
|
|
20
|
+
* @param {string} [cwd] корінь
|
|
21
|
+
* @returns {Promise<number>} exit code
|
|
22
|
+
*/
|
|
23
|
+
export function lint(files, cwd = process.cwd()) {
|
|
24
|
+
const args = ['stylelint', '--fix']
|
|
25
|
+
if (files === undefined) {
|
|
26
|
+
args.push('**/*.{css,scss,vue}')
|
|
27
|
+
} else {
|
|
28
|
+
const style = filterStyleFiles(files)
|
|
29
|
+
if (style.length === 0) return Promise.resolve(0)
|
|
30
|
+
args.push(...style)
|
|
31
|
+
}
|
|
32
|
+
const r = spawnSync('npx', args, { cwd, stdio: 'inherit' })
|
|
33
|
+
return Promise.resolve(typeof r.status === 'number' ? r.status : 1)
|
|
34
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{ "auto": { "glob": ["**/*.css", "**/*.vue"] } }
|
|
1
|
+
{ "auto": { "glob": ["**/*.css", "**/*.vue"] }, "lint": "quick" }
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ci-крок text: делегує у наявний CLI правила (per-file режиму немає — `files` ігнорується).
|
|
3
|
+
*/
|
|
4
|
+
import { runLintTextCli } from '../lint/lint.mjs'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {string[] | undefined} _files ігнорується (whole-repo аналіз)
|
|
8
|
+
* @returns {Promise<number>} exit code
|
|
9
|
+
*/
|
|
10
|
+
export async function lint(_files) {
|
|
11
|
+
return runLintTextCli()
|
|
12
|
+
}
|
package/rules/text/meta.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{ "auto": "завжди" }
|
|
1
|
+
{ "auto": "завжди", "lint": "ci" }
|
package/schemas/rule-meta.json
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
"type": "object",
|
|
7
7
|
"additionalProperties": false,
|
|
8
8
|
"properties": {
|
|
9
|
+
"lint": { "type": "string", "enum": ["quick", "ci"], "description": "Фаза lint-кроку: quick (по змінених, у lint і lint-ci) або ci (лише lint-ci)." },
|
|
9
10
|
"auto": {
|
|
10
11
|
"description": "Умова автоактивації правила: \"завжди\", масив id правил-залежностей, glob, або іменований предикат.",
|
|
11
12
|
"oneOf": [
|
|
@@ -0,0 +1,30 @@
|
|
|
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.split('\n').map(s => s.trim()).filter(Boolean)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Relative-posix список змінених + untracked файлів робочого дерева.
|
|
23
|
+
* @param {string} [cwd] корінь репо
|
|
24
|
+
* @returns {string[]} унікальні шляхи (без видалених)
|
|
25
|
+
*/
|
|
26
|
+
export function collectChangedFiles(cwd = process.cwd()) {
|
|
27
|
+
const modified = gitLines(['diff', 'HEAD', '--name-only', '--diff-filter=ACMR'], cwd)
|
|
28
|
+
const untracked = gitLines(['ls-files', '--others', '--exclude-standard'], cwd)
|
|
29
|
+
return [...new Set([...modified, ...untracked])]
|
|
30
|
+
}
|
|
@@ -48,6 +48,18 @@ export function parseRuleAutoSpec(value) {
|
|
|
48
48
|
return null
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/** Допустимі фази lint. */
|
|
52
|
+
const LINT_PHASES = new Set(['quick', 'ci'])
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Нормалізує значення `meta.json.lint` у фазу lint.
|
|
56
|
+
* @param {unknown} value значення поля `lint`
|
|
57
|
+
* @returns {'quick' | 'ci' | null} фаза або `null` (відсутнє/невалідне = не lint-крок)
|
|
58
|
+
*/
|
|
59
|
+
export function parseRuleLintPhase(value) {
|
|
60
|
+
return typeof value === 'string' && LINT_PHASES.has(value) ? /** @type {'quick'|'ci'} */ (value) : null
|
|
61
|
+
}
|
|
62
|
+
|
|
51
63
|
/**
|
|
52
64
|
* Читає й парсить `meta.json` одного правила.
|
|
53
65
|
* @param {string} ruleDir абсолютний шлях до каталогу правила
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Оркестратор `n-cursor lint` (quick) / `n-cursor lint-ci` (full).
|
|
3
|
+
*
|
|
4
|
+
* Data-driven: сканує `rules/<id>/meta.json` за полем `lint` (`quick`|`ci`),
|
|
5
|
+
* послідовно (заборона паралельного eslint) викликає `rules/<id>/js/lint.mjs`:
|
|
6
|
+
* - quick: `lint(changedFiles)` — лише змінені файли (git diff HEAD + untracked);
|
|
7
|
+
* - ci: `lint(undefined)` — весь проєкт.
|
|
8
|
+
* Порядок правил — алфавітний; ci-набір = quick ∪ ci. Fail-fast: перший ненульовий код спиняє.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
11
|
+
import { dirname, join } from 'node:path'
|
|
12
|
+
import { fileURLToPath } from 'node:url'
|
|
13
|
+
import { cwd as processCwd } from 'node:process'
|
|
14
|
+
|
|
15
|
+
import { parseRuleLintPhase, readRuleMetaRaw } from './lib/rule-meta.mjs'
|
|
16
|
+
import { collectChangedFiles } from './lib/changed-files.mjs'
|
|
17
|
+
|
|
18
|
+
const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
19
|
+
const RULES_DIR = join(PACKAGE_ROOT, 'rules')
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Вибирає id правил для фази, алфавітно.
|
|
23
|
+
* @param {Record<string, {lint?: unknown}>} metaById мапа id → meta-обʼєкт
|
|
24
|
+
* @param {'quick'|'ci'} phase цільова фаза (quick → лише quick; ci → quick+ci)
|
|
25
|
+
* @returns {string[]} відсортовані id
|
|
26
|
+
*/
|
|
27
|
+
export function selectLintRules(metaById, phase) {
|
|
28
|
+
const out = []
|
|
29
|
+
for (const [id, raw] of Object.entries(metaById)) {
|
|
30
|
+
const p = parseRuleLintPhase(raw?.lint)
|
|
31
|
+
if (p === 'quick' || (phase === 'ci' && p === 'ci')) out.push(id)
|
|
32
|
+
}
|
|
33
|
+
return out.toSorted((a, b) => a.localeCompare(b))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Зчитує meta всіх правил пакета.
|
|
38
|
+
* @param {string} rulesDir каталог rules
|
|
39
|
+
* @returns {Record<string, Record<string, unknown>>} id → meta
|
|
40
|
+
*/
|
|
41
|
+
function readAllMeta(rulesDir) {
|
|
42
|
+
/** @type {Record<string, Record<string, unknown>>} */
|
|
43
|
+
const out = {}
|
|
44
|
+
if (!existsSync(rulesDir)) return out
|
|
45
|
+
for (const e of readdirSync(rulesDir, { withFileTypes: true })) {
|
|
46
|
+
if (!e.isDirectory() || e.name.startsWith('.')) continue
|
|
47
|
+
const raw = readRuleMetaRaw(join(rulesDir, e.name))
|
|
48
|
+
if (raw) out[e.name] = raw
|
|
49
|
+
}
|
|
50
|
+
return out
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Запускає lint-оркестрацію.
|
|
55
|
+
* @param {{ ci?: boolean, cwd?: string, rulesDir?: string, log?: (s: string) => void }} [opts] параметри
|
|
56
|
+
* @returns {Promise<number>} exit code
|
|
57
|
+
*/
|
|
58
|
+
export async function runLint(opts = {}) {
|
|
59
|
+
const ci = opts.ci === true
|
|
60
|
+
const cwd = opts.cwd ?? processCwd()
|
|
61
|
+
const rulesDir = opts.rulesDir ?? RULES_DIR
|
|
62
|
+
const log = opts.log ?? (s => process.stdout.write(s))
|
|
63
|
+
|
|
64
|
+
const changed = ci ? undefined : collectChangedFiles(cwd)
|
|
65
|
+
if (!ci && changed.length === 0) {
|
|
66
|
+
log('\nℹ️ lint: немає змінених файлів — нічого перевіряти.\n')
|
|
67
|
+
return 0
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const ids = selectLintRules(readAllMeta(rulesDir), ci ? 'ci' : 'quick')
|
|
71
|
+
for (const id of ids) {
|
|
72
|
+
const lintPath = join(rulesDir, id, 'js', 'lint.mjs')
|
|
73
|
+
if (!existsSync(lintPath)) {
|
|
74
|
+
log(`⚠️ lint: правило ${id} має lint-фазу, але немає js/lint.mjs — пропускаю.\n`)
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
// eslint-disable-next-line no-unsanitized/method -- шлях з discovered rule dir
|
|
78
|
+
const mod = await import(lintPath)
|
|
79
|
+
const code = await mod.lint(changed, cwd)
|
|
80
|
+
if (code !== 0) return code
|
|
81
|
+
}
|
|
82
|
+
return 0
|
|
83
|
+
}
|
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `n-cursor lint` — оркестратор лінт-ланцюжка з вимірюванням часу на кожен крок.
|
|
3
|
-
*
|
|
4
|
-
* Замість агрегатора `bun run lint-ga && bun run lint-js && ... && oxfmt .` у кореневому
|
|
5
|
-
* `package.json` (де child-процеси анонімні і час кожного не видно), цей оркестратор:
|
|
6
|
-
*
|
|
7
|
-
* - читає `scripts` з кореневого `package.json`,
|
|
8
|
-
* - бере **присутні** ключі з фіксованого списку `LINT_SCRIPTS` (відсутні мовчки пропускає),
|
|
9
|
-
* - послідовно запускає `bun run <script>`,
|
|
10
|
-
* - заміряє час кожного,
|
|
11
|
-
* - **fail-fast**: при першому ненульовому exit-коді зупиняється, друкує таблицю
|
|
12
|
-
* лише по виконаних і повертає той самий код,
|
|
13
|
-
* - друкує підсумкову таблицю `⏱ Lint timing` і повертає 0, якщо все ОК.
|
|
14
|
-
*
|
|
15
|
-
* Список + порядок зумисне фіксований: збігається з канонічним ланцюжком, що його раніше
|
|
16
|
-
* тримав root `package.json`. Динамічний discovery (`scripts/^lint-/`) дав би непередбачуваний
|
|
17
|
-
* порядок і небажану інтерпретацію власних `lint-*` користувача.
|
|
18
|
-
*
|
|
19
|
-
* `oxfmt` — окремий рядок поза префіксом `lint-`, ставиться в кінець (як було у `lint`).
|
|
20
|
-
*/
|
|
21
|
-
import { spawnSync as defaultSpawnSync } from 'node:child_process'
|
|
22
|
-
import { existsSync, readFileSync } from 'node:fs'
|
|
23
|
-
import { join } from 'node:path'
|
|
24
|
-
|
|
25
|
-
import { formatTimingSummary } from './timing-summary.mjs'
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Імена npm-скриптів, які `n-cursor lint` запускає **по черзі**, якщо вони є у root `package.json`.
|
|
29
|
-
* Порядок дзеркалить попередній агрегатор `lint`: cheap-checks першими, формат — в кінці.
|
|
30
|
-
*/
|
|
31
|
-
export const LINT_SCRIPTS = /** @type {const} */ ([
|
|
32
|
-
'lint-ga',
|
|
33
|
-
'lint-js',
|
|
34
|
-
'lint-rego',
|
|
35
|
-
'lint-style',
|
|
36
|
-
'lint-text',
|
|
37
|
-
'lint-security',
|
|
38
|
-
'oxfmt'
|
|
39
|
-
])
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Читає `scripts` з `package.json` у заданій теці. Повертає `null`, якщо файла немає, JSON
|
|
43
|
-
* некоректний або поля `scripts` нема. Не кидає — викликач сам вирішує, що робити.
|
|
44
|
-
* @param {string} root абсолютний шлях до теки з `package.json`
|
|
45
|
-
* @returns {Record<string, string> | null} мапа scripts або null
|
|
46
|
-
*/
|
|
47
|
-
function readRootScripts(root) {
|
|
48
|
-
const packageJsonPath = join(root, 'package.json')
|
|
49
|
-
if (!existsSync(packageJsonPath)) {
|
|
50
|
-
return null
|
|
51
|
-
}
|
|
52
|
-
try {
|
|
53
|
-
const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
|
|
54
|
-
const scripts = parsed?.scripts
|
|
55
|
-
if (!scripts || typeof scripts !== 'object') {
|
|
56
|
-
return null
|
|
57
|
-
}
|
|
58
|
-
return /** @type {Record<string, string>} */ (scripts)
|
|
59
|
-
} catch {
|
|
60
|
-
return null
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* @typedef {{
|
|
66
|
-
* cwd?: string,
|
|
67
|
-
* spawnSyncFn?: typeof defaultSpawnSync,
|
|
68
|
-
* now?: () => number,
|
|
69
|
-
* log?: (text: string) => void,
|
|
70
|
-
* logError?: (text: string) => void
|
|
71
|
-
* }} RunLintCliOptions
|
|
72
|
-
*/
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Виконує лінт-ланцюжок з вимірюванням часу. Повертає exit-код, не кидає винятків (для прямого
|
|
76
|
-
* присвоєння у `process.exitCode`).
|
|
77
|
-
* @param {RunLintCliOptions} [options] DI для тестів (підміняємо spawn / fs / clock)
|
|
78
|
-
* @returns {number} 0 = успіх, ненульовий = code першого впалого скрипта, або 1 при структурних проблемах
|
|
79
|
-
*/
|
|
80
|
-
export function runLintCli(options = {}) {
|
|
81
|
-
const root = options.cwd ?? process.cwd()
|
|
82
|
-
const spawnSync = options.spawnSyncFn ?? defaultSpawnSync
|
|
83
|
-
const now = options.now ?? Date.now
|
|
84
|
-
const log = options.log ?? (text => process.stdout.write(text))
|
|
85
|
-
const logError = options.logError ?? (text => process.stderr.write(text))
|
|
86
|
-
|
|
87
|
-
const scripts = readRootScripts(root)
|
|
88
|
-
if (scripts === null) {
|
|
89
|
-
logError(`❌ n-cursor lint: не знайдено package.json або поля "scripts" у ${root}\n`)
|
|
90
|
-
return 1
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const present = LINT_SCRIPTS.filter(name => typeof scripts[name] === 'string' && scripts[name].length > 0)
|
|
94
|
-
if (present.length === 0) {
|
|
95
|
-
log('\nℹ️ n-cursor lint: у package.json немає жодного з lint-* / oxfmt скриптів — нічого запускати.\n')
|
|
96
|
-
return 0
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** @type {{ id: string, ms: number, ok: boolean }[]} */
|
|
100
|
-
const timings = []
|
|
101
|
-
let failedCode = 0
|
|
102
|
-
for (const name of present) {
|
|
103
|
-
const startedAt = now()
|
|
104
|
-
const result = spawnSync('bun', ['run', name], { stdio: 'inherit', cwd: root })
|
|
105
|
-
const code = typeof result.status === 'number' ? result.status : 1
|
|
106
|
-
const ok = code === 0
|
|
107
|
-
timings.push({ id: name, ms: now() - startedAt, ok })
|
|
108
|
-
if (!ok) {
|
|
109
|
-
failedCode = code === 0 ? 1 : code
|
|
110
|
-
break
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
log(formatTimingSummary('Lint timing', timings))
|
|
115
|
-
return failedCode
|
|
116
|
-
}
|