@nitra/cursor 1.35.5 → 1.37.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 +16 -0
- package/README.md +1 -1
- package/bin/n-cursor.js +9 -2
- package/package.json +1 -1
- package/rules/graphql/js/tooling.mjs +3 -1
- package/rules/npm-module/js/package_structure.mjs +4 -1
- package/rules/npm-module/js/skill_meta.mjs +61 -0
- package/rules/release/change.mjs +3 -1
- package/rules/release/lib/change-file.mjs +3 -1
- package/rules/release/release.mjs +6 -3
- package/rules/test/js/data/stryker_config/stryker-vue-macros-ignorer.mjs +2 -1
- package/rules/test/js/stryker_config.mjs +6 -8
- package/rules/test/test.mdc +46 -1
- package/rules/vue/js/packages.mjs +1 -9
- package/schemas/skill-meta.json +22 -0
- package/schemas/v8r-catalog.json +5 -0
- package/scripts/auto-skills.mjs +14 -37
- package/scripts/lib/skill-meta.mjs +54 -0
- package/scripts/lib/worktree-notice.mjs +55 -0
- package/scripts/utils/lock-cache-dir.mjs +37 -0
- package/scripts/utils/with-lock.mjs +2 -1
- package/skills/adr-normalize/meta.json +1 -0
- package/skills/coverage-fix/meta.json +1 -0
- package/skills/fix/meta.json +1 -0
- package/skills/fix-tests/meta.json +1 -0
- package/skills/lint/meta.json +1 -0
- package/skills/llm-patch/meta.json +1 -0
- package/skills/publish-telegram/meta.json +1 -0
- package/skills/start-check/meta.json +1 -0
- package/skills/taze/meta.json +1 -0
- package/skills/adr-normalize/auto.md +0 -1
- package/skills/coverage-fix/auto.md +0 -1
- package/skills/fix/auto.md +0 -1
- package/skills/fix-tests/auto.md +0 -1
- package/skills/lint/auto.md +0 -1
- package/skills/llm-patch/auto.md +0 -1
- package/skills/publish-telegram/auto.md +0 -1
- package/skills/start-check/auto.md +0 -1
- package/skills/taze/auto.md +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.37.0] - 2026-05-31
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- skills: meta.json замість auto.md (+ worktree-прапорець з вшиванням у SKILL.md і забороною паралелі)
|
|
8
|
+
|
|
9
|
+
## [1.36.0] - 2026-05-31
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- test rule v2.7: канони Console mocking (vi.spyOn), Sandbox-aware тестів (withTmpDir+git init як default, skipIf STRYKER_MUTATOR_WORKER як виняток для smoke-аудиту) і явний {cwd:dir} у child_process
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- withLock: стан локу спільний для всіх git-worktree через resolveLockCacheDir (git-common-dir) — серіалізація важких команд між worktree, не лише в одному checkout
|
|
18
|
+
|
|
3
19
|
## [1.35.5] - 2026-05-30
|
|
4
20
|
|
|
5
21
|
### Changed
|
package/README.md
CHANGED
|
@@ -114,7 +114,7 @@ npm/
|
|
|
114
114
|
```
|
|
115
115
|
npm/rules/<id>/
|
|
116
116
|
├── <id>.mdc # текст правила (після синку — .cursor/rules/n-<id>.mdc)
|
|
117
|
-
├──
|
|
117
|
+
├── meta.json # метадані скілу: auto (автоактивація) + worktree
|
|
118
118
|
├── js/ # JS для `npx @nitra/cursor fix`
|
|
119
119
|
│ └── <concern>/
|
|
120
120
|
│ ├── check.mjs # діагностика — повертає список violations
|
package/bin/n-cursor.js
CHANGED
|
@@ -88,6 +88,8 @@ import {
|
|
|
88
88
|
RULE_MIGRATIONS
|
|
89
89
|
} from '../scripts/auto-rules.mjs'
|
|
90
90
|
import { detectAutoSkills } from '../scripts/auto-skills.mjs'
|
|
91
|
+
import { readSkillMetaRaw } from '../scripts/lib/skill-meta.mjs'
|
|
92
|
+
import { injectWorktreeNotice } from '../scripts/lib/worktree-notice.mjs'
|
|
91
93
|
import { runPostToolUseFixCli } from '../scripts/post-tool-use-fix.mjs'
|
|
92
94
|
import { discoverCheckRulesFromCursorRules } from '../scripts/lib/discover-check-rules-from-cursor.mjs'
|
|
93
95
|
import { listRuleIds } from '../scripts/lib/list-rule-ids.mjs'
|
|
@@ -762,10 +764,15 @@ async function syncSkills(configSkills, bundledSkillsDir = BUNDLED_SKILLS_DIR) {
|
|
|
762
764
|
process.stdout.write(` ⬇ ${id} → ${SKILLS_DIR}/${destDirName} ... `)
|
|
763
765
|
try {
|
|
764
766
|
await mkdir(destDir, { recursive: true })
|
|
767
|
+
const meta = readSkillMetaRaw(srcDir)
|
|
768
|
+
const worktree = meta?.worktree === true
|
|
765
769
|
const files = await readdir(srcDir)
|
|
766
770
|
for (const file of files) {
|
|
767
|
-
if (file === '
|
|
768
|
-
|
|
771
|
+
if (file === 'meta.json') continue
|
|
772
|
+
let content = await readFile(join(srcDir, file), 'utf8')
|
|
773
|
+
if (file === 'SKILL.md') {
|
|
774
|
+
content = injectWorktreeNotice(content, worktree)
|
|
775
|
+
}
|
|
769
776
|
await writeFile(join(destDir, file), content, 'utf8')
|
|
770
777
|
}
|
|
771
778
|
console.log(`✅`)
|
package/package.json
CHANGED
|
@@ -82,7 +82,9 @@ function checkExtensionsRecommendation(pass, fail, cwd) {
|
|
|
82
82
|
const pathRel = '.vscode/extensions.json'
|
|
83
83
|
const pathAbs = join(cwd, pathRel)
|
|
84
84
|
if (!existsSync(pathAbs)) {
|
|
85
|
-
fail(
|
|
85
|
+
fail(
|
|
86
|
+
`${pathRel} не існує — створи файл і додай у recommendations ${REQUIRED_GRAPHQL_VSCODE_EXTENSION} (graphql.mdc)`
|
|
87
|
+
)
|
|
86
88
|
return
|
|
87
89
|
}
|
|
88
90
|
const violations = runConftestBatch({
|
|
@@ -422,7 +422,10 @@ async function collectPublishedFiles(filesField, cwd) {
|
|
|
422
422
|
}
|
|
423
423
|
if (!s.isDirectory()) continue
|
|
424
424
|
await walkDir(fullPath, p => {
|
|
425
|
-
const rel = p
|
|
425
|
+
const rel = p
|
|
426
|
+
.slice(npmRoot.length + 1)
|
|
427
|
+
.split(sep)
|
|
428
|
+
.join('/')
|
|
426
429
|
collected.add(rel)
|
|
427
430
|
})
|
|
428
431
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Перевірка метаданих скілів пакета `@nitra/cursor` (концерн правила npm-module).
|
|
3
|
+
*
|
|
4
|
+
* Кожен `npm/skills/<id>/` має містити валідний `meta.json`:
|
|
5
|
+
* - `worktree` присутнє і boolean;
|
|
6
|
+
* - `auto` (якщо присутнє) — розпізнане (`"завжди"` або непорожній масив рядків);
|
|
7
|
+
* - залишковий `auto.md` заборонено (міграція на meta.json завершена).
|
|
8
|
+
*
|
|
9
|
+
* Концерн застосовний лише в репо самого пакета (де є `npm/skills/`); у споживача
|
|
10
|
+
* каталогу `npm/skills/` нема, тож перевірка мовчки проходить.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
|
|
15
|
+
import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
|
|
16
|
+
import { parseSkillAutoSpec, readSkillMetaRaw } from '../../../scripts/lib/skill-meta.mjs'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Валідує всі `npm/skills/<id>/meta.json`.
|
|
20
|
+
* @param {string} [cwd] корінь репозиторію
|
|
21
|
+
* @returns {Promise<number>} 0 — OK, 1 — порушення
|
|
22
|
+
*/
|
|
23
|
+
export function check(cwd = process.cwd()) {
|
|
24
|
+
const reporter = createCheckReporter()
|
|
25
|
+
const skillsDir = join(cwd, 'npm', 'skills')
|
|
26
|
+
if (!existsSync(skillsDir)) {
|
|
27
|
+
reporter.pass('npm/skills/ відсутній — немає скілів для валідації')
|
|
28
|
+
return Promise.resolve(reporter.getExitCode())
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
for (const entry of readdirSync(skillsDir, { withFileTypes: true })) {
|
|
32
|
+
if (!entry.isDirectory() || entry.name.startsWith('.')) continue
|
|
33
|
+
const id = entry.name
|
|
34
|
+
const skillDir = join(skillsDir, id)
|
|
35
|
+
let skillOk = true
|
|
36
|
+
|
|
37
|
+
if (existsSync(join(skillDir, 'auto.md'))) {
|
|
38
|
+
reporter.fail(`skills/${id}: залишковий auto.md — видали (метадані тепер у meta.json)`)
|
|
39
|
+
skillOk = false
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const raw = readSkillMetaRaw(skillDir)
|
|
43
|
+
if (!raw) {
|
|
44
|
+
reporter.fail(`skills/${id}: відсутній або невалідний meta.json (очікується {"auto"?, "worktree": bool})`)
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
if (typeof raw.worktree !== 'boolean') {
|
|
48
|
+
reporter.fail(`skills/${id}: meta.json.worktree має бути boolean`)
|
|
49
|
+
skillOk = false
|
|
50
|
+
}
|
|
51
|
+
if (raw.auto !== undefined && parseSkillAutoSpec(raw.auto) === null) {
|
|
52
|
+
reporter.fail(`skills/${id}: meta.json.auto нерозпізнане — очікується "завжди" або непорожній масив правил`)
|
|
53
|
+
skillOk = false
|
|
54
|
+
}
|
|
55
|
+
if (skillOk) {
|
|
56
|
+
reporter.pass(`skills/${id}: meta.json валідний`)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return Promise.resolve(reporter.getExitCode())
|
|
61
|
+
}
|
package/rules/release/change.mjs
CHANGED
|
@@ -43,7 +43,9 @@ export async function runChangeCli(args) {
|
|
|
43
43
|
const message = get('--message')
|
|
44
44
|
const ws = get('--ws') ?? '.'
|
|
45
45
|
if (!bump || !section || !message) {
|
|
46
|
-
console.error(
|
|
46
|
+
console.error(
|
|
47
|
+
'❌ Використання: n-cursor change --bump <major|minor|patch> --section <Added|Changed|Fixed|Removed> --message "<опис>" [--ws <шлях>]'
|
|
48
|
+
)
|
|
47
49
|
return 1
|
|
48
50
|
}
|
|
49
51
|
try {
|
|
@@ -45,7 +45,9 @@ export function parseChangeFile(text) {
|
|
|
45
45
|
throw new Error(`change-файл: bump має бути одним із ${VALID_BUMPS.join('|')} (отримано «${fm.bump ?? ''}»)`)
|
|
46
46
|
}
|
|
47
47
|
if (!VALID_SECTIONS.includes(fm.section)) {
|
|
48
|
-
throw new Error(
|
|
48
|
+
throw new Error(
|
|
49
|
+
`change-файл: section має бути одним із ${VALID_SECTIONS.join('|')} (отримано «${fm.section ?? ''}»)`
|
|
50
|
+
)
|
|
49
51
|
}
|
|
50
52
|
if (!description) throw new Error('change-файл: порожній опис')
|
|
51
53
|
return { bump: fm.bump, section: fm.section, description }
|
|
@@ -28,7 +28,9 @@ async function writeManifestVersion(cwd, manifest, newVersion) {
|
|
|
28
28
|
const re = manifest.kind === 'npm' ? SEMVER_LINE_RE : PY_VERSION_LINE_RE
|
|
29
29
|
const replaced = text.replace(re, `$1${newVersion}$2`)
|
|
30
30
|
if (replaced === text) {
|
|
31
|
-
throw new Error(
|
|
31
|
+
throw new Error(
|
|
32
|
+
`release: не вдалося оновити version у ${manifest.ws}/${manifest.manifestRel} — патерн version не знайдено`
|
|
33
|
+
)
|
|
32
34
|
}
|
|
33
35
|
await writeFile(path, replaced)
|
|
34
36
|
}
|
|
@@ -117,11 +119,12 @@ export async function release(opts = {}) {
|
|
|
117
119
|
|
|
118
120
|
/**
|
|
119
121
|
* @param {string[]} _args аргументи CLI (наразі без опцій)
|
|
122
|
+
* @param {import('./release.mjs').ReleaseOpts} [opts] опції для тестів (cwd, date, runGit)
|
|
120
123
|
* @returns {Promise<number>} exit-код
|
|
121
124
|
*/
|
|
122
|
-
export async function runReleaseCli(_args) {
|
|
125
|
+
export async function runReleaseCli(_args, opts = {}) {
|
|
123
126
|
try {
|
|
124
|
-
const released = await release()
|
|
127
|
+
const released = await release(opts)
|
|
125
128
|
if (released.length === 0) {
|
|
126
129
|
console.log('release: немає змін для релізу')
|
|
127
130
|
} else {
|
|
@@ -24,7 +24,8 @@ const VUE_SETUP_MACROS = new Set([
|
|
|
24
24
|
'defineOptions'
|
|
25
25
|
])
|
|
26
26
|
|
|
27
|
-
const IGNORE_MESSAGE =
|
|
27
|
+
const IGNORE_MESSAGE =
|
|
28
|
+
'Vue <script setup> macro call cannot be mutated (defineProps/defineEmits/etc. must be statically analyzable for @vue/compiler-sfc).'
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* @param {{isCallExpression: () => boolean, node: {callee: {type: string, name?: string}}}} path babel NodePath, переданий Stryker-instrumenter
|
|
@@ -111,13 +111,7 @@ export async function check(cwd = process.cwd()) {
|
|
|
111
111
|
for (const jsRoot of jsRoots) {
|
|
112
112
|
const isVueRoot = await hasVueFiles(jsRoot)
|
|
113
113
|
const strykerBaseline = isVueRoot ? STRYKER_VUE_BASELINE_PATH : STRYKER_BASELINE_PATH
|
|
114
|
-
await ensureBaselineFile(
|
|
115
|
-
reporter,
|
|
116
|
-
cwd,
|
|
117
|
-
strykerBaseline,
|
|
118
|
-
join(jsRoot, 'stryker.config.mjs'),
|
|
119
|
-
'stryker.config.mjs'
|
|
120
|
-
)
|
|
114
|
+
await ensureBaselineFile(reporter, cwd, strykerBaseline, join(jsRoot, 'stryker.config.mjs'), 'stryker.config.mjs')
|
|
121
115
|
if (isVueRoot) {
|
|
122
116
|
await ensureBaselineFile(
|
|
123
117
|
reporter,
|
|
@@ -133,7 +127,11 @@ export async function check(cwd = process.cwd()) {
|
|
|
133
127
|
// Гарантуємо що тест-артефакти (Stryker output, lcov HTML-звіт) ніколи не
|
|
134
128
|
// потрапляють у commit. Patterns покривають усі workspaces через `**/`-префікс
|
|
135
129
|
// (єдиний root .gitignore).
|
|
136
|
-
const { added } = await ensureGitignoreEntries(
|
|
130
|
+
const { added } = await ensureGitignoreEntries(
|
|
131
|
+
cwd,
|
|
132
|
+
TEST_GITIGNORE_ENTRIES,
|
|
133
|
+
'Test artifacts: Stryker + coverage (test.mdc)'
|
|
134
|
+
)
|
|
137
135
|
if (added.length > 0) {
|
|
138
136
|
reporter.pass(`.gitignore: додано тест-патерни (${added.join(', ')}) (test.mdc)`)
|
|
139
137
|
}
|
package/rules/test/test.mdc
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: JS-тести (*.test.mjs) живуть у tests/. Правило `test` керує stryker.config.mjs + vitest.config.js (якщо js-lint enabled) і .cargo/mutants.toml (якщо rust enabled).
|
|
3
|
-
version: '2.
|
|
3
|
+
version: '2.7'
|
|
4
4
|
globs: "**/{.n-cursor.json,package.json,Cargo.toml,stryker.config.mjs,vitest.config.js,.cargo/mutants.toml},**/*.test.mjs"
|
|
5
5
|
alwaysApply: false
|
|
6
6
|
---
|
|
@@ -81,6 +81,51 @@ Recursive globs ловлять файли всередині `tests/` так с
|
|
|
81
81
|
|
|
82
82
|
Canonical `vitest.config.js` (для довідки — `pool: 'forks'` + `include` + `coverage`) — у `rules/test/js/data/vitest_config/vitest.config.baseline.js` (концерн `stryker_config` копіює його у кожен JS-root).
|
|
83
83
|
|
|
84
|
+
## Console mocking у тестах
|
|
85
|
+
|
|
86
|
+
`console.log` / `console.error` / `console.warn` — **process-wide** мутації, рівно як `process.cwd()`. Якщо тест перехоплює їх через `const orig = console.log; console.log = (...) => …; try { … } finally { console.log = orig }`, паралельний test файл у `pool: 'forks'` (різний процес) ізольований, але в межах **одного** процесу всі тести у файлі ділять єдиний `console`-об'єкт. Якщо try/finally не виконається (наприклад, асинхронний помилка повз `await`), restore не спрацює і наступні тести втрачають вивід.
|
|
87
|
+
|
|
88
|
+
Тому:
|
|
89
|
+
|
|
90
|
+
- **Канон**: `vi.spyOn(console, 'log').mockReturnValue()` (та аналоги для `error`/`warn`/`info`) + `afterEach(() => vi.restoreAllMocks())`. Vitest сам слідкує за scope mock-у і гарантовано відновлює оригінал між тестами, навіть якщо тест кинув виняток.
|
|
91
|
+
- **Заборонено**: ручний store/restore (`const orig = console.log; console.log = stub`). Виняток — коли тест **необхідно** перехопити вивід **до** того, як завантажиться модуль із top-level-логуванням; у цьому випадку фіксуй pattern explicit-коментарем.
|
|
92
|
+
- Якщо потрібен лог зі stub-ом — `const logs = []; vi.spyOn(console, 'log').mockImplementation((...args) => logs.push(args.join(' ')))`.
|
|
93
|
+
|
|
94
|
+
Перевірка — концерн `no-console-store-restore` (`rules/test/js/no-console-store-restore.mjs`): AST-сканер, який ловить присвоєння `console.<method> = …` у `*.test.{js,mjs}`.
|
|
95
|
+
|
|
96
|
+
## Sandbox-aware тести (Stryker)
|
|
97
|
+
|
|
98
|
+
Stryker за замовчуванням копіює репо у sandbox-каталог (`reports/stryker/.tmp/sandbox-XXX/`), щоб AST-патчити мутантів без зачеплення робочого дерева. У sandbox **немає `.git/`**, і будь-який тест, що звертається до **реального** git-дерева через `import.meta.url + N≥3 рівнів вгору` (типу `const REPO_ROOT = join(import.meta.dirname, '..', '..', '..', '..', '..')`) фактично адресує sandbox-корінь, а не справжній репо. Це ламає `git rev-parse`/`git ls-files`/перевірки `.n-cursor.json`/CHANGELOG-співставлення — Stryker dry-run падає, мутаційний прогон не стартує, `mutation.json` лишається stale.
|
|
99
|
+
|
|
100
|
+
**Канон**: тест не повинен залежати від реального git-дерева через `import.meta.url + N≥3 рівнів вгору`. Якщо тест перевіряє git-логіку — ізолюй **повністю**:
|
|
101
|
+
|
|
102
|
+
```js
|
|
103
|
+
await withTmpDir(async dir => {
|
|
104
|
+
execFileSync('git', ['init', '-q', '--initial-branch=main'], { cwd: dir })
|
|
105
|
+
await writeFile(join(dir, 'a.sh'), '#!/bin/sh\n', 'utf8')
|
|
106
|
+
execFileSync('git', ['add', '-A'], { cwd: dir })
|
|
107
|
+
expect(listShellScriptPaths(dir)).toEqual(['a.sh'])
|
|
108
|
+
})
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Приклад у репо: `rules/text/lint/tests/run-shellcheck.test.mjs` — тест `listShellScriptPaths всередині git-репо`.
|
|
112
|
+
|
|
113
|
+
**Виняток** — top-level smoke-аудит тести, що **за визначенням** перевіряють інваріанти **живого** репо (структура файлів, узгодженість CHANGELOG з `package.json`, кожне правило має fixture). Приклади: `tests/integration-repo-checks.test.mjs`, `tests/check-rule-fixtures.test.mjs`. Такі тести **не дають додаткового coverage у unit-сенсі** — концерни, які вони перевіряють, уже покриті per-rule unit-тестами (`rules/<rule>/js/tests/`). Захищаємо їх `test.skipIf(env.STRYKER_MUTATOR_WORKER)` від запуску всередині Stryker-sandbox:
|
|
114
|
+
|
|
115
|
+
```js
|
|
116
|
+
import { env } from 'node:process'
|
|
117
|
+
|
|
118
|
+
test.skipIf(env.STRYKER_MUTATOR_WORKER)('узгоджені з поточним деревом', async () => {
|
|
119
|
+
// …перевірки на REPO_ROOT
|
|
120
|
+
})
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Перевірка — концерн `sandbox-aware-test` (`rules/test/js/sandbox-aware-test.mjs`): сканер `*.test.{js,mjs}`, який знаходить тести з `import.meta.url`-deep-relative навігацією (4+ `..`-рівнів) і вимагає або `withTmpDir` у їхніх `test`-блоках, або `test.skipIf(env.STRYKER_MUTATOR_WORKER)`.
|
|
124
|
+
|
|
125
|
+
## `child_process` у тестах: явний `cwd`
|
|
126
|
+
|
|
127
|
+
Уже зазначено в `## Заборона process.chdir у тестах`. Підсилення: **кожен** виклик `execFile`/`execFileSync`/`spawn`/`spawnSync` у `*.test.{js,mjs}` приймає або **явний `{ cwd: dir }`** (де `dir` — змінна, не `process.cwd()`), або працює з **абсолютними шляхами** до бінарників/fixture-файлів (наприклад `spawnSync('node', [absoluteFixturePath])`). Це гарантує, що мутаційний test-flow у `pool: 'forks'` не страждає від implicit-`process.cwd()`-stride між тестами в одному воркері.
|
|
128
|
+
|
|
84
129
|
## Покриття + мутаційне тестування
|
|
85
130
|
|
|
86
131
|
Канонічна команда — `n-cursor coverage`: збирає метрики покриття (`vitest run --coverage`, `cargo llvm-cov` тощо) і мутаційного тестування (Stryker з vitest-runner + `coverageAnalysis: 'perTest'`, `cargo-mutants`) з усіх активних провайдерів у `.n-cursor.json#rules` і пише `COVERAGE.md` у корінь проєкту. Лок і дедуп — `withLock('coverage', ...)`.
|
|
@@ -422,15 +422,7 @@ async function checkVuePackage(rootDir, ignorePaths, fail, passFn, cwd) {
|
|
|
422
422
|
await checkViteClientEnvAndEditorConfig(rootDir, prefix, passFn, fail, cwd)
|
|
423
423
|
|
|
424
424
|
const { hasVueAutoImport } = await checkViteConfig(rootDir, prefix, passFn, fail, cwd)
|
|
425
|
-
await checkVueImportViolations(
|
|
426
|
-
rootDir,
|
|
427
|
-
join(cwd, rootDir),
|
|
428
|
-
ignorePaths,
|
|
429
|
-
hasVueAutoImport,
|
|
430
|
-
prefix,
|
|
431
|
-
passFn,
|
|
432
|
-
fail
|
|
433
|
-
)
|
|
425
|
+
await checkVueImportViolations(rootDir, join(cwd, rootDir), ignorePaths, hasVueAutoImport, prefix, passFn, fail)
|
|
434
426
|
await checkVueNodeImportViolations(rootDir, join(cwd, rootDir), ignorePaths, prefix, passFn, fail)
|
|
435
427
|
await checkEsbuildMentions(rootDir, join(cwd, rootDir), ignorePaths, prefix, passFn, fail)
|
|
436
428
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "https://unpkg.com/@nitra/cursor/schemas/skill-meta.json",
|
|
4
|
+
"title": "n-cursor skill meta",
|
|
5
|
+
"description": "Метадані скіла @nitra/cursor: умова автоактивації (auto) і чи виконувати в окремому git-worktree (worktree). Файл npm/skills/<id>/meta.json.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["worktree"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"auto": {
|
|
11
|
+
"description": "Умова автоактивації: \"завжди\" або непорожній масив id правил, від яких залежить скіл.",
|
|
12
|
+
"oneOf": [
|
|
13
|
+
{ "const": "завжди" },
|
|
14
|
+
{ "type": "array", "items": { "type": "string", "minLength": 1 }, "minItems": 1 }
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
"worktree": {
|
|
18
|
+
"type": "boolean",
|
|
19
|
+
"description": "true — виконувати скіл в окремому git-worktree, один інстанс за раз (без паралельного запуску); false — у worktree не виконується."
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
package/schemas/v8r-catalog.json
CHANGED
|
@@ -25,6 +25,11 @@
|
|
|
25
25
|
"description": "TypeScript config для pi extension templates, які Schema Store не матчить через вкладений/прихований шлях",
|
|
26
26
|
"url": "https://json.schemastore.org/tsconfig.json",
|
|
27
27
|
"fileMatch": [".pi/extensions/*/tsconfig.json", "npm/.pi-template/extensions/*/tsconfig.json"]
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"name": "n-cursor skill meta",
|
|
31
|
+
"fileMatch": ["npm/skills/*/meta.json"],
|
|
32
|
+
"url": "https://unpkg.com/@nitra/cursor/schemas/skill-meta.json"
|
|
28
33
|
}
|
|
29
34
|
]
|
|
30
35
|
}
|
package/scripts/auto-skills.mjs
CHANGED
|
@@ -1,56 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Автовизначення skills для `.n-cursor.json` за умовами
|
|
2
|
+
* Автовизначення skills для `.n-cursor.json` за умовами з `npm/skills/<skill>/meta.json`.
|
|
3
3
|
*
|
|
4
|
-
* `
|
|
4
|
+
* `meta.json` — джерело правди (а не hardcoded мапа). Підтримуються три варіанти:
|
|
5
5
|
*
|
|
6
|
-
* -
|
|
6
|
+
* - `auto: "завжди"` — скіл активується незалежно від інших правил
|
|
7
7
|
* (приклади: `fix`, `lint`, `llm-patch`, `publish-telegram`).
|
|
8
|
-
* - `[rule,
|
|
9
|
-
* auto-rules (приклади: `adr-normalize - [adr]`, `taze - [bun]`).
|
|
10
|
-
* -
|
|
8
|
+
* - `auto: ["rule", …]` — скіл активується, якщо ВСІ перелічені правила вже виявлені
|
|
9
|
+
* auto-rules (приклади: `adr-normalize - ["adr"]`, `taze - ["bun"]`).
|
|
10
|
+
* - поле `auto` відсутнє або формат не розпізнано — скіл opt-in лише через `.n-cursor.json:skills`.
|
|
11
11
|
*
|
|
12
12
|
* Сканування `npm/skills/` — sync під час завантаження модуля (детермінізм + sync API
|
|
13
13
|
* `auto-rules.mjs`-сусіда). Кеш на час процесу.
|
|
14
14
|
*/
|
|
15
|
-
import { existsSync, readdirSync
|
|
15
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
16
16
|
import { dirname, join } from 'node:path'
|
|
17
17
|
import { fileURLToPath } from 'node:url'
|
|
18
18
|
|
|
19
|
+
import { parseSkillAutoSpec, readSkillMetaRaw } from './lib/skill-meta.mjs'
|
|
20
|
+
|
|
19
21
|
const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
20
22
|
const SKILLS_DIR = join(PACKAGE_ROOT, 'skills')
|
|
21
23
|
|
|
22
|
-
const ALWAYS_LITERAL = 'завжди'
|
|
23
|
-
const BRACKET_LIST_RE = /^\[([^\]]+)\]$/u
|
|
24
|
-
|
|
25
24
|
/**
|
|
26
25
|
* @typedef {{ always: true } | { rules: readonly string[] }} SkillAutoSpec
|
|
27
26
|
*/
|
|
28
27
|
|
|
29
28
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* @returns {SkillAutoSpec | null} `null` — формат не розпізнано (= opt-in)
|
|
33
|
-
*/
|
|
34
|
-
function parseSkillAutoSpec(text) {
|
|
35
|
-
const trimmed = text.trim()
|
|
36
|
-
if (trimmed === ALWAYS_LITERAL) {
|
|
37
|
-
return { always: true }
|
|
38
|
-
}
|
|
39
|
-
const m = trimmed.match(BRACKET_LIST_RE)
|
|
40
|
-
if (m) {
|
|
41
|
-
const rules = m[1]
|
|
42
|
-
.split(',')
|
|
43
|
-
.map(s => s.trim())
|
|
44
|
-
.filter(s => s.length > 0)
|
|
45
|
-
if (rules.length === 0) return null
|
|
46
|
-
return { rules: Object.freeze(rules) }
|
|
47
|
-
}
|
|
48
|
-
return null
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Сканує `npm/skills/<id>/auto.md`. Скіли без `auto.md` або з нерозпізнаним
|
|
53
|
-
* вмістом не потрапляють у результат — їх можна вмикати лише вручну в конфізі.
|
|
29
|
+
* Сканує `npm/skills/<id>/meta.json`. Скіли без `meta.json` або без розпізнаного
|
|
30
|
+
* `auto` не потрапляють у результат — їх вмикають лише вручну в конфізі.
|
|
54
31
|
* @param {string} [skillsDir] override для тестів
|
|
55
32
|
* @returns {Record<string, SkillAutoSpec>} мапа `skillId → spec`
|
|
56
33
|
*/
|
|
@@ -60,9 +37,9 @@ export function discoverSkillAutoActivation(skillsDir = SKILLS_DIR) {
|
|
|
60
37
|
const out = {}
|
|
61
38
|
for (const entry of readdirSync(skillsDir, { withFileTypes: true })) {
|
|
62
39
|
if (!entry.isDirectory() || entry.name.startsWith('.')) continue
|
|
63
|
-
const
|
|
64
|
-
if (!
|
|
65
|
-
const spec = parseSkillAutoSpec(
|
|
40
|
+
const raw = readSkillMetaRaw(join(skillsDir, entry.name))
|
|
41
|
+
if (!raw) continue
|
|
42
|
+
const spec = parseSkillAutoSpec(raw.auto)
|
|
66
43
|
if (spec) out[entry.name] = spec
|
|
67
44
|
}
|
|
68
45
|
return out
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Спільний парсер метаданих скіла з `npm/skills/<id>/meta.json`.
|
|
3
|
+
*
|
|
4
|
+
* `meta.json` — єдине джерело правди для скіла замість колишнього `auto.md`:
|
|
5
|
+
* - `auto` — умова автоактивації (`"завжди"` | масив id правил), опційне;
|
|
6
|
+
* - `worktree` — boolean: чи виконувати скіл в окремому git-worktree (один інстанс).
|
|
7
|
+
*
|
|
8
|
+
* Цим хелпером користуються `auto-skills.mjs` (автоактивація), `n-cursor.js`
|
|
9
|
+
* (sync + вшивання worktree-блоку) і check-концерн `npm-module/js/skill_meta.mjs`,
|
|
10
|
+
* щоб не дублювати парсинг і форму валідації.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
|
|
15
|
+
/** Літерал безумовної автоактивації (українською, як у `auto-skills.mjs`). */
|
|
16
|
+
export const SKILL_ALWAYS = 'завжди'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {{ always: true } | { rules: string[] }} SkillAutoSpec
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Перетворює значення поля `auto` з `meta.json` у `SkillAutoSpec`.
|
|
24
|
+
* @param {unknown} value значення `meta.json.auto`
|
|
25
|
+
* @returns {SkillAutoSpec | null} `null` — формат не розпізнано (= opt-in)
|
|
26
|
+
*/
|
|
27
|
+
export function parseSkillAutoSpec(value) {
|
|
28
|
+
if (value === SKILL_ALWAYS) {
|
|
29
|
+
return { always: true }
|
|
30
|
+
}
|
|
31
|
+
if (Array.isArray(value)) {
|
|
32
|
+
const rules = value.map(s => String(s).trim()).filter(s => s.length > 0)
|
|
33
|
+
if (rules.length === 0) return null
|
|
34
|
+
return { rules }
|
|
35
|
+
}
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Читає й парсить `meta.json` одного скіла.
|
|
41
|
+
* @param {string} skillDir абсолютний шлях до каталогу скіла
|
|
42
|
+
* @returns {Record<string, unknown> | null} розпарсений обʼєкт або `null` (немає файлу / невалідний JSON / не-обʼєкт)
|
|
43
|
+
*/
|
|
44
|
+
export function readSkillMetaRaw(skillDir) {
|
|
45
|
+
const metaPath = join(skillDir, 'meta.json')
|
|
46
|
+
if (!existsSync(metaPath)) return null
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(readFileSync(metaPath, 'utf8'))
|
|
49
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null
|
|
50
|
+
return /** @type {Record<string, unknown>} */ (parsed)
|
|
51
|
+
} catch {
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Вшивання worktree-інструкції у синкнутий `SKILL.md` (рішення D2 зі spec).
|
|
3
|
+
*
|
|
4
|
+
* Коли `meta.json.worktree === true`, скіл має виконуватись в окремому git-worktree
|
|
5
|
+
* і не паралелитись. Підказка адресована агенту, який читає `SKILL.md`, тож
|
|
6
|
+
* вставляється в текст між стабільними маркерами — ре-синк ідемпотентний:
|
|
7
|
+
* наявний блок замінюється, при `worktree:false` — видаляється.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Маркер початку worktree-блоку (стабільний, не залежить від тексту всередині). */
|
|
11
|
+
export const WORKTREE_START = '<!-- n-cursor:worktree:start -->'
|
|
12
|
+
/** Маркер кінця worktree-блоку. */
|
|
13
|
+
export const WORKTREE_END = '<!-- n-cursor:worktree:end -->'
|
|
14
|
+
|
|
15
|
+
const NOTICE_BODY =
|
|
16
|
+
'> **Worktree:** виконуй цей скіл в окремому git-worktree (`git worktree add`); ' +
|
|
17
|
+
'**не** запускай паралельно — один інстанс за раз.'
|
|
18
|
+
|
|
19
|
+
/** Наявний блок разом із сусідніми порожніми рядками (для чистого видалення). */
|
|
20
|
+
const BLOCK_RE = /\n*<!-- n-cursor:worktree:start -->[\s\S]*?<!-- n-cursor:worktree:end -->\n*/u
|
|
21
|
+
|
|
22
|
+
/** Закриття YAML-frontmatter на початку файла. */
|
|
23
|
+
const FRONTMATTER_RE = /^(---\n[\s\S]*?\n---\n)/u
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Канонічний блок worktree-інструкції.
|
|
27
|
+
* @returns {string} текст блоку від START до END
|
|
28
|
+
*/
|
|
29
|
+
function buildBlock() {
|
|
30
|
+
return `${WORKTREE_START}\n${NOTICE_BODY}\n${WORKTREE_END}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Вставляє / оновлює / видаляє worktree-блок у вмісті `SKILL.md`.
|
|
35
|
+
* @param {string} content вміст `SKILL.md`
|
|
36
|
+
* @param {boolean} enabled чи має бути блок (значення `meta.json.worktree`)
|
|
37
|
+
* @returns {string} оновлений вміст (ідемпотентно)
|
|
38
|
+
*/
|
|
39
|
+
export function injectWorktreeNotice(content, enabled) {
|
|
40
|
+
const hadBlock = content.includes(WORKTREE_START)
|
|
41
|
+
const withoutBlock = content.replace(BLOCK_RE, '\n\n')
|
|
42
|
+
|
|
43
|
+
if (!enabled) {
|
|
44
|
+
return hadBlock ? withoutBlock : content
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const block = buildBlock()
|
|
48
|
+
const fm = withoutBlock.match(FRONTMATTER_RE)
|
|
49
|
+
if (fm) {
|
|
50
|
+
const head = fm[1]
|
|
51
|
+
const rest = withoutBlock.slice(head.length).replace(/^\n+/u, '')
|
|
52
|
+
return `${head}\n${block}\n\n${rest}`
|
|
53
|
+
}
|
|
54
|
+
return `${block}\n\n${withoutBlock.replace(/^\n+/u, '')}`
|
|
55
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Обчислення директорії стану `withLock` (lock + dedup), СПІЛЬНОЇ для всіх git-worktree.
|
|
3
|
+
*
|
|
4
|
+
* Лок-стан (`lock/owner.json`, `result.json`) має бути один на головний checkout
|
|
5
|
+
* і всі linked-worktree. Інакше важкі команди (eslint/oxlint/jscpd, conftest,
|
|
6
|
+
* hadolint, per-rule fix), запущені в різних worktree, НЕ серіалізуються —
|
|
7
|
+
* кожен worktree має власний `node_modules/.cache/`, локи одне одного не бачать,
|
|
8
|
+
* і паралельний eslint перевантажує CPU/диск на macOS.
|
|
9
|
+
*
|
|
10
|
+
* `git rev-parse --git-common-dir` повертає той самий `.git` головного репо з
|
|
11
|
+
* будь-якого worktree, тож стан кладемо під `<git-common-dir>/n-cursor/<key>`
|
|
12
|
+
* (всередині `.git` — спільне, ніколи не трекається, переживає `bun i`).
|
|
13
|
+
* Поза git-репо (git недоступний / каталог не репо) — fallback на per-checkout
|
|
14
|
+
* `node_modules/.cache/n-cursor/<key>`, як було історично.
|
|
15
|
+
*/
|
|
16
|
+
import { spawnSync } from 'node:child_process'
|
|
17
|
+
import { join, resolve } from 'node:path'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string} key ключ локу (`lint-ga`, `fix-bun`, …)
|
|
21
|
+
* @param {{cwd?:string, spawn?:typeof import('child_process').spawnSync}} [opts] робоча директорія та sync-виклик git (ін'єкція для тестів)
|
|
22
|
+
* @returns {string} абсолютний шлях до директорії стану локу для цього ключа
|
|
23
|
+
*/
|
|
24
|
+
export function resolveLockCacheDir(key, opts = {}) {
|
|
25
|
+
const cwd = opts.cwd ?? process.cwd()
|
|
26
|
+
const spawn = opts.spawn ?? spawnSync
|
|
27
|
+
|
|
28
|
+
const r = spawn('git', ['rev-parse', '--git-common-dir'], { cwd, encoding: 'utf8' })
|
|
29
|
+
const commonDir = r.status === 0 && !r.error ? r.stdout.trim() : ''
|
|
30
|
+
|
|
31
|
+
if (commonDir === '') {
|
|
32
|
+
return join(cwd, 'node_modules/.cache/n-cursor', key)
|
|
33
|
+
}
|
|
34
|
+
// commonDir буває відносним (`.git` з кореня) або абсолютним (linked-worktree):
|
|
35
|
+
// resolve проти cwd дає однаковий абсолютний `<main>/.git` в обох випадках.
|
|
36
|
+
return join(resolve(cwd, commonDir), 'n-cursor', key)
|
|
37
|
+
}
|
|
@@ -6,6 +6,7 @@ import * as fs from 'node:fs'
|
|
|
6
6
|
import { join } from 'node:path'
|
|
7
7
|
import * as os from 'node:os'
|
|
8
8
|
import { setTimeout as sleep } from 'node:timers/promises'
|
|
9
|
+
import { resolveLockCacheDir } from './lock-cache-dir.mjs'
|
|
9
10
|
import { worktreeFingerprint } from './worktree-fingerprint.mjs'
|
|
10
11
|
|
|
11
12
|
const DEFAULTS = {
|
|
@@ -62,7 +63,7 @@ export function shouldDedup(result, fingerprint, ttl) {
|
|
|
62
63
|
export async function withLock(key, runFn, opts = {}) {
|
|
63
64
|
const { ttl, staleThreshold, waitTimeout, pollInterval } = { ...DEFAULTS, ...opts }
|
|
64
65
|
const getFingerprint = opts.getFingerprint ?? worktreeFingerprint
|
|
65
|
-
const cacheDir = opts.cacheDir ??
|
|
66
|
+
const cacheDir = opts.cacheDir ?? resolveLockCacheDir(key)
|
|
66
67
|
const lockDir = join(cacheDir, 'lock')
|
|
67
68
|
const ownerFile = join(lockDir, 'owner.json')
|
|
68
69
|
const resultFile = join(cacheDir, 'result.json')
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "auto": ["adr"], "worktree": true }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "auto": ["js-lint"], "worktree": true }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "auto": "завжди", "worktree": true }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "auto": ["js-lint"], "worktree": true }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "auto": "завжди", "worktree": false }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "auto": "завжди", "worktree": false }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "auto": "завжди", "worktree": false }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "auto": "завжди", "worktree": false }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "auto": ["bun"], "worktree": true }
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
[adr]
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
[js-lint]
|
package/skills/fix/auto.md
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
завжди
|
package/skills/fix-tests/auto.md
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
[js-lint]
|
package/skills/lint/auto.md
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
завжди
|
package/skills/llm-patch/auto.md
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
завжди
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
завжди
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
[bun]
|
package/skills/taze/auto.md
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
[bun]
|