@nitra/cursor 5.2.1 → 5.3.1
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 +13 -0
- package/bin/n-cursor.js +72 -50
- package/lib/llm.mjs +60 -47
- package/lib/models.mjs +1 -1
- package/lib/omlx-trace.mjs +158 -0
- package/lib/omlx.mjs +49 -11
- package/package.json +1 -1
- package/rules/js-bun-db/js-bun-db.mdc +7 -7
- package/rules/js-lint/js-lint.mdc +14 -1
- package/rules/js-run/js-run.mdc +16 -16
- package/rules/k8s/js/manifests.mjs +144 -82
- package/rules/npm-module/js/header_doc_pointer.mjs +72 -27
- package/rules/npm-module/js/rule_meta.mjs +72 -36
- package/rules/npm-module/js/skill_meta.mjs +59 -35
- package/rules/style-lint/js/tooling.mjs +13 -4
- package/rules/style-lint/style-lint.mdc +1 -1
- package/rules/test/js/data/stryker_config/stryker.config.baseline.mjs +1 -1
- package/rules/test/js/data/stryker_config/stryker.config.vue.baseline.mjs +1 -1
- package/rules/test/js/stryker_config.mjs +33 -5
- package/rules/test/js/vitest-config-pool-forks.mjs +11 -7
- package/rules/test/test.mdc +9 -9
- package/rules/vue/vue.mdc +6 -6
- package/scripts/coverage-classify/index.mjs +5 -17
- package/scripts/coverage-classify/verdict-schema.mjs +1 -1
- package/scripts/lib/assert-project-root.mjs +1 -1
- package/scripts/lib/discover-check-rules-from-cursor.mjs +1 -1
- package/scripts/lib/rule-predicates.mjs +30 -18
- package/scripts/lib/run-rule-cli.mjs +1 -1
- package/scripts/lib/run-standard-rule.mjs +1 -1
- package/scripts/post-tool-use-fix.mjs +3 -3
- package/scripts/skills-cli.mjs +5 -5
- package/scripts/worktree-cli.mjs +5 -5
- package/skills/doc-files/js/docgen-extract.mjs +1 -1
- package/skills/doc-files/js/docgen-files-batch.mjs +65 -34
- package/skills/doc-files/js/docgen-gen.mjs +121 -36
- package/skills/doc-files/js/docgen-prompts.mjs +20 -5
- package/skills/fix/js/llm-worker.mjs +10 -22
- package/skills/fix/js/orchestrator.mjs +64 -35
- package/skills/fix/js/t0.mjs +44 -32
- package/skills/start-check/js/check.mjs +1 -1
|
@@ -5,6 +5,64 @@ import { join } from 'node:path'
|
|
|
5
5
|
import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
|
|
6
6
|
import { parseSkillAutoSpec, readSkillMetaRaw } from '../../../scripts/lib/skill-meta.mjs'
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Перевіряє поля сирого meta.json одного скіла (без auto.md / відсутності файлу).
|
|
10
|
+
* @param {string} id ідентифікатор скіла
|
|
11
|
+
* @param {Record<string, unknown>} raw сирий meta.json
|
|
12
|
+
* @param {ReturnType<typeof createCheckReporter>} reporter репортер
|
|
13
|
+
* @returns {boolean} true, якщо всі поля валідні
|
|
14
|
+
*/
|
|
15
|
+
function checkSkillFields(id, raw, reporter) {
|
|
16
|
+
let ok = true
|
|
17
|
+
if (typeof raw.worktree !== 'boolean') {
|
|
18
|
+
reporter.fail(`skills/${id}: meta.json.worktree має бути boolean`)
|
|
19
|
+
ok = false
|
|
20
|
+
}
|
|
21
|
+
if (raw.auto !== undefined && parseSkillAutoSpec(raw.auto) === null) {
|
|
22
|
+
reporter.fail(`skills/${id}: meta.json.auto нерозпізнане — очікується "завжди" або непорожній масив правил`)
|
|
23
|
+
ok = false
|
|
24
|
+
}
|
|
25
|
+
if (raw.requireRoot !== undefined && typeof raw.requireRoot !== 'boolean') {
|
|
26
|
+
reporter.fail(`skills/${id}: meta.json.requireRoot має бути boolean`)
|
|
27
|
+
ok = false
|
|
28
|
+
}
|
|
29
|
+
if (raw.worktree === true && raw.requireRoot === false) {
|
|
30
|
+
reporter.fail(
|
|
31
|
+
`skills/${id}: requireRoot:false суперечить worktree:true (worktree вже вимагає кореня — прибери поле)`
|
|
32
|
+
)
|
|
33
|
+
ok = false
|
|
34
|
+
}
|
|
35
|
+
return ok
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Валідує meta.json одного скіла.
|
|
40
|
+
* @param {string} id ідентифікатор скіла
|
|
41
|
+
* @param {string} skillDir каталог скіла
|
|
42
|
+
* @param {ReturnType<typeof createCheckReporter>} reporter репортер
|
|
43
|
+
* @returns {void}
|
|
44
|
+
*/
|
|
45
|
+
function checkSkill(id, skillDir, reporter) {
|
|
46
|
+
let skillOk = true
|
|
47
|
+
|
|
48
|
+
if (existsSync(join(skillDir, 'auto.md'))) {
|
|
49
|
+
reporter.fail(`skills/${id}: залишковий auto.md — видали (метадані тепер у meta.json)`)
|
|
50
|
+
skillOk = false
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const raw = readSkillMetaRaw(skillDir)
|
|
54
|
+
if (!raw) {
|
|
55
|
+
reporter.fail(`skills/${id}: відсутній або невалідний meta.json (очікується {"auto"?, "worktree": bool})`)
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!checkSkillFields(id, raw, reporter)) skillOk = false
|
|
60
|
+
|
|
61
|
+
if (skillOk) {
|
|
62
|
+
reporter.pass(`skills/${id}: meta.json валідний`)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
8
66
|
/**
|
|
9
67
|
* Валідує всі `npm/skills/<id>/meta.json`.
|
|
10
68
|
* @param {string} [cwd] корінь репозиторію
|
|
@@ -20,41 +78,7 @@ export function check(cwd = process.cwd()) {
|
|
|
20
78
|
|
|
21
79
|
for (const entry of readdirSync(skillsDir, { withFileTypes: true })) {
|
|
22
80
|
if (!entry.isDirectory() || entry.name.startsWith('.')) continue
|
|
23
|
-
|
|
24
|
-
const skillDir = join(skillsDir, id)
|
|
25
|
-
let skillOk = true
|
|
26
|
-
|
|
27
|
-
if (existsSync(join(skillDir, 'auto.md'))) {
|
|
28
|
-
reporter.fail(`skills/${id}: залишковий auto.md — видали (метадані тепер у meta.json)`)
|
|
29
|
-
skillOk = false
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const raw = readSkillMetaRaw(skillDir)
|
|
33
|
-
if (!raw) {
|
|
34
|
-
reporter.fail(`skills/${id}: відсутній або невалідний meta.json (очікується {"auto"?, "worktree": bool})`)
|
|
35
|
-
continue
|
|
36
|
-
}
|
|
37
|
-
if (typeof raw.worktree !== 'boolean') {
|
|
38
|
-
reporter.fail(`skills/${id}: meta.json.worktree має бути boolean`)
|
|
39
|
-
skillOk = false
|
|
40
|
-
}
|
|
41
|
-
if (raw.auto !== undefined && parseSkillAutoSpec(raw.auto) === null) {
|
|
42
|
-
reporter.fail(`skills/${id}: meta.json.auto нерозпізнане — очікується "завжди" або непорожній масив правил`)
|
|
43
|
-
skillOk = false
|
|
44
|
-
}
|
|
45
|
-
if (raw.requireRoot !== undefined && typeof raw.requireRoot !== 'boolean') {
|
|
46
|
-
reporter.fail(`skills/${id}: meta.json.requireRoot має бути boolean`)
|
|
47
|
-
skillOk = false
|
|
48
|
-
}
|
|
49
|
-
if (raw.worktree === true && raw.requireRoot === false) {
|
|
50
|
-
reporter.fail(
|
|
51
|
-
`skills/${id}: requireRoot:false суперечить worktree:true (worktree вже вимагає кореня — прибери поле)`
|
|
52
|
-
)
|
|
53
|
-
skillOk = false
|
|
54
|
-
}
|
|
55
|
-
if (skillOk) {
|
|
56
|
-
reporter.pass(`skills/${id}: meta.json валідний`)
|
|
57
|
-
}
|
|
81
|
+
checkSkill(entry.name, join(skillsDir, entry.name), reporter)
|
|
58
82
|
}
|
|
59
83
|
|
|
60
84
|
return Promise.resolve(reporter.getExitCode())
|
|
@@ -5,6 +5,18 @@ import { join } from 'node:path'
|
|
|
5
5
|
|
|
6
6
|
import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
|
|
7
7
|
|
|
8
|
+
// Зовнішні файли конфігу stylelint, які підхоплює cosmiconfig. Канон нових
|
|
9
|
+
// JS-конфігів — `.mjs`/`.cjs` (js-lint.mdc), legacy `.js` лишається валідним.
|
|
10
|
+
const STYLELINT_CONFIG_FILES = [
|
|
11
|
+
'.stylelintrc.json',
|
|
12
|
+
'.stylelintrc.js',
|
|
13
|
+
'.stylelintrc.cjs',
|
|
14
|
+
'.stylelintrc.mjs',
|
|
15
|
+
'stylelint.config.js',
|
|
16
|
+
'stylelint.config.cjs',
|
|
17
|
+
'stylelint.config.mjs'
|
|
18
|
+
]
|
|
19
|
+
|
|
8
20
|
/**
|
|
9
21
|
* Альтернатива полю `stylelint` у `package.json` — зовнішній файл конфігу. Якщо
|
|
10
22
|
* поля немає і файлу немає, фейлимося; якщо є хоч щось — пропускаємо. Поле
|
|
@@ -18,10 +30,7 @@ async function checkStylelintConfigPresence(reporter, cwd) {
|
|
|
18
30
|
if (!existsSync(pkgPath)) return
|
|
19
31
|
const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
|
|
20
32
|
const hasField = pkg.stylelint && typeof pkg.stylelint === 'object'
|
|
21
|
-
const hasExternalCfg =
|
|
22
|
-
existsSync(join(cwd, '.stylelintrc.json')) ||
|
|
23
|
-
existsSync(join(cwd, '.stylelintrc.js')) ||
|
|
24
|
-
existsSync(join(cwd, 'stylelint.config.js'))
|
|
33
|
+
const hasExternalCfg = STYLELINT_CONFIG_FILES.some(name => existsSync(join(cwd, name)))
|
|
25
34
|
if (hasField || hasExternalCfg) {
|
|
26
35
|
pass('Конфіг stylelint є — у package.json або окремим файлом')
|
|
27
36
|
} else {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** @type {import('@stryker-mutator/core').PartialStrykerOptions} */
|
|
2
2
|
export default {
|
|
3
3
|
testRunner: 'vitest',
|
|
4
|
-
vitest: { configFile: 'vitest.config.
|
|
4
|
+
vitest: { configFile: 'vitest.config.mjs' },
|
|
5
5
|
// perTest: Stryker запускає лише тести, що покривають мутовану лінію — головний приріст
|
|
6
6
|
// швидкості проти command runner (де треба було б ганяти ввесь test-suite на кожен мутант).
|
|
7
7
|
coverageAnalysis: 'perTest',
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** @type {import('@stryker-mutator/core').PartialStrykerOptions} */
|
|
2
2
|
export default {
|
|
3
3
|
testRunner: 'vitest',
|
|
4
|
-
vitest: { configFile: 'vitest.config.
|
|
4
|
+
vitest: { configFile: 'vitest.config.mjs' },
|
|
5
5
|
// perTest: Stryker запускає лише тести, що покривають мутовану лінію — головний приріст
|
|
6
6
|
// швидкості проти command runner (де треба було б ганяти ввесь test-suite на кожен мутант).
|
|
7
7
|
coverageAnalysis: 'perTest',
|
|
@@ -18,6 +18,24 @@ const STRYKER_VUE_PLUGIN_PATH = join(HERE, 'data', 'stryker_config', 'stryker-vu
|
|
|
18
18
|
const STRYKER_VUE_PLUGIN_FILENAME = 'stryker-vue-macros-ignorer.mjs'
|
|
19
19
|
const VITEST_BASELINE_PATH = join(HERE, 'data', 'vitest_config', 'vitest.config.baseline.js')
|
|
20
20
|
|
|
21
|
+
// Канонічна назва vitest-конфіга — `.mjs` (нові файли, js-lint.mdc); legacy
|
|
22
|
+
// `.js` лишається валідним. Перший знайдений виграє (.mjs пріоритетніший).
|
|
23
|
+
const VITEST_CONFIG_NAMES = ['vitest.config.mjs', 'vitest.config.js']
|
|
24
|
+
// Заміна literal `configFile` у скопійованому stryker-baseline на фактичне
|
|
25
|
+
// ім'я vitest-конфіга jsRoot-а (узгодження Stryker ↔ vitest).
|
|
26
|
+
const STRYKER_CONFIG_FILE_RE = /configFile: 'vitest\.config\.[cm]?js'/u
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Визначає ім'я vitest-конфіга для jsRoot: існуючий `.mjs`/`.js` (якщо є),
|
|
30
|
+
* інакше дефолт `vitest.config.mjs` (нові файли — `.mjs`). Існуючий
|
|
31
|
+
* `vitest.config.js` лишається валідним (backward-compat), новий не плодиться.
|
|
32
|
+
* @param {string} jsRoot абсолютний шлях до workspace-каталогу
|
|
33
|
+
* @returns {string} ім'я vitest-конфіга
|
|
34
|
+
*/
|
|
35
|
+
function resolveVitestConfigName(jsRoot) {
|
|
36
|
+
return VITEST_CONFIG_NAMES.find(name => existsSync(join(jsRoot, name))) ?? 'vitest.config.mjs'
|
|
37
|
+
}
|
|
38
|
+
|
|
21
39
|
// Канонічні entries, які vue-варіант baseline тримає у `plugins`/`ignorers`.
|
|
22
40
|
// Augment-крок (augmentVueStrykerConfig) дбає, щоб саме вони були присутні в
|
|
23
41
|
// уже-існуючому `stryker.config.mjs` Vue-root-а. Нову property пишемо у
|
|
@@ -64,15 +82,20 @@ async function hasVueFiles(jsRoot) {
|
|
|
64
82
|
* @param {string} cwd корінь проєкту (для relative-шляхів у логах)
|
|
65
83
|
* @param {string} baselinePath абсолютний шлях до canonical baseline
|
|
66
84
|
* @param {string} target абсолютний шлях, куди копіювати
|
|
67
|
-
* @param {string} label зрозуміла для людини мітка ("stryker.config.mjs" / "vitest.config.
|
|
85
|
+
* @param {string} label зрозуміла для людини мітка ("stryker.config.mjs" / "vitest.config.mjs")
|
|
86
|
+
* @param {(content: string) => string} [transform] опційне перетворення тексту baseline перед записом
|
|
68
87
|
* @returns {Promise<void>}
|
|
69
88
|
*/
|
|
70
|
-
async function ensureBaselineFile(reporter, cwd, baselinePath, target, label) {
|
|
89
|
+
async function ensureBaselineFile(reporter, cwd, baselinePath, target, label, transform) {
|
|
71
90
|
if (existsSync(target)) {
|
|
72
91
|
reporter.pass(`${label} існує (${relative(cwd, target)})`)
|
|
73
92
|
return
|
|
74
93
|
}
|
|
75
|
-
|
|
94
|
+
if (transform) {
|
|
95
|
+
await writeFile(target, transform(await readFile(baselinePath, 'utf8')), 'utf8')
|
|
96
|
+
} else {
|
|
97
|
+
await copyFile(baselinePath, target)
|
|
98
|
+
}
|
|
76
99
|
reporter.pass(`${label} створено з canonical baseline (${relative(cwd, target)}) (test.mdc)`)
|
|
77
100
|
}
|
|
78
101
|
|
|
@@ -341,7 +364,12 @@ export async function check(cwd = process.cwd()) {
|
|
|
341
364
|
// і саме тут augment закриває drift-hole.
|
|
342
365
|
const wasMissing = !existsSync(strykerTarget)
|
|
343
366
|
const strykerBaseline = isVueRoot ? STRYKER_VUE_BASELINE_PATH : STRYKER_BASELINE_PATH
|
|
344
|
-
|
|
367
|
+
// configFile у новоствореному baseline має вказувати на фактичний vitest-конфіг
|
|
368
|
+
// jsRoot-а (existing `.js`/`.mjs` або дефолтний `.mjs`).
|
|
369
|
+
const vitestName = resolveVitestConfigName(jsRoot)
|
|
370
|
+
await ensureBaselineFile(reporter, cwd, strykerBaseline, strykerTarget, 'stryker.config.mjs', content =>
|
|
371
|
+
content.replace(STRYKER_CONFIG_FILE_RE, `configFile: '${vitestName}'`)
|
|
372
|
+
)
|
|
345
373
|
if (isVueRoot) {
|
|
346
374
|
if (!wasMissing) {
|
|
347
375
|
await augmentVueStrykerConfig(reporter, cwd, jsRoot)
|
|
@@ -354,7 +382,7 @@ export async function check(cwd = process.cwd()) {
|
|
|
354
382
|
STRYKER_VUE_PLUGIN_FILENAME
|
|
355
383
|
)
|
|
356
384
|
}
|
|
357
|
-
await ensureBaselineFile(reporter, cwd, VITEST_BASELINE_PATH, join(jsRoot,
|
|
385
|
+
await ensureBaselineFile(reporter, cwd, VITEST_BASELINE_PATH, join(jsRoot, vitestName), vitestName)
|
|
358
386
|
}
|
|
359
387
|
|
|
360
388
|
// Гарантуємо що тест-артефакти (Stryker output, lcov HTML-звіт) ніколи не
|
|
@@ -8,8 +8,12 @@ import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
|
|
|
8
8
|
/** Subтring-pattern: `pool: 'forks'` або `pool: "forks"` (з опційним whitespace). */
|
|
9
9
|
const POOL_FORKS_RE = /pool\s*:\s*['"]forks['"]/u
|
|
10
10
|
|
|
11
|
+
// Канонічна назва — `.mjs` (нові файли, js-lint.mdc), але legacy `.js` лишається
|
|
12
|
+
// валідним. Перший знайдений виграє: `.mjs` пріоритетніший.
|
|
13
|
+
const VITEST_CONFIG_NAMES = ['vitest.config.mjs', 'vitest.config.js']
|
|
14
|
+
|
|
11
15
|
/**
|
|
12
|
-
* Перевіряє, що `vitest.config.js` (якщо існує) містить `pool: 'forks'`.
|
|
16
|
+
* Перевіряє, що `vitest.config.{mjs,js}` (якщо існує) містить `pool: 'forks'`.
|
|
13
17
|
* @param {string} [cwdParam] корінь репозиторію
|
|
14
18
|
* @returns {Promise<number>} 0 — OK або skip, 1 — config без `pool: 'forks'`
|
|
15
19
|
*/
|
|
@@ -17,18 +21,18 @@ export async function check(cwdParam = process.cwd()) {
|
|
|
17
21
|
const reporter = createCheckReporter()
|
|
18
22
|
const { pass, fail } = reporter
|
|
19
23
|
|
|
20
|
-
const
|
|
21
|
-
if (!
|
|
22
|
-
pass('vitest.config.js відсутній — pool-перевірку пропущено')
|
|
24
|
+
const configName = VITEST_CONFIG_NAMES.find(name => existsSync(join(cwdParam, name)))
|
|
25
|
+
if (!configName) {
|
|
26
|
+
pass('vitest.config.mjs/.js відсутній — pool-перевірку пропущено')
|
|
23
27
|
return reporter.getExitCode()
|
|
24
28
|
}
|
|
25
29
|
|
|
26
|
-
const body = await readFile(
|
|
30
|
+
const body = await readFile(join(cwdParam, configName), 'utf8')
|
|
27
31
|
if (POOL_FORKS_RE.test(body)) {
|
|
28
|
-
pass(
|
|
32
|
+
pass(`${configName} містить pool: 'forks' (test.mdc)`)
|
|
29
33
|
} else {
|
|
30
34
|
fail(
|
|
31
|
-
|
|
35
|
+
`${configName} має містити pool: 'forks' — defense-in-depth для race у process.cwd() між паралельними test files (test.mdc)`
|
|
32
36
|
)
|
|
33
37
|
}
|
|
34
38
|
|
package/rules/test/test.mdc
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: JS-тести (*.test.mjs) живуть у tests/. Правило `test` керує stryker.config.mjs + vitest.config.
|
|
3
|
-
version: '2.
|
|
4
|
-
globs: "**/{.n-cursor.json,package.json,Cargo.toml,stryker.config.mjs,vitest.config.js,.cargo/mutants.toml},**/*.test.mjs"
|
|
2
|
+
description: JS-тести (*.test.mjs) живуть у tests/. Правило `test` керує stryker.config.mjs + vitest.config.mjs (якщо js-lint enabled) і .cargo/mutants.toml (якщо rust enabled).
|
|
3
|
+
version: '2.8'
|
|
4
|
+
globs: "**/{.n-cursor.json,package.json,Cargo.toml,stryker.config.mjs,vitest.config.mjs,vitest.config.js,.cargo/mutants.toml},**/*.test.mjs"
|
|
5
5
|
alwaysApply: false
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -71,15 +71,15 @@ Recursive globs ловлять файли всередині `tests/` так с
|
|
|
71
71
|
- Усі FS-операції у тесті — через `join(dir, …)` і `writeJson(join(dir, …), …)` / `ensureDir(join(dir, …))` (хелпери валідують `isAbsolute`).
|
|
72
72
|
- Усі child-процеси — `execFile(bin, args, { cwd: dir })`, `spawnSync(bin, args, { cwd: dir })`.
|
|
73
73
|
- Concern-функції правил — `await check(dir)`, `await applies(dir)`, `await fix(dir)`; усі production функції приймають перший параметр `cwd = process.cwd()` (default зберігає CLI-сумісність).
|
|
74
|
-
- `vitest.config.js` додатково ставить `pool: 'forks'` як defense-in-depth: навіть якщо хтось пропустить правило вище, fork-ізоляція не дасть race у production tree.
|
|
74
|
+
- `vitest.config.mjs` (або legacy `vitest.config.js`) додатково ставить `pool: 'forks'` як defense-in-depth: навіть якщо хтось пропустить правило вище, fork-ізоляція не дасть race у production tree.
|
|
75
75
|
|
|
76
76
|
Це **обов'язково** і для тестів пакета `@nitra/cursor`, і для кожного проєкту-споживача. Триплет перевірок:
|
|
77
77
|
|
|
78
78
|
- **`no-process-chdir`** (`rules/test/js/no-process-chdir.mjs`) — сканує `**/*.test.{js,mjs}` і падає з ❌ на будь-яке вживання `process.chdir(`.
|
|
79
79
|
- **`no-relative-fs-path`** (`rules/test/js/no-relative-fs-path.mjs`) — AST-сканер (`oxc-parser`): знаходить виклики FS-функцій із `node:fs`/`node:fs/promises` (`writeFile`, `copyFile`, `mkdir`, `readFile`, `existsSync`, `rename`, `symlink`, `cp`, … включно з `*Sync`-варіантами та `writeJson`/`ensureDir`-хелперами), де path-аргумент — це **string literal** без префікса `/`, `\`, `file:`, `http(s):`, `data:`, чи Windows-disk-letter `C:\`. Виклики `copyFile`/`rename`/`symlink`/`link`/`cp` перевіряють обидва path-аргументи. Виклики з обчисленим path (`join(dir, …)`, змінна, template-literal з виразом) пропускаються. Виловив би інцидент v1.28.0 у `tests/check-rule-fixtures.test.mjs` (`copyFile(src, 'default.conf.template')` → файл у production tree).
|
|
80
|
-
- **`vitest-config-pool-forks`** (`rules/test/js/vitest-config-pool-forks.mjs`) — substring-перевірка `pool: 'forks'` у `vitest.config.js`. Defense-in-depth.
|
|
80
|
+
- **`vitest-config-pool-forks`** (`rules/test/js/vitest-config-pool-forks.mjs`) — substring-перевірка `pool: 'forks'` у `vitest.config.mjs` (або legacy `vitest.config.js`; `.mjs` пріоритетніший). Defense-in-depth.
|
|
81
81
|
|
|
82
|
-
Canonical `vitest.config.
|
|
82
|
+
Canonical `vitest.config.mjs` (для довідки — `pool: 'forks'` + `include` + `coverage`) — у `rules/test/js/data/vitest_config/vitest.config.baseline.js` (концерн `stryker_config` копіює його у кожен JS-root; нові файли — `.mjs`, наявний `vitest.config.js` лишається валідним і не дублюється).
|
|
83
83
|
|
|
84
84
|
## Console mocking у тестах
|
|
85
85
|
|
|
@@ -144,7 +144,7 @@ test.skipIf(env.STRYKER_MUTATOR_WORKER)('узгоджені з поточним
|
|
|
144
144
|
|
|
145
145
|
## Налаштування mutation-testing
|
|
146
146
|
|
|
147
|
-
Якщо у `.n-cursor.json#rules` присутнє правило `js-lint` — правило `test` створює canonical baseline `stryker.config.mjs` + `vitest.config.
|
|
147
|
+
Якщо у `.n-cursor.json#rules` присутнє правило `js-lint` — правило `test` створює canonical baseline `stryker.config.mjs` + `vitest.config.mjs` у **кожному** JS-root проєкту: у кожному workspace з власним `package.json` (або в корені для single-package). У monorepo з `workspaces: ['app', 'scripts']` отримаєте `app/stryker.config.mjs` + `app/vitest.config.mjs` і `scripts/stryker.config.mjs` + `scripts/vitest.config.mjs`. Якщо у JS-root уже лежить legacy `vitest.config.js` — він лишається валідним, новий `.mjs` поряд не створюється, а `vitest.configFile` у скопійованому `stryker.config.mjs` приводиться до фактичного імені.
|
|
148
148
|
|
|
149
149
|
Канон Stryker config (Vitest runner + perTest): [stryker.config.baseline.mjs](./js/data/stryker_config/stryker.config.baseline.mjs)
|
|
150
150
|
|
|
@@ -160,7 +160,7 @@ JS-root без `.vue` отримує дефолтний baseline без `plugins
|
|
|
160
160
|
|
|
161
161
|
### Vitest baseline та `package.json#scripts`
|
|
162
162
|
|
|
163
|
-
Поряд зі Stryker концерн `stryker_config` без дублювання копіює `vitest.config.
|
|
163
|
+
Поряд зі Stryker концерн `stryker_config` без дублювання копіює `vitest.config.mjs` (тільки якщо немає ні `.mjs`, ні legacy `.js`). Canonical: [vitest.config.baseline.js](./js/data/vitest_config/vitest.config.baseline.js) — `environment: 'node'`, `coverage.provider: 'v8'` з lcov+text-summary репортами, `include: ['**/*.test.{js,mjs}', 'tests/**/*.test.{js,mjs}']` (підхоплює обидві розкладки — тести у `tests/`-піддиректоріях і top-level integration suites у `<root>/tests/`).
|
|
164
164
|
|
|
165
165
|
У `package.json#scripts` має бути `"test": "vitest run"` (canonical contains-substring `vitest` — допустимо `vitest run` та інші локальні розширення); опційно — `"test:watch": "vitest"`.
|
|
166
166
|
|
|
@@ -168,7 +168,7 @@ JS-root без `.vue` отримує дефолтний baseline без `plugins
|
|
|
168
168
|
|
|
169
169
|
### Frontend-варіант (Vue/Vite + happy-dom)
|
|
170
170
|
|
|
171
|
-
Для проєктів зі своїм `vite.config.js` `vitest.config.
|
|
171
|
+
Для проєктів зі своїм `vite.config.js` `vitest.config.mjs` має повторно використовувати vite-плагіни та aliases і перемкнути `environment` на `'happy-dom'` (або `'jsdom'`):
|
|
172
172
|
|
|
173
173
|
```js
|
|
174
174
|
import { defineConfig, mergeConfig } from 'vitest/config'
|
package/rules/vue/vue.mdc
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Vue
|
|
3
|
-
version: '2.
|
|
3
|
+
version: '2.2'
|
|
4
4
|
globs: "**/*.vue"
|
|
5
5
|
alwaysApply: false
|
|
6
6
|
---
|
|
@@ -44,14 +44,14 @@ const folderStructure = `
|
|
|
44
44
|
assets/
|
|
45
45
|
public/
|
|
46
46
|
App.vue
|
|
47
|
-
main.
|
|
47
|
+
main.mjs
|
|
48
48
|
`
|
|
49
49
|
```
|
|
50
50
|
|
|
51
51
|
### Найменування файлів
|
|
52
52
|
|
|
53
53
|
- **SFC:** імена файлів компонентів у **PascalCase** починаючи з букви N(`NMyWidget.vue`).
|
|
54
|
-
- **Інші JS-модулі:** узгоджено **kebab-case** (`date-utils.
|
|
54
|
+
- **Інші JS-модулі:** узгоджено **kebab-case** (`date-utils.mjs`).
|
|
55
55
|
|
|
56
56
|
### Модулі та архітектура
|
|
57
57
|
|
|
@@ -116,9 +116,9 @@ const additionalInstructions = `
|
|
|
116
116
|
|
|
117
117
|
### Тестування
|
|
118
118
|
|
|
119
|
-
- **Unit + Component / DOM:** **Vitest** (`vitest`) + **Vue Test Utils** з **happy-dom** як DOM-середовищем. Це канон, узгоджений з `test.mdc` (Stryker з vitest-runner + `perTest`-аналіз покриття). `vitest.config.
|
|
119
|
+
- **Unit + Component / DOM:** **Vitest** (`vitest`) + **Vue Test Utils** з **happy-dom** як DOM-середовищем. Це канон, узгоджений з `test.mdc` (Stryker з vitest-runner + `perTest`-аналіз покриття). `vitest.config.mjs` повторно використовує `vite.config.js` через `mergeConfig` і перемикає `environment` на `'happy-dom'`:
|
|
120
120
|
|
|
121
|
-
```js title="vitest.config.
|
|
121
|
+
```js title="vitest.config.mjs"
|
|
122
122
|
import { defineConfig, mergeConfig } from 'vitest/config'
|
|
123
123
|
import viteConfig from './vite.config.js'
|
|
124
124
|
|
|
@@ -590,7 +590,7 @@ function getTr() {
|
|
|
590
590
|
Називай store за назвою сторінки або компонента — `customerPageStore`, `routePageStore` тощо. На сторінці звертайся до нього через змінну `pageStore`.
|
|
591
591
|
|
|
592
592
|
```javascript
|
|
593
|
-
// store/customerPage.
|
|
593
|
+
// store/customerPage.mjs
|
|
594
594
|
export const useCustomerPageStore = defineStore('customerPage', {
|
|
595
595
|
state: () => ({
|
|
596
596
|
filterName: '',
|
|
@@ -11,11 +11,10 @@
|
|
|
11
11
|
* решта → pi CLI. Якщо omlx-Tier 1 недоступний, помилка падає в той самий catch
|
|
12
12
|
* і класифікація відкочується на хмарний Tier 2 через pi.
|
|
13
13
|
*/
|
|
14
|
-
import { spawnSync } from 'node:child_process'
|
|
15
14
|
import { join } from 'node:path'
|
|
16
15
|
|
|
17
16
|
import { CLOUD_MIN, resolveModel } from '../../lib/models.mjs'
|
|
18
|
-
import {
|
|
17
|
+
import { callLlm } from '../../lib/llm.mjs'
|
|
19
18
|
import { deriveCacheKey, readCache, writeCache } from './cache.mjs'
|
|
20
19
|
import { buildUserPrompt, SYSTEM_PROMPT } from './prompt.mjs'
|
|
21
20
|
import { parseVerdict } from './verdict-schema.mjs'
|
|
@@ -27,25 +26,14 @@ const FALLBACK_VERDICT = {
|
|
|
27
26
|
}
|
|
28
27
|
|
|
29
28
|
/**
|
|
30
|
-
* Викликає LLM за model-id
|
|
31
|
-
* `omlx/...` → прямий HTTP до omlx (text-only); решта → pi CLI.
|
|
29
|
+
* Викликає LLM через спільний `callLlm` (маршрут за префіксом model-id; wire-trace).
|
|
32
30
|
* @param {string} prompt текст промпта
|
|
33
31
|
* @param {string} model provider/model-id, `omlx/...` або '' для pi-дефолту
|
|
34
32
|
* @returns {string} текст відповіді моделі
|
|
35
|
-
* @throws якщо backend недоступний або повертає помилку
|
|
33
|
+
* @throws {Error} якщо backend недоступний або повертає помилку
|
|
36
34
|
*/
|
|
37
35
|
function callModel(prompt, model) {
|
|
38
|
-
|
|
39
|
-
return callOmlx([{ role: 'user', content: prompt }], model, { timeoutMs: 60_000 })
|
|
40
|
-
}
|
|
41
|
-
const modelArgs = model ? ['--model', model] : []
|
|
42
|
-
const r = spawnSync('pi', ['-p', prompt, ...modelArgs, '--no-session', '--mode', 'text', '--no-tools'], {
|
|
43
|
-
encoding: 'utf8',
|
|
44
|
-
timeout: 60_000
|
|
45
|
-
})
|
|
46
|
-
if (r.error) throw new Error(`pi error: ${r.error.message}`)
|
|
47
|
-
if (r.status !== 0) throw new Error(`pi exit ${r.status}: ${r.stderr?.slice(0, 200) ?? ''}`)
|
|
48
|
-
return r.stdout?.trim() ?? ''
|
|
36
|
+
return callLlm([{ role: 'user', content: prompt }], model, { timeoutMs: 60_000, caller: 'coverage' })
|
|
49
37
|
}
|
|
50
38
|
|
|
51
39
|
/**
|
|
@@ -80,7 +68,7 @@ function classifyOne(group, mutant, cwd, callModelFn) {
|
|
|
80
68
|
* Класифікує survived мутантів (resolveModel('min') → CLOUD_MIN → fallback).
|
|
81
69
|
* @param {Array<{file: string, mutants: object[], exampleTest?: object|null, recommendationText?: string|null}>} survived список вцілілих мутантів
|
|
82
70
|
* @param {string} cwd корінь проєкту
|
|
83
|
-
* @param {{cachePath?: string, callModel?:
|
|
71
|
+
* @param {{cachePath?: string, callModel?: (prompt: string, model: string) => string}} [opts] ін'єкції для тестів
|
|
84
72
|
* @returns {Promise<Array<{key: string, verdict: object}>>} verdicts
|
|
85
73
|
*/
|
|
86
74
|
export function classify(survived, cwd, opts = {}) {
|
|
@@ -22,7 +22,7 @@ export const VerdictSchema = z.object({
|
|
|
22
22
|
* Витягує JSON-об'єкт з raw-text LLM-відповіді і валідує через VerdictSchema.
|
|
23
23
|
* @param {string} rawText raw-text відповідь LLM
|
|
24
24
|
* @returns {{verdict: string, confidence: number, reason: string, suggestedTest?: string}} verdict
|
|
25
|
-
* @throws якщо JSON не знайдено, не парситься, або не відповідає схемі
|
|
25
|
+
* @throws {Error} якщо JSON не знайдено, не парситься, або не відповідає схемі
|
|
26
26
|
*/
|
|
27
27
|
export function parseVerdict(rawText) {
|
|
28
28
|
const jsonStart = rawText.indexOf('{')
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Guard для дефолтної синхронізації `npx
|
|
2
|
+
* Guard для дефолтної синхронізації `npx \@nitra/cursor` (гілка без підкоманди).
|
|
3
3
|
*
|
|
4
4
|
* Дефолтний sync (`runSync` у `bin/n-cursor.js`) скаффолдить у `cwd()` керовані
|
|
5
5
|
* артефакти — `.cursor/rules/`, `.cursor/skills/`, `.claude/`, `AGENTS.md`,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Визначає список id правил для `npx
|
|
2
|
+
* Визначає список id правил для `npx \@nitra/cursor fix` без аргументів:
|
|
3
3
|
* зчитує базові імена `*.mdc` у `.cursor/rules/` і залишає лише ті id,
|
|
4
4
|
* для яких у пакеті є programmatic перевірка (JS-концерн або policy з target.json).
|
|
5
5
|
*/
|
|
@@ -23,35 +23,44 @@ const IGNORED_DIR_NAMES = new Set(['node_modules', '.git', '.next', '.turbo'])
|
|
|
23
23
|
*/
|
|
24
24
|
async function anyDepInTree(root, keys) {
|
|
25
25
|
const wanted = new Set(keys)
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Чи package.json за `abs` оголошує будь-який пакет із `wanted` у `dependencies`.
|
|
28
|
+
* @param {string} abs шлях до package.json
|
|
29
|
+
* @returns {Promise<boolean>} true, якщо знайдено хоч один
|
|
30
|
+
*/
|
|
31
|
+
async function pkgDeclaresWanted(abs) {
|
|
32
|
+
try {
|
|
33
|
+
const deps = JSON.parse(await readFile(abs, 'utf8'))?.dependencies
|
|
34
|
+
if (deps && typeof deps === 'object' && !Array.isArray(deps)) {
|
|
35
|
+
for (const k of wanted) if (Object.hasOwn(deps, k)) return true
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
/* ігноруємо пошкоджені package.json */
|
|
39
|
+
}
|
|
40
|
+
return false
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* @param {string} dir каталог обходу
|
|
44
|
+
* @returns {Promise<boolean>} true, якщо знайдено хоч один пакет
|
|
45
|
+
*/
|
|
28
46
|
async function walk(dir) {
|
|
29
|
-
if (found) return
|
|
30
47
|
let entries
|
|
31
48
|
try {
|
|
32
49
|
entries = await readdir(dir, { withFileTypes: true })
|
|
33
50
|
} catch {
|
|
34
|
-
return
|
|
51
|
+
return false
|
|
35
52
|
}
|
|
36
53
|
for (const entry of entries) {
|
|
37
|
-
if (found) return
|
|
38
54
|
const abs = join(dir, entry.name)
|
|
39
55
|
if (entry.isDirectory()) {
|
|
40
|
-
if (!IGNORED_DIR_NAMES.has(entry.name)
|
|
41
|
-
} else if (entry.isFile() && entry.name === 'package.json') {
|
|
42
|
-
|
|
43
|
-
const deps = JSON.parse(await readFile(abs, 'utf8'))?.dependencies
|
|
44
|
-
if (deps && typeof deps === 'object' && !Array.isArray(deps)) {
|
|
45
|
-
for (const k of wanted) if (Object.hasOwn(deps, k)) found = true
|
|
46
|
-
}
|
|
47
|
-
} catch {
|
|
48
|
-
/* ігноруємо пошкоджені package.json */
|
|
49
|
-
}
|
|
56
|
+
if (!IGNORED_DIR_NAMES.has(entry.name) && (await walk(abs))) return true
|
|
57
|
+
} else if (entry.isFile() && entry.name === 'package.json' && (await pkgDeclaresWanted(abs))) {
|
|
58
|
+
return true
|
|
50
59
|
}
|
|
51
60
|
}
|
|
61
|
+
return false
|
|
52
62
|
}
|
|
53
|
-
|
|
54
|
-
return found
|
|
63
|
+
return walk(root)
|
|
55
64
|
}
|
|
56
65
|
|
|
57
66
|
/**
|
|
@@ -62,7 +71,10 @@ async function anyDepInTree(root, keys) {
|
|
|
62
71
|
async function nestedWithoutVite(root) {
|
|
63
72
|
const rootPkg = join(root, 'package.json')
|
|
64
73
|
let result = false
|
|
65
|
-
/**
|
|
74
|
+
/**
|
|
75
|
+
* @param {string} dir каталог
|
|
76
|
+
* @returns {Promise<void>}
|
|
77
|
+
*/
|
|
66
78
|
async function walk(dir) {
|
|
67
79
|
if (result) return
|
|
68
80
|
let entries
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Standalone CLI runner для одного правила. Викликається з `rules/<id>/fix.mjs`
|
|
3
3
|
* у блоці `if (import.meta.main)` — це робить `bun rules/<id>/fix.mjs` повним
|
|
4
|
-
* еквівалентом старого `npx
|
|
4
|
+
* еквівалентом старого `npx \@nitra/cursor fix <id>`: читає `.n-cursor.json`,
|
|
5
5
|
* перевіряє whitelist, друкує summary, повертає aggregated exit-code.
|
|
6
6
|
*
|
|
7
7
|
* Library-mode виклик з CLI orchestration — інше: див. `runStandardRule` + `fix.mjs::run(ctx)`.
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Локальна логіка в правилах заборонена; розширення поведінки — через `ctx`-опції.
|
|
6
6
|
*
|
|
7
7
|
* Серіалізація: загортає виконання у `withLock('fix-<ruleId>')` — паралельні запуски
|
|
8
|
-
* того самого правила (через `npx
|
|
8
|
+
* того самого правила (через `npx \@nitra/cursor fix`, прямий `bun rules/<id>/fix.mjs`
|
|
9
9
|
* чи `run(ctx)`-композицію) дедупляться за станом git-дерева; різні правила можуть
|
|
10
10
|
* виконуватись паралельно. Точка інтеграції — тут, щоб не дублювати лок у кожному
|
|
11
11
|
* `fix.mjs`.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* PostToolUse hook для Claude Code: точкова маршрутизація `npx
|
|
2
|
+
* PostToolUse hook для Claude Code: точкова маршрутизація `npx \@nitra/cursor fix`
|
|
3
3
|
* за типом зміненого файла. Запускається після кожного `Edit` / `Write` / `MultiEdit`;
|
|
4
4
|
* замінює дорогий синхронний `Stop`-хук, що ганяв повний `fix` усіх правил на кожному
|
|
5
5
|
* turn-і.
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* - stdin Claude Code: JSON із `tool_input.file_path` (відносний шлях зміненого файла);
|
|
9
9
|
* - exit 0, якщо файл не має маршрут (PostToolUse не блокує turn у будь-якому випадку,
|
|
10
10
|
* але ми лишаємо exit-код прозорим — для діагностики);
|
|
11
|
-
* - інакше spawn `npx --no
|
|
11
|
+
* - інакше spawn `npx --no \@nitra/cursor fix <rules…>` із передаванням exit-коду.
|
|
12
12
|
*
|
|
13
13
|
* Маршрути впорядковані від найбільш специфічного до загального; перший збіг — переможець.
|
|
14
14
|
* `docs/adr/**\/*.md` свідомо повертає `[]`: ADR-нормалізація вже покривається async
|
|
@@ -46,7 +46,7 @@ const ROUTES = Object.freeze([
|
|
|
46
46
|
* Повертає список правил, які слід прогнати для зміненого `filePath`.
|
|
47
47
|
* Перший збіг із `ROUTES` — переможець; невідомі шляхи / некоректні входи → `[]`.
|
|
48
48
|
* @param {unknown} filePath відносний шлях зміненого файла зі stdin Claude Code
|
|
49
|
-
* @returns {string[]} ID правил для `npx
|
|
49
|
+
* @returns {string[]} ID правил для `npx \@nitra/cursor fix`
|
|
50
50
|
*/
|
|
51
51
|
export function routeFilePathToRules(filePath) {
|
|
52
52
|
if (typeof filePath !== 'string' || filePath === '') {
|
package/scripts/skills-cli.mjs
CHANGED
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
* `.n-cursor.json`) — далі stdout або делегування в `cursor-agent` / `claude`.
|
|
7
7
|
*
|
|
8
8
|
* Підтримувані формати:
|
|
9
|
-
* `npx
|
|
10
|
-
* `npx
|
|
11
|
-
* `npx
|
|
12
|
-
* `npx
|
|
13
|
-
* `npx
|
|
9
|
+
* `npx \@nitra/cursor skill list`
|
|
10
|
+
* `npx \@nitra/cursor skill taze`
|
|
11
|
+
* `npx \@nitra/cursor skill cursor taze`
|
|
12
|
+
* `npx \@nitra/cursor skill cursor taze "онови залежності"`
|
|
13
|
+
* `npx \@nitra/cursor skill claude taze` — те саме через Claude Code CLI
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { spawnSync } from 'node:child_process'
|