@nitra/cursor 5.4.0 → 7.0.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 +22 -0
- package/bin/n-cursor.js +25 -42
- package/package.json +1 -1
- package/rules/doc-files/js/docgen-files-batch.mjs +18 -5
- package/rules/doc-files/js/docgen-gen.mjs +46 -5
- package/rules/doc-files/js/docgen-scan.mjs +2 -2
- package/rules/doc-files/js/docs/docgen-files-batch.md +1 -1
- package/rules/doc-files/js/docs/docgen-gen.md +1 -1
- package/rules/doc-files/js/docs/docgen-scan.md +1 -1
- package/rules/doc-files/meta.json +1 -1
- package/rules/ga/meta.json +1 -1
- package/rules/js-lint/js/docs/lint.md +1 -1
- package/rules/js-lint/js/lint.mjs +19 -12
- package/rules/js-lint/js-lint.mdc +1 -1
- package/rules/js-lint/meta.json +1 -1
- package/rules/js-lint-ci/js-lint-ci.mdc +1 -1
- package/rules/js-lint-ci/meta.json +1 -1
- package/rules/npm-module/js/docs/rule_meta.md +1 -1
- package/rules/npm-module/js/rule_meta.mjs +3 -3
- package/rules/rego/meta.json +1 -1
- package/rules/security/meta.json +1 -1
- package/rules/style-lint/js/docs/lint.md +1 -1
- package/rules/style-lint/js/lint.mjs +4 -3
- package/rules/style-lint/meta.json +1 -1
- package/rules/text/js/docs/lint.md +1 -1
- package/rules/text/js/lint.mjs +5 -3
- package/rules/text/lint/docs/lint.md +1 -1
- package/rules/text/lint/docs/run-dotenv-linter.md +1 -1
- package/rules/text/lint/docs/run-shellcheck.md +1 -1
- package/rules/text/lint/lint.mjs +13 -9
- package/rules/text/lint/run-dotenv-linter.mjs +13 -10
- package/rules/text/lint/run-shellcheck.mjs +10 -6
- package/rules/text/meta.json +1 -1
- package/scripts/docs/lint-cli.md +1 -1
- package/scripts/lib/docs/rule-meta.md +1 -1
- package/scripts/lib/rule-meta.mjs +10 -6
- package/scripts/lint-cli.mjs +78 -20
- package/scripts/post-tool-use-fix.mjs +17 -65
- package/skills/fix/SKILL.md +13 -14
- package/skills/lint/SKILL.md +7 -8
- /package/{skills/fix/js → scripts/lib/fix}/docs/llm-worker.md +0 -0
- /package/{skills/fix/js → scripts/lib/fix}/docs/orchestrator.md +0 -0
- /package/{skills/fix/js → scripts/lib/fix}/docs/t0.md +0 -0
- /package/{skills/fix/js → scripts/lib/fix}/llm-worker.mjs +0 -0
- /package/{skills/fix/js → scripts/lib/fix}/orchestrator.mjs +0 -0
- /package/{skills/fix/js → scripts/lib/fix}/t0.mjs +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [7.0.0] - 2026-06-14
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- lint --full поглинає конформність (колишній fix): read-only → детект через per-rule fix.mjs run(); fix → convergence-движок. Конформність як whole-repo фаза лише у --full
|
|
8
|
+
- lint приймає фільтр правил (lint <rule>) → конформність лише цих правил (мапить колишній fix <rule>); hk pre-commit changelog → lint changelog
|
|
9
|
+
- governance: знято заборону паралельного eslint — паралельно по диз'юнктних файлах дозволено; серіалізувати лише whole-tree прогони того самого корпусу (CLAUDE.md-секція + n-lint SKILL)
|
|
10
|
+
|
|
11
|
+
### Removed
|
|
12
|
+
|
|
13
|
+
- Видалено команди fix/check/fix-run; рух-движок конформності переміщено skills/fix/js→scripts/lib/fix і поглинуто в lint. PostToolUse-хук → read-only детект усіх правил (без роутингу). /n-fix → делегат на /n-lint. fix-t0/_fix-check лишаються внутрішніми фазами движка
|
|
14
|
+
|
|
15
|
+
## [6.0.0] - 2026-06-14
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- fix-doc-files: пер-файловий таймінг у виводі — `<total>s (llm <llmS>/<N> calls, orch <orchS>)`: видно, скільки часу зайняла модель (і кількість LLM-викликів) проти JS-оркестрації. `generateDoc` повертає `llmMs`/`llmCalls`; облік через прозору обгортку `callLlm` (синхронні spawnSync-виклики, послідовна генерація — без гонок).
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- lint: вісь scope per-file|full (база-origin) + вісь behavior fix-by-default/--read-only; meta.json:lint hard-rename quick|ci→per-file|full; lint-ci=--read-only --full; контракт lint(files,cwd,{readOnly})
|
|
24
|
+
|
|
3
25
|
## [5.4.0] - 2026-06-14
|
|
4
26
|
|
|
5
27
|
### Added
|
package/bin/n-cursor.js
CHANGED
|
@@ -637,15 +637,16 @@ async function removeOrphanManagedSkillDirs(skillsRoot, configSkills) {
|
|
|
637
637
|
}
|
|
638
638
|
|
|
639
639
|
/**
|
|
640
|
-
* Рендерить коротку секцію для CLAUDE.md:
|
|
640
|
+
* Рендерить коротку секцію для CLAUDE.md: паралелізм лінту — по диз'юнктних файлах дозволено,
|
|
641
|
+
* серіалізувати лише whole-tree прогони того самого корпусу.
|
|
641
642
|
* @returns {string[]} рядки для вставки (з порожнім рядком на початку)
|
|
642
643
|
*/
|
|
643
644
|
function buildClaudeLintParallelismSectionLines() {
|
|
644
645
|
return [
|
|
645
646
|
'',
|
|
646
|
-
'## Лінт і ESLint (
|
|
647
|
+
'## Лінт і ESLint (паралелізм)',
|
|
647
648
|
'',
|
|
648
|
-
'
|
|
649
|
+
'Паралельний лінт по **різних** файлах — **дозволено**: диз\'юнктні набори (per-file `lint` на змінених vs origin) не конфліктують і не перевантажують диск/CPU. Серіалізувати треба лише **whole-tree** прогони того самого корпусу (`bun run lint`, `n-cursor lint --full` по всьому репо) — щоб не дублювати важкий full-scan. Деталі: `.cursor/skills/n-lint/SKILL.md`.',
|
|
649
650
|
''
|
|
650
651
|
]
|
|
651
652
|
}
|
|
@@ -1597,7 +1598,7 @@ async function runSync() {
|
|
|
1597
1598
|
* `--root`-команди `lint-doc-files`/`fix-doc-files`/`doc-files`/`doc-aggregate`/`rename-yaml-extensions`, `worktree`,
|
|
1598
1599
|
* sub-лінтери) гард не зачіпає.
|
|
1599
1600
|
*/
|
|
1600
|
-
const ROOT_GUARDED_COMMANDS = new Set([undefined, '', '
|
|
1601
|
+
const ROOT_GUARDED_COMMANDS = new Set([undefined, '', 'lint', 'coverage', 'change', 'release'])
|
|
1601
1602
|
|
|
1602
1603
|
/**
|
|
1603
1604
|
* Короткий опис дії для тексту root-guard помилки за іменем команди.
|
|
@@ -1610,12 +1611,8 @@ function describeRootGuardedAction(cmd) {
|
|
|
1610
1611
|
case '': {
|
|
1611
1612
|
return 'Дефолтна синхронізація скаффолдить .cursor/, .claude/, CLAUDE.md, .n-cursor.json і робить bun install у поточному каталозі'
|
|
1612
1613
|
}
|
|
1613
|
-
case 'fix':
|
|
1614
|
-
case 'check': {
|
|
1615
|
-
return '`fix` запускає programmatic-перевірки правил, що переписують конфіги в поточному каталозі'
|
|
1616
|
-
}
|
|
1617
1614
|
case 'lint': {
|
|
1618
|
-
return '`lint`
|
|
1615
|
+
return '`lint` за замовчуванням авто-fix лінтерів (oxfmt/eslint --fix/stylelint --fix) і конформності (--full) у поточному каталозі'
|
|
1619
1616
|
}
|
|
1620
1617
|
case 'coverage': {
|
|
1621
1618
|
return '`coverage` генерує COVERAGE.md і Stryker-артефакти в поточному каталозі'
|
|
@@ -1646,29 +1643,13 @@ try {
|
|
|
1646
1643
|
}
|
|
1647
1644
|
await ensureNitraCursorInRootDevDependencies(cwd())
|
|
1648
1645
|
switch (command) {
|
|
1649
|
-
case 'fix': {
|
|
1650
|
-
const { runOrchestratorCli } = await import('../skills/fix/js/orchestrator.mjs')
|
|
1651
|
-
process.exitCode = await runOrchestratorCli(args, cwd())
|
|
1652
|
-
break
|
|
1653
|
-
}
|
|
1654
1646
|
case '_fix-check': {
|
|
1655
|
-
// Внутрішня команда
|
|
1656
|
-
// Повертає JSON {total, failed, rules:[{ruleId, ok, output}]} у stdout.
|
|
1647
|
+
// Внутрішня команда движка конформності (не публічний API): per-rule fix.mjs run() = детект.
|
|
1648
|
+
// Повертає JSON {total, failed, rules:[{ruleId, ok, output}]} у stdout. Викликається
|
|
1649
|
+
// конформність-фазою `lint` (read-only) і движком orchestrator/t0.
|
|
1657
1650
|
await runFixCommand(args, { json: true })
|
|
1658
1651
|
break
|
|
1659
1652
|
}
|
|
1660
|
-
case 'check': {
|
|
1661
|
-
// Backward-compatibility alias. Перейменовано на `fix` у 1.13.84 (узгоджено з ім'ям файла `rules/<id>/fix.mjs`).
|
|
1662
|
-
console.warn(
|
|
1663
|
-
`⚠️ Команда \`check\` deprecated — використовуйте \`fix\` (\`npx ${PACKAGE_NAME} fix [<rule>...]\`)`
|
|
1664
|
-
)
|
|
1665
|
-
await runFixCommand(
|
|
1666
|
-
args.filter(a => a !== '--json'),
|
|
1667
|
-
{ json: args.includes('--json') }
|
|
1668
|
-
)
|
|
1669
|
-
|
|
1670
|
-
break
|
|
1671
|
-
}
|
|
1672
1653
|
case 'rename-yaml-extensions': {
|
|
1673
1654
|
const code = await runRenameYamlExtensionsCli(args)
|
|
1674
1655
|
if (code !== 0) {
|
|
@@ -1686,12 +1667,21 @@ try {
|
|
|
1686
1667
|
break
|
|
1687
1668
|
}
|
|
1688
1669
|
case 'lint': {
|
|
1689
|
-
|
|
1670
|
+
// Дві ортогональні осі: --full (scope: весь репо vs дельта vs origin) × --read-only (behavior).
|
|
1671
|
+
// Позиційні (не-флаг) аргументи — фільтр правил конформності (напр. `lint changelog`):
|
|
1672
|
+
// прогнати лише конформність цих правил, без лінтер-скану (мапить колишній `fix <rule>`).
|
|
1673
|
+
const rules = args.filter(a => !a.startsWith('-'))
|
|
1674
|
+
process.exitCode = await runLint({
|
|
1675
|
+
full: args.includes('--full'),
|
|
1676
|
+
readOnly: args.includes('--read-only'),
|
|
1677
|
+
rules
|
|
1678
|
+
})
|
|
1690
1679
|
|
|
1691
1680
|
break
|
|
1692
1681
|
}
|
|
1693
1682
|
case 'lint-ci': {
|
|
1694
|
-
|
|
1683
|
+
// CI = весь репо в read-only (нуль мутацій, нуль LLM) — еквівалент `lint --read-only --full`.
|
|
1684
|
+
process.exitCode = await runLint({ full: true, readOnly: true })
|
|
1695
1685
|
|
|
1696
1686
|
break
|
|
1697
1687
|
}
|
|
@@ -1762,19 +1752,12 @@ try {
|
|
|
1762
1752
|
|
|
1763
1753
|
break
|
|
1764
1754
|
}
|
|
1765
|
-
case 'fix-run': {
|
|
1766
|
-
// Backward-compatibility alias → перенаправляємо на `fix`.
|
|
1767
|
-
console.warn(`⚠️ \`fix-run\` deprecated — використовуйте \`fix\``)
|
|
1768
|
-
const { runOrchestratorCli } = await import('../skills/fix/js/orchestrator.mjs')
|
|
1769
|
-
process.exitCode = await runOrchestratorCli(args, cwd())
|
|
1770
|
-
break
|
|
1771
|
-
}
|
|
1772
1755
|
case 'fix-t0': {
|
|
1773
|
-
//
|
|
1774
|
-
// Запускає
|
|
1756
|
+
// Внутрішня фаза движка конформності (не публічний API): T0-auto рівень.
|
|
1757
|
+
// Запускає _fix-check, знаходить violation-output із детермінованим паттерном
|
|
1775
1758
|
// (vscode-ext-add, rm-forbidden-file тощо), застосовує програмний фікс (0 LLM),
|
|
1776
|
-
// перевіряє check-gate.
|
|
1777
|
-
const { runT0AutoCli } = await import('../
|
|
1759
|
+
// перевіряє check-gate. Викликається orchestrator.mjs (fix-режим конформності lint).
|
|
1760
|
+
const { runT0AutoCli } = await import('../scripts/lib/fix/t0.mjs')
|
|
1778
1761
|
process.exitCode = await runT0AutoCli(args, cwd())
|
|
1779
1762
|
|
|
1780
1763
|
break
|
|
@@ -1890,7 +1873,7 @@ try {
|
|
|
1890
1873
|
default: {
|
|
1891
1874
|
console.error(`❌ Невідома команда: ${command}`)
|
|
1892
1875
|
console.error(
|
|
1893
|
-
` Очікується: (без аргументів) синхронізація правил,
|
|
1876
|
+
` Очікується: (без аргументів) синхронізація правил, rename-yaml-extensions, post-tool-use-fix, lint, lint-ga, lint-rego, lint-k8s, lint-docker, lint-text, lint-doc-files, fix-doc-files, coverage, coverage-fix, taze, start-check, change, release, skill, worktree, lint-ci, trace, doc-files, doc-aggregate`
|
|
1894
1877
|
)
|
|
1895
1878
|
process.exitCode = 1
|
|
1896
1879
|
}
|
package/package.json
CHANGED
|
@@ -90,6 +90,19 @@ function modeSuffix({ overwrite, retryDegraded }) {
|
|
|
90
90
|
return ''
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Рядок таймінгу одного файлу: загальний час, час у LLM (і кількість викликів)
|
|
95
|
+
* та залишок — оркестрація (екстракт фактів, скоринг, парсинг, IO). Дає зрозуміти,
|
|
96
|
+
* скільки коштує сама модель проти JS-оркестрації.
|
|
97
|
+
* @param {{ ms: number, llmMs?: number, llmCalls?: number }} r результат generateDoc
|
|
98
|
+
* @returns {string} напр. `12.3s (llm 11.8s/7 calls, orch 0.5s)`
|
|
99
|
+
*/
|
|
100
|
+
function fmtTiming(r) {
|
|
101
|
+
const s = ms => `${(ms / 1000).toFixed(1)}s`
|
|
102
|
+
const llmMs = r.llmMs ?? 0
|
|
103
|
+
return `${s(r.ms)} (llm ${s(llmMs)}/${r.llmCalls ?? 0} calls, orch ${s(r.ms - llmMs)})`
|
|
104
|
+
}
|
|
105
|
+
|
|
93
106
|
/**
|
|
94
107
|
* Генерує й штампує доку для одного файлу, оновлюючи лічильники й прогрес.
|
|
95
108
|
* @param {object} file елемент scanForDocFiles
|
|
@@ -114,9 +127,9 @@ async function generateOne(file, root, progress, stats) {
|
|
|
114
127
|
stats.ok++
|
|
115
128
|
if (result.degraded) {
|
|
116
129
|
stats.degraded++
|
|
117
|
-
process.stdout.write(`⚠ degraded score=${result.score} crc=${crc}\n`)
|
|
130
|
+
process.stdout.write(`⚠ degraded score=${result.score} crc=${crc} ${fmtTiming(result)}\n`)
|
|
118
131
|
} else {
|
|
119
|
-
process.stdout.write(`✓ score=${result.score ?? '—'} crc=${crc}\n`)
|
|
132
|
+
process.stdout.write(`✓ score=${result.score ?? '—'} crc=${crc} ${fmtTiming(result)}\n`)
|
|
120
133
|
}
|
|
121
134
|
} catch (error) {
|
|
122
135
|
stats.err++
|
|
@@ -137,7 +150,7 @@ function reportStats(stats) {
|
|
|
137
150
|
for (const e of stats.errors) console.log(` - ${e}`)
|
|
138
151
|
}
|
|
139
152
|
if (stats.degraded > 0) {
|
|
140
|
-
console.log(`Degraded-доки перегенеровуються пізніше: npx @nitra/cursor doc-files
|
|
153
|
+
console.log(`Degraded-доки перегенеровуються пізніше: npx @nitra/cursor fix-doc-files --retry-degraded`)
|
|
141
154
|
}
|
|
142
155
|
}
|
|
143
156
|
|
|
@@ -164,7 +177,7 @@ export async function runDocFilesGenCli(argv) {
|
|
|
164
177
|
|
|
165
178
|
const problem = preflightProblem()
|
|
166
179
|
if (problem) {
|
|
167
|
-
console.error(`✗ doc-files
|
|
180
|
+
console.error(`✗ fix-doc-files: ${problem}`)
|
|
168
181
|
return 1
|
|
169
182
|
}
|
|
170
183
|
|
|
@@ -201,7 +214,7 @@ export function runDocFilesStampCli(argv) {
|
|
|
201
214
|
writeFileSync(docAbs, stampDoc(md, file.sourcePath, crc, score === null ? null : { score, issues }))
|
|
202
215
|
stamped++
|
|
203
216
|
}
|
|
204
|
-
console.log(`✓ doc-files stamp: оновлено frontmatter у ${stamped} доці(ах).`)
|
|
217
|
+
console.log(`✓ fix-doc-files --stamp: оновлено frontmatter у ${stamped} доці(ах).`)
|
|
205
218
|
return 0
|
|
206
219
|
}
|
|
207
220
|
|
|
@@ -4,7 +4,7 @@ import { basename } from 'node:path'
|
|
|
4
4
|
import { env } from 'node:process'
|
|
5
5
|
import { resolveModel } from '../../../lib/models.mjs'
|
|
6
6
|
import { DEFAULT_OMLX_MODEL } from '../../../lib/omlx.mjs'
|
|
7
|
-
import { callLlm } from '../../../lib/llm.mjs'
|
|
7
|
+
import { callLlm as callLlmRaw } from '../../../lib/llm.mjs'
|
|
8
8
|
import { isRunAsCli } from '../../../scripts/cli-entry.mjs'
|
|
9
9
|
import { docPathForSource } from './docgen-scan.mjs'
|
|
10
10
|
import { extractFacts } from './docgen-extract.mjs'
|
|
@@ -19,6 +19,26 @@ import {
|
|
|
19
19
|
guaranteesFromMarkers
|
|
20
20
|
} from './docgen-prompts.mjs'
|
|
21
21
|
|
|
22
|
+
/** Облік LLM-викликів і часу в них у межах однієї генерації (скидається на старті generateDoc). */
|
|
23
|
+
let llmMeter = { calls: 0, ms: 0 }
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Обгортка callLlm з обліком: лічить кількість викликів і сумарний час у них.
|
|
27
|
+
* callLlm синхронний (spawnSync/curl), генерація одного файлу послідовна — лічильник без гонок.
|
|
28
|
+
* Усі виклики `callLlm(...)` у цьому модулі йдуть через неї автоматично (імпорт як callLlmRaw).
|
|
29
|
+
* @param {...any} args ті самі аргументи, що й у callLlm з lib/llm.mjs
|
|
30
|
+
* @returns {string} відповідь моделі
|
|
31
|
+
*/
|
|
32
|
+
function callLlm(...args) {
|
|
33
|
+
const started = Date.now()
|
|
34
|
+
try {
|
|
35
|
+
return callLlmRaw(...args)
|
|
36
|
+
} finally {
|
|
37
|
+
llmMeter.calls += 1
|
|
38
|
+
llmMeter.ms += Date.now() - started
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
22
42
|
const FENCE_OPEN_RE = /^```[a-z]*\n?/
|
|
23
43
|
const FENCE_CLOSE_RE = /\n?```\s*$/
|
|
24
44
|
const LEADING_HEADING_RE = /^#{1,6}[ \t]{1,8}[^\n]{0,400}\n{1,8}/
|
|
@@ -360,12 +380,13 @@ export const DEFAULT_LOCAL_MODEL = env.N_CURSOR_DOCGEN_MODEL ?? (resolveModel('m
|
|
|
360
380
|
* позначається `degraded`, рішення про перегенерацію приймає batch/користувач.
|
|
361
381
|
* @param {string} file абсолютний шлях джерела
|
|
362
382
|
* @param {{ model?: string, threshold?: number, existingMd?: string|null }} [opts] model-id, поріг degraded, наявна дока (для збереження захищеної секції)
|
|
363
|
-
* @returns {{ md: string, ms: number, score: number|null, issues: string[], degraded: boolean, model: string }} документ і метадані генерації
|
|
383
|
+
* @returns {{ md: string, ms: number, llmMs: number, llmCalls: number, score: number|null, issues: string[], degraded: boolean, model: string }} документ і метадані генерації (ms — увесь файл; llmMs/llmCalls — лише LLM; решта ms — оркестрація)
|
|
364
384
|
*/
|
|
365
385
|
export function generateDoc(file, { model = DEFAULT_LOCAL_MODEL, threshold = QUALITY_THRESHOLD, existingMd = null } = {}) {
|
|
366
386
|
const src = readFileSync(file, 'utf8')
|
|
367
387
|
const facts = extractFacts(src, file)
|
|
368
388
|
const t0 = Date.now()
|
|
389
|
+
llmMeter = { calls: 0, ms: 0 }
|
|
369
390
|
|
|
370
391
|
// Варіант B: захищена секція «Призначення» з наявної доки — зберегти й подати як контекст
|
|
371
392
|
const intent = existingMd ? splitProtected(existingMd).body : null
|
|
@@ -376,7 +397,16 @@ export function generateDoc(file, { model = DEFAULT_LOCAL_MODEL, threshold = QUA
|
|
|
376
397
|
|
|
377
398
|
// unsupported (vue/py до юніт-шару): скорер не застосовний — score=null, не degraded
|
|
378
399
|
if (facts.unsupported) {
|
|
379
|
-
return {
|
|
400
|
+
return {
|
|
401
|
+
...r,
|
|
402
|
+
ms: Date.now() - t0,
|
|
403
|
+
llmMs: llmMeter.ms,
|
|
404
|
+
llmCalls: llmMeter.calls,
|
|
405
|
+
score: null,
|
|
406
|
+
issues: [],
|
|
407
|
+
degraded: false,
|
|
408
|
+
model
|
|
409
|
+
}
|
|
380
410
|
}
|
|
381
411
|
|
|
382
412
|
// Stage 2.5: детермінований скоринг (0 токенів)
|
|
@@ -399,7 +429,16 @@ export function generateDoc(file, { model = DEFAULT_LOCAL_MODEL, threshold = QUA
|
|
|
399
429
|
}
|
|
400
430
|
}
|
|
401
431
|
|
|
402
|
-
return {
|
|
432
|
+
return {
|
|
433
|
+
...r,
|
|
434
|
+
ms: Date.now() - t0,
|
|
435
|
+
llmMs: llmMeter.ms,
|
|
436
|
+
llmCalls: llmMeter.calls,
|
|
437
|
+
score,
|
|
438
|
+
issues,
|
|
439
|
+
degraded: score < threshold,
|
|
440
|
+
model
|
|
441
|
+
}
|
|
403
442
|
}
|
|
404
443
|
|
|
405
444
|
// CLI: node docgen-gen.mjs <file> [--model <m>]
|
|
@@ -416,6 +455,8 @@ if (isRunAsCli(import.meta.url)) {
|
|
|
416
455
|
const existingMd = existsSync(docPath) ? readFileSync(docPath, 'utf8') : null
|
|
417
456
|
const r = generateDoc(file, { model, existingMd })
|
|
418
457
|
const issuesTxt = r.issues?.length ? ` issues=${r.issues.join(',')}` : ''
|
|
419
|
-
process.stderr.write(
|
|
458
|
+
process.stderr.write(
|
|
459
|
+
`[local ${r.model}] ${r.ms}ms (llm ${r.llmMs}ms/${r.llmCalls} calls, orch ${r.ms - r.llmMs}ms) / score=${r.score}${r.degraded ? ' DEGRADED' : ''}${issuesTxt}\n`
|
|
460
|
+
)
|
|
420
461
|
process.stdout.write(r.md)
|
|
421
462
|
}
|
|
@@ -229,7 +229,7 @@ function runDegradedReport(root) {
|
|
|
229
229
|
})
|
|
230
230
|
.join('\n')
|
|
231
231
|
console.log(
|
|
232
|
-
`⚠ doc-files: degraded-док ${degraded.length} (score < ${QUALITY_THRESHOLD}):\n${list}\n→ перегенеруй: npx @nitra/cursor doc-files
|
|
232
|
+
`⚠ doc-files: degraded-док ${degraded.length} (score < ${QUALITY_THRESHOLD}):\n${list}\n→ перегенеруй: npx @nitra/cursor fix-doc-files --retry-degraded`
|
|
233
233
|
)
|
|
234
234
|
return 0
|
|
235
235
|
}
|
|
@@ -285,7 +285,7 @@ export async function runDocFilesCheckCli(argv) {
|
|
|
285
285
|
// Великий прогін: Stop-гейт не блокує, лише попереджає (захист від нескінченного блоку).
|
|
286
286
|
if (gitMode && stale.length > gateMax) {
|
|
287
287
|
console.error(
|
|
288
|
-
`⚠ doc-files: застарілих док ${stale.length} (> ${gateMax}) — гейт не блокує. Запусти масовий прогін:\n npx @nitra/cursor doc-files
|
|
288
|
+
`⚠ doc-files: застарілих док ${stale.length} (> ${gateMax}) — гейт не блокує. Запусти масовий прогін:\n npx @nitra/cursor fix-doc-files`
|
|
289
289
|
)
|
|
290
290
|
return 0
|
|
291
291
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{ "auto": "завжди", "lint": "
|
|
1
|
+
{ "auto": "завжди", "lint": "per-file" }
|
package/rules/ga/meta.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{ "auto": { "glob": ".github/workflows/**" }, "lint": "
|
|
1
|
+
{ "auto": { "glob": ".github/workflows/**" }, "lint": "full" }
|
|
@@ -54,14 +54,15 @@ function runJson(args, cwd) {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
|
-
* Full-режим (
|
|
57
|
+
* Full-режим (--full): лінт усього проєкту зі стрімінгом і fail-fast (без класифікації).
|
|
58
58
|
* @param {string} cwd корінь
|
|
59
|
+
* @param {boolean} readOnly true → без `--fix` (детект, нуль мутацій — CI)
|
|
59
60
|
* @returns {number} exit code
|
|
60
61
|
*/
|
|
61
|
-
function lintFullProject(cwd) {
|
|
62
|
-
const ox = runInherit(['oxlint', '--fix'], cwd)
|
|
62
|
+
function lintFullProject(cwd, readOnly) {
|
|
63
|
+
const ox = runInherit(readOnly ? ['oxlint'] : ['oxlint', '--fix'], cwd)
|
|
63
64
|
if (ox !== 0) return ox
|
|
64
|
-
return runInherit(['eslint', '--fix', '.'], cwd)
|
|
65
|
+
return runInherit(readOnly ? ['eslint', '.'] : ['eslint', '--fix', '.'], cwd)
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
/**
|
|
@@ -69,12 +70,16 @@ function lintFullProject(cwd) {
|
|
|
69
70
|
* на introduced / pre-existing (беклог #6/A). Блокування на будь-якому finding.
|
|
70
71
|
* @param {string[]} js js-подібні змінені файли
|
|
71
72
|
* @param {string} cwd корінь
|
|
73
|
+
* @param {boolean} readOnly true → пропустити фікс-пас (детект, нуль мутацій)
|
|
72
74
|
* @returns {number} exit code (0 — чисто; 1 — лишились findings)
|
|
73
75
|
*/
|
|
74
|
-
function lintChangedClassified(js, cwd) {
|
|
76
|
+
function lintChangedClassified(js, cwd, readOnly) {
|
|
75
77
|
// Фікс-пас обох інструментів (послідовно; обидва — щоб репорт показав повну картину).
|
|
76
|
-
|
|
77
|
-
|
|
78
|
+
// У read-only пропускаємо — лише детект без мутацій (CI / pre-commit).
|
|
79
|
+
if (!readOnly) {
|
|
80
|
+
runFix(['oxlint', '--fix', ...js], cwd)
|
|
81
|
+
runFix(['eslint', '--fix', ...js], cwd)
|
|
82
|
+
}
|
|
78
83
|
|
|
79
84
|
// Репорт-пас по ФІНАЛЬНОМУ (пост-фікс) файлу — рядки findings і diff узгоджені.
|
|
80
85
|
const oxRes = runJson(['oxlint', '--format=json', ...js], cwd)
|
|
@@ -99,16 +104,18 @@ function lintChangedClassified(js, cwd) {
|
|
|
99
104
|
}
|
|
100
105
|
|
|
101
106
|
/**
|
|
102
|
-
* Запускає oxlint+eslint з
|
|
103
|
-
* @param {string[] | undefined} files
|
|
107
|
+
* Запускає oxlint+eslint. За замовчуванням — з автофіксом; `opts.readOnly` — лише детект.
|
|
108
|
+
* @param {string[] | undefined} files per-file: лише ці файли; undefined: весь проєкт (--full)
|
|
104
109
|
* @param {string} [cwd] корінь репо
|
|
110
|
+
* @param {{ readOnly?: boolean }} [opts] readOnly → без `--fix` (нуль мутацій)
|
|
105
111
|
* @returns {Promise<number>} 0 — OK, ≠0 — порушення
|
|
106
112
|
*/
|
|
107
|
-
export function lint(files, cwd = process.cwd()) {
|
|
113
|
+
export function lint(files, cwd = process.cwd(), opts = {}) {
|
|
114
|
+
const readOnly = opts.readOnly === true
|
|
108
115
|
if (files === undefined) {
|
|
109
|
-
return Promise.resolve(lintFullProject(cwd))
|
|
116
|
+
return Promise.resolve(lintFullProject(cwd, readOnly))
|
|
110
117
|
}
|
|
111
118
|
const js = filterJsFiles(files)
|
|
112
119
|
if (js.length === 0) return Promise.resolve(0)
|
|
113
|
-
return Promise.resolve(lintChangedClassified(js, cwd))
|
|
120
|
+
return Promise.resolve(lintChangedClassified(js, cwd, readOnly))
|
|
114
121
|
}
|
|
@@ -75,7 +75,7 @@ version: '1.30'
|
|
|
75
75
|
|
|
76
76
|
## knip
|
|
77
77
|
|
|
78
|
-
Залежнісний аналіз (knip — невикористані залежності/експорти, `knip.json` канон) крос-файловий, тож винесений у правило `js-lint-ci` (`lint:
|
|
78
|
+
Залежнісний аналіз (knip — невикористані залежності/експорти, `knip.json` канон) крос-файловий, тож винесений у правило `js-lint-ci` (`lint: full`). Див. `js-lint-ci`.
|
|
79
79
|
|
|
80
80
|
## jscpd: рефакторинг і структура
|
|
81
81
|
|
package/rules/js-lint/meta.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{ "auto": { "glob": ["**/*.mjs", "**/*.cjs", "**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"] }, "lint": "
|
|
1
|
+
{ "auto": { "glob": ["**/*.mjs", "**/*.cjs", "**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"] }, "lint": "per-file" }
|
|
@@ -10,7 +10,7 @@ version: '1.0'
|
|
|
10
10
|
`jscpd` і `knip` аналізують увесь граф проєкту, тож мають сенс лише у повному прогоні
|
|
11
11
|
`npx @nitra/cursor lint-ci` (не у швидкому `lint` по змінених файлах). Per-file режиму нема.
|
|
12
12
|
|
|
13
|
-
Швидкий етап js-lint (oxlint/eslint) — у правилі `js-lint` (`lint:
|
|
13
|
+
Швидкий етап js-lint (oxlint/eslint) — у правилі `js-lint` (`lint: per-file`).
|
|
14
14
|
|
|
15
15
|
## Залежнісна політика (що не додавати)
|
|
16
16
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{ "auto": { "glob": ["**/*.mjs", "**/*.cjs", "**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"] }, "lint": "
|
|
1
|
+
{ "auto": { "glob": ["**/*.mjs", "**/*.cjs", "**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"] }, "lint": "full" }
|
|
@@ -3,7 +3,7 @@ import { existsSync, readdirSync } from 'node:fs'
|
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
|
|
5
5
|
import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
|
|
6
|
-
import { parseRuleAutoSpec,
|
|
6
|
+
import { parseRuleAutoSpec, parseRuleLintSpec, readRuleMetaRaw } from '../../../scripts/lib/rule-meta.mjs'
|
|
7
7
|
import { RULE_PREDICATES } from '../../../scripts/lib/rule-predicates.mjs'
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -37,8 +37,8 @@ function checkAutoField(id, raw, reporter) {
|
|
|
37
37
|
*/
|
|
38
38
|
function checkLintField(id, ruleDir, raw, reporter) {
|
|
39
39
|
if (raw.lint === undefined) return true
|
|
40
|
-
if (
|
|
41
|
-
reporter.fail(`rules/${id}: meta.json.lint нерозпізнане (очікується "
|
|
40
|
+
if (parseRuleLintSpec(raw.lint) === null) {
|
|
41
|
+
reporter.fail(`rules/${id}: meta.json.lint нерозпізнане (очікується "per-file"|"full")`)
|
|
42
42
|
return false
|
|
43
43
|
}
|
|
44
44
|
if (!existsSync(join(ruleDir, 'js', 'lint.mjs'))) {
|
package/rules/rego/meta.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{ "auto": { "glob": "**/*.rego" }, "lint": "
|
|
1
|
+
{ "auto": { "glob": "**/*.rego" }, "lint": "full" }
|
package/rules/security/meta.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{ "auto": "завжди", "lint": "
|
|
1
|
+
{ "auto": "завжди", "lint": "per-file" }
|
|
@@ -12,12 +12,13 @@ export function filterStyleFiles(files) {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* @param {string[] | undefined} files
|
|
15
|
+
* @param {string[] | undefined} files per-file: ці файли; undefined: весь проєкт (--full)
|
|
16
16
|
* @param {string} [cwd] корінь
|
|
17
|
+
* @param {{ readOnly?: boolean }} [opts] readOnly → без `--fix` (детект, нуль мутацій)
|
|
17
18
|
* @returns {Promise<number>} exit code
|
|
18
19
|
*/
|
|
19
|
-
export function lint(files, cwd = process.cwd()) {
|
|
20
|
-
const args = ['stylelint', '--fix']
|
|
20
|
+
export function lint(files, cwd = process.cwd(), opts = {}) {
|
|
21
|
+
const args = opts.readOnly === true ? ['stylelint'] : ['stylelint', '--fix']
|
|
21
22
|
if (files === undefined) {
|
|
22
23
|
args.push('**/*.{css,scss,vue}')
|
|
23
24
|
} else {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{ "auto": { "glob": ["**/*.css", "**/*.vue"] }, "lint": "
|
|
1
|
+
{ "auto": { "glob": ["**/*.css", "**/*.vue"] }, "lint": "per-file" }
|
package/rules/text/js/lint.mjs
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Крок text: делегує у наявний CLI правила (per-file режиму немає — `files` ігнорується).
|
|
3
3
|
*/
|
|
4
4
|
import { runLintTextCli } from '../lint/lint.mjs'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* @param {string[] | undefined} _files ігнорується (whole-repo аналіз)
|
|
8
|
+
* @param {string} [_cwd] корінь (ігнорується — CLI працює від process.cwd())
|
|
9
|
+
* @param {{ readOnly?: boolean }} [opts] readOnly → детект без авто-фіксу (нуль мутацій)
|
|
8
10
|
* @returns {Promise<number>} exit code
|
|
9
11
|
*/
|
|
10
|
-
export function lint(_files) {
|
|
11
|
-
return runLintTextCli()
|
|
12
|
+
export function lint(_files, _cwd, opts = {}) {
|
|
13
|
+
return runLintTextCli({ readOnly: opts.readOnly === true })
|
|
12
14
|
}
|
package/rules/text/lint/lint.mjs
CHANGED
|
@@ -95,28 +95,30 @@ function preflight(dep) {
|
|
|
95
95
|
|
|
96
96
|
/**
|
|
97
97
|
* Внутрішні кроки `lint-text` без локу.
|
|
98
|
+
* @param {boolean} [readOnly] true → лише детект без авто-фіксу (нуль мутацій — CI/pre-commit)
|
|
98
99
|
* @returns {number} 0 — все OK, інакше — код першого кроку, що впав
|
|
99
100
|
*/
|
|
100
|
-
function runLintTextSteps() {
|
|
101
|
+
function runLintTextSteps(readOnly = false) {
|
|
101
102
|
// Auto-install: throws on failure → propagates as exit 1 from runStandardLint
|
|
102
103
|
ensureTool('shellcheck')
|
|
103
104
|
ensureTool('dotenv-linter')
|
|
104
105
|
|
|
105
|
-
// patch
|
|
106
|
-
if (!preflight(PATCH_PREFLIGHT)) return 1
|
|
106
|
+
// patch потрібен лише для авто-фіксу shellcheck; у read-only пропускаємо preflight.
|
|
107
|
+
if (!readOnly && !preflight(PATCH_PREFLIGHT)) return 1
|
|
107
108
|
|
|
108
109
|
const cspellCode = runLintStep('cspell', 'npx', ['cspell', '.'])
|
|
109
110
|
if (cspellCode !== 0) return cspellCode
|
|
110
111
|
|
|
111
|
-
console.log(
|
|
112
|
-
const shellcheckCode = runShellcheckText()
|
|
112
|
+
console.log(`\n▶ shellcheck (${readOnly ? 'перевірка' : 'авто-фікс + фінальна перевірка'} *.sh)`)
|
|
113
|
+
const shellcheckCode = runShellcheckText(process.cwd(), readOnly)
|
|
113
114
|
if (shellcheckCode !== 0) return shellcheckCode
|
|
114
115
|
|
|
115
|
-
console.log(
|
|
116
|
-
const dotenvCode = runDotenvLinter()
|
|
116
|
+
console.log(`\n▶ dotenv-linter (${readOnly ? 'перевірка' : 'авто-фікс + фінальна перевірка'} .env*)`)
|
|
117
|
+
const dotenvCode = runDotenvLinter(process.cwd(), readOnly)
|
|
117
118
|
if (dotenvCode !== 0) return dotenvCode
|
|
118
119
|
|
|
119
|
-
const
|
|
120
|
+
const mdArgs = readOnly ? ['markdownlint-cli2', '**/*.md', '**/*.mdc'] : ['markdownlint-cli2', '--fix', '**/*.md', '**/*.mdc']
|
|
121
|
+
const markdownlintCode = runLintStep('markdownlint', 'bunx', mdArgs)
|
|
120
122
|
if (markdownlintCode !== 0) return markdownlintCode
|
|
121
123
|
|
|
122
124
|
console.log('\n▶ v8r (schema-валідація json/json5/yaml/yml/toml)')
|
|
@@ -125,6 +127,8 @@ function runLintTextSteps() {
|
|
|
125
127
|
|
|
126
128
|
/**
|
|
127
129
|
* Публічна CLI-форма: серіалізує через `withLock('lint-text')` + дедуп за станом git-дерева.
|
|
130
|
+
* @param {{ readOnly?: boolean }} [opts] readOnly → детект без авто-фіксу
|
|
128
131
|
* @returns {Promise<number>} код виходу
|
|
129
132
|
*/
|
|
130
|
-
export const runLintTextCli = (
|
|
133
|
+
export const runLintTextCli = (opts = {}) =>
|
|
134
|
+
runStandardLint(import.meta.dirname, () => runLintTextSteps(opts.readOnly === true))
|
|
@@ -52,9 +52,10 @@ function buildExcludeArgs() {
|
|
|
52
52
|
/**
|
|
53
53
|
* Запускає dotenv-linter з авто-фіксом і фінальною перевіркою.
|
|
54
54
|
* @param {string} [cwd] робочий каталог (за замовчуванням `process.cwd()`)
|
|
55
|
+
* @param {boolean} [readOnly] true → пропустити авто-фікс (`fix`), лише `check` (нуль мутацій)
|
|
55
56
|
* @returns {number} 0 — OK; 1 — інструмент відсутній або є залишкові порушення
|
|
56
57
|
*/
|
|
57
|
-
export function runDotenvLinter(cwd = process.cwd()) {
|
|
58
|
+
export function runDotenvLinter(cwd = process.cwd(), readOnly = false) {
|
|
58
59
|
const root = resolve(cwd)
|
|
59
60
|
const bin = resolveCmd('dotenv-linter')
|
|
60
61
|
if (!bin) {
|
|
@@ -63,15 +64,17 @@ export function runDotenvLinter(cwd = process.cwd()) {
|
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
const exclude = buildExcludeArgs()
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
67
|
+
if (!readOnly) {
|
|
68
|
+
const fixRun = spawnSync(bin, ['fix', '-r', '--no-backup', '--quiet', ...exclude, '.'], {
|
|
69
|
+
cwd: root,
|
|
70
|
+
encoding: 'utf8',
|
|
71
|
+
env: process.env,
|
|
72
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
73
|
+
})
|
|
74
|
+
if (fixRun.error) {
|
|
75
|
+
process.stderr.write(`${fixRun.error.message}\n`)
|
|
76
|
+
return 1
|
|
77
|
+
}
|
|
75
78
|
}
|
|
76
79
|
|
|
77
80
|
const checkRun = spawnSync(bin, ['check', '-r', '--quiet', ...exclude, '.'], {
|
|
@@ -96,17 +96,19 @@ export function listShellScriptPaths(cwd) {
|
|
|
96
96
|
/**
|
|
97
97
|
* Запускає shellcheck із авто-виправленнями і фінальною перевіркою.
|
|
98
98
|
* @param {string} [cwd] робочий каталог (за замовчуванням `process.cwd()`)
|
|
99
|
+
* @param {boolean} [readOnly] true → пропустити авто-фікс (diff+patch), лише фінальна перевірка
|
|
99
100
|
* @returns {number} 0 — OK; 1 — помилка середовища або залишкові зауваження shellcheck
|
|
100
101
|
*/
|
|
101
|
-
export function runShellcheckText(cwd = process.cwd()) {
|
|
102
|
+
export function runShellcheckText(cwd = process.cwd(), readOnly = false) {
|
|
102
103
|
const root = resolve(cwd)
|
|
103
104
|
const shellcheck = resolveCmd('shellcheck')
|
|
104
105
|
if (!shellcheck) {
|
|
105
106
|
printShellcheckInstallHints()
|
|
106
107
|
return 1
|
|
107
108
|
}
|
|
108
|
-
|
|
109
|
-
|
|
109
|
+
// patch потрібен лише для авто-фіксу (diff+patch); у read-only його відсутність не блокує детект.
|
|
110
|
+
const patchBin = readOnly ? null : resolveCmd('patch')
|
|
111
|
+
if (!readOnly && !patchBin) {
|
|
110
112
|
printPatchInstallHints()
|
|
111
113
|
return 1
|
|
112
114
|
}
|
|
@@ -116,9 +118,11 @@ export function runShellcheckText(cwd = process.cwd()) {
|
|
|
116
118
|
return 0
|
|
117
119
|
}
|
|
118
120
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
121
|
+
if (!readOnly) {
|
|
122
|
+
for (const rel of files) {
|
|
123
|
+
const fixCode = autofixOneFile(shellcheck, /** @type {string} */ (patchBin), root, rel)
|
|
124
|
+
if (fixCode !== 0) return fixCode
|
|
125
|
+
}
|
|
122
126
|
}
|
|
123
127
|
|
|
124
128
|
return runFinalShellcheck(shellcheck, files, root)
|
package/rules/text/meta.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{ "auto": "завжди", "lint": "
|
|
1
|
+
{ "auto": "завжди", "lint": "per-file" }
|
package/scripts/docs/lint-cli.md
CHANGED
|
@@ -48,16 +48,20 @@ export function parseRuleAutoSpec(value) {
|
|
|
48
48
|
return null
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
/** Допустимі
|
|
52
|
-
const
|
|
51
|
+
/** Допустимі значення `meta.json.lint` (вісь scope: чи детектор дробиться на changed-set). */
|
|
52
|
+
const LINT_SCOPES = new Set(['per-file', 'full'])
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
|
-
* Нормалізує значення `meta.json.lint` у
|
|
55
|
+
* Нормалізує значення `meta.json.lint` у scope детектора.
|
|
56
|
+
* - `"per-file"` — детектор декомпозується на змінені файли (дельта vs origin);
|
|
57
|
+
* - `"full"` — нероздільно крос-файловий (лише `--full` / CI).
|
|
58
|
+
* Об'єктна форма `{scope, ci}` скасована: CI=`--read-only --full` ганяє все повністю,
|
|
59
|
+
* тож per-rule CI-override не потрібен (spec 2026-06-14-lint-rule-consolidation §3-А).
|
|
56
60
|
* @param {unknown} value значення поля `lint`
|
|
57
|
-
* @returns {'
|
|
61
|
+
* @returns {'per-file' | 'full' | null} scope або `null` (відсутнє/невалідне = не lint-крок)
|
|
58
62
|
*/
|
|
59
|
-
export function
|
|
60
|
-
return typeof value === 'string' &&
|
|
63
|
+
export function parseRuleLintSpec(value) {
|
|
64
|
+
return typeof value === 'string' && LINT_SCOPES.has(value) ? /** @type {'per-file'|'full'} */ (value) : null
|
|
61
65
|
}
|
|
62
66
|
|
|
63
67
|
/**
|
package/scripts/lint-cli.mjs
CHANGED
|
@@ -1,34 +1,74 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Оркестратор `n-cursor lint` (
|
|
2
|
+
* Оркестратор `n-cursor lint` — дві ортогональні осі (spec 2026-06-14-lint-rule-consolidation
|
|
3
|
+
* + компаньйон 2026-06-14-lint-orchestrator-fix-readonly-unification):
|
|
4
|
+
* - **scope** (`--full`): default = дельта vs origin (лише `per-file` правила);
|
|
5
|
+
* `--full` = весь репо (`per-file` ∪ `full` правила);
|
|
6
|
+
* - **behavior** (`--read-only`): default = fix; `--read-only` = лише детект без мутацій.
|
|
3
7
|
*
|
|
4
|
-
* Data-driven: сканує `rules/<id>/meta.json` за полем `lint` (`
|
|
5
|
-
*
|
|
6
|
-
* -
|
|
7
|
-
* -
|
|
8
|
-
* Порядок правил —
|
|
8
|
+
* Data-driven: сканує `rules/<id>/meta.json` за полем `lint` (`per-file`|`full`),
|
|
9
|
+
* викликає `rules/<id>/js/lint.mjs` → `lint(files, cwd, { readOnly })`:
|
|
10
|
+
* - default scope: `files` = змінені відносно origin (`collectChangedFilesSince`);
|
|
11
|
+
* - `--full`: `files = undefined` — весь проєкт.
|
|
12
|
+
* Порядок правил — алфавітний. Fail-fast: перший ненульовий код спиняє.
|
|
9
13
|
*/
|
|
10
14
|
import { existsSync, readdirSync } from 'node:fs'
|
|
11
15
|
import { dirname, join } from 'node:path'
|
|
12
16
|
import { fileURLToPath } from 'node:url'
|
|
17
|
+
import { spawnSync } from 'node:child_process'
|
|
13
18
|
import { cwd as processCwd } from 'node:process'
|
|
14
19
|
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
20
|
+
import { parseRuleLintSpec, readRuleMetaRaw } from './lib/rule-meta.mjs'
|
|
21
|
+
import { collectChangedFilesSince, resolveChangedBase } from './lib/changed-files.mjs'
|
|
17
22
|
|
|
18
23
|
const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
19
24
|
const RULES_DIR = join(PACKAGE_ROOT, 'rules')
|
|
25
|
+
const N_CURSOR_BIN = join(PACKAGE_ROOT, 'bin', 'n-cursor.js')
|
|
20
26
|
|
|
21
27
|
/**
|
|
22
|
-
*
|
|
28
|
+
* Конформність-фаза lint (whole-repo: config/file/workflow conformance — те, що раніше робив `fix`).
|
|
29
|
+
* Per-file декомпозиції немає, тож виконується лише у `--full`.
|
|
30
|
+
* - read-only: детект через `_fix-check` (per-rule `fix.mjs run()` = перевірка, без мутацій);
|
|
31
|
+
* - fix: convergence-движок (check → Tier0 → omlx) через orchestrator.
|
|
32
|
+
* @param {string} cwd корінь
|
|
33
|
+
* @param {boolean} readOnly true → лише детект (нуль мутацій)
|
|
34
|
+
* @param {(s: string) => void} log логер
|
|
35
|
+
* @param {string[]} [filter] фільтр правил (порожній — усі)
|
|
36
|
+
* @returns {Promise<number>} 0 — чисто, 1 — порушення/помилка
|
|
37
|
+
*/
|
|
38
|
+
async function runConformance(cwd, readOnly, log, filter = []) {
|
|
39
|
+
if (!readOnly) {
|
|
40
|
+
const { runOrchestratorCli } = await import('./lib/fix/orchestrator.mjs')
|
|
41
|
+
return runOrchestratorCli(filter, cwd)
|
|
42
|
+
}
|
|
43
|
+
const r = spawnSync('bun', [N_CURSOR_BIN, '_fix-check', ...filter], { cwd, encoding: 'utf8', timeout: 600_000 })
|
|
44
|
+
let parsed = null
|
|
45
|
+
try {
|
|
46
|
+
parsed = JSON.parse((r.stdout ?? '').trim())
|
|
47
|
+
} catch {
|
|
48
|
+
parsed = null
|
|
49
|
+
}
|
|
50
|
+
if (!parsed) {
|
|
51
|
+
log('❌ lint: конформність — помилка перевірки (_fix-check не повернув JSON)\n')
|
|
52
|
+
return 1
|
|
53
|
+
}
|
|
54
|
+
const failed = parsed.rules.filter(/** @param {{ok:boolean}} x */ x => !x.ok)
|
|
55
|
+
if (failed.length === 0) return 0
|
|
56
|
+
log(`❌ lint: конформність — ${failed.length} порушень: ${failed.map(/** @param {{ruleId:string}} x */ x => x.ruleId).join(', ')}\n`)
|
|
57
|
+
for (const f of failed) if (f.output) log(`${f.output}\n`)
|
|
58
|
+
return 1
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Вибирає id правил для контексту, алфавітно.
|
|
23
63
|
* @param {Record<string, {lint?: unknown}>} metaById мапа id → meta-обʼєкт
|
|
24
|
-
* @param {
|
|
64
|
+
* @param {boolean} full `false` → лише `per-file` правила; `true` → усі (`per-file` ∪ `full`)
|
|
25
65
|
* @returns {string[]} відсортовані id
|
|
26
66
|
*/
|
|
27
|
-
export function selectLintRules(metaById,
|
|
67
|
+
export function selectLintRules(metaById, full) {
|
|
28
68
|
const out = []
|
|
29
69
|
for (const [id, raw] of Object.entries(metaById)) {
|
|
30
|
-
const
|
|
31
|
-
if (
|
|
70
|
+
const scope = parseRuleLintSpec(raw?.lint)
|
|
71
|
+
if (scope === 'per-file' || (full && scope === 'full')) out.push(id)
|
|
32
72
|
}
|
|
33
73
|
return out.toSorted((a, b) => a.localeCompare(b))
|
|
34
74
|
}
|
|
@@ -52,22 +92,33 @@ function readAllMeta(rulesDir) {
|
|
|
52
92
|
|
|
53
93
|
/**
|
|
54
94
|
* Запускає lint-оркестрацію.
|
|
55
|
-
* @param {{
|
|
95
|
+
* @param {{ full?: boolean, readOnly?: boolean, rules?: string[], cwd?: string, rulesDir?: string, log?: (s: string) => void }} [opts] параметри
|
|
96
|
+
* - `full` — весь репо (`true`) проти дельти vs origin (`false`, default);
|
|
97
|
+
* - `readOnly` — лише детект без мутацій (`true`) проти fix (`false`, default);
|
|
98
|
+
* - `rules` — непорожній фільтр → лише конформність цих правил (без лінтер-скану; мапить `fix <rule>`).
|
|
56
99
|
* @returns {Promise<number>} exit code
|
|
57
100
|
*/
|
|
58
101
|
export async function runLint(opts = {}) {
|
|
59
|
-
const
|
|
102
|
+
const full = opts.full === true
|
|
103
|
+
const readOnly = opts.readOnly === true
|
|
104
|
+
const rules = Array.isArray(opts.rules) ? opts.rules : []
|
|
60
105
|
const cwd = opts.cwd ?? processCwd()
|
|
61
106
|
const rulesDir = opts.rulesDir ?? RULES_DIR
|
|
62
107
|
const log = opts.log ?? (s => process.stdout.write(s))
|
|
63
108
|
|
|
64
|
-
|
|
65
|
-
if (
|
|
66
|
-
|
|
109
|
+
// Rule-filter режим (напр. `lint changelog` із hk): лише конформність указаних правил, без лінтерів.
|
|
110
|
+
if (rules.length > 0) {
|
|
111
|
+
return runConformance(cwd, readOnly, log, rules)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Default scope — дельта vs origin (merge-base main/origin/main); `--full` — весь репо.
|
|
115
|
+
const changed = full ? undefined : collectChangedFilesSince(resolveChangedBase(cwd), cwd)
|
|
116
|
+
if (!full && changed.length === 0) {
|
|
117
|
+
log('\nℹ️ lint: немає змінених файлів відносно origin — нічого перевіряти.\n')
|
|
67
118
|
return 0
|
|
68
119
|
}
|
|
69
120
|
|
|
70
|
-
const ids = selectLintRules(readAllMeta(rulesDir),
|
|
121
|
+
const ids = selectLintRules(readAllMeta(rulesDir), full)
|
|
71
122
|
for (const id of ids) {
|
|
72
123
|
const lintPath = join(rulesDir, id, 'js', 'lint.mjs')
|
|
73
124
|
if (!existsSync(lintPath)) {
|
|
@@ -75,8 +126,15 @@ export async function runLint(opts = {}) {
|
|
|
75
126
|
continue
|
|
76
127
|
}
|
|
77
128
|
const mod = await import(lintPath)
|
|
78
|
-
const code = await mod.lint(changed, cwd)
|
|
129
|
+
const code = await mod.lint(changed, cwd, { readOnly })
|
|
79
130
|
if (code !== 0) return code
|
|
80
131
|
}
|
|
132
|
+
|
|
133
|
+
// Конформність-фаза (поглинула `fix`): whole-repo, лише у `--full`. Кастомний rulesDir
|
|
134
|
+
// (юніт-тести селектора) — реальний пакет недоступний, тож пропускаємо.
|
|
135
|
+
if (full && opts.rulesDir === undefined) {
|
|
136
|
+
const conformanceCode = await runConformance(cwd, readOnly, log)
|
|
137
|
+
if (conformanceCode !== 0) return conformanceCode
|
|
138
|
+
}
|
|
81
139
|
return 0
|
|
82
140
|
}
|
|
@@ -1,65 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* PostToolUse hook для Claude Code:
|
|
3
|
-
*
|
|
4
|
-
* замінює дорогий синхронний `Stop`-хук, що ганяв повний `fix` усіх правил на кожному
|
|
5
|
-
* turn-і.
|
|
2
|
+
* PostToolUse hook для Claude Code: read-only детект конформності **всіх** активованих правил
|
|
3
|
+
* після редагування файлу. Запускається після кожного `Edit` / `Write` / `MultiEdit`.
|
|
6
4
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* але ми лишаємо exit-код прозорим — для діагностики);
|
|
11
|
-
* - інакше spawn `npx --no \@nitra/cursor fix <rules…>` із передаванням exit-коду.
|
|
5
|
+
* Раніше хук маршрутизував змінений файл у релевантні правила й ганяв повний `fix` (автофікс
|
|
6
|
+
* + LLM) — дорого, тож звужували. Тепер хук — **детект** (нуль мутацій, нуль LLM), тож роутинг
|
|
7
|
+
* зайвий: один виклик `_fix-check` (per-rule `fix.mjs run()` = перевірка) по всіх правилах.
|
|
12
8
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
9
|
+
* Контракт:
|
|
10
|
+
* - stdin Claude Code: JSON із `tool_input.file_path`; якщо файлу немає (напр. Bash) — exit 0 (skip);
|
|
11
|
+
* - інакше spawn `_fix-check` (детект усіх правил), exit-код прозоро пробрасуємо (PostToolUse
|
|
12
|
+
* не блокує turn, але код лишаємо інформативним: 1 — є порушення конформності).
|
|
16
13
|
*/
|
|
17
14
|
import { spawn } from 'node:child_process'
|
|
18
15
|
import { once } from 'node:events'
|
|
19
16
|
|
|
20
|
-
import picomatch from 'picomatch'
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* @typedef {object} Route
|
|
24
|
-
* @property {string} pattern picomatch glob (з підтримкою `**` і `{a,b}`)
|
|
25
|
-
* @property {string[]} rules ID правил `npm/rules/<id>` (бо `fix.mjs` обов'язковий)
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
/** Порядок важливий: специфічні маршрути (`.github/workflows/*`, `**\/k8s/**`) — перед загальними. */
|
|
29
|
-
/** @type {readonly Route[]} */
|
|
30
|
-
const ROUTES = Object.freeze([
|
|
31
|
-
{ pattern: 'docs/adr/**/*.md', rules: [] },
|
|
32
|
-
{ pattern: '.github/workflows/*.{yml,yaml}', rules: ['ga'] },
|
|
33
|
-
{ pattern: '**/k8s/**/*.{yaml,yml}', rules: ['k8s'] },
|
|
34
|
-
{ pattern: '**/*.vue', rules: ['js-lint', 'style-lint', 'vue'] },
|
|
35
|
-
{ pattern: '**/*.{mjs,js,cjs,ts,tsx,jsx}', rules: ['js-lint'] },
|
|
36
|
-
{ pattern: '**/*.{css,scss,sass}', rules: ['style-lint'] },
|
|
37
|
-
{ pattern: '**/*.rego', rules: ['rego'] },
|
|
38
|
-
{ pattern: '{**/,}Dockerfile', rules: ['docker'] },
|
|
39
|
-
{ pattern: '**/*.Dockerfile', rules: ['docker'] },
|
|
40
|
-
{ pattern: '**/*.sh', rules: ['security'] },
|
|
41
|
-
{ pattern: '{**/,}package.json', rules: ['npm-module', 'bun'] },
|
|
42
|
-
{ pattern: '**/*.md', rules: ['text'] }
|
|
43
|
-
])
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Повертає список правил, які слід прогнати для зміненого `filePath`.
|
|
47
|
-
* Перший збіг із `ROUTES` — переможець; невідомі шляхи / некоректні входи → `[]`.
|
|
48
|
-
* @param {unknown} filePath відносний шлях зміненого файла зі stdin Claude Code
|
|
49
|
-
* @returns {string[]} ID правил для `npx \@nitra/cursor fix`
|
|
50
|
-
*/
|
|
51
|
-
export function routeFilePathToRules(filePath) {
|
|
52
|
-
if (typeof filePath !== 'string' || filePath === '') {
|
|
53
|
-
return []
|
|
54
|
-
}
|
|
55
|
-
for (const { pattern, rules } of ROUTES) {
|
|
56
|
-
if (picomatch.isMatch(filePath, pattern, { dot: true })) {
|
|
57
|
-
return [...rules]
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
return []
|
|
61
|
-
}
|
|
62
|
-
|
|
63
17
|
/**
|
|
64
18
|
* Зчитує stdin до EOF як utf8 рядок. На TTY — повертає `''` одразу.
|
|
65
19
|
* @returns {Promise<string>} вміст stdin
|
|
@@ -87,7 +41,7 @@ async function readStdin() {
|
|
|
87
41
|
* @param {string} stdinJson сирий вміст stdin
|
|
88
42
|
* @returns {string | null} відносний шлях або `null`
|
|
89
43
|
*/
|
|
90
|
-
function extractFilePath(stdinJson) {
|
|
44
|
+
export function extractFilePath(stdinJson) {
|
|
91
45
|
if (!stdinJson) {
|
|
92
46
|
return null
|
|
93
47
|
}
|
|
@@ -103,27 +57,25 @@ function extractFilePath(stdinJson) {
|
|
|
103
57
|
/**
|
|
104
58
|
* Точка входу. Викликається з `bin/n-cursor.js` коли argv[0] === `post-tool-use-fix`.
|
|
105
59
|
* Параметри доступні для інʼєкції для тестів: `stdinJson` обходить read від `process.stdin`,
|
|
106
|
-
* `spawnFn` — заміна `node:child_process.spawn
|
|
107
|
-
* @param {{ stdinJson?: string, spawnFn?: typeof spawn }} [options] параметри для тестів
|
|
108
|
-
* @returns {Promise<number>} exit code (0 — пропущено /
|
|
60
|
+
* `spawnFn` — заміна `node:child_process.spawn`.
|
|
61
|
+
* @param {{ stdinJson?: string, spawnFn?: typeof spawn }} [options] параметри для тестів
|
|
62
|
+
* @returns {Promise<number>} exit code (0 — пропущено / конформність ОК; інше — є порушення)
|
|
109
63
|
*/
|
|
110
64
|
export async function runPostToolUseFixCli(options = {}) {
|
|
111
65
|
const stdinJson = options.stdinJson ?? (await readStdin())
|
|
112
66
|
const filePath = extractFilePath(stdinJson)
|
|
67
|
+
// Тільки після редагування файлу (Edit/Write/MultiEdit мають file_path); Bash тощо — skip.
|
|
113
68
|
if (filePath === null) {
|
|
114
69
|
return 0
|
|
115
70
|
}
|
|
116
|
-
const rules = routeFilePathToRules(filePath)
|
|
117
|
-
if (rules.length === 0) {
|
|
118
|
-
return 0
|
|
119
|
-
}
|
|
120
71
|
const spawnFn = options.spawnFn ?? spawn
|
|
121
|
-
|
|
72
|
+
// Один read-only виклик: детект конформності всіх активованих правил, без роутингу.
|
|
73
|
+
const child = spawnFn('npx', ['--no', '@nitra/cursor', '_fix-check'], { stdio: 'inherit' })
|
|
122
74
|
try {
|
|
123
75
|
const [code] = await once(child, 'exit')
|
|
124
76
|
return code ?? 1
|
|
125
77
|
} catch (error) {
|
|
126
|
-
process.stderr.write(`post-tool-use-fix: не вдалося запустити
|
|
78
|
+
process.stderr.write(`post-tool-use-fix: не вдалося запустити детект конформності — ${error.message}\n`)
|
|
127
79
|
return 1
|
|
128
80
|
}
|
|
129
81
|
}
|
package/skills/fix/SKILL.md
CHANGED
|
@@ -1,23 +1,22 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: n-fix
|
|
3
3
|
description: >-
|
|
4
|
-
|
|
4
|
+
DEPRECATED — використовуй /n-lint. fix злито в lint: `n-cursor lint` тепер і
|
|
5
|
+
детектить, і виправляє (конформність + лінтери) за один прохід.
|
|
5
6
|
---
|
|
6
7
|
|
|
7
|
-
# n-fix —
|
|
8
|
+
# n-fix — DEPRECATED (делегат на /n-lint)
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
Команду `n-cursor fix` **видалено**: рух-движок конформності (convergence-loop /
|
|
11
|
+
check-gate / Tier0 / LLM) поглинуто в `lint` (спека
|
|
12
|
+
`docs/specs/2026-06-14-lint-orchestrator-fix-readonly-unification-design.md`).
|
|
10
13
|
|
|
11
|
-
|
|
14
|
+
**Використовуй `/n-lint`** замість цього скіла:
|
|
12
15
|
|
|
13
|
-
|
|
16
|
+
- `n-cursor lint` — дельта vs origin, **fix за замовчуванням** (лінтери на змінених файлах);
|
|
17
|
+
- `n-cursor lint --full` — весь репо + **конформність** (колишній `fix`: конфіги/файли/воркфлоу
|
|
18
|
+
через convergence-движок);
|
|
19
|
+
- `n-cursor lint --read-only [--full]` — лише детект, нуль мутацій (CI / pre-commit);
|
|
20
|
+
- `n-cursor lint <rule>` — конформність одного правила (колишній `fix <rule>`).
|
|
14
21
|
|
|
15
|
-
|
|
16
|
-
n_cursor_npx fix
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
Exit 0 = чисто, 1 = є unresolved (перевір вивід — буде список правил що не закрились після 3 ітерацій).
|
|
20
|
-
|
|
21
|
-
Якщо змінились залежності — `bun i`. Якщо змінились JS/TS файли — `oxfmt .`.
|
|
22
|
-
|
|
23
|
-
Для конкретних правил: `n_cursor_npx fix bun ga`.
|
|
22
|
+
Цей скіл лишено як тонкий делегат до наступного major; уся логіка — у `/n-lint`.
|
package/skills/lint/SKILL.md
CHANGED
|
@@ -91,18 +91,17 @@ bun run lint
|
|
|
91
91
|
- Якщо тестів **немає** або вони **не покривають** блок, який змінюєш — **спочатку** додай/розшир тести, переконайся, що вони стабільно проходять, **потім** роби рефакторинг, **потім** знову прогони тести й **`bun run lint`**, щоб підтвердити, що функціональність коректна й лінт чистий.
|
|
92
92
|
- Якщо після рефакторингу тести або лінт падають — **не** залишай «половинчастий» рефакторинг: відкотись або доведи зміни до зеленого стану.
|
|
93
93
|
|
|
94
|
-
##
|
|
94
|
+
## Паралелізм і навантаження на macOS
|
|
95
95
|
|
|
96
|
-
|
|
96
|
+
**Паралельно по різних файлах — дозволено.** Диз'юнктні набори (per-file `n-cursor lint` на змінених vs origin) не конфліктують: кожен процес обробляє свій підмножину файлів, тож гонки за тим самим корпусом немає.
|
|
97
97
|
|
|
98
|
-
|
|
98
|
+
Проблема — не паралелізм як такий, а **кілька одночасних whole-tree прогонів того самого корпусу** (**`bun run lint`**, **`n-cursor lint --full`**) у різних Bash-задачах/shells: кожен повторно сканує **весь** репо (eslint + oxlint + jscpd + knip), і диск/CPU перевантажуються дублюванням важкого full-scan.
|
|
99
99
|
|
|
100
|
-
### Що робити агенту під час виконання цього скілу
|
|
100
|
+
### Що робити агенту під час виконання цього скілу
|
|
101
101
|
|
|
102
|
-
1.
|
|
103
|
-
2.
|
|
104
|
-
3. Якщо
|
|
105
|
-
4. Якщо сесія/користувач уже запускає лінт — **не** дублювати; зачекати завершення або **не** стартувати лінт повторно в іншому shell.
|
|
102
|
+
1. **Whole-tree** прогін (**`bun run lint`** / **`lint --full`**) — **один** за раз, у одному foreground shell; **не** запускати другий full-прогін того самого корпусу паралельно.
|
|
103
|
+
2. **Per-file** лінт по диз'юнктних файлах (різні субагенти на різні файли) — **дозволено** й не потребує серіалізації.
|
|
104
|
+
3. Якщо сесія/користувач уже запускає **whole-tree** лінт — не дублювати його; зачекати завершення.
|
|
106
105
|
|
|
107
106
|
**Що можна змінити у проєкті (локально або в `package.json`)**
|
|
108
107
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|