@nitra/cursor 10.0.1 → 10.2.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 +12 -0
- package/bin/n-cursor.js +8 -15
- package/package.json +1 -1
- package/rules/doc-files/js/docgen-extract.mjs +7 -2
- package/rules/doc-files/js/docgen-judge-measure.mjs +147 -0
- package/rules/js-lint-ci/js-lint-ci.mdc +2 -2
- package/rules/tauri/tauri.mdc +23 -1
- package/rules/tool-surface/fix.mjs +18 -0
- package/rules/tool-surface/meta.json +1 -0
- package/rules/tool-surface/tool-surface.mdc +72 -0
- package/schemas/rule-meta.json +2 -2
- package/scripts/lib/docs/run-rule-cli.md +6 -9
- package/scripts/lib/fix/docs/run-fix-check.md +6 -5
- package/scripts/lib/fix/run-fix-check.mjs +28 -7
- package/scripts/lib/run-rule-cli.mjs +7 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [10.2.0] - 2026-06-15
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- tool-surface rule — паритет «UI ↔ LLM ↔ оркестратор»: будь-яка дія фронтенду виконувана без UI через спільний каталог тулів (catalog → dispatch → UI/CLI/LLM-адаптери). Платформо-незалежне ядро; per-stack деталі делегуються правилам tauri/vue/capacitor. Авто-активація на фронтенд-залежностях. Додано per-stack секцію «Tool Surface у Tauri» в правило tauri (делегація в Rust-крейт/бінарник, два транспорти, схеми через schemars, дозволи плагінів).
|
|
8
|
+
|
|
9
|
+
## [10.1.0] - 2026-06-15
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Конформність-селекція: .n-cursor.json — єдине джерело правди. resolveCheckRuleIds бере активні правила прямо з конфіга (available ∩ enabled), .cursor/rules/*.mdc лишається лише fallback'ом коли конфіга нема. Прибрано дрейф «правило enabled, але .mdc нема → тихо пропущено». Per-rule whitelist-гейт у runRuleCli видалено як дубль — гейтинг живе виключно у селекції.
|
|
14
|
+
|
|
3
15
|
## [10.0.1] - 2026-06-14
|
|
4
16
|
|
|
5
17
|
### Changed
|
package/bin/n-cursor.js
CHANGED
|
@@ -4,19 +4,18 @@
|
|
|
4
4
|
* n-cursor — CLI завантаження правил та перевірки проєкту
|
|
5
5
|
*
|
|
6
6
|
* Використання:
|
|
7
|
-
* `npx \@nitra/cursor` — завантажити cursor-правила
|
|
8
|
-
*
|
|
9
|
-
* якщо в корені вже є `.n-cursor.json`, спочатку зчитується конфіг і за потреби дописується `$schema`
|
|
10
|
-
* `npx \@nitra/cursor fix bun` — оркестратор лише для вказаних правил; `--json` = check-only (structured output для CI)
|
|
7
|
+
* `npx \@nitra/cursor` — завантажити cursor-правила (синк); якщо в корені вже є `.n-cursor.json`,
|
|
8
|
+
* спочатку зчитується конфіг і за потреби дописується `$schema`
|
|
11
9
|
* `npx \@nitra/cursor rename-yaml-extensions` — k8s `*.yml` → `*.yaml`, `.github` `*.yaml` → `*.yml` (опції: `--dry-run`, `--root=…`; див. bin/rename-yaml-extensions.mjs)
|
|
12
10
|
* `npx \@nitra/cursor post-tool-use-fix` — точка входу PostToolUse hook Claude Code: читає stdin JSON,
|
|
13
11
|
* дістає `tool_input.file_path`, маршрутизує його у відповідні правила
|
|
14
12
|
* (`*.mjs` → `js-lint`, `*.vue` → `js-lint style-lint vue` тощо) і викликає
|
|
15
13
|
* `fix` лише з ними. Прописується автоматично в `.claude/settings.json`.
|
|
16
|
-
* `npx \@nitra/cursor
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
14
|
+
* `npx \@nitra/cursor lint` — data-driven оркестратор lint+конформності по `rules/<id>/meta.json` (`lint: per-file|full`):
|
|
15
|
+
* за замовчуванням fix-by-default по дельті vs origin (лише `per-file` правила); `--full` =
|
|
16
|
+
* весь репо (`per-file` ∪ `full`); `--read-only` = без мутацій/LLM (CI); позиційні
|
|
17
|
+
* (не-флаг) аргументи — фільтр правил конформності (мапить колишній `fix <rule>`).
|
|
18
|
+
* CI = `lint --read-only --full` (весь репо, нуль мутацій/LLM).
|
|
20
19
|
* `npx \@nitra/cursor lint-ga` — канонічний lint-ga (ga.mdc): preflight на `shellcheck` →
|
|
21
20
|
* `bunx github-actionlint` → `uvx zizmor --offline --collect=workflows .`
|
|
22
21
|
* `npx \@nitra/cursor lint-rego` — канонічний lint-rego (conftest.mdc + rego.mdc):
|
|
@@ -1542,12 +1541,6 @@ try {
|
|
|
1542
1541
|
|
|
1543
1542
|
break
|
|
1544
1543
|
}
|
|
1545
|
-
case 'lint-ci': {
|
|
1546
|
-
// CI = весь репо в read-only (нуль мутацій, нуль LLM) — еквівалент `lint --read-only --full`.
|
|
1547
|
-
process.exitCode = await runLint({ full: true, readOnly: true })
|
|
1548
|
-
|
|
1549
|
-
break
|
|
1550
|
-
}
|
|
1551
1544
|
case 'lint-ga': {
|
|
1552
1545
|
// Канонічний lint-ga з preflight на shellcheck → actionlint → zizmor → check-ga (ga.mdc).
|
|
1553
1546
|
// Останній крок (check-ga) async — тому await обов'язковий, інакше process.exitCode буде Promise.
|
|
@@ -1727,7 +1720,7 @@ try {
|
|
|
1727
1720
|
default: {
|
|
1728
1721
|
console.error(`❌ Невідома команда: ${command}`)
|
|
1729
1722
|
console.error(
|
|
1730
|
-
` Очікується: (без аргументів) синхронізація правил, rename-yaml-extensions, post-tool-use-fix, lint, lint-ga, lint-rego, lint-k8s, lint-docker, lint-text, lint-doc-files, fix-doc-files, coverage, coverage-fix, taze, start-check, change, release, skill, worktree,
|
|
1723
|
+
` Очікується: (без аргументів) синхронізація правил, rename-yaml-extensions, post-tool-use-fix, lint, lint-ga, lint-rego, lint-k8s, lint-docker, lint-text, lint-doc-files, fix-doc-files, coverage, coverage-fix, taze, start-check, change, release, skill, worktree, trace, doc-files, doc-aggregate`
|
|
1731
1724
|
)
|
|
1732
1725
|
process.exitCode = 1
|
|
1733
1726
|
}
|
package/package.json
CHANGED
|
@@ -42,8 +42,13 @@ const IMPORT_AS_RE = /[ \t]{1,8}as[ \t]{1,8}.{0,200}/
|
|
|
42
42
|
const WRITE_FS_RE = /\b(writeFile|mkdir|rmdir|unlink|appendFile|createWriteStream|rm\()/
|
|
43
43
|
const CATCH_RE = /catch\s*\(/
|
|
44
44
|
const TRY_RE = /\btry\s*\{/
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
// Falsy-return як «fail-safe» — лише коли воно в catch/error-гілці (інакше це
|
|
46
|
+
// звичайний guard `if (!x) return null`, не обробка помилки). Уникає over-claim.
|
|
47
|
+
const FALSY_RETURN_RE = /catch[\s\S]{0,400}?return\s+(false|null|''|"")/
|
|
48
|
+
// Мережа: окрім явного fetch/http, ловимо абстраговані клієнти (graphql/db/rpc/
|
|
49
|
+
// octokit/.request/.query). Хибний false-negative тут = небезпечна гарантія
|
|
50
|
+
// «без мережі», тож свідомо схиляємось до over-detection (м'якший бік помилки).
|
|
51
|
+
const NETWORK_RE = /\bfetch\(|https?:\/\/|\bhttps?\.|axios|\bgot\(|graphql|\.request\(|\.query\(|\.mutate\(|octokit|node-fetch|undici|\bgrpc\b|websocket/i
|
|
47
52
|
// Кеш — лише за ІМЕНОВАНИМ маркером (`cache`/`Cache`/`memoize`), не за будь-яким
|
|
48
53
|
// `new Map()`: акумулятор (напр. `byPath = new Map()`) — не кеш, а хибна гарантія
|
|
49
54
|
// «Кешує результати» гірша за пропуск (фабрикація > мовчання).
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* docgen-judge-measure.mjs — Q4 офлайн-вимірювач (spec 2026-06-14-docgen-judge-design).
|
|
4
|
+
*
|
|
5
|
+
* Міряє false-positive rate детермінованого `scoreDoc`: серед доків, що ПРОЙШЛИ
|
|
6
|
+
* (score ≥ threshold), який % сильна хмарна модель-суддя класифікує як
|
|
7
|
+
* `generic`/`inaccurate`. Це число вирішує, чи будувати рантайм-judge-гейт.
|
|
8
|
+
*
|
|
9
|
+
* Генерація: локальна (N_LOCAL_MIN_MODEL, omlx/* → прямий HTTP) — реальний пайплайн.
|
|
10
|
+
* Суддя: openai-codex/gpt-5.4-mini (сильніша хмара, ніж генератор — інакше вимір беззмістовний).
|
|
11
|
+
* Обидва — через існуючий `../../../lib/llm.mjs callLlm` (маршрутизація за префіксом).
|
|
12
|
+
*
|
|
13
|
+
* Кеш на диску (за хешем контенту) → повторні прогони не регенерують і не пересуджують.
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* node docgen-judge-measure.mjs <file1> <file2> ...
|
|
17
|
+
* MEASURE_CACHE=/tmp/x N_CURSOR_DOCGEN_JUDGE_MODEL=openai-codex/gpt-5.4 node docgen-judge-measure.mjs ...
|
|
18
|
+
*/
|
|
19
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
|
|
20
|
+
import { createHash } from 'node:crypto'
|
|
21
|
+
import { join } from 'node:path'
|
|
22
|
+
import { generateDoc } from './docgen-gen.mjs'
|
|
23
|
+
import { callLlm } from '../../../lib/llm.mjs'
|
|
24
|
+
import { QUALITY_THRESHOLD } from './docgen-crc.mjs'
|
|
25
|
+
|
|
26
|
+
const env = process.env
|
|
27
|
+
const GEN_MODEL = env.N_LOCAL_MIN_MODEL ?? 'omlx/gemma-4-e4b-it-OptiQ-4bit'
|
|
28
|
+
const JUDGE_MODEL = env.N_CURSOR_DOCGEN_JUDGE_MODEL ?? 'openai-codex/gpt-5.4-mini'
|
|
29
|
+
const THRESHOLD = Number(env.N_CURSOR_DOC_FILES_THRESHOLD ?? QUALITY_THRESHOLD) || 70
|
|
30
|
+
const CACHE_DIR = env.MEASURE_CACHE ?? '/tmp/docgen-judge-measure'
|
|
31
|
+
const JUDGE_TIMEOUT = Number(env.MEASURE_JUDGE_TIMEOUT_MS ?? 120_000)
|
|
32
|
+
|
|
33
|
+
const SYSTEM = `You are a strict technical-documentation reviewer. You receive a SOURCE file and an auto-generated Markdown DOC describing it. Classify the DOC into exactly one verdict:
|
|
34
|
+
- "accurate": specific to THIS file AND every factual claim is supported by the source.
|
|
35
|
+
- "generic": could describe almost any file of this kind; vague/boilerplate; lacks file-specific substance.
|
|
36
|
+
- "inaccurate": contains at least one claim that is NOT supported by, or is contradicted by, the source code.
|
|
37
|
+
Prefer "inaccurate" over "generic" if any claim is wrong. Respond with ONLY a JSON object, no prose:
|
|
38
|
+
{"verdict":"accurate|generic|inaccurate","confidence":0.0-1.0,"reason":"<20-300 chars>","offending":["<short quote from doc>"]}`
|
|
39
|
+
|
|
40
|
+
const sha = s => createHash('sha256').update(s).digest('hex').slice(0, 16)
|
|
41
|
+
|
|
42
|
+
function cacheGet(key) {
|
|
43
|
+
const p = join(CACHE_DIR, key + '.json')
|
|
44
|
+
return existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : null
|
|
45
|
+
}
|
|
46
|
+
function cacheSet(key, val) {
|
|
47
|
+
if (!existsSync(CACHE_DIR)) mkdirSync(CACHE_DIR, { recursive: true })
|
|
48
|
+
writeFileSync(join(CACHE_DIR, key + '.json'), JSON.stringify(val))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Генерує (з кешем за хешем src). */
|
|
52
|
+
function genCached(file, src) {
|
|
53
|
+
const key = 'gen-' + sha(GEN_MODEL + '\0' + src)
|
|
54
|
+
const hit = cacheGet(key)
|
|
55
|
+
if (hit) return { ...hit, cached: true }
|
|
56
|
+
const r = generateDoc(file, { model: GEN_MODEL })
|
|
57
|
+
const out = { md: r.md, score: r.score, issues: r.issues, degraded: r.degraded }
|
|
58
|
+
cacheSet(key, out)
|
|
59
|
+
return { ...out, cached: false }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Судить (з кешем за хешем src+doc). */
|
|
63
|
+
function judgeCached(src, doc) {
|
|
64
|
+
const key = 'judge-' + sha(JUDGE_MODEL + '\0' + src + '\0' + doc)
|
|
65
|
+
const hit = cacheGet(key)
|
|
66
|
+
if (hit) return { ...hit, cached: true }
|
|
67
|
+
const user = `SOURCE FILE:\n\`\`\`\n${src.slice(0, 12000)}\n\`\`\`\n\nGENERATED DOC:\n\`\`\`md\n${doc.slice(0, 8000)}\n\`\`\`\n\nReturn the JSON verdict.`
|
|
68
|
+
const raw = callLlm([{ role: 'system', content: SYSTEM }, { role: 'user', content: user }], JUDGE_MODEL, { timeoutMs: JUDGE_TIMEOUT, temperature: 0 })
|
|
69
|
+
const a = raw.indexOf('{'), b = raw.lastIndexOf('}')
|
|
70
|
+
if (a < 0 || b < 0) throw new Error('no JSON in judge reply: ' + raw.slice(0, 160))
|
|
71
|
+
const v = JSON.parse(raw.slice(a, b + 1))
|
|
72
|
+
cacheSet(key, v)
|
|
73
|
+
return { ...v, cached: false }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function main() {
|
|
77
|
+
const files = process.argv.slice(2).filter(f => !f.startsWith('--'))
|
|
78
|
+
if (!files.length) {
|
|
79
|
+
console.error('Usage: node docgen-judge-measure.mjs <file1> <file2> ...')
|
|
80
|
+
process.exit(2)
|
|
81
|
+
}
|
|
82
|
+
console.error(`[measure] gen=${GEN_MODEL} judge=${JUDGE_MODEL} threshold=${THRESHOLD} files=${files.length} cache=${CACHE_DIR}`)
|
|
83
|
+
|
|
84
|
+
const rows = []
|
|
85
|
+
for (const [i, file] of files.entries()) {
|
|
86
|
+
const tag = `(${i + 1}/${files.length}) ${file}`
|
|
87
|
+
let src
|
|
88
|
+
try { src = readFileSync(file, 'utf8') } catch (e) { console.error(`[skip] ${tag}: read ${e.message}`); continue }
|
|
89
|
+
|
|
90
|
+
let gen
|
|
91
|
+
try { gen = genCached(file, src) } catch (e) { console.error(`[gen-err] ${tag}: ${e.message.slice(0, 120)}`); rows.push({ file, error: 'gen', detail: e.message.slice(0, 200) }); continue }
|
|
92
|
+
if (gen.score == null) { console.error(`[unsupported] ${tag}`); rows.push({ file, score: null, unsupported: true }); continue }
|
|
93
|
+
|
|
94
|
+
const passed = gen.score >= THRESHOLD
|
|
95
|
+
const row = { file, score: gen.score, degraded: gen.degraded, passed, genCached: gen.cached }
|
|
96
|
+
console.error(`[gen${gen.cached ? '*' : ''}] ${tag} score=${gen.score} ${passed ? 'PASS' : 'degraded'}`)
|
|
97
|
+
|
|
98
|
+
if (passed) {
|
|
99
|
+
try {
|
|
100
|
+
const v = judgeCached(src, gen.md)
|
|
101
|
+
row.verdict = v.verdict; row.confidence = v.confidence; row.reason = v.reason; row.offending = v.offending; row.judgeCached = v.cached
|
|
102
|
+
console.error(` [judge${v.cached ? '*' : ''}] ${v.verdict} (${v.confidence}) — ${(v.reason || '').slice(0, 90)}`)
|
|
103
|
+
} catch (e) { row.judgeError = e.message.slice(0, 200); console.error(` [judge-err] ${e.message.slice(0, 120)}`) }
|
|
104
|
+
}
|
|
105
|
+
rows.push(row)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Aggregate
|
|
109
|
+
const scored = rows.filter(r => typeof r.score === 'number')
|
|
110
|
+
const passedRows = scored.filter(r => r.passed && r.verdict)
|
|
111
|
+
const byVerdict = { accurate: 0, generic: 0, inaccurate: 0 }
|
|
112
|
+
for (const r of passedRows) byVerdict[r.verdict] = (byVerdict[r.verdict] ?? 0) + 1
|
|
113
|
+
const M = passedRows.length
|
|
114
|
+
const bad = byVerdict.generic + byVerdict.inaccurate
|
|
115
|
+
const pct = n => (M ? ((100 * n) / M).toFixed(1) : '—')
|
|
116
|
+
|
|
117
|
+
const report = {
|
|
118
|
+
config: { genModel: GEN_MODEL, judgeModel: JUDGE_MODEL, threshold: THRESHOLD },
|
|
119
|
+
counts: {
|
|
120
|
+
files: files.length, generated: scored.length,
|
|
121
|
+
unsupported: rows.filter(r => r.unsupported).length,
|
|
122
|
+
genErrors: rows.filter(r => r.error === 'gen').length,
|
|
123
|
+
passedDetScorer: scored.filter(r => r.passed).length,
|
|
124
|
+
judged: M, judgeErrors: rows.filter(r => r.judgeError).length
|
|
125
|
+
},
|
|
126
|
+
falsePositiveRate: { // серед PASSED+judged
|
|
127
|
+
accurate: byVerdict.accurate, generic: byVerdict.generic, inaccurate: byVerdict.inaccurate,
|
|
128
|
+
badPct: pct(bad), inaccuratePct: pct(byVerdict.inaccurate), genericPct: pct(byVerdict.generic)
|
|
129
|
+
},
|
|
130
|
+
offenders: passedRows.filter(r => r.verdict !== 'accurate').map(r => ({ file: r.file, score: r.score, verdict: r.verdict, confidence: r.confidence, reason: r.reason })),
|
|
131
|
+
rows
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!existsSync(CACHE_DIR)) mkdirSync(CACHE_DIR, { recursive: true })
|
|
135
|
+
const out = join(CACHE_DIR, 'report.json')
|
|
136
|
+
writeFileSync(out, JSON.stringify(report, null, 2))
|
|
137
|
+
|
|
138
|
+
console.log('\n===== Q4 MEASUREMENT =====')
|
|
139
|
+
console.log(`generated: ${report.counts.generated}/${files.length} (unsupported=${report.counts.unsupported}, gen-errors=${report.counts.genErrors})`)
|
|
140
|
+
console.log(`passed det-scorer (score≥${THRESHOLD}): ${report.counts.passedDetScorer} judged: ${M}`)
|
|
141
|
+
console.log(`among PASSED+judged → accurate=${byVerdict.accurate} generic=${byVerdict.generic} inaccurate=${byVerdict.inaccurate}`)
|
|
142
|
+
console.log(`>>> det-scorer FALSE-POSITIVE rate: ${pct(bad)}% (inaccurate=${pct(byVerdict.inaccurate)}%, generic=${pct(byVerdict.generic)}%)`)
|
|
143
|
+
console.log(`decision guide: <~5% → don't build gate; >~15% → build (inaccurate-only)`)
|
|
144
|
+
console.log(`report: ${out}`)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
main()
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Крос-файловий ci-етап js-lint — jscpd (детектор клонів) і knip (невикористані експорти). Лише у lint
|
|
2
|
+
description: Крос-файловий ci-етап js-lint — jscpd (детектор клонів) і knip (невикористані експорти). Лише у `lint --full`, по всьому репо.
|
|
3
3
|
globs: "**/{.oxlintrc.json,eslint.config.js,.jscpd.json,knip.json,package.json},**/*.{js,mjs,cjs,jsx,ts,tsx}"
|
|
4
4
|
alwaysApply: false
|
|
5
5
|
version: '1.0'
|
|
@@ -8,7 +8,7 @@ version: '1.0'
|
|
|
8
8
|
# js-lint-ci — крос-файловий ci-етап
|
|
9
9
|
|
|
10
10
|
`jscpd` і `knip` аналізують увесь граф проєкту, тож мають сенс лише у повному прогоні
|
|
11
|
-
`npx @nitra/cursor lint-
|
|
11
|
+
`npx @nitra/cursor lint --full` (CI: `lint --read-only --full`) — не у швидкому `lint` по змінених файлах. Per-file режиму нема.
|
|
12
12
|
|
|
13
13
|
Швидкий етап js-lint (oxlint/eslint) — у правилі `js-lint` (`lint: per-file`).
|
|
14
14
|
|
package/rules/tauri/tauri.mdc
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
description: Tauri
|
|
3
3
|
globs: "**/src-tauri/**,**/tauri.conf.json"
|
|
4
4
|
alwaysApply: false
|
|
5
|
-
version: '1.
|
|
5
|
+
version: '1.5'
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
У `.vscode/extensions.json` `recommendations` має містити `tauri-apps.tauri-vscode`:
|
|
@@ -66,3 +66,25 @@ exclude_globs = [
|
|
|
66
66
|
- якщо обидва канонічні ключі (`additional_cargo_test_args`, `exclude_globs`) вже присутні — `manual cargo-mutants config preserved`, нічого не зміниться;
|
|
67
67
|
- якщо якийсь канонічний ключ відсутній — додається окремим блоком у кінці файла, без зміни існуючих значень.
|
|
68
68
|
- Послідовний `fix test` → `fix tauri` створює Tauri-config; повторний `fix tauri` не дублює секцій; повторний `fix test` не перетирає Tauri-tuning.
|
|
69
|
+
|
|
70
|
+
## Tool Surface у Tauri (реалізація ядра `tool-surface`)
|
|
71
|
+
|
|
72
|
+
Це per-stack реалізація правила **`tool-surface`** (`n-tool-surface`) для Tauri+Rust. Контракт (каталог → `dispatch` → UI/оркестратор/LLM, інваріант паритету, конверт) — у тому правилі; тут — як він лягає на Tauri.
|
|
73
|
+
|
|
74
|
+
**Реальна робота живе в Rust, JS-каталог — тонкий call surface.** Handler тула не містить логіки сам — він **делегує** в native. Одна реалізація в Rust-крейті backs два споживачі:
|
|
75
|
+
|
|
76
|
+
- **Бінарник** (`src-tauri` як CLI, або окремий crate-bin) — headless-вхід для оркестратора;
|
|
77
|
+
- **Tauri-команда** (`#[tauri::command]`) — той самий крейт-fn, обгорнутий для UI.
|
|
78
|
+
|
|
79
|
+
**Два транспорти одного каталогу** (`src/tool/transports`):
|
|
80
|
+
|
|
81
|
+
- **UI (in-app):** `invoke(tool.tauri, input)` → Tauri-команда → крейт. Ключі `input` мапляться 1:1 на аргументи команди (camelCase, напр. `tasksDir`); поля вкладених struct лишаються snake_case (Tauri конвертить лише імена top-level аргументів).
|
|
82
|
+
- **Оркестратор (headless):** `bin/<app>.mjs` спавнить зібраний бінарник (`<bin> <verb> …` per-verb, або уніфікований `<bin> exec '<json>'`), парсить JSON stdout.
|
|
83
|
+
|
|
84
|
+
**Конверт:** Tauri-команда повертає `Result<T, String>`; адаптер мапить у `{ ok, output }` / `{ ok:false, error }`. Бінарник друкує конверт у stdout, exit ≠ 0 на `ok:false`.
|
|
85
|
+
|
|
86
|
+
**Єдине джерело схем:** надавай перевагу `schemars`-derive на Rust-param-структурах → бінарник віддає маніфест (`<bin> schema`); або тримай схему в JS-каталозі й валідуй до `invoke`. **Не дублюй** контракт між Rust і JS — лише деривація + спільні тест-вектори.
|
|
87
|
+
|
|
88
|
+
**Дозволи:** будь-який плагін, який смикають тули (`tauri-plugin-dialog`, `tauri-plugin-http` для локальної LLM тощо), має бути в `src-tauri/capabilities/*.json` і зареєстрований у `lib.rs` — інакше виклик тихо падає.
|
|
89
|
+
|
|
90
|
+
**LLM-раннер in-app:** chat-loop ходить до OpenAI-сумісного ендпоінта через `tauri-plugin-http` fetch (бо webview-fetch обмежений CSP/capability), а тули виконує через спільний `dispatch` (той самий, що й UI).
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { isRunAsCli, runRuleCli } from '../../scripts/lib/run-rule-cli.mjs'
|
|
2
|
+
import { runStandardRule } from '../../scripts/lib/run-standard-rule.mjs'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Запускає правило: applies → JS-concerns → policy → mdc-refs (через runStandardRule).
|
|
6
|
+
* Library mode: викликається CLI orchestration через `import + run(ctx)`.
|
|
7
|
+
* @param {import('../../scripts/lib/run-standard-rule.mjs').RuleContext} [ctx] контекст прогону (walkCache тощо)
|
|
8
|
+
* @returns {Promise<number>} 0 — OK, 1 — порушення
|
|
9
|
+
*/
|
|
10
|
+
export function run(ctx) {
|
|
11
|
+
return runStandardRule(import.meta.dirname, ctx)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (isRunAsCli(import.meta.url)) {
|
|
15
|
+
// Standalone: bun rules/<id>/fix.mjs — повний еквівалент `npx @nitra/cursor fix <id>`
|
|
16
|
+
// (config-loading + whitelist + summary). Дві ролі fix.mjs: library (run) + standalone (main).
|
|
17
|
+
process.exitCode = await runRuleCli(import.meta.dirname)
|
|
18
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "auto": { "predicate": "depInAnyPackageJson", "arg": ["vue", "react", "svelte", "@angular/core", "preact", "solid-js", "@tauri-apps/api", "@capacitor/core"] } }
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Tool Surface — будь-яка дія фронтенду має бути виконуваною без UI (CLI + LLM) через спільний каталог тулів; UI/оркестратор/LLM — рівноправні адаптери
|
|
3
|
+
alwaysApply: true
|
|
4
|
+
version: '1.0'
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Tool Surface — паритет «UI ↔ LLM ↔ оркестратор»
|
|
8
|
+
|
|
9
|
+
## Принцип
|
|
10
|
+
|
|
11
|
+
**Будь-яка дія, яку людина виконує через фронтенд, мусить бути виконуваною як `tool` — без UI — бо вона організована як іменований виклик зі схемою, до якого однаково дотягуються UI, скриптовий оркестратор і LLM.**
|
|
12
|
+
|
|
13
|
+
Ключовий новий споживач — **LLM**, тож одиниця називається `tool`. Фронтенд — лише один з адаптерів, а не єдині двері.
|
|
14
|
+
|
|
15
|
+
## Це НЕ про «винести логіку на бекенд»
|
|
16
|
+
|
|
17
|
+
Лінія поділу не «фронт ↔ бек», а:
|
|
18
|
+
|
|
19
|
+
> виклик, досяжний **лише через UI-взаємодію** → погано
|
|
20
|
+
> виклик, досяжний як **іменований tool зі схемою** → добре
|
|
21
|
+
|
|
22
|
+
JS-логіка може лишатися на фронті — її лише треба **витягнути** з обробника події в окремий tool (ім'я + параметри + схема), який однаково кличе UI, оркестратор і LLM. Tool Surface — це **call surface**, не обов'язково бекенд.
|
|
23
|
+
|
|
24
|
+
## Три шари
|
|
25
|
+
|
|
26
|
+
1. **Tool Catalog** (`src/tool/`) — декларативний каталог. Кожен tool: `name`, `summary`, схема входу/виходу, `handler`. Handler **може бути фронтендовою JS-функцією** (або делегувати в native/HTTP/DB). Це **єдине джерело правди**; з нього генеруються schema-маніфест (для LLM) і типи клієнта.
|
|
27
|
+
2. **Dispatch** — одна функція `dispatch(name, input) → { ok, output } | { ok: false, error }`: валідує вхід за схемою, кличе handler, повертає **уніфікований конверт**. Не прив'язана до UI-рендеру/подій.
|
|
28
|
+
3. **Споживачі** (усі обов'язкові):
|
|
29
|
+
- **UI** — компонент кличе `dispatch`, без inline-логіки дії.
|
|
30
|
+
- **Оркестратор** — машинний вхід `<bin> <tool> '<json>'` (+ `schema`/`list`). Кличуть скрипти, не люди — людський CLI з verb-ами не потрібен.
|
|
31
|
+
- **LLM** — tool-маніфест із каталогу; tool_call маршрутизується в `dispatch`.
|
|
32
|
+
|
|
33
|
+
## Інваріант паритету (серце правила)
|
|
34
|
+
|
|
35
|
+
- **Жодної дії, досяжної лише через UI-взаємодію.** Обробник події **делегує** в `dispatch`/каталог, а не містить inline-логіку (мережа, мутація моделі, виклик бекенду).
|
|
36
|
+
- Дозволено: логіка у фронтенд-коді. Заборонено: логіка **зашита** в обробник кліку/сабміту так, що дотягтись можна лише кліком.
|
|
37
|
+
- Кожен tool каталогу має **усіх** споживачів (UI + оркестратор + LLM), інакше це не headless.
|
|
38
|
+
|
|
39
|
+
## Єдине джерело схем
|
|
40
|
+
|
|
41
|
+
Схема входу tool-а живе в каталозі (zod / JSON Schema; для Rust — `schemars`). З неї генеруються: tool-маніфест для LLM (формат залежить від провайдера — OpenAI function-calling, Anthropic tools або MCP), CLI-довідка, типи клієнта. Тул-визначення **не повинні розходитися** з каталогом — лише деривація, не дублювання.
|
|
42
|
+
|
|
43
|
+
## Уніфікований конверт
|
|
44
|
+
|
|
45
|
+
```jsonc
|
|
46
|
+
{ "ok": true, "output": { /* результат */ } }
|
|
47
|
+
{ "ok": false, "error": { "code": "validation|not_found|io|…", "message": "…" } }
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Оркестратор: `ok:false` → ненульовий exit. UI/LLM: `Result` / tool_result з `is_error`.
|
|
51
|
+
|
|
52
|
+
## Дворівнева структура (архітектура спільна, реалізація per-stack)
|
|
53
|
+
|
|
54
|
+
Архітектура **спільна**, реалізація **навмисно розходиться** за стеком. Це правило тримає платформо-незалежне **ядро**; конкретику делегують профільні правила:
|
|
55
|
+
|
|
56
|
+
| Рівень | Що тут | Де |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| **Ядро** | принцип, інваріант паритету, контракт каталог/`dispatch`/схема/конверт, 3 споживачі | це правило |
|
|
59
|
+
| **Tauri+Rust** | handler делегує в Rust-крейт/бінарник; машинний bin; `invoke`/spawn | `n-tauri` |
|
|
60
|
+
| **Capacitor / pure-web** | handler — JS напряму; bin = node/bun-скрипт, що імпортує handlers | `n-capacitor` |
|
|
61
|
+
| **UI-адаптер** | компонент делегує в `dispatch`, нуль inline-логіки | `n-vue` |
|
|
62
|
+
|
|
63
|
+
## Конвенція файлів
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
src/tool/
|
|
67
|
+
catalog.(js|ts) ← каталог тулів (single source): name, summary, input schema, mapping
|
|
68
|
+
dispatch.(js|ts) ← dispatch(name, input) + валідація + конверт
|
|
69
|
+
manifest.(js|ts) ← каталог → LLM tools ; CLI help
|
|
70
|
+
transports.(js|ts) ← UI-транспорт (invoke/fetch) ; CLI-транспорт (spawn/import)
|
|
71
|
+
bin/<app>.mjs ← машинний вхід: <tool> '<json>' | schema | list
|
|
72
|
+
```
|
package/schemas/rule-meta.json
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
"properties": {
|
|
9
9
|
"lint": {
|
|
10
10
|
"type": "string",
|
|
11
|
-
"enum": ["
|
|
12
|
-
"description": "
|
|
11
|
+
"enum": ["per-file", "full"],
|
|
12
|
+
"description": "Scope lint-кроку: per-file (декомпозиція по змінених файлах, дельта vs origin) або full (нероздільно крос-файловий, лише `lint --full`)."
|
|
13
13
|
},
|
|
14
14
|
"auto": {
|
|
15
15
|
"description": "Умова автоактивації правила: \"завжди\", масив id правил-залежностей, glob, або іменований предикат.",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
docgen:
|
|
3
3
|
source: npm/scripts/lib/run-rule-cli.mjs
|
|
4
|
-
crc:
|
|
4
|
+
crc: 264e7ab0
|
|
5
5
|
score: 100
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -9,18 +9,15 @@ docgen:
|
|
|
9
9
|
|
|
10
10
|
## Огляд
|
|
11
11
|
|
|
12
|
-
Файл є автономним CLI-запускачем для одного правила. Він
|
|
12
|
+
Файл є автономним CLI-запускачем для одного правила. Він друкує звіт про перевірку та повертає агрегований код виходу. Whitelist-гейту тут немає: гейтинг активних правил живе виключно у `resolveCheckRuleIds` (селекція за `.n-cursor.json`), а прямий запуск файлу правила — свідома debug/override-дія, тож виконується беззастережно.
|
|
13
13
|
|
|
14
14
|
## Поведінка
|
|
15
15
|
|
|
16
16
|
1. Викликається для запуску правила.
|
|
17
|
-
2.
|
|
18
|
-
3.
|
|
19
|
-
4.
|
|
20
|
-
5.
|
|
21
|
-
6. Використовує кеш для перевірки.
|
|
22
|
-
7. Викликає функцію для виконання стандартного правила з кешем.
|
|
23
|
-
8. Повертає агрегований код виходу.
|
|
17
|
+
2. Друкує повідомлення про перевірку правила.
|
|
18
|
+
3. Використовує кеш для перевірки.
|
|
19
|
+
4. Викликає функцію для виконання стандартного правила з кешем.
|
|
20
|
+
5. Повертає агрегований код виходу.
|
|
24
21
|
|
|
25
22
|
## Гарантії поведінки
|
|
26
23
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
docgen:
|
|
3
3
|
source: npm/scripts/lib/fix/run-fix-check.mjs
|
|
4
|
-
crc:
|
|
4
|
+
crc: 76874730
|
|
5
5
|
model: omlx/gemma-4-e4b-it-OptiQ-4bit
|
|
6
6
|
score: 100
|
|
7
7
|
---
|
|
@@ -10,15 +10,16 @@ docgen:
|
|
|
10
10
|
|
|
11
11
|
## Огляд
|
|
12
12
|
|
|
13
|
-
Викликає конформність-фазу `lint` (read-only), движок (`orchestrator.mjs`, `t0.mjs`) та PostToolUse-хук. Перевірка конформності виконується як пряма функція, без зовнішньої обгортки через `subprocess`. Ізоляція на рівні кожного правила зберігається: кожен файл `rules/<id>/fix.mjs` все ще запускається окремим процесом `bun` (
|
|
13
|
+
Викликає конформність-фазу `lint` (read-only), движок (`orchestrator.mjs`, `t0.mjs`) та PostToolUse-хук. Перевірка конформності виконується як пряма функція, без зовнішньої обгортки через `subprocess`. Ізоляція на рівні кожного правила зберігається: кожен файл `rules/<id>/fix.mjs` все ще запускається окремим процесом `bun` (crash-isolation). Селекція активних правил — єдине джерело: `resolveCheckRuleIds` за `.n-cursor.json`; per-rule whitelist у спавнених процесах прибрано як дубль.
|
|
14
14
|
|
|
15
15
|
## Поведінка
|
|
16
16
|
|
|
17
17
|
1. Визначається наявність інструменту `conftest`.
|
|
18
18
|
2. Отримується список усіх доступних ідентифікаторів правил з каталогу правил.
|
|
19
|
-
3. Визначається список ідентифікаторів правил для
|
|
20
|
-
а. Якщо надано явний список
|
|
21
|
-
б. Якщо
|
|
19
|
+
3. Визначається список ідентифікаторів правил для прогону (`resolveCheckRuleIds`), де `.n-cursor.json` — єдине джерело правди:
|
|
20
|
+
а. Якщо надано явний список правил — він валідується проти доступних і звужується до активних (вимкнене правило не вмикається навіть на явний запит).
|
|
21
|
+
б. Якщо явного списку нема й конфіг є — беруться активні правила конфіга (`available ∩ enabled`); `.cursor/rules/*.mdc` ігнорується (фікс дрейфу «enabled, але .mdc нема»).
|
|
22
|
+
в. Якщо конфіга нема (open-by-default debug) — fallback на скан `.cursor/rules/*.mdc`.
|
|
22
23
|
4. Якщо визначено ідентифікатори правил для прогону, для кожного ідентифікатора запускається окремий процес `bun` з файлом `fix.mjs` відповідного правила.
|
|
23
24
|
5. Захоплюється вивід кожного процесу.
|
|
24
25
|
6. Підраховується загальна кількість правил, що не пройшли перевірку.
|
|
@@ -4,8 +4,11 @@
|
|
|
4
4
|
* (`orchestrator.mjs`, `t0.mjs`) і PostToolUse-хук.
|
|
5
5
|
*
|
|
6
6
|
* Per-rule ізоляція зберігається: кожне `rules/<id>/fix.mjs` усе ще запускається окремим
|
|
7
|
-
* процесом `bun` (
|
|
8
|
-
*
|
|
7
|
+
* процесом `bun` (crash-isolation). Прибрано лише зовнішній wrapper-subprocess, що його
|
|
8
|
+
* раніше шелили оркестратор/хук.
|
|
9
|
+
*
|
|
10
|
+
* Селекція активних правил — виключно тут (`resolveCheckRuleIds` за `.n-cursor.json`);
|
|
11
|
+
* per-rule whitelist у спавнених процесах прибрано як дубль (див. `runRuleCli`).
|
|
9
12
|
*/
|
|
10
13
|
import { spawnSync } from 'node:child_process'
|
|
11
14
|
import { dirname, join } from 'node:path'
|
|
@@ -16,24 +19,42 @@ import { listRuleIds } from '../list-rule-ids.mjs'
|
|
|
16
19
|
import { ensureTool } from '../ensure-tool.mjs'
|
|
17
20
|
import { discoverCheckRulesFromCursorRules } from '../discover-check-rules-from-cursor.mjs'
|
|
18
21
|
import { listProjectRulesMdcFiles } from '../list-project-rules-mdc.mjs'
|
|
22
|
+
import { isRuleEnabled, readNCursorConfigLite } from '../read-n-cursor-config-lite.mjs'
|
|
19
23
|
|
|
20
24
|
// Цей файл: npm/scripts/lib/fix/run-fix-check.mjs → npm/rules (чотири dirname угору + rules).
|
|
21
25
|
const BUNDLED_RULES_DIR = join(dirname(dirname(dirname(dirname(fileURLToPath(import.meta.url))))), 'rules')
|
|
22
26
|
|
|
23
27
|
/**
|
|
24
|
-
* Визначає id правил для
|
|
25
|
-
*
|
|
26
|
-
*
|
|
28
|
+
* Визначає id правил для прогону. `.n-cursor.json` — **єдине джерело правди** селекції:
|
|
29
|
+
* - явні `requestedRules` — валідуються проти `available`, тоді звужуються до активних
|
|
30
|
+
* (явний запит лише фільтрує всередині активних, не вмикає вимкнене правило);
|
|
31
|
+
* - без явних і конфіг **є** — беремо саме активні правила конфіга (`available ∩ enabled`).
|
|
32
|
+
* Це прибирає дрейф «правило в `.n-cursor.json:rules`, але `.cursor/rules/*.mdc` нема
|
|
33
|
+
* (sync не прогнаний) → раніше тихо пропускалось»;
|
|
34
|
+
* - без явних і конфіга **нема** — open-by-default debug: fallback на зматеріалізовані
|
|
35
|
+
* `.cursor/rules/*.mdc` (єдиний сигнал «що встановлено», коли немає whitelist).
|
|
36
|
+
*
|
|
37
|
+
* Per-rule дубль-гейту (`runRuleCli → isRuleEnabled`) більше немає — гейтинг живе лише тут.
|
|
38
|
+
* @param {string[]} requestedRules запитані (порожній → auto-селекція)
|
|
39
|
+
* @param {string[]} available доступні rule-id у пакеті (алфавітно)
|
|
27
40
|
* @param {string} cwd корінь
|
|
28
41
|
* @returns {Promise<string[]>} id для прогону (можливо порожній)
|
|
29
42
|
* @throws {Error} на невідомих явно заданих правилах
|
|
30
43
|
*/
|
|
31
|
-
async function resolveCheckRuleIds(requestedRules, available, cwd) {
|
|
44
|
+
export async function resolveCheckRuleIds(requestedRules, available, cwd) {
|
|
45
|
+
const config = await readNCursorConfigLite(cwd)
|
|
46
|
+
|
|
32
47
|
if (requestedRules.length > 0) {
|
|
33
48
|
const unknown = requestedRules.filter(id => !available.includes(id))
|
|
34
49
|
if (unknown.length > 0) throw new Error(`Unknown rules: ${unknown.join(', ')}`)
|
|
35
|
-
return requestedRules
|
|
50
|
+
return requestedRules.filter(id => isRuleEnabled(config, id))
|
|
36
51
|
}
|
|
52
|
+
|
|
53
|
+
if (config.exists) {
|
|
54
|
+
return available.filter(id => isRuleEnabled(config, id))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Конфіга нема → fallback на зматеріалізоване (debug / open-by-default).
|
|
37
58
|
const mdcFiles = await listProjectRulesMdcFiles(cwd)
|
|
38
59
|
if (mdcFiles.length === 0) return []
|
|
39
60
|
return discoverCheckRulesFromCursorRules(available, mdcFiles)
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Standalone CLI runner для одного правила. Викликається з `rules/<id>/fix.mjs`
|
|
3
3
|
* у блоці `if (import.meta.main)` — це робить `bun rules/<id>/fix.mjs` повним
|
|
4
|
-
* еквівалентом
|
|
5
|
-
*
|
|
4
|
+
* еквівалентом `npx \@nitra/cursor fix <id>`: друкує summary, повертає aggregated exit-code.
|
|
5
|
+
*
|
|
6
|
+
* **Без whitelist-гейту.** Гейтинг активних правил — єдине джерело: `resolveCheckRuleIds`
|
|
7
|
+
* (`scripts/lib/fix/run-fix-check.mjs`) за `.n-cursor.json`. Прямий `bun rules/<id>/fix.mjs` —
|
|
8
|
+
* свідомий запуск саме цього правила (debug / override), тож виконується беззастережно;
|
|
9
|
+
* усі автоматичні шляхи (lint-конформність, orchestrator, t0, hook) уже спавнять лише активні.
|
|
6
10
|
*
|
|
7
11
|
* Library-mode виклик з CLI orchestration — інше: див. `runStandardRule` + `fix.mjs::run(ctx)`.
|
|
8
12
|
*/
|
|
9
13
|
import { basename } from 'node:path'
|
|
10
14
|
|
|
11
|
-
import { isRuleEnabled, readNCursorConfigLite } from './read-n-cursor-config-lite.mjs'
|
|
12
15
|
import { runStandardRule } from './run-standard-rule.mjs'
|
|
13
16
|
import { getOrCreateWalkCache } from '../utils/walk-cache.mjs'
|
|
14
17
|
|
|
@@ -21,16 +24,10 @@ const PACKAGE_NAME = '@nitra/cursor'
|
|
|
21
24
|
|
|
22
25
|
/**
|
|
23
26
|
* @param {string} ruleDir абсолютний шлях до `rules/<id>/`
|
|
24
|
-
* @returns {Promise<number>} 0 — OK
|
|
27
|
+
* @returns {Promise<number>} 0 — OK; 1 — порушення
|
|
25
28
|
*/
|
|
26
29
|
export async function runRuleCli(ruleDir) {
|
|
27
30
|
const ruleId = basename(ruleDir)
|
|
28
|
-
const config = await readNCursorConfigLite()
|
|
29
|
-
|
|
30
|
-
if (!isRuleEnabled(config, ruleId)) {
|
|
31
|
-
console.log(`\n🔍 ${PACKAGE_NAME} fix ${ruleId} — правило не в \`.n-cursor.json:rules\`. Пропущено.\n`)
|
|
32
|
-
return 0
|
|
33
|
-
}
|
|
34
31
|
|
|
35
32
|
console.log(`\n🔍 ${PACKAGE_NAME} fix ${ruleId} — перевірка правила\n`)
|
|
36
33
|
|