@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 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 { runLintCli } from '../scripts/lib/run-lint-cli.mjs'
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
- // Оркестратор lint-ланцюжка з вимірюванням часу на кожен крок (fail-fast).
1468
- // Замінює раніше використовуваний агрегатор `bun run lint-ga && bun run lint-js && …` у root package.json.
1469
- process.exitCode = runLintCli()
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.39.1",
3
+ "version": "1.40.0",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }
@@ -1 +1 @@
1
- { "auto": "завжди" }
1
+ { "auto": "завжди", "lint": "ci" }
@@ -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
- }