@nitra/cursor
Advanced tools
+1
-1
@@ -17,3 +17,3 @@ /** | ||
| * | ||
| * model-id з префіксом `omlx/...` маршрутизується прямим HTTP до локального | ||
| * model-id з префіксом `omlx/...` іде прямим HTTP до локального | ||
| * omlx-сервера (`npm/lib/omlx.mjs`), минаючи pi; решта (`openai/...`, | ||
@@ -20,0 +20,0 @@ * `ollama/...`, '') — через pi CLI. Тому локальні тири варто задавати у форматі |
+1
-1
| { | ||
| "name": "@nitra/cursor", | ||
| "version": "5.3.0", | ||
| "version": "5.3.1", | ||
| "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій", | ||
@@ -5,0 +5,0 @@ "keywords": [ |
@@ -45,2 +45,73 @@ /** Контракт: ./docs/header_doc_pointer.md */ | ||
| /** | ||
| * Чи `.mjs`-файл, що не є тестом (`*.test.mjs`). | ||
| * @param {import('node:fs').Dirent} fileEntry запис каталогу | ||
| * @returns {boolean} true для звичайних source-файлів | ||
| */ | ||
| function isSourceMjs(fileEntry) { | ||
| return ( | ||
| fileEntry.isFile() && fileEntry.name.endsWith('.mjs') && !fileEntry.name.endsWith('.test.mjs') | ||
| ) | ||
| } | ||
| /** | ||
| * Перевіряє один source-файл: якщо поряд є `docs/<stem>.md` і module-level JSDoc | ||
| * містить >1 непорожній рядок — репортить порушення. | ||
| * @param {string} jsDir каталог `js/` | ||
| * @param {import('node:fs').Dirent} fileEntry запис файлу | ||
| * @param {string} cwd корінь репозиторію | ||
| * @param {ReturnType<typeof createCheckReporter>} reporter репортер | ||
| * @returns {Promise<void>} | ||
| */ | ||
| async function checkSourceFile(jsDir, fileEntry, cwd, reporter) { | ||
| const stem = basename(fileEntry.name, '.mjs') | ||
| const docsPath = join(jsDir, 'docs', `${stem}.md`) | ||
| if (!existsSync(docsPath)) return | ||
| const filePath = join(jsDir, fileEntry.name) | ||
| const source = await readFile(filePath, 'utf8') | ||
| const block = moduleJsDoc(source) | ||
| if (!block) return | ||
| const count = contentLineCount(block) | ||
| if (count > 1) { | ||
| reporter.fail( | ||
| `${filePath.slice(cwd.length + 1)}: docs/${stem}.md вже описує поведінку — module-level JSDoc має бути pointer (≤1 рядок, зараз ${count})` | ||
| ) | ||
| } | ||
| } | ||
| /** | ||
| * Перевіряє всі source-файли в одному `js/`-каталозі правила/скіла. | ||
| * @param {string} jsDir каталог `js/` | ||
| * @param {string} cwd корінь репозиторію | ||
| * @param {ReturnType<typeof createCheckReporter>} reporter репортер | ||
| * @returns {Promise<void>} | ||
| */ | ||
| async function checkJsDir(jsDir, cwd, reporter) { | ||
| for (const fileEntry of await readdir(jsDir, { withFileTypes: true })) { | ||
| if (!isSourceMjs(fileEntry)) continue | ||
| await checkSourceFile(jsDir, fileEntry, cwd, reporter) | ||
| } | ||
| } | ||
| /** | ||
| * Перевіряє один base-сегмент (`npm/rules` чи `npm/skills`): обходить піддиректорії | ||
| * правил/скілів і їхні `js/`-каталоги. | ||
| * @param {string} absBase абсолютний шлях до base-сегмента | ||
| * @param {string} cwd корінь репозиторію | ||
| * @param {ReturnType<typeof createCheckReporter>} reporter репортер | ||
| * @returns {Promise<void>} | ||
| */ | ||
| async function checkBaseSegment(absBase, cwd, reporter) { | ||
| for (const ruleEntry of await readdir(absBase, { withFileTypes: true })) { | ||
| if (!ruleEntry.isDirectory() || ruleEntry.name.startsWith('.')) continue | ||
| const jsDir = join(absBase, ruleEntry.name, 'js') | ||
| if (!existsSync(jsDir)) continue | ||
| await checkJsDir(jsDir, cwd, reporter) | ||
| } | ||
| } | ||
| /** | ||
| * Сканує `npm/rules/*\/js/*.mjs` і `npm/skills/*\/js/*.mjs`. | ||
@@ -58,29 +129,3 @@ * Якщо поряд існує `docs/<stem>.md` — module-level JSDoc має бути pointer (≤1 рядок), | ||
| if (!existsSync(absBase)) continue | ||
| for (const ruleEntry of await readdir(absBase, { withFileTypes: true })) { | ||
| if (!ruleEntry.isDirectory() || ruleEntry.name.startsWith('.')) continue | ||
| const jsDir = join(absBase, ruleEntry.name, 'js') | ||
| if (!existsSync(jsDir)) continue | ||
| for (const fileEntry of await readdir(jsDir, { withFileTypes: true })) { | ||
| if (!fileEntry.isFile() || !fileEntry.name.endsWith('.mjs') || fileEntry.name.endsWith('.test.mjs')) continue | ||
| const stem = basename(fileEntry.name, '.mjs') | ||
| const docsPath = join(jsDir, 'docs', `${stem}.md`) | ||
| if (!existsSync(docsPath)) continue | ||
| const filePath = join(jsDir, fileEntry.name) | ||
| const source = await readFile(filePath, 'utf8') | ||
| const block = moduleJsDoc(source) | ||
| if (!block) continue | ||
| const count = contentLineCount(block) | ||
| if (count > 1) { | ||
| reporter.fail( | ||
| `${filePath.slice(cwd.length + 1)}: docs/${stem}.md вже описує поведінку — module-level JSDoc має бути pointer (≤1 рядок, зараз ${count})` | ||
| ) | ||
| } | ||
| } | ||
| } | ||
| await checkBaseSegment(absBase, cwd, reporter) | ||
| } | ||
@@ -87,0 +132,0 @@ |
@@ -10,2 +10,73 @@ /** @see ./docs/rule_meta.md */ | ||
| /** | ||
| * Перевіряє поле `auto` у meta.json одного правила. | ||
| * @param {string} id ідентифікатор правила | ||
| * @param {Record<string, unknown>} raw сирий meta.json | ||
| * @param {ReturnType<typeof createCheckReporter>} reporter репортер | ||
| * @returns {boolean} true, якщо поле валідне (або відсутнє) | ||
| */ | ||
| function checkAutoField(id, raw, reporter) { | ||
| if (raw.auto === undefined) return true | ||
| const spec = parseRuleAutoSpec(raw.auto) | ||
| if (spec === null) { | ||
| reporter.fail(`rules/${id}: meta.json.auto нерозпізнане (очікується "завжди" / масив / {glob} / {predicate})`) | ||
| return false | ||
| } | ||
| if ('predicate' in spec && !Object.hasOwn(RULE_PREDICATES, spec.predicate)) { | ||
| reporter.fail(`rules/${id}: невідомий predicate "${spec.predicate}" (немає в RULE_PREDICATES)`) | ||
| return false | ||
| } | ||
| return true | ||
| } | ||
| /** | ||
| * Перевіряє поле `lint` у meta.json одного правила. | ||
| * @param {string} id ідентифікатор правила | ||
| * @param {string} ruleDir каталог правила | ||
| * @param {Record<string, unknown>} raw сирий meta.json | ||
| * @param {ReturnType<typeof createCheckReporter>} reporter репортер | ||
| * @returns {boolean} true, якщо поле валідне (або відсутнє) | ||
| */ | ||
| function checkLintField(id, ruleDir, raw, reporter) { | ||
| if (raw.lint === undefined) return true | ||
| if (parseRuleLintPhase(raw.lint) === null) { | ||
| reporter.fail(`rules/${id}: meta.json.lint нерозпізнане (очікується "quick"|"ci")`) | ||
| return false | ||
| } | ||
| if (!existsSync(join(ruleDir, 'js', 'lint.mjs'))) { | ||
| reporter.fail(`rules/${id}: lint:"${raw.lint}" але немає js/lint.mjs`) | ||
| return false | ||
| } | ||
| return true | ||
| } | ||
| /** | ||
| * Валідує meta.json одного правила. | ||
| * @param {string} id ідентифікатор правила | ||
| * @param {string} ruleDir каталог правила | ||
| * @param {ReturnType<typeof createCheckReporter>} reporter репортер | ||
| * @returns {void} | ||
| */ | ||
| function checkRule(id, ruleDir, reporter) { | ||
| let ruleOk = true | ||
| if (existsSync(join(ruleDir, 'auto.md'))) { | ||
| reporter.fail(`rules/${id}: залишковий auto.md — видали (метадані тепер у meta.json)`) | ||
| ruleOk = false | ||
| } | ||
| const raw = readRuleMetaRaw(ruleDir) | ||
| if (!raw) { | ||
| reporter.fail(`rules/${id}: відсутній або невалідний meta.json`) | ||
| return | ||
| } | ||
| if (!checkAutoField(id, raw, reporter)) ruleOk = false | ||
| if (!checkLintField(id, ruleDir, raw, reporter)) ruleOk = false | ||
| if (ruleOk) { | ||
| reporter.pass(`rules/${id}: meta.json валідний`) | ||
| } | ||
| } | ||
| /** | ||
| * Валідує всі `npm/rules/<id>/meta.json`. | ||
@@ -25,38 +96,3 @@ * @param {string} [cwd] корінь репозиторію | ||
| if (!entry.isDirectory() || entry.name.startsWith('.')) continue | ||
| const id = entry.name | ||
| const ruleDir = join(rulesDir, id) | ||
| let ruleOk = true | ||
| if (existsSync(join(ruleDir, 'auto.md'))) { | ||
| reporter.fail(`rules/${id}: залишковий auto.md — видали (метадані тепер у meta.json)`) | ||
| ruleOk = false | ||
| } | ||
| const raw = readRuleMetaRaw(ruleDir) | ||
| if (!raw) { | ||
| reporter.fail(`rules/${id}: відсутній або невалідний meta.json`) | ||
| continue | ||
| } | ||
| if (raw.auto !== undefined) { | ||
| const spec = parseRuleAutoSpec(raw.auto) | ||
| if (spec === null) { | ||
| reporter.fail(`rules/${id}: meta.json.auto нерозпізнане (очікується "завжди" / масив / {glob} / {predicate})`) | ||
| ruleOk = false | ||
| } else if ('predicate' in spec && !Object.hasOwn(RULE_PREDICATES, spec.predicate)) { | ||
| reporter.fail(`rules/${id}: невідомий predicate "${spec.predicate}" (немає в RULE_PREDICATES)`) | ||
| ruleOk = false | ||
| } | ||
| } | ||
| if (raw.lint !== undefined) { | ||
| if (parseRuleLintPhase(raw.lint) === null) { | ||
| reporter.fail(`rules/${id}: meta.json.lint нерозпізнане (очікується "quick"|"ci")`) | ||
| ruleOk = false | ||
| } else if (!existsSync(join(ruleDir, 'js', 'lint.mjs'))) { | ||
| reporter.fail(`rules/${id}: lint:"${raw.lint}" але немає js/lint.mjs`) | ||
| ruleOk = false | ||
| } | ||
| } | ||
| if (ruleOk) { | ||
| reporter.pass(`rules/${id}: meta.json валідний`) | ||
| } | ||
| checkRule(entry.name, join(rulesDir, entry.name), reporter) | ||
| } | ||
@@ -63,0 +99,0 @@ |
@@ -9,2 +9,60 @@ /** @see ./docs/skill_meta.md */ | ||
| /** | ||
| * Перевіряє поля сирого meta.json одного скіла (без auto.md / відсутності файлу). | ||
| * @param {string} id ідентифікатор скіла | ||
| * @param {Record<string, unknown>} raw сирий meta.json | ||
| * @param {ReturnType<typeof createCheckReporter>} reporter репортер | ||
| * @returns {boolean} true, якщо всі поля валідні | ||
| */ | ||
| function checkSkillFields(id, raw, reporter) { | ||
| let ok = true | ||
| if (typeof raw.worktree !== 'boolean') { | ||
| reporter.fail(`skills/${id}: meta.json.worktree має бути boolean`) | ||
| ok = false | ||
| } | ||
| if (raw.auto !== undefined && parseSkillAutoSpec(raw.auto) === null) { | ||
| reporter.fail(`skills/${id}: meta.json.auto нерозпізнане — очікується "завжди" або непорожній масив правил`) | ||
| ok = false | ||
| } | ||
| if (raw.requireRoot !== undefined && typeof raw.requireRoot !== 'boolean') { | ||
| reporter.fail(`skills/${id}: meta.json.requireRoot має бути boolean`) | ||
| ok = false | ||
| } | ||
| if (raw.worktree === true && raw.requireRoot === false) { | ||
| reporter.fail( | ||
| `skills/${id}: requireRoot:false суперечить worktree:true (worktree вже вимагає кореня — прибери поле)` | ||
| ) | ||
| ok = false | ||
| } | ||
| return ok | ||
| } | ||
| /** | ||
| * Валідує meta.json одного скіла. | ||
| * @param {string} id ідентифікатор скіла | ||
| * @param {string} skillDir каталог скіла | ||
| * @param {ReturnType<typeof createCheckReporter>} reporter репортер | ||
| * @returns {void} | ||
| */ | ||
| function checkSkill(id, skillDir, reporter) { | ||
| let skillOk = true | ||
| if (existsSync(join(skillDir, 'auto.md'))) { | ||
| reporter.fail(`skills/${id}: залишковий auto.md — видали (метадані тепер у meta.json)`) | ||
| skillOk = false | ||
| } | ||
| const raw = readSkillMetaRaw(skillDir) | ||
| if (!raw) { | ||
| reporter.fail(`skills/${id}: відсутній або невалідний meta.json (очікується {"auto"?, "worktree": bool})`) | ||
| return | ||
| } | ||
| if (!checkSkillFields(id, raw, reporter)) skillOk = false | ||
| if (skillOk) { | ||
| reporter.pass(`skills/${id}: meta.json валідний`) | ||
| } | ||
| } | ||
| /** | ||
| * Валідує всі `npm/skills/<id>/meta.json`. | ||
@@ -24,37 +82,3 @@ * @param {string} [cwd] корінь репозиторію | ||
| if (!entry.isDirectory() || entry.name.startsWith('.')) continue | ||
| const id = entry.name | ||
| const skillDir = join(skillsDir, id) | ||
| let skillOk = true | ||
| if (existsSync(join(skillDir, 'auto.md'))) { | ||
| reporter.fail(`skills/${id}: залишковий auto.md — видали (метадані тепер у meta.json)`) | ||
| skillOk = false | ||
| } | ||
| const raw = readSkillMetaRaw(skillDir) | ||
| if (!raw) { | ||
| reporter.fail(`skills/${id}: відсутній або невалідний meta.json (очікується {"auto"?, "worktree": bool})`) | ||
| continue | ||
| } | ||
| if (typeof raw.worktree !== 'boolean') { | ||
| reporter.fail(`skills/${id}: meta.json.worktree має бути boolean`) | ||
| skillOk = false | ||
| } | ||
| if (raw.auto !== undefined && parseSkillAutoSpec(raw.auto) === null) { | ||
| reporter.fail(`skills/${id}: meta.json.auto нерозпізнане — очікується "завжди" або непорожній масив правил`) | ||
| skillOk = false | ||
| } | ||
| if (raw.requireRoot !== undefined && typeof raw.requireRoot !== 'boolean') { | ||
| reporter.fail(`skills/${id}: meta.json.requireRoot має бути boolean`) | ||
| skillOk = false | ||
| } | ||
| if (raw.worktree === true && raw.requireRoot === false) { | ||
| reporter.fail( | ||
| `skills/${id}: requireRoot:false суперечить worktree:true (worktree вже вимагає кореня — прибери поле)` | ||
| ) | ||
| skillOk = false | ||
| } | ||
| if (skillOk) { | ||
| reporter.pass(`skills/${id}: meta.json валідний`) | ||
| } | ||
| checkSkill(entry.name, join(skillsDir, entry.name), reporter) | ||
| } | ||
@@ -61,0 +85,0 @@ |
@@ -33,3 +33,3 @@ /** | ||
| * @returns {string} текст відповіді моделі | ||
| * @throws якщо backend недоступний або повертає помилку | ||
| * @throws {Error} якщо backend недоступний або повертає помилку | ||
| */ | ||
@@ -72,3 +72,3 @@ function callModel(prompt, model) { | ||
| * @param {string} cwd корінь проєкту | ||
| * @param {{cachePath?: string, callModel?: Function}} [opts] ін'єкції для тестів | ||
| * @param {{cachePath?: string, callModel?: (prompt: string, model: string) => string}} [opts] ін'єкції для тестів | ||
| * @returns {Promise<Array<{key: string, verdict: object}>>} verdicts | ||
@@ -75,0 +75,0 @@ */ |
@@ -25,3 +25,3 @@ /** | ||
| * @returns {{verdict: string, confidence: number, reason: string, suggestedTest?: string}} verdict | ||
| * @throws якщо JSON не знайдено, не парситься, або не відповідає схемі | ||
| * @throws {Error} якщо JSON не знайдено, не парситься, або не відповідає схемі | ||
| */ | ||
@@ -28,0 +28,0 @@ export function parseVerdict(rawText) { |
| /** | ||
| * Guard для дефолтної синхронізації `npx @nitra/cursor` (гілка без підкоманди). | ||
| * Guard для дефолтної синхронізації `npx \@nitra/cursor` (гілка без підкоманди). | ||
| * | ||
@@ -4,0 +4,0 @@ * Дефолтний sync (`runSync` у `bin/n-cursor.js`) скаффолдить у `cwd()` керовані |
| /** | ||
| * Визначає список id правил для `npx @nitra/cursor fix` без аргументів: | ||
| * Визначає список id правил для `npx \@nitra/cursor fix` без аргументів: | ||
| * зчитує базові імена `*.mdc` у `.cursor/rules/` і залишає лише ті id, | ||
@@ -4,0 +4,0 @@ * для яких у пакеті є programmatic перевірка (JS-концерн або policy з target.json). |
@@ -26,6 +26,23 @@ /** | ||
| const wanted = new Set(keys) | ||
| let found = false | ||
| /** @param {string} dir каталог обходу @returns {Promise<void>} */ | ||
| /** | ||
| * Чи package.json за `abs` оголошує будь-який пакет із `wanted` у `dependencies`. | ||
| * @param {string} abs шлях до package.json | ||
| * @returns {Promise<boolean>} true, якщо знайдено хоч один | ||
| */ | ||
| async function pkgDeclaresWanted(abs) { | ||
| try { | ||
| const deps = JSON.parse(await readFile(abs, 'utf8'))?.dependencies | ||
| if (deps && typeof deps === 'object' && !Array.isArray(deps)) { | ||
| for (const k of wanted) if (Object.hasOwn(deps, k)) return true | ||
| } | ||
| } catch { | ||
| /* ігноруємо пошкоджені package.json */ | ||
| } | ||
| return false | ||
| } | ||
| /** | ||
| * @param {string} dir каталог обходу | ||
| * @returns {Promise<boolean>} true, якщо знайдено хоч один пакет | ||
| */ | ||
| async function walk(dir) { | ||
| if (found) return | ||
| let entries | ||
@@ -35,23 +52,15 @@ try { | ||
| } catch { | ||
| return | ||
| return false | ||
| } | ||
| for (const entry of entries) { | ||
| if (found) return | ||
| const abs = join(dir, entry.name) | ||
| if (entry.isDirectory()) { | ||
| if (!IGNORED_DIR_NAMES.has(entry.name)) await walk(abs) | ||
| } else if (entry.isFile() && entry.name === 'package.json') { | ||
| try { | ||
| const deps = JSON.parse(await readFile(abs, 'utf8'))?.dependencies | ||
| if (deps && typeof deps === 'object' && !Array.isArray(deps)) { | ||
| for (const k of wanted) if (Object.hasOwn(deps, k)) found = true | ||
| } | ||
| } catch { | ||
| /* ігноруємо пошкоджені package.json */ | ||
| } | ||
| if (!IGNORED_DIR_NAMES.has(entry.name) && (await walk(abs))) return true | ||
| } else if (entry.isFile() && entry.name === 'package.json' && (await pkgDeclaresWanted(abs))) { | ||
| return true | ||
| } | ||
| } | ||
| return false | ||
| } | ||
| await walk(root) | ||
| return found | ||
| return walk(root) | ||
| } | ||
@@ -67,3 +76,6 @@ | ||
| let result = false | ||
| /** @param {string} dir каталог @returns {Promise<void>} */ | ||
| /** | ||
| * @param {string} dir каталог | ||
| * @returns {Promise<void>} | ||
| */ | ||
| async function walk(dir) { | ||
@@ -70,0 +82,0 @@ if (result) return |
| /** | ||
| * Standalone CLI runner для одного правила. Викликається з `rules/<id>/fix.mjs` | ||
| * у блоці `if (import.meta.main)` — це робить `bun rules/<id>/fix.mjs` повним | ||
| * еквівалентом старого `npx @nitra/cursor fix <id>`: читає `.n-cursor.json`, | ||
| * еквівалентом старого `npx \@nitra/cursor fix <id>`: читає `.n-cursor.json`, | ||
| * перевіряє whitelist, друкує summary, повертає aggregated exit-code. | ||
@@ -6,0 +6,0 @@ * |
@@ -8,3 +8,3 @@ /** | ||
| * Серіалізація: загортає виконання у `withLock('fix-<ruleId>')` — паралельні запуски | ||
| * того самого правила (через `npx @nitra/cursor fix`, прямий `bun rules/<id>/fix.mjs` | ||
| * того самого правила (через `npx \@nitra/cursor fix`, прямий `bun rules/<id>/fix.mjs` | ||
| * чи `run(ctx)`-композицію) дедупляться за станом git-дерева; різні правила можуть | ||
@@ -11,0 +11,0 @@ * виконуватись паралельно. Точка інтеграції — тут, щоб не дублювати лок у кожному |
| /** | ||
| * PostToolUse hook для Claude Code: точкова маршрутизація `npx @nitra/cursor fix` | ||
| * PostToolUse hook для Claude Code: точкова маршрутизація `npx \@nitra/cursor fix` | ||
| * за типом зміненого файла. Запускається після кожного `Edit` / `Write` / `MultiEdit`; | ||
@@ -11,3 +11,3 @@ * замінює дорогий синхронний `Stop`-хук, що ганяв повний `fix` усіх правил на кожному | ||
| * але ми лишаємо exit-код прозорим — для діагностики); | ||
| * - інакше spawn `npx --no @nitra/cursor fix <rules…>` із передаванням exit-коду. | ||
| * - інакше spawn `npx --no \@nitra/cursor fix <rules…>` із передаванням exit-коду. | ||
| * | ||
@@ -50,3 +50,3 @@ * Маршрути впорядковані від найбільш специфічного до загального; перший збіг — переможець. | ||
| * @param {unknown} filePath відносний шлях зміненого файла зі stdin Claude Code | ||
| * @returns {string[]} ID правил для `npx @nitra/cursor fix` | ||
| * @returns {string[]} ID правил для `npx \@nitra/cursor fix` | ||
| */ | ||
@@ -53,0 +53,0 @@ export function routeFilePathToRules(filePath) { |
@@ -9,7 +9,7 @@ /** | ||
| * Підтримувані формати: | ||
| * `npx @nitra/cursor skill list` | ||
| * `npx @nitra/cursor skill taze` | ||
| * `npx @nitra/cursor skill cursor taze` | ||
| * `npx @nitra/cursor skill cursor taze "онови залежності"` | ||
| * `npx @nitra/cursor skill claude taze` — те саме через Claude Code CLI | ||
| * `npx \@nitra/cursor skill list` | ||
| * `npx \@nitra/cursor skill taze` | ||
| * `npx \@nitra/cursor skill cursor taze` | ||
| * `npx \@nitra/cursor skill cursor taze "онови залежності"` | ||
| * `npx \@nitra/cursor skill claude taze` — те саме через Claude Code CLI | ||
| */ | ||
@@ -16,0 +16,0 @@ |
@@ -82,3 +82,3 @@ /** | ||
| * @param {string[]} rest [branch, ...descParts] | ||
| * @param {{ cwd: string, log: Function, logError: Function, now: () => Date }} ctx контекст | ||
| * @param {{ cwd: string, log: (line: string) => void, logError: (line: string) => void, now: () => Date }} ctx контекст | ||
| * @returns {number} exit code | ||
@@ -139,3 +139,3 @@ */ | ||
| * @param {string[]} rest [branch, ...flags] | ||
| * @param {{ cwd: string, log: Function, logError: Function }} ctx контекст | ||
| * @param {{ cwd: string, log: (line: string) => void, logError: (line: string) => void }} ctx контекст | ||
| * @returns {number} exit code | ||
@@ -172,3 +172,3 @@ */ | ||
| * list: git worktree list + вміст .md-описів. | ||
| * @param {{ cwd: string, log: Function }} ctx контекст | ||
| * @param {{ cwd: string, log: (line: string) => void }} ctx контекст | ||
| * @returns {number} exit code | ||
@@ -187,3 +187,3 @@ */ | ||
| * prune: git worktree prune + видалити осиротілі .md. | ||
| * @param {{ cwd: string, log: Function }} ctx контекст | ||
| * @param {{ cwd: string, log: (line: string) => void }} ctx контекст | ||
| * @returns {number} exit code | ||
@@ -205,3 +205,3 @@ */ | ||
| * @param {string[]} argv аргументи після `worktree` | ||
| * @param {{ cwd?: string, log?: Function, logError?: Function, now?: () => Date }} [options] ін'єкція для тестів | ||
| * @param {{ cwd?: string, log?: (line: string) => void, logError?: (line: string) => void, now?: () => Date }} [options] ін'єкція для тестів | ||
| * @returns {Promise<number>} exit code | ||
@@ -208,0 +208,0 @@ */ |
@@ -68,3 +68,3 @@ /** @see ./docs/docgen-extract.md */ | ||
| /** | ||
| * Опис (без @-тегів) + параметри з @param як «name — опис». | ||
| * Опис (без @-тегів) + параметри з `@param` як «name — опис». | ||
| * @param {string} raw сирий JSDoc-блок | ||
@@ -71,0 +71,0 @@ * @returns {{desc:string, params:Array<{name:string, desc:string}>, ret:string}} розпарсений опис, параметри й опис повернення |
@@ -83,2 +83,64 @@ /** | ||
| /** | ||
| * Текст-суфікс режиму для прогрес-рядка. | ||
| * @param {{ overwrite: boolean, retryDegraded: boolean }} mode режими | ||
| * @returns {string} ` (--overwrite)` / ` (--retry-degraded)` / порожній рядок | ||
| */ | ||
| function modeSuffix({ overwrite, retryDegraded }) { | ||
| if (overwrite) return ' (--overwrite)' | ||
| if (retryDegraded) return ' (--retry-degraded)' | ||
| return '' | ||
| } | ||
| /** | ||
| * Генерує й штампує доку для одного файлу, оновлюючи лічильники й прогрес. | ||
| * @param {object} file елемент scanForDocFiles | ||
| * @param {string} root абсолютний корінь | ||
| * @param {{ done: number, total: number }} progress позиція у прогресі | ||
| * @param {{ ok: number, degraded: number, err: number, errors: string[] }} stats акумулятор статистики | ||
| * @returns {Promise<void>} | ||
| */ | ||
| async function generateOne(file, root, progress, stats) { | ||
| const sourceAbs = join(root, file.sourcePath) | ||
| process.stdout.write(` [${progress.done}/${progress.total}] ${file.sourcePath} … `) | ||
| try { | ||
| const docAbs = join(root, file.docPath) | ||
| // Варіант B: передаємо наявну доку, щоб зберегти захищену секцію «Призначення» | ||
| const existingMd = existsSync(docAbs) ? readFileSync(docAbs, 'utf8') : null | ||
| const result = await generateDoc(sourceAbs, { existingMd }) | ||
| const crc = crc32(readFileSync(sourceAbs)) | ||
| mkdirSync(dirname(docAbs), { recursive: true }) | ||
| const quality = | ||
| result.score === null ? null : { score: result.score, issues: result.degraded ? result.issues : [] } | ||
| writeFileSync(docAbs, stampDoc(result.md, file.sourcePath, crc, quality)) | ||
| stats.ok++ | ||
| if (result.degraded) { | ||
| stats.degraded++ | ||
| process.stdout.write(`⚠ degraded score=${result.score} crc=${crc}\n`) | ||
| } else { | ||
| process.stdout.write(`✓ score=${result.score ?? '—'} crc=${crc}\n`) | ||
| } | ||
| } catch (error) { | ||
| stats.err++ | ||
| stats.errors.push(file.sourcePath) | ||
| process.stdout.write(`✗ ${error.message}\n`) | ||
| } | ||
| } | ||
| /** | ||
| * Підсумковий звіт прогону у stdout. | ||
| * @param {{ ok: number, degraded: number, err: number, errors: string[] }} stats статистика | ||
| * @returns {void} | ||
| */ | ||
| function reportStats(stats) { | ||
| console.log(`\n${'─'.repeat(50)}\n✓ OK: ${stats.ok} ⚠ degraded: ${stats.degraded} ✗ Err: ${stats.err}`) | ||
| if (stats.errors.length > 0) { | ||
| console.log('Помилки:') | ||
| for (const e of stats.errors) console.log(` - ${e}`) | ||
| } | ||
| if (stats.degraded > 0) { | ||
| console.log(`Degraded-доки перегенеровуються пізніше: npx @nitra/cursor doc-files gen --retry-degraded`) | ||
| } | ||
| } | ||
| /** | ||
| * `doc-files gen` — згенерувати документацію для застарілих/відсутніх док. | ||
@@ -110,6 +172,3 @@ * @param {string[]} argv аргументи після назви субкоманди | ||
| let modeTxt = '' | ||
| if (overwrite) modeTxt = ' (--overwrite)' | ||
| else if (retryDegraded) modeTxt = ' (--retry-degraded)' | ||
| console.log(`📋 doc-files: до генерації ${targets.length} файл(ів)${modeTxt}`) | ||
| console.log(`📋 doc-files: до генерації ${targets.length} файл(ів)${modeSuffix({ overwrite, retryDegraded })}`) | ||
| const stats = { ok: 0, degraded: 0, err: 0, errors: [] } | ||
@@ -120,34 +179,6 @@ | ||
| done++ | ||
| const sourceAbs = join(root, file.sourcePath) | ||
| process.stdout.write(` [${done}/${targets.length}] ${file.sourcePath} … `) | ||
| try { | ||
| const result = await generateDoc(sourceAbs) | ||
| const crc = crc32(readFileSync(sourceAbs)) | ||
| const docAbs = join(root, file.docPath) | ||
| mkdirSync(dirname(docAbs), { recursive: true }) | ||
| const quality = | ||
| result.score === null ? null : { score: result.score, issues: result.degraded ? result.issues : [] } | ||
| writeFileSync(docAbs, stampDoc(result.md, file.sourcePath, crc, quality)) | ||
| stats.ok++ | ||
| if (result.degraded) { | ||
| stats.degraded++ | ||
| process.stdout.write(`⚠ degraded score=${result.score} crc=${crc}\n`) | ||
| } else { | ||
| process.stdout.write(`✓ score=${result.score ?? '—'} crc=${crc}\n`) | ||
| } | ||
| } catch (error) { | ||
| stats.err++ | ||
| stats.errors.push(file.sourcePath) | ||
| process.stdout.write(`✗ ${error.message}\n`) | ||
| } | ||
| await generateOne(file, root, { done, total: targets.length }, stats) | ||
| } | ||
| console.log(`\n${'─'.repeat(50)}\n✓ OK: ${stats.ok} ⚠ degraded: ${stats.degraded} ✗ Err: ${stats.err}`) | ||
| if (stats.errors.length > 0) { | ||
| console.log('Помилки:') | ||
| for (const e of stats.errors) console.log(` - ${e}`) | ||
| } | ||
| if (stats.degraded > 0) { | ||
| console.log(`Degraded-доки перегенеровуються пізніше: npx @nitra/cursor doc-files gen --retry-degraded`) | ||
| } | ||
| reportStats(stats) | ||
| return stats.err > 0 ? 1 : 0 | ||
@@ -154,0 +185,0 @@ } |
| /** @see ./docs/docgen-gen.md */ | ||
| import { readFileSync } from 'node:fs' | ||
| import { readFileSync, existsSync } from 'node:fs' | ||
| import { basename } from 'node:path' | ||
@@ -9,2 +9,3 @@ import { env } from 'node:process' | ||
| import { isRunAsCli } from '../../../scripts/cli-entry.mjs' | ||
| import { docPathForSource } from './docgen-scan.mjs' | ||
| import { extractFacts } from './docgen-extract.mjs' | ||
@@ -30,5 +31,12 @@ import { extractAnchors, anchorTokens } from './docgen-extract-anchors.mjs' | ||
| const CRITIC_NONE_RE = /^\s*NONE\s*$/i | ||
| // R4: абстрактні «нічого-не-кажучі» формули, які обходять exact-blocklist і дають score=100 | ||
| const GENERIC_RE = | ||
| /відповідност\S*\s+(?:даних\s+)?(?:визначеному\s+)?контракту|валідаці\S*\s+даних|перевірк\S*\s+(?:відповідності\s+)?даних|обробк\S*\s+даних|застосову\S*\s+логіку|інспекту\S*\s+та\s+збира\S*\s+дан/i | ||
| // R4: абстрактні «нічого-не-кажучі» формули, які обходять exact-blocklist і дають score=100. | ||
| // Масив дрібних патернів замість однієї alternation-regex (sonarjs/regex-complexity); .some() еквівалентний. | ||
| const GENERIC_RES = [ | ||
| /відповідност\S*\s+(?:даних\s+)?(?:визначеному\s+)?контракту/i, | ||
| /валідаці\S*\s+даних/i, | ||
| /перевірк\S*\s+(?:відповідності\s+)?даних/i, | ||
| /обробк\S*\s+даних/i, | ||
| /застосову\S*\s+логіку/i, | ||
| /інспекту\S*\s+та\s+збира\S*\s+дан/i | ||
| ] | ||
| // R7: часті русизми/суржик (курований безпечний список — без false-positive на нормальній мові). | ||
@@ -40,2 +48,8 @@ // Без \b: кирилиця не є ASCII-`\w`, тож межі слова в JS-regex не спрацьовують — терміни специфічні. | ||
| const ANCHOR_MISS_CAP = 20 | ||
| // Захищена людино-керована секція (Варіант B): дослівно зберігається, ніколи не | ||
| // перезаписується LLM-виходом, виключена зі скорингу. Opt-in = сам факт наявності. | ||
| const PROTECTED_HEADING = 'Призначення' | ||
| const PROTECTED_START_RE = /^##\s+Призначення\s*$/ | ||
| const H2_RE = /^##\s/ | ||
| const H1_RE = /^#\s/ | ||
@@ -89,2 +103,39 @@ /** | ||
| /** | ||
| * Відокремлює захищену секцію `## Призначення` (Варіант B). Межа — наступний `## ` | ||
| * (H2); `###`+ усередині не обривають блок. | ||
| * @param {string} md документ | ||
| * @returns {{ body: string|null, without: string }} тіло блоку (або null) і md без нього | ||
| */ | ||
| export function splitProtected(md) { | ||
| const lines = md.split('\n') | ||
| const start = lines.findIndex(l => PROTECTED_START_RE.test(l)) | ||
| if (start === -1) return { body: null, without: md } | ||
| let end = lines.length | ||
| for (let i = start + 1; i < lines.length; i++) { | ||
| if (H2_RE.test(lines[i])) { | ||
| end = i | ||
| break | ||
| } | ||
| } | ||
| const body = lines.slice(start + 1, end).join('\n').trim() | ||
| const without = [...lines.slice(0, start), ...lines.slice(end)].join('\n') | ||
| return { body: body || null, without } | ||
| } | ||
| /** | ||
| * Вставляє захищений блок `## Призначення` одразу після H1 (фіксована позиція). | ||
| * @param {string} md машинно-згенерований документ (без блоку) | ||
| * @param {string|null} intent тіло блоку або null | ||
| * @returns {string} документ із блоком (або без змін, якщо intent порожній) | ||
| */ | ||
| export function insertProtected(md, intent) { | ||
| if (!intent) return md | ||
| const lines = md.split('\n') | ||
| const h1 = lines.findIndex(l => H1_RE.test(l)) | ||
| const at = h1 === -1 ? 0 : h1 + 1 | ||
| lines.splice(at, 0, '', `## ${PROTECTED_HEADING}`, '', intent) | ||
| return lines.join('\n') | ||
| } | ||
| /** | ||
| * Чи містить текст бектік-обгорнуте імʼя символу (`sym`) — уникає substring false positives. | ||
@@ -100,2 +151,41 @@ * @param {string} text текст секції | ||
| /** | ||
| * R6: штраф за службові (неекспортовані) символи, подані як публічні. | ||
| * @param {object} facts факт-лист про файл | ||
| * @param {{ overview: string, behavior: string, api: string, guarantees: string }} secs тексти секцій | ||
| * @param {string[]} issues акумулятор кодів проблем (мутується) | ||
| * @returns {number} сумарний штраф (≥0) | ||
| */ | ||
| function internalSymbolPenalty(facts, { overview, behavior, api, guarantees }, issues) { | ||
| let penalty = 0 | ||
| for (const sym of [...(facts.internalSymbols ?? []), ...(facts.localSymbols ?? [])]) { | ||
| const inDoc = hasName(guarantees, sym) || hasName(overview, sym) || hasName(behavior, sym) || hasName(api, sym) | ||
| if (inDoc) { | ||
| penalty += 10 | ||
| issues.push(`internal-name:${sym}`) | ||
| } | ||
| } | ||
| return penalty | ||
| } | ||
| /** | ||
| * R5: штраф за відсутні в документі валідні анкори (дослівні підрядки src). | ||
| * @param {string} md зібраний документ | ||
| * @param {object} anchors анкори файлу | ||
| * @param {string} src вміст файлу | ||
| * @param {string[]} issues акумулятор кодів проблем (мутується) | ||
| * @returns {number} штраф, обмежений ANCHOR_MISS_CAP | ||
| */ | ||
| function anchorMissPenalty(md, anchors, src, issues) { | ||
| let penalty = 0 | ||
| for (const tok of anchorTokens(anchors)) { | ||
| if (!src.includes(tok)) continue // валідність: фейковий анкор не вимагаємо | ||
| if (!md.includes(tok) && penalty < ANCHOR_MISS_CAP) { | ||
| penalty += ANCHOR_MISS_PENALTY | ||
| issues.push(`anchor-miss:${tok}`) | ||
| } | ||
| } | ||
| return penalty | ||
| } | ||
| /** | ||
| * Stage 2.5 — детермінований скоринг (0 токенів): перевіряє вихід проти фактів. | ||
@@ -119,3 +209,3 @@ * @param {string} md зібраний документ | ||
| // R4: generic-Огляд (парафрази, які обходять exact-blocklist) — як майже-відсутній. | ||
| if (GENERIC_RE.test(overview)) { | ||
| if (GENERIC_RES.some(re => re.test(overview))) { | ||
| score -= 35 | ||
@@ -142,25 +232,11 @@ issues.push('generic-overview') | ||
| const api = s['публічнийapi'] ?? '' | ||
| for (const sym of [...(facts.internalSymbols ?? []), ...(facts.localSymbols ?? [])]) { | ||
| const inDoc = hasName(guarantees, sym) || hasName(overview, sym) || hasName(behavior, sym) || hasName(api, sym) | ||
| if (inDoc) { | ||
| score -= 10 | ||
| issues.push(`internal-name:${sym}`) | ||
| } | ||
| } | ||
| score -= internalSymbolPenalty(facts, { overview, behavior, api, guarantees }, issues) | ||
| // R5: кожен валідний анкор (дослівний підрядок src) має зʼявитися в документі | ||
| if (anchors && src) { | ||
| let missPenalty = 0 | ||
| for (const tok of anchorTokens(anchors)) { | ||
| if (!src.includes(tok)) continue // валідність: фейковий анкор не вимагаємо | ||
| if (!md.includes(tok) && missPenalty < ANCHOR_MISS_CAP) { | ||
| missPenalty += ANCHOR_MISS_PENALTY | ||
| issues.push(`anchor-miss:${tok}`) | ||
| } | ||
| } | ||
| score -= missPenalty | ||
| score -= anchorMissPenalty(md, anchors, src, issues) | ||
| } | ||
| // R7: суржик/русизми | ||
| if (SURZHIK_RE.test(md)) { | ||
| // R7: суржик/русизми — лише в машинних секціях (захищене «Призначення» — людське, не штрафуємо) | ||
| if (SURZHIK_RE.test(splitProtected(md).without)) { | ||
| score -= 10 | ||
@@ -209,9 +285,10 @@ issues.push('surzhik') | ||
| * @param {number} [timeoutMs] ліміт на виклик | ||
| * @param {{ intent?: string|null }} [opts] захищена секція «Призначення» для збереження | ||
| * @returns {{ md: string }} зібраний документ | ||
| */ | ||
| function oneShotDoc(facts, src, model, timeoutMs = LOCAL_TIMEOUT_MS) { | ||
| function oneShotDoc(facts, src, model, timeoutMs = LOCAL_TIMEOUT_MS, { intent = null } = {}) { | ||
| const text = callLlm(oneShotMessages(facts, src), model, { timeoutMs }) | ||
| let md = stripSignatures(stripSection(text)) | ||
| if (!md.startsWith('#')) md = `# ${basename(facts.relPath)}\n\n${md}` | ||
| return { md: md + '\n' } | ||
| return { md: insertProtected(md + '\n', intent) } | ||
| } | ||
@@ -247,6 +324,6 @@ | ||
| * @param {number} timeoutMs ліміт на один виклик | ||
| * @param {{ anchors?: object|null, temperature?: number }} [opts] анкори й температура семплінгу | ||
| * @param {{ anchors?: object|null, temperature?: number, intent?: string|null }} [opts] анкори, температура, захищена секція як контекст | ||
| * @returns {{ md: string }} зібраний документ | ||
| */ | ||
| function orchestratedDoc(facts, src, model, timeoutMs, { anchors = null, temperature = 0.2 } = {}) { | ||
| function orchestratedDoc(facts, src, model, timeoutMs, { anchors = null, temperature = 0.2, intent = null } = {}) { | ||
| const sections = {} | ||
@@ -257,3 +334,3 @@ const anc = anchors ?? extractAnchors(src) | ||
| // Спершу Поведінка (+API) — секції з фактажем | ||
| for (const s of sectionMessages(facts, src, anc)) { | ||
| for (const s of sectionMessages(facts, src, anc, intent)) { | ||
| let draft = stripSignatures(stripSection(callLlm(s.messages, model, { timeoutMs, temperature }))) | ||
@@ -268,7 +345,10 @@ // E2: critique→refine для API, коли всі описи порожні (модель зриває на generic) | ||
| let overview = stripSignatures( | ||
| stripSection(callLlm(overviewMessages(facts, sections.behavior ?? '', anc), model, { timeoutMs, temperature })) | ||
| stripSection( | ||
| callLlm(overviewMessages(facts, sections.behavior ?? '', anc, intent), model, { timeoutMs, temperature }) | ||
| ) | ||
| ) | ||
| overview = critiqueRefineSection('overview', overview, facts, anc, model, timeoutMs) | ||
| sections.overview = overview | ||
| return { md: assemble(basename(facts.relPath), sections) } | ||
| // Варіант B: дослівно повертаємо захищений блок у фіксовану позицію | ||
| return { md: insertProtected(assemble(basename(facts.relPath), sections), intent) } | ||
| } | ||
@@ -293,6 +373,6 @@ | ||
| * @param {string} file абсолютний шлях джерела | ||
| * @param {{ model?: string, threshold?: number }} [opts] model-id і поріг degraded | ||
| * @param {{ model?: string, threshold?: number, existingMd?: string|null }} [opts] model-id, поріг degraded, наявна дока (для збереження захищеної секції) | ||
| * @returns {{ md: string, ms: number, score: number|null, issues: string[], degraded: boolean, model: string }} документ і метадані генерації | ||
| */ | ||
| export function generateDoc(file, { model = DEFAULT_LOCAL_MODEL, threshold = QUALITY_THRESHOLD } = {}) { | ||
| export function generateDoc(file, { model = DEFAULT_LOCAL_MODEL, threshold = QUALITY_THRESHOLD, existingMd = null } = {}) { | ||
| const src = readFileSync(file, 'utf8') | ||
@@ -302,6 +382,8 @@ const facts = extractFacts(src, file) | ||
| // Варіант B: захищена секція «Призначення» з наявної доки — зберегти й подати як контекст | ||
| const intent = existingMd ? splitProtected(existingMd).body : null | ||
| const anchors = facts.unsupported ? null : extractAnchors(src) | ||
| let r = facts.unsupported | ||
| ? oneShotDoc(facts, src, model) | ||
| : orchestratedDoc(facts, src, model, LOCAL_TIMEOUT_MS, { anchors }) | ||
| ? oneShotDoc(facts, src, model, LOCAL_TIMEOUT_MS, { intent }) | ||
| : orchestratedDoc(facts, src, model, LOCAL_TIMEOUT_MS, { anchors, intent }) | ||
@@ -319,3 +401,3 @@ // unsupported (vue/py до юніт-шару): скорер не застосовний — score=null, не degraded | ||
| try { | ||
| const r2 = orchestratedDoc(facts, src, model, LOCAL_TIMEOUT_MS, { anchors, temperature: 0.5 }) | ||
| const r2 = orchestratedDoc(facts, src, model, LOCAL_TIMEOUT_MS, { anchors, temperature: 0.5, intent }) | ||
| const s2 = scoreDoc(r2.md, facts, { anchors, src }) | ||
@@ -346,3 +428,6 @@ if (s2.score > score) { | ||
| } | ||
| const r = generateDoc(file, { model }) | ||
| // Зберегти захищену секцію «Призначення», якщо дока вже існує | ||
| const docPath = docPathForSource(file) | ||
| const existingMd = existsSync(docPath) ? readFileSync(docPath, 'utf8') : null | ||
| const r = generateDoc(file, { model, existingMd }) | ||
| const issuesTxt = r.issues?.length ? ` issues=${r.issues.join(',')}` : '' | ||
@@ -349,0 +434,0 @@ process.stderr.write(`[local ${r.model}] ${r.ms}ms / score=${r.score}${r.degraded ? ' DEGRADED' : ''}${issuesTxt}\n`) |
@@ -54,2 +54,13 @@ /** @see ./docs/docgen-prompts.md */ | ||
| /** | ||
| * Блок read-only авторитетного контексту із захищеної секції «Призначення» | ||
| * (Варіант B): машинні секції мають узгоджуватися з ним і НЕ дублювати його. | ||
| * @param {string|null} intent тіло секції «Призначення» або null | ||
| * @returns {string} текстовий блок для system-промпта або порожній рядок | ||
| */ | ||
| function intentContext(intent) { | ||
| if (!intent) return '' | ||
| return `\n\nАВТОРИТЕТНИЙ КОНТЕКСТ (секція «Призначення», написана людиною — НЕ повторюй дослівно, узгоджуйся й доповнюй):\n${intent}` | ||
| } | ||
| /** | ||
| * Секційні набори messages з МІНІМАЛЬНИМ контекстом під кожну секцію. | ||
@@ -61,7 +72,9 @@ * Код потрапляє лише в `behavior`; «Огляд» генерується окремо ОСТАННІМ | ||
| * @param {object|null} [anchors] анкори файлу для обовʼязкового включення | ||
| * @param {string|null} [intent] захищена секція «Призначення» як read-only контекст | ||
| * @returns {Array<{key:string, messages:object[], numPredict:number}>} набір секційних промптів (behavior[, api]) | ||
| */ | ||
| export function sectionMessages(facts, src, anchors = null) { | ||
| export function sectionMessages(facts, src, anchors = null, intent = null) { | ||
| const factsTxt = factsSummary(facts) | ||
| const anch = anchorsBlock(anchors) | ||
| const intentCtx = intentContext(intent) | ||
| const multi = (facts.exports?.length || 0) > 1 | ||
@@ -84,3 +97,3 @@ | ||
| messages: msgs( | ||
| `${STYLE}\n\nФАЙЛ ${facts.relPath}:\n\`\`\`\n${src}\n\`\`\`\n\nВІДОМІ ФАКТИ:\n${factsTxt}${anch}`, | ||
| `${STYLE}\n\nФАЙЛ ${facts.relPath}:\n\`\`\`\n${src}\n\`\`\`\n\nВІДОМІ ФАКТИ:\n${factsTxt}${anch}${intentCtx}`, | ||
| `Напиши вміст секції «Поведінка»: ${behaviorTask}.${onlyExports} Якщо у фактах є свідомі пропуски шляхів — згадай їх там, де доречно (не вигадуй інших «не перевіряє»). НЕ пиши аргументи функцій у дужках, без regex.${noInternal} Без заголовка, без додаткових ## чи # підзаголовків усередині секції.` | ||
@@ -110,10 +123,12 @@ ) | ||
| * @param {object|null} [anchors] анкори файлу | ||
| * @param {string|null} [intent] захищена секція «Призначення» як read-only контекст | ||
| * @returns {Array<{role:string,content:string}>} messages-масив для Огляду | ||
| */ | ||
| export function overviewMessages(facts, behaviorText, anchors = null) { | ||
| export function overviewMessages(facts, behaviorText, anchors = null, intent = null) { | ||
| const factsTxt = factsSummary(facts) | ||
| const anch = anchorsBlock(anchors) | ||
| const dedup = intent ? ' Не дублюй секцію «Призначення».' : '' | ||
| return msgs( | ||
| `${STYLE}\n\nВІДОМІ ФАКТИ:\n${factsTxt}${anch}`, | ||
| `На основі вже написаної секції «Поведінка» (нижче) напиши «Огляд»: 1-3 речення — що файл робить і навіщо існує (роль у системі). Узагальнюй САМЕ описану поведінку, не додавай нових фактів. Без заголовка, без переліку функцій. Заборонені абстрактні формули без конкретики («перевірка/валідація/обробка даних», «відповідність контракту», «застосовує логіку») — пиши, ЩО саме і за яким контрактом.\n\nПОВЕДІНКА:\n${behaviorText}` | ||
| `${STYLE}\n\nВІДОМІ ФАКТИ:\n${factsTxt}${anch}${intentContext(intent)}`, | ||
| `На основі вже написаної секції «Поведінка» (нижче) напиши «Огляд»: 1-3 речення — що файл робить і навіщо існує (роль у системі). Узагальнюй САМЕ описану поведінку, не додавай нових фактів. Без заголовка, без переліку функцій. Заборонені абстрактні формули без конкретики («перевірка/валідація/обробка даних», «відповідність контракту», «застосовує логіку») — пиши, ЩО саме і за яким контрактом.${dedup}\n\nПОВЕДІНКА:\n${behaviorText}` | ||
| ) | ||
@@ -120,0 +135,0 @@ } |
@@ -14,9 +14,7 @@ /** @see ./docs/orchestrator.md */ | ||
| /** | ||
| * @param {string[]} args CLI аргументи після 'fix' | ||
| * @param {string} cwd корінь проєкту | ||
| * @returns {Promise<number>} 0 = all clean, 1 = unresolved | ||
| * Парсить `--max-iter N` і збирає rule-filter (позиційні аргументи без прапорців). | ||
| * @param {string[]} args CLI аргументи після 'fix' | ||
| * @returns {{ maxIter: number, ruleFilter: string[] }} ліміт ітерацій і фільтр правил | ||
| */ | ||
| export async function runOrchestratorCli(args, cwd) { | ||
| const { runLlmWorker, MODEL, MODEL_HEAVY } = await import('./llm-worker.mjs') | ||
| function parseOrchestratorArgs(args) { | ||
| const maxIterIdx = args.indexOf('--max-iter') | ||
@@ -27,3 +25,61 @@ const maxIter = | ||
| const ruleFilter = args.filter((a, i) => !a.startsWith('-') && !skipIdxs.has(i)) | ||
| return { maxIter, ruleFilter } | ||
| } | ||
| /** | ||
| * Крок T0-auto: детермінований фікс без LLM, повертає правила, що лишились. | ||
| * @param {string} cwd корінь проєкту | ||
| * @param {string[]} ruleFilter фільтр правил | ||
| * @param {Array<{ ruleId: string }>} failed правила перед кроком | ||
| * @returns {Array<{ ruleId: string, ok: boolean, output: string }>} правила після T0 | ||
| */ | ||
| function runT0Step(cwd, ruleFilter, failed) { | ||
| spawnSync('bun', [N_CURSOR_BIN, 'fix-t0', ...ruleFilter], { cwd, stdio: 'pipe' }) | ||
| const afterT0 = runFixCheck(cwd, ruleFilter) | ||
| const failedAfterT0 = afterT0?.rules.filter(r => !r.ok) ?? failed | ||
| const t0Fixed = failed.filter(r => !failedAfterT0.some(f => f.ruleId === r.ruleId)) | ||
| if (t0Fixed.length > 0) { | ||
| console.log(` ⚙️ T0-auto: ${t0Fixed.map(r => r.ruleId).join(', ')}`) | ||
| } | ||
| return failedAfterT0 | ||
| } | ||
| /** | ||
| * Крок T1: LLM через pi для кожного правила, з ескалацією моделі за провалами. | ||
| * @param {Array<{ ruleId: string, output: string }>} failed правила до фіксу | ||
| * @param {string} cwd корінь проєкту | ||
| * @param {Map<string, number>} failCount ruleId → кількість провалів підряд (мутується) | ||
| * @param {{ runLlmWorker: (ruleId: string, output: string, projectRoot: string, opts: {model: string}) => Promise<{ok: boolean, error?: string}>, MODEL: string, MODEL_HEAVY: string }} worker воркер і моделі | ||
| * @returns {Promise<void>} | ||
| */ | ||
| async function runLlmStep(failed, cwd, failCount, { runLlmWorker, MODEL, MODEL_HEAVY }) { | ||
| for (const rule of failed) { | ||
| const prevFails = failCount.get(rule.ruleId) ?? 0 | ||
| const model = prevFails >= ESCALATE_AFTER ? MODEL_HEAVY : MODEL | ||
| const label = model || 'pi' | ||
| const result = await runLlmWorker(rule.ruleId, rule.output, cwd, { model }) | ||
| if (result.ok) { | ||
| console.log(` ⚡ LLM (${label}): ${rule.ruleId}`) | ||
| failCount.delete(rule.ruleId) | ||
| } else { | ||
| failCount.set(rule.ruleId, prevFails + 1) | ||
| const hint = (result.error ?? '').slice(0, 200) | ||
| console.log(` ⚡ LLM (${label}): ${rule.ruleId} ❌ ${hint}`) | ||
| } | ||
| } | ||
| } | ||
| /** | ||
| * @param {string[]} args CLI аргументи після 'fix' | ||
| * @param {string} cwd корінь проєкту | ||
| * @returns {Promise<number>} 0 = all clean, 1 = unresolved | ||
| */ | ||
| export async function runOrchestratorCli(args, cwd) { | ||
| const worker = await import('./llm-worker.mjs') | ||
| const { maxIter, ruleFilter } = parseOrchestratorArgs(args) | ||
| /** @type {Map<string, number>} ruleId → кількість LLM-провалів підряд */ | ||
@@ -53,34 +109,7 @@ const failCount = new Map() | ||
| for (let iter = 1; iter <= maxIter; iter++) { | ||
| // ── T0-auto: детермінований фікс без LLM ── | ||
| spawnSync('bun', [N_CURSOR_BIN, 'fix-t0', ...ruleFilter], { cwd, stdio: 'pipe' }) | ||
| const afterT0 = runFixCheck(cwd, ruleFilter) | ||
| const failedAfterT0 = afterT0?.rules.filter(r => !r.ok) ?? failed | ||
| const t0Fixed = failed.filter(r => !failedAfterT0.some(f => f.ruleId === r.ruleId)) | ||
| if (t0Fixed.length > 0) { | ||
| console.log(` ⚙️ T0-auto: ${t0Fixed.map(r => r.ruleId).join(', ')}`) | ||
| } | ||
| failed = failedAfterT0 | ||
| failed = runT0Step(cwd, ruleFilter, failed) | ||
| if (failed.length === 0) break | ||
| // ── T1: LLM через pi ── | ||
| for (const rule of failed) { | ||
| const prevFails = failCount.get(rule.ruleId) ?? 0 | ||
| const model = prevFails >= ESCALATE_AFTER ? MODEL_HEAVY : MODEL | ||
| const label = model || 'pi' | ||
| await runLlmStep(failed, cwd, failCount, worker) | ||
| const result = await runLlmWorker(rule.ruleId, rule.output, cwd, { model }) | ||
| if (result.ok) { | ||
| console.log(` ⚡ LLM (${label}): ${rule.ruleId}`) | ||
| failCount.delete(rule.ruleId) | ||
| } else { | ||
| failCount.set(rule.ruleId, prevFails + 1) | ||
| const hint = (result.error ?? '').slice(0, 200) | ||
| console.log(` ⚡ LLM (${label}): ${rule.ruleId} ❌ ${hint}`) | ||
| } | ||
| } | ||
| // Перевірка після LLM | ||
@@ -87,0 +116,0 @@ const afterLLM = runFixCheck(cwd, ruleFilter) |
+44
-32
@@ -117,2 +117,39 @@ /** @see ./docs/t0.md */ | ||
| /** | ||
| * Запускає `_fix-check` і парсить JSON-результат. | ||
| * @param {string[]} ruleFilter список rule-ids (порожній — усі) | ||
| * @param {string} cwd корінь проєкту | ||
| * @returns {{ rules: Array<{ ruleId: string, ok: boolean, output: string }> } | { _empty: true } | { _badJson: true }} JSON або маркер помилки | ||
| */ | ||
| function fixCheck(ruleFilter, cwd) { | ||
| const r = spawnSync('bun', [N_CURSOR_BIN, '_fix-check', ...ruleFilter], { cwd, encoding: 'utf8', timeout: 120_000 }) | ||
| const raw = r.stdout?.trim() | ||
| if (!raw) return { _empty: true, stderr: r.stderr } | ||
| try { | ||
| return JSON.parse(raw) | ||
| } catch { | ||
| return { _badJson: true } | ||
| } | ||
| } | ||
| /** | ||
| * Застосовує T0-auto до кожного провального правила, розділяючи на applied/skipped. | ||
| * @param {Array<{ ruleId: string, output: string }>} failed провальні правила | ||
| * @param {string} cwd корінь проєкту | ||
| * @returns {{ applied: Array<{ ruleId: string, actions: string[] }>, skipped: string[] }} застосовані й пропущені | ||
| */ | ||
| function applyT0ToFailed(failed, cwd) { | ||
| const applied = [] | ||
| const skipped = [] | ||
| for (const r of failed) { | ||
| const result = applyT0Auto(r.ruleId, r.output, cwd) | ||
| if (result.applied) { | ||
| applied.push({ ruleId: r.ruleId, actions: result.actions }) | ||
| } else { | ||
| skipped.push(r.ruleId) | ||
| } | ||
| } | ||
| return { applied, skipped } | ||
| } | ||
| /** | ||
| * CLI підкоманда `n-cursor fix-t0 [rule...]`. | ||
@@ -130,18 +167,9 @@ * Запускає `fix --json`, застосовує T0-auto для кожного violation, | ||
| // 1. Запустити fix --json | ||
| const fixResult = spawnSync('bun', [N_CURSOR_BIN, '_fix-check', ...ruleFilter], { | ||
| cwd, | ||
| encoding: 'utf8', | ||
| timeout: 120_000 | ||
| }) | ||
| const raw = fixResult.stdout?.trim() | ||
| if (!raw) { | ||
| const fixJson = fixCheck(ruleFilter, cwd) | ||
| if (fixJson._empty) { | ||
| console.error(`n-cursor fix-t0: fix --json повернув порожній stdout`) | ||
| console.error(fixResult.stderr?.slice(0, 300) ?? '') | ||
| console.error(fixJson.stderr?.slice(0, 300) ?? '') | ||
| return 1 | ||
| } | ||
| let fixJson | ||
| try { | ||
| fixJson = JSON.parse(raw) | ||
| } catch { | ||
| if (fixJson._badJson) { | ||
| console.error(`n-cursor fix-t0: fix --json повернув невалідний JSON`) | ||
@@ -158,12 +186,3 @@ return 1 | ||
| // 2. Застосувати T0-auto | ||
| const applied = [] | ||
| const skipped = [] | ||
| for (const r of failed) { | ||
| const result = applyT0Auto(r.ruleId, r.output, cwd) | ||
| if (result.applied) { | ||
| applied.push({ ruleId: r.ruleId, actions: result.actions }) | ||
| } else { | ||
| skipped.push(r.ruleId) | ||
| } | ||
| } | ||
| const { applied, skipped } = applyT0ToFailed(failed, cwd) | ||
@@ -182,14 +201,7 @@ if (applied.length === 0) { | ||
| // 4. Check-gate: перевірити лише ті правила, що ми чіпали | ||
| const recheck = spawnSync('bun', [N_CURSOR_BIN, '_fix-check', ...applied.map(a => a.ruleId)], { | ||
| cwd, | ||
| encoding: 'utf8', | ||
| timeout: 120_000 | ||
| }) | ||
| const recheckRaw = recheck.stdout?.trim() | ||
| if (!recheckRaw) { | ||
| const recheckJson = fixCheck(applied.map(a => a.ruleId), cwd) | ||
| if (recheckJson._empty) { | ||
| console.error(`fix-t0: check-gate: fix --json повернув порожній stdout`) | ||
| return 1 | ||
| } | ||
| const recheckJson = JSON.parse(recheckRaw) | ||
| const stillFailed = recheckJson.rules.filter(r => !r.ok) | ||
@@ -196,0 +208,0 @@ |
@@ -114,3 +114,3 @@ /** @see ./docs/check.md */ | ||
| * @param {string} workspace відносний шлях воркспейсу | ||
| * @param {{graceMs?:number, type?:('server'|'cli'), spawnImpl?:Function}} [opts] grace-період, тип (інакше з package.json), інʼєкція spawn для тестів | ||
| * @param {{graceMs?:number, type?:('server'|'cli'), spawnImpl?:typeof import('node:child_process').spawnSync}} [opts] grace-період, тип (інакше з package.json), інʼєкція spawn для тестів | ||
| * @returns {Promise<{workspace:string, type:string, exitCode:number|null, timedOut:boolean, status:('OK'|'FAIL'), ready:boolean, firstError:string|null, logTail:string, sideEffects:{newFiles:string[], changedTracked:string[]}}>} результат прогону | ||
@@ -117,0 +117,0 @@ */ |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
5398017
0.36%35361
0.98%