@nitra/cursor 5.4.0 → 6.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 +10 -0
- package/bin/n-cursor.js +4 -2
- 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 +28 -20
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [6.0.0] - 2026-06-14
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- fix-doc-files: пер-файловий таймінг у виводі — `<total>s (llm <llmS>/<N> calls, orch <orchS>)`: видно, скільки часу зайняла модель (і кількість LLM-викликів) проти JS-оркестрації. `generateDoc` повертає `llmMs`/`llmCalls`; облік через прозору обгортку `callLlm` (синхронні spawnSync-виклики, послідовна генерація — без гонок).
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- 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})
|
|
12
|
+
|
|
3
13
|
## [5.4.0] - 2026-06-14
|
|
4
14
|
|
|
5
15
|
### Added
|
package/bin/n-cursor.js
CHANGED
|
@@ -1686,12 +1686,14 @@ try {
|
|
|
1686
1686
|
break
|
|
1687
1687
|
}
|
|
1688
1688
|
case 'lint': {
|
|
1689
|
-
|
|
1689
|
+
// Дві ортогональні осі: --full (scope: весь репо vs дельта vs origin) × --read-only (behavior).
|
|
1690
|
+
process.exitCode = await runLint({ full: args.includes('--full'), readOnly: args.includes('--read-only') })
|
|
1690
1691
|
|
|
1691
1692
|
break
|
|
1692
1693
|
}
|
|
1693
1694
|
case 'lint-ci': {
|
|
1694
|
-
|
|
1695
|
+
// CI = весь репо в read-only (нуль мутацій, нуль LLM) — еквівалент `lint --read-only --full`.
|
|
1696
|
+
process.exitCode = await runLint({ full: true, readOnly: true })
|
|
1695
1697
|
|
|
1696
1698
|
break
|
|
1697
1699
|
}
|
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,38 @@
|
|
|
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'
|
|
13
17
|
import { cwd as processCwd } from 'node:process'
|
|
14
18
|
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
19
|
+
import { parseRuleLintSpec, readRuleMetaRaw } from './lib/rule-meta.mjs'
|
|
20
|
+
import { collectChangedFilesSince, resolveChangedBase } from './lib/changed-files.mjs'
|
|
17
21
|
|
|
18
22
|
const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
19
23
|
const RULES_DIR = join(PACKAGE_ROOT, 'rules')
|
|
20
24
|
|
|
21
25
|
/**
|
|
22
|
-
* Вибирає id правил для
|
|
26
|
+
* Вибирає id правил для контексту, алфавітно.
|
|
23
27
|
* @param {Record<string, {lint?: unknown}>} metaById мапа id → meta-обʼєкт
|
|
24
|
-
* @param {
|
|
28
|
+
* @param {boolean} full `false` → лише `per-file` правила; `true` → усі (`per-file` ∪ `full`)
|
|
25
29
|
* @returns {string[]} відсортовані id
|
|
26
30
|
*/
|
|
27
|
-
export function selectLintRules(metaById,
|
|
31
|
+
export function selectLintRules(metaById, full) {
|
|
28
32
|
const out = []
|
|
29
33
|
for (const [id, raw] of Object.entries(metaById)) {
|
|
30
|
-
const
|
|
31
|
-
if (
|
|
34
|
+
const scope = parseRuleLintSpec(raw?.lint)
|
|
35
|
+
if (scope === 'per-file' || (full && scope === 'full')) out.push(id)
|
|
32
36
|
}
|
|
33
37
|
return out.toSorted((a, b) => a.localeCompare(b))
|
|
34
38
|
}
|
|
@@ -52,22 +56,26 @@ function readAllMeta(rulesDir) {
|
|
|
52
56
|
|
|
53
57
|
/**
|
|
54
58
|
* Запускає lint-оркестрацію.
|
|
55
|
-
* @param {{
|
|
59
|
+
* @param {{ full?: boolean, readOnly?: boolean, cwd?: string, rulesDir?: string, log?: (s: string) => void }} [opts] параметри
|
|
60
|
+
* - `full` — весь репо (`true`) проти дельти vs origin (`false`, default);
|
|
61
|
+
* - `readOnly` — лише детект без мутацій (`true`) проти fix (`false`, default).
|
|
56
62
|
* @returns {Promise<number>} exit code
|
|
57
63
|
*/
|
|
58
64
|
export async function runLint(opts = {}) {
|
|
59
|
-
const
|
|
65
|
+
const full = opts.full === true
|
|
66
|
+
const readOnly = opts.readOnly === true
|
|
60
67
|
const cwd = opts.cwd ?? processCwd()
|
|
61
68
|
const rulesDir = opts.rulesDir ?? RULES_DIR
|
|
62
69
|
const log = opts.log ?? (s => process.stdout.write(s))
|
|
63
70
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
71
|
+
// Default scope — дельта vs origin (merge-base main/origin/main); `--full` — весь репо.
|
|
72
|
+
const changed = full ? undefined : collectChangedFilesSince(resolveChangedBase(cwd), cwd)
|
|
73
|
+
if (!full && changed.length === 0) {
|
|
74
|
+
log('\nℹ️ lint: немає змінених файлів відносно origin — нічого перевіряти.\n')
|
|
67
75
|
return 0
|
|
68
76
|
}
|
|
69
77
|
|
|
70
|
-
const ids = selectLintRules(readAllMeta(rulesDir),
|
|
78
|
+
const ids = selectLintRules(readAllMeta(rulesDir), full)
|
|
71
79
|
for (const id of ids) {
|
|
72
80
|
const lintPath = join(rulesDir, id, 'js', 'lint.mjs')
|
|
73
81
|
if (!existsSync(lintPath)) {
|
|
@@ -75,7 +83,7 @@ export async function runLint(opts = {}) {
|
|
|
75
83
|
continue
|
|
76
84
|
}
|
|
77
85
|
const mod = await import(lintPath)
|
|
78
|
-
const code = await mod.lint(changed, cwd)
|
|
86
|
+
const code = await mod.lint(changed, cwd, { readOnly })
|
|
79
87
|
if (code !== 0) return code
|
|
80
88
|
}
|
|
81
89
|
return 0
|