@nitra/cursor 3.27.0 → 3.28.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 +11 -0
- package/package.json +1 -1
- package/rules/abie/js/applies.mjs +1 -5
- package/rules/abie/js/env_dns.mjs +1 -9
- package/rules/abie/js/firebase_hosting.mjs +1 -5
- package/rules/abie/js/hc_pairing.mjs +1 -8
- package/rules/abie/js/ua_http_route.mjs +1 -10
- package/rules/abie/js/ua_node_selector.mjs +1 -8
- package/rules/adr/js/hooks.mjs +1 -20
- package/rules/bun/js/layout.mjs +1 -19
- package/rules/capacitor/js/platforms.mjs +1 -23
- package/rules/changelog/js/consistency.mjs +1 -29
- package/rules/ci4/js/marksman_config.mjs +1 -19
- package/rules/docker/js/lint.mjs +1 -34
- package/rules/ga/docs/fix.md +4 -4
- package/rules/ga/js/docs/lint.md +3 -3
- package/rules/ga/js/docs/workflows.md +14 -14
- package/rules/ga/js/workflows.mjs +1 -16
- package/rules/ga/lint/docs/lint.md +9 -9
- package/rules/graphql/js/tooling.mjs +1 -9
- package/rules/hasura/js/internal_urls.mjs +1 -24
- package/rules/image-avif/js/avif_generation.mjs +1 -27
- package/rules/image-compress/js/package_setup.mjs +1 -18
- package/rules/js-bun-db/js/safety.mjs +1 -31
- package/rules/js-bun-redis/js/imports.mjs +1 -12
- package/rules/js-lint/js/docs/lint-findings.md +30 -0
- package/rules/js-lint/js/lint-findings.mjs +1 -7
- package/rules/js-lint/js/lint.mjs +1 -10
- package/rules/js-lint/js/tooling.mjs +1 -13
- package/rules/js-lint/js/utils_imports.mjs +1 -18
- package/rules/js-lint-ci/js/lint.mjs +1 -6
- package/rules/js-mssql/js/deps.mjs +1 -10
- package/rules/js-run/js/runtime.mjs +1 -37
- package/rules/js-run/lib/docs/temporal-scan.md +25 -0
- package/rules/k8s/js/manifests.mjs +1 -137
- package/rules/nginx-default-tpl/js/template.mjs +1 -18
- package/rules/npm-module/js/docs/header_doc_pointer.md +25 -0
- package/rules/npm-module/js/header_doc_pointer.mjs +82 -0
- package/rules/npm-module/js/package_structure.mjs +1 -28
- package/rules/npm-module/js/rule_meta.mjs +1 -10
- package/rules/npm-module/js/skill_meta.mjs +1 -13
- package/rules/php/js/tooling.mjs +1 -11
- package/rules/python/js/applies.mjs +1 -8
- package/rules/python/js/tooling.mjs +1 -21
- package/rules/rego/js/applies.mjs +1 -11
- package/rules/rust/js/applies.mjs +1 -7
- package/rules/security/js/sample_secret.mjs +1 -28
- package/rules/security/js/trufflehog.mjs +1 -8
- package/rules/style-lint/js/lint.mjs +1 -5
- package/rules/style-lint/js/tooling.mjs +1 -19
- package/rules/tauri/js/cargo_mutants_config.mjs +1 -20
- package/rules/tauri/js/tooling.mjs +1 -21
- package/rules/test/js/cargo_mutants_config.mjs +1 -12
- package/rules/test/js/location.mjs +1 -9
- package/rules/test/js/no-process-chdir.mjs +1 -21
- package/rules/test/js/no-relative-fs-path.mjs +1 -23
- package/rules/test/js/stryker_config.mjs +4 -25
- package/rules/test/js/vitest-config-pool-forks.mjs +1 -17
- package/rules/text/js/forbidden-prettier.mjs +1 -10
- package/rules/text/js/formatting.mjs +1 -31
- package/rules/vue/js/packages.mjs +1 -24
- package/scripts/docs/coverage-fix-extract.md +32 -0
- package/scripts/docs/lint-cli.md +25 -0
- package/scripts/docs/post-tool-use-fix.md +27 -0
- package/scripts/docs/rename-yaml-extensions.md +36 -0
- package/scripts/docs/skills-cli.md +35 -0
- package/scripts/docs/sync-claude-config.md +52 -0
- package/scripts/docs/sync-setup-bun-deps-action.md +26 -0
- package/scripts/docs/upgrade-nitra-cursor-and-install.md +29 -0
- package/scripts/docs/worktree-cli.md +46 -0
- package/scripts/lib/docs/assert-project-root.md +28 -0
- package/scripts/lib/docs/diff-added-lines.md +34 -0
- package/scripts/lib/docs/read-n-cursor-config-lite.md +28 -0
- package/scripts/lib/docs/resolve-target-files.md +34 -0
- package/scripts/lib/docs/root-notice.md +28 -0
- package/scripts/lib/docs/rule-meta-helpers.md +34 -0
- package/scripts/lib/docs/rule-meta.md +34 -0
- package/scripts/lib/docs/rule-predicates.md +30 -0
- package/scripts/lib/docs/run-conftest-batch.md +26 -0
- package/scripts/lib/docs/run-lint-step.md +25 -0
- package/scripts/lib/docs/run-rule-cli.md +27 -0
- package/scripts/lib/docs/run-rule.md +32 -0
- package/scripts/lib/docs/run-standard-lint.md +22 -0
- package/scripts/lib/docs/run-standard-rule.md +24 -0
- package/scripts/lib/docs/skill-meta.md +31 -0
- package/scripts/lib/docs/sync-gitignore-worktree.md +31 -0
- package/scripts/lib/docs/template.md +40 -0
- package/scripts/lib/docs/timing-summary.md +24 -0
- package/scripts/lib/docs/workspaces.md +30 -0
- package/scripts/lib/docs/worktree-notice.md +27 -0
- package/scripts/lib/docs/worktree.md +38 -0
- package/scripts/utils/docs/ast-scan-utils.md +50 -0
- package/scripts/utils/docs/ensure-gitignore-entries.md +28 -0
- package/scripts/utils/docs/find-package-json-paths.md +26 -0
- package/scripts/utils/docs/lock-cache-dir.md +25 -0
- package/scripts/utils/docs/pass.md +25 -0
- package/scripts/utils/docs/resolve-cargo-manifest.md +23 -0
- package/scripts/utils/docs/resolve-cmd.md +29 -0
- package/scripts/utils/docs/resolve-js-root.md +25 -0
- package/scripts/utils/docs/test-helpers.md +36 -0
- package/scripts/utils/docs/walk-cache.md +27 -0
- package/scripts/utils/docs/walkDir.md +32 -0
- package/scripts/utils/docs/with-lock.md +25 -0
- package/scripts/utils/docs/worktree-fingerprint.md +27 -0
- package/skills/docgen/js/docgen-batch.mjs +95 -0
- package/skills/docgen/js/docgen-extract.mjs +33 -18
- package/skills/docgen/js/docgen-gen.mjs +125 -98
- package/skills/docgen/js/docgen-ignore.mjs +1 -6
- package/skills/docgen/js/docgen-prompts.mjs +33 -22
- package/skills/docgen/js/docgen-scan.mjs +1 -8
- package/skills/docgen/js/docs/docgen-extract.md +28 -0
- package/skills/docgen/js/docs/docgen-gen.md +41 -0
- package/skills/docgen/js/docs/docgen-ignore.md +24 -0
- package/skills/docgen/js/docs/docgen-prompts.md +24 -0
- package/skills/docgen/js/docs/docgen-scan.md +48 -0
- package/skills/fix/js/docs/llm-worker.md +27 -0
- package/skills/fix/js/docs/orchestrator.md +32 -0
- package/skills/fix/js/docs/t0.md +29 -0
- package/skills/fix/js/llm-worker.mjs +64 -29
- package/skills/fix/js/orchestrator.mjs +45 -54
- package/skills/fix/js/t0.mjs +16 -32
- package/skills/start-check/js/check.mjs +1 -16
- package/skills/start-check/js/docs/check.md +34 -0
- package/skills/taze/js/diff.mjs +1 -15
- package/skills/taze/js/docs/diff.md +33 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batch docgen для відсутніх файлів проєкту.
|
|
3
|
+
* sym < 4 → gemma3:4b orchestrated (local)
|
|
4
|
+
* sym ≥ 4 → Claude Sonnet (cloud, via generateDoc pre-routing)
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
7
|
+
import { dirname, join, resolve } from 'node:path'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
import { generateDoc } from './docgen-gen.mjs'
|
|
10
|
+
import { extractFacts } from './docgen-extract.mjs'
|
|
11
|
+
import { execSync } from 'node:child_process'
|
|
12
|
+
|
|
13
|
+
const ROOT = resolve(fileURLToPath(import.meta.url), '../../../../..')
|
|
14
|
+
|
|
15
|
+
// 1. Отримати список відсутніх файлів
|
|
16
|
+
const scanOut = execSync('node npm/bin/n-cursor.js docgen scan', { cwd: ROOT, encoding: 'utf8' })
|
|
17
|
+
const allFiles = JSON.parse(scanOut)
|
|
18
|
+
const missing = allFiles.filter(x => !x.exists)
|
|
19
|
+
|
|
20
|
+
console.log(`\n📋 Файлів для генерації: ${missing.length}`)
|
|
21
|
+
|
|
22
|
+
// 2. Розкласти по тирах
|
|
23
|
+
const local = [],
|
|
24
|
+
cloud = []
|
|
25
|
+
for (const f of missing) {
|
|
26
|
+
try {
|
|
27
|
+
const src = readFileSync(join(ROOT, f.sourcePath), 'utf8')
|
|
28
|
+
const facts = extractFacts(src, join(ROOT, f.sourcePath))
|
|
29
|
+
const sym = (facts.internalSymbols ?? []).length
|
|
30
|
+
if (sym >= 4) cloud.push({ ...f, sym })
|
|
31
|
+
else local.push({ ...f, sym })
|
|
32
|
+
} catch {
|
|
33
|
+
local.push({ ...f, sym: 0 })
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
console.log(` Local (sym<4): ${local.length}`)
|
|
38
|
+
console.log(` Cloud (sym≥4): ${cloud.length}`)
|
|
39
|
+
|
|
40
|
+
const stats = { ok: 0, err: 0, localOk: 0, cloudOk: 0, errors: [] }
|
|
41
|
+
|
|
42
|
+
// 3. Cloud файли (sym≥4) — generateDoc auto-routes до Claude
|
|
43
|
+
console.log('\n☁️ Cloud tier...')
|
|
44
|
+
for (const f of cloud) {
|
|
45
|
+
const t0 = Date.now()
|
|
46
|
+
try {
|
|
47
|
+
const result = await generateDoc(join(ROOT, f.sourcePath), { symThreshold: 4 })
|
|
48
|
+
const docAbs = join(ROOT, f.docPath)
|
|
49
|
+
mkdirSync(dirname(docAbs), { recursive: true })
|
|
50
|
+
writeFileSync(docAbs, result.md)
|
|
51
|
+
stats.ok++
|
|
52
|
+
stats.cloudOk++
|
|
53
|
+
console.log(` ✓ ${f.sourcePath} (sym=${f.sym}, ${Math.round((Date.now() - t0) / 1000)}s)`)
|
|
54
|
+
} catch (e) {
|
|
55
|
+
stats.err++
|
|
56
|
+
stats.errors.push(f.sourcePath)
|
|
57
|
+
console.error(` ✗ ${f.sourcePath}: ${e.message}`)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 4. Local файли (sym<4) — gemma3:4b orchestrated
|
|
62
|
+
console.log('\n💻 Local tier...')
|
|
63
|
+
let done = 0
|
|
64
|
+
for (const f of local) {
|
|
65
|
+
done++
|
|
66
|
+
const t0 = Date.now()
|
|
67
|
+
const pct = Math.round((done / local.length) * 100)
|
|
68
|
+
process.stdout.write(` [${done}/${local.length} ${pct}%] ${f.sourcePath} ... `)
|
|
69
|
+
try {
|
|
70
|
+
const result = await generateDoc(join(ROOT, f.sourcePath), {
|
|
71
|
+
mode: 'orchestrated',
|
|
72
|
+
symThreshold: 999 // force local
|
|
73
|
+
})
|
|
74
|
+
const docAbs = join(ROOT, f.docPath)
|
|
75
|
+
mkdirSync(dirname(docAbs), { recursive: true })
|
|
76
|
+
writeFileSync(docAbs, result.md)
|
|
77
|
+
stats.ok++
|
|
78
|
+
stats.localOk++
|
|
79
|
+
process.stdout.write(`✓ ${Math.round((Date.now() - t0) / 1000)}s score=${result.score ?? '?'}\n`)
|
|
80
|
+
} catch (e) {
|
|
81
|
+
stats.err++
|
|
82
|
+
stats.errors.push(f.sourcePath)
|
|
83
|
+
process.stdout.write(`✗ ${e.message}\n`)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 5. Підсумок
|
|
88
|
+
console.log(`\n${'─'.repeat(50)}`)
|
|
89
|
+
console.log(`✓ OK: ${stats.ok} ✗ Err: ${stats.err}`)
|
|
90
|
+
console.log(` 💻 Local (gemma3:4b): ${stats.localOk} файлів`)
|
|
91
|
+
console.log(` ☁️ Cloud (Claude/pi): ${stats.cloudOk} файлів`)
|
|
92
|
+
if (stats.errors.length > 0) {
|
|
93
|
+
console.log('Помилки:')
|
|
94
|
+
stats.errors.forEach(e => console.log(` - ${e}`))
|
|
95
|
+
}
|
|
@@ -1,16 +1,22 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Stage 0 docgen-конвеєра: детермінована екстракція «факт-листа» з коду (0 токенів LLM).
|
|
3
|
-
*
|
|
4
|
-
* Ідея: винести з-під локальної моделі все, що вона псує (імена експортів, stdlib-vs-internal,
|
|
5
|
-
* крайові деталі поведінки), і віддати їй лише перефразування вже відомих фактів. Тут — суто
|
|
6
|
-
* парсинг JS/MJS регулярками: жодних мереж, LLM чи запису.
|
|
7
|
-
*
|
|
8
|
-
* Повертає об'єкт-факт-лист, який Stage 1 (docgen-prompts) перетворює на точкові промпти.
|
|
9
|
-
*/
|
|
1
|
+
/** @see ./docs/docgen-extract.md */
|
|
10
2
|
|
|
11
3
|
const BUILTIN_MODULES = new Set([
|
|
12
|
-
'fs',
|
|
13
|
-
'
|
|
4
|
+
'fs',
|
|
5
|
+
'path',
|
|
6
|
+
'crypto',
|
|
7
|
+
'os',
|
|
8
|
+
'util',
|
|
9
|
+
'stream',
|
|
10
|
+
'events',
|
|
11
|
+
'http',
|
|
12
|
+
'https',
|
|
13
|
+
'url',
|
|
14
|
+
'child_process',
|
|
15
|
+
'process',
|
|
16
|
+
'assert',
|
|
17
|
+
'buffer',
|
|
18
|
+
'zlib',
|
|
19
|
+
'readline'
|
|
14
20
|
])
|
|
15
21
|
|
|
16
22
|
/** Прибирає `/** */`-обрамлення й `*`-префікси, повертає чистий текст рядками. */
|
|
@@ -40,7 +46,10 @@ function parseJsDoc(raw) {
|
|
|
40
46
|
params.push({ name: pm[1], desc: desc === 'опис.' ? '' : desc })
|
|
41
47
|
continue
|
|
42
48
|
}
|
|
43
|
-
if (rm) {
|
|
49
|
+
if (rm) {
|
|
50
|
+
ret = rm[1].trim()
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
44
53
|
if (l.startsWith('@')) continue
|
|
45
54
|
descLines.push(l)
|
|
46
55
|
}
|
|
@@ -81,7 +90,9 @@ function extractExports(src) {
|
|
|
81
90
|
|
|
82
91
|
/** Імпорти, класифіковані на stdlib / npm / internal. */
|
|
83
92
|
function extractImports(src) {
|
|
84
|
-
const stdlib = new Set(),
|
|
93
|
+
const stdlib = new Set(),
|
|
94
|
+
npm = new Set(),
|
|
95
|
+
internal = new Set()
|
|
85
96
|
const re = /^import\s+[\s\S]*?from\s+['"]([^'"]+)['"]/gm
|
|
86
97
|
let m
|
|
87
98
|
while ((m = re.exec(src))) {
|
|
@@ -100,10 +111,11 @@ function extractInternalSymbols(src) {
|
|
|
100
111
|
let m
|
|
101
112
|
while ((m = re.exec(src))) {
|
|
102
113
|
if (m[1]) out.add(m[1].trim())
|
|
103
|
-
if (m[2])
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
114
|
+
if (m[2])
|
|
115
|
+
for (const n of m[2].split(',')) {
|
|
116
|
+
const name = n.replace(/\s+as\s+.*/, '').trim()
|
|
117
|
+
if (name) out.add(name)
|
|
118
|
+
}
|
|
107
119
|
}
|
|
108
120
|
return [...out]
|
|
109
121
|
}
|
|
@@ -152,7 +164,10 @@ import { isRunAsCli } from '../../../scripts/cli-entry.mjs'
|
|
|
152
164
|
import { readFileSync } from 'node:fs'
|
|
153
165
|
if (isRunAsCli(import.meta.url)) {
|
|
154
166
|
const file = process.argv[2]
|
|
155
|
-
if (!file) {
|
|
167
|
+
if (!file) {
|
|
168
|
+
console.error('Usage: node docgen-extract.mjs <file>')
|
|
169
|
+
process.exit(1)
|
|
170
|
+
}
|
|
156
171
|
const facts = extractFacts(readFileSync(file, 'utf8'), file)
|
|
157
172
|
console.log(JSON.stringify(facts, null, 2))
|
|
158
173
|
}
|
|
@@ -1,19 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* docgen-конвеєр (входна точка): код файлу → .md-документація.
|
|
3
|
-
*
|
|
4
|
-
* Інверсія керування: веде цей JS, а локальна модель — лише сервіс перефразування.
|
|
5
|
-
* Stage 0 extractFacts — факти з коду (0 токенів)
|
|
6
|
-
* Stage 1 sectionInstructions — точкові промпти на кожну секцію (спільний KV-cached префікс)
|
|
7
|
-
* Stage 2 stripSignatures — детермінований зріз сигнатур (0 токенів)
|
|
8
|
-
* Stage 2.5 scoreDoc — детермінований скоринг проти фактів (0 токенів)
|
|
9
|
-
* Stage 3 assemble — фіксовані заголовки/порядок + зрізання fence
|
|
10
|
-
* Tier 2 claudeOneShot — хмарний fallback якщо score < QUALITY_THRESHOLD
|
|
11
|
-
*
|
|
12
|
-
* Hybrid routing (sym-threshold):
|
|
13
|
-
* sym < BORDERLINE_SYM_LOW → Tier 1 local (без хмарного рефері)
|
|
14
|
-
* sym ∈ [BORDERLINE_SYM_LOW, sym<4) → Tier 1 + cloudScoreDoc (Haiku) → при низькому балі → Tier 2
|
|
15
|
-
* sym >= DEFAULT_SYM_THRESHOLD → одразу Tier 2 (pre-routing, без local)
|
|
16
|
-
*/
|
|
1
|
+
/** @see ./docs/docgen-gen.md */
|
|
17
2
|
import { readFileSync } from 'node:fs'
|
|
18
3
|
import { basename } from 'node:path'
|
|
19
4
|
import { request } from 'node:http'
|
|
@@ -27,16 +12,26 @@ const QUALITY_THRESHOLD = 70
|
|
|
27
12
|
/** Один виклик чату до ollama зі streaming (токени стримуються → socket активний, жодного timeout). */
|
|
28
13
|
async function ollamaChat(messages, { model, numPredict = 600 }) {
|
|
29
14
|
const body = JSON.stringify({
|
|
30
|
-
model,
|
|
15
|
+
model,
|
|
16
|
+
messages,
|
|
17
|
+
stream: true,
|
|
18
|
+
think: false,
|
|
31
19
|
options: { num_ctx: 8192, temperature: 0.2, num_predict: numPredict },
|
|
32
20
|
keep_alive: '15m'
|
|
33
21
|
})
|
|
34
22
|
return new Promise((resolve, reject) => {
|
|
35
23
|
const req = request(
|
|
36
|
-
{
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
24
|
+
{
|
|
25
|
+
hostname: 'localhost',
|
|
26
|
+
port: 11434,
|
|
27
|
+
path: '/api/chat',
|
|
28
|
+
method: 'POST',
|
|
29
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }
|
|
30
|
+
},
|
|
31
|
+
res => {
|
|
32
|
+
let text = '',
|
|
33
|
+
genTok = 0,
|
|
34
|
+
buf = ''
|
|
40
35
|
res.on('data', chunk => {
|
|
41
36
|
buf += chunk.toString()
|
|
42
37
|
const lines = buf.split('\n')
|
|
@@ -47,7 +42,9 @@ async function ollamaChat(messages, { model, numPredict = 600 }) {
|
|
|
47
42
|
const j = JSON.parse(line)
|
|
48
43
|
text += j.message?.content ?? ''
|
|
49
44
|
if (j.done) genTok = j.eval_count ?? 0
|
|
50
|
-
} catch {
|
|
45
|
+
} catch {
|
|
46
|
+
/* partial line */
|
|
47
|
+
}
|
|
51
48
|
}
|
|
52
49
|
})
|
|
53
50
|
res.on('end', () => resolve({ text, genTok }))
|
|
@@ -64,7 +61,10 @@ async function ollamaChat(messages, { model, numPredict = 600 }) {
|
|
|
64
61
|
function stripSection(text) {
|
|
65
62
|
let t = text.trim()
|
|
66
63
|
if (t.startsWith('```')) {
|
|
67
|
-
t = t
|
|
64
|
+
t = t
|
|
65
|
+
.replace(/^```[a-z]*\n?/, '')
|
|
66
|
+
.replace(/\n?```\s*$/, '')
|
|
67
|
+
.trim()
|
|
68
68
|
}
|
|
69
69
|
t = t.replace(/^#{1,6}\s+.*\n+/, '') // зрізати випадковий заголовок
|
|
70
70
|
return t.trim()
|
|
@@ -87,8 +87,10 @@ function parseSections(md) {
|
|
|
87
87
|
let cur = null
|
|
88
88
|
for (const line of md.split('\n')) {
|
|
89
89
|
const m = line.match(/^##\s+(.+)/)
|
|
90
|
-
if (m) {
|
|
91
|
-
|
|
90
|
+
if (m) {
|
|
91
|
+
cur = m[1].toLowerCase().replace(/[^а-яіїєґa-z0-9]/gi, '')
|
|
92
|
+
result[cur] = ''
|
|
93
|
+
} else if (cur) result[cur] += line + '\n'
|
|
92
94
|
}
|
|
93
95
|
return result
|
|
94
96
|
}
|
|
@@ -102,25 +104,34 @@ function scoreDoc(md, facts) {
|
|
|
102
104
|
let score = 100
|
|
103
105
|
const issues = []
|
|
104
106
|
|
|
105
|
-
if (!s['огляд'])
|
|
106
|
-
|
|
107
|
+
if (!s['огляд']) {
|
|
108
|
+
score -= 25
|
|
109
|
+
issues.push('no-overview')
|
|
110
|
+
}
|
|
107
111
|
|
|
108
112
|
const behavior = s['поведінка'] ?? ''
|
|
109
|
-
if (behavior.length < 60)
|
|
110
|
-
|
|
113
|
+
if (behavior.length < 60) {
|
|
114
|
+
score -= 20
|
|
115
|
+
issues.push('short-behavior')
|
|
116
|
+
}
|
|
111
117
|
|
|
112
118
|
const guarantees = s['гарантіїповедінки'] ?? ''
|
|
113
119
|
// Будь-яка згадка "кеш" у Гарантіях коли файл не кешує — галюцинація
|
|
114
120
|
// Негація: "не кешує", "не має кешування", "без кешування", "немає кешу"
|
|
115
121
|
const cacheHit = /кеш/i.test(guarantees) && !/(?:не|без)\s+(?:\S+\s+)?кеш|немає\s+кеш/i.test(guarantees)
|
|
116
|
-
if (!facts.markers?.caches && cacheHit)
|
|
117
|
-
|
|
122
|
+
if (!facts.markers?.caches && cacheHit) {
|
|
123
|
+
score -= 20
|
|
124
|
+
issues.push('cache-hallucination')
|
|
125
|
+
}
|
|
118
126
|
|
|
119
127
|
// Перевіряємо лише бектік-обгорнуті імена (`sym`) — уникаємо substring false positives
|
|
120
128
|
const hasName = (text, sym) => text.includes('`' + sym + '`')
|
|
121
129
|
for (const sym of facts.internalSymbols ?? []) {
|
|
122
130
|
const inDoc = hasName(guarantees, sym) || hasName(s['огляд'] ?? '', sym) || hasName(s['поведінка'] ?? '', sym)
|
|
123
|
-
if (inDoc) {
|
|
131
|
+
if (inDoc) {
|
|
132
|
+
score -= 10
|
|
133
|
+
issues.push(`internal-name:${sym}`)
|
|
134
|
+
}
|
|
124
135
|
}
|
|
125
136
|
|
|
126
137
|
return { score: Math.max(0, score), issues }
|
|
@@ -149,25 +160,29 @@ async function cloudScoreDoc(md, facts, src, model = 'claude-haiku-4-5-20251001'
|
|
|
149
160
|
facts.markers?.caches ? 'Кешування: є' : 'Кешування: немає',
|
|
150
161
|
facts.markers?.network ? 'Мережа: є' : 'Мережа: немає',
|
|
151
162
|
facts.markers?.readOnly ? 'Read-only (не змінює файли/стан)' : ''
|
|
152
|
-
]
|
|
163
|
+
]
|
|
164
|
+
.filter(Boolean)
|
|
165
|
+
.join('\n')
|
|
153
166
|
|
|
154
167
|
const msg = await client.messages.create({
|
|
155
168
|
model,
|
|
156
169
|
max_tokens: 256,
|
|
157
170
|
system: SCORE_RUBRIC,
|
|
158
|
-
messages: [
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
171
|
+
messages: [
|
|
172
|
+
{
|
|
173
|
+
role: 'user',
|
|
174
|
+
content: [
|
|
175
|
+
{ type: 'text', text: `ФАКТИ:\n${factsTxt}`, cache_control: { type: 'ephemeral' } },
|
|
176
|
+
{ type: 'text', text: `КОД:\n\`\`\`\n${src.slice(0, 4000)}\n\`\`\``, cache_control: { type: 'ephemeral' } },
|
|
177
|
+
{ type: 'text', text: `ДОКУМЕНТАЦІЯ:\n${md}` }
|
|
178
|
+
]
|
|
179
|
+
}
|
|
180
|
+
]
|
|
166
181
|
})
|
|
167
182
|
const tok = (msg.usage?.input_tokens ?? 0) + (msg.usage?.output_tokens ?? 0)
|
|
168
183
|
try {
|
|
169
184
|
const j = JSON.parse(msg.content[0]?.text ?? '{}')
|
|
170
|
-
const total = ((j.огляд ?? 0) + (j.поведінка ?? 0) + (j.гарантії ?? 0) + (j.стиль ?? 0)) / 12 * 100
|
|
185
|
+
const total = (((j.огляд ?? 0) + (j.поведінка ?? 0) + (j.гарантії ?? 0) + (j.стиль ?? 0)) / 12) * 100
|
|
171
186
|
return { score: Math.round(total), scores: j, issues: j.issues ?? [], tok }
|
|
172
187
|
} catch {
|
|
173
188
|
return { score: 50, scores: {}, issues: ['parse-error'], tok }
|
|
@@ -229,31 +244,37 @@ async function generateOneShot(facts, src, model) {
|
|
|
229
244
|
|
|
230
245
|
/** Файли з sym ≥ цього значення одразу йдуть у Tier 2 (без локального проходу). */
|
|
231
246
|
const DEFAULT_SYM_THRESHOLD = 4
|
|
232
|
-
/**
|
|
233
|
-
const
|
|
247
|
+
/** Максимальний час локальної генерації на один файл перед ескалацією у Tier 2. */
|
|
248
|
+
const LOCAL_TIMEOUT_MS = 5 * 60 * 1000
|
|
249
|
+
|
|
250
|
+
/** Повертає promise, що відхиляється через `ms` мс з повідомленням про timeout. */
|
|
251
|
+
function withTimeout(promise, ms) {
|
|
252
|
+
return Promise.race([
|
|
253
|
+
promise,
|
|
254
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error(`local timeout after ${ms / 1000}s`)), ms))
|
|
255
|
+
])
|
|
256
|
+
}
|
|
234
257
|
|
|
235
258
|
/**
|
|
236
259
|
* Головний API: файл → { md, genTok, ms, score, issues, tier }.
|
|
237
260
|
*
|
|
238
261
|
* Routing (sym-threshold):
|
|
239
|
-
* sym <
|
|
240
|
-
*
|
|
241
|
-
* sym >= symThreshold
|
|
242
|
-
* scoreCloud=true → примусово запускає cloudScoreDoc для всіх Tier 1
|
|
262
|
+
* sym < symThreshold → Tier 1 local (timeout: LOCAL_TIMEOUT_MS) + det-scorer
|
|
263
|
+
* → timeout або det-score < threshold → Tier 2
|
|
264
|
+
* sym >= symThreshold → Pre-routing одразу Tier 2
|
|
243
265
|
*
|
|
244
|
-
* @param {string} scoreModel — модель для хмарного рефері (Haiku за замовч.)
|
|
245
266
|
* @param {string} cloudModel — модель для Tier 2 генерації (Sonnet за замовч.)
|
|
246
|
-
* @param {boolean} scoreCloud — якщо true, cloudScoreDoc запускається для всіх Tier 1 файлів
|
|
247
267
|
*/
|
|
248
|
-
export async function generateDoc(
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
} = {}
|
|
268
|
+
export async function generateDoc(
|
|
269
|
+
file,
|
|
270
|
+
{
|
|
271
|
+
model = 'gemma3:4b',
|
|
272
|
+
mode = 'orchestrated',
|
|
273
|
+
cloudModel = 'claude-sonnet-4-6',
|
|
274
|
+
threshold = QUALITY_THRESHOLD,
|
|
275
|
+
symThreshold = DEFAULT_SYM_THRESHOLD
|
|
276
|
+
} = {}
|
|
277
|
+
) {
|
|
257
278
|
const src = readFileSync(file, 'utf8')
|
|
258
279
|
const facts = extractFacts(src, file)
|
|
259
280
|
const t0 = Date.now()
|
|
@@ -262,73 +283,79 @@ export async function generateDoc(file, {
|
|
|
262
283
|
const complexity = facts.internalSymbols?.length ?? 0
|
|
263
284
|
if (complexity >= symThreshold && env.ANTHROPIC_API_KEY) {
|
|
264
285
|
const r2 = await claudeOneShot(facts, src, cloudModel)
|
|
265
|
-
return {
|
|
286
|
+
return {
|
|
287
|
+
...r2,
|
|
288
|
+
ms: Date.now() - t0,
|
|
289
|
+
score: null,
|
|
290
|
+
issues: [`pre-routed:sym=${complexity}`],
|
|
291
|
+
tier: 2,
|
|
292
|
+
model: cloudModel
|
|
293
|
+
}
|
|
266
294
|
}
|
|
267
295
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
const isBorderline = complexity >= BORDERLINE_SYM_LOW && complexity < symThreshold
|
|
279
|
-
if ((isBorderline || scoreCloud) && env.ANTHROPIC_API_KEY) {
|
|
280
|
-
const cs = await cloudScoreDoc(r.md, facts, src, scoreModel)
|
|
281
|
-
if (cs.score < threshold) {
|
|
296
|
+
// Tier 1: локальна генерація з timeout 5 хв — при перевищенні одразу Tier 2
|
|
297
|
+
let r
|
|
298
|
+
try {
|
|
299
|
+
const localPromise =
|
|
300
|
+
facts.unsupported || mode === 'oneshot'
|
|
301
|
+
? generateOneShot(facts, src, model)
|
|
302
|
+
: generateOrchestrated(facts, src, model)
|
|
303
|
+
r = await withTimeout(localPromise, LOCAL_TIMEOUT_MS)
|
|
304
|
+
} catch (e) {
|
|
305
|
+
if (env.ANTHROPIC_API_KEY) {
|
|
282
306
|
const r2 = await claudeOneShot(facts, src, cloudModel)
|
|
283
|
-
return {
|
|
284
|
-
|
|
307
|
+
return {
|
|
308
|
+
...r2,
|
|
309
|
+
ms: Date.now() - t0,
|
|
310
|
+
score: null,
|
|
311
|
+
issues: [`local-timeout: ${e.message}`],
|
|
312
|
+
tier: 2,
|
|
313
|
+
model: cloudModel
|
|
314
|
+
}
|
|
285
315
|
}
|
|
286
|
-
|
|
287
|
-
issues: cs.issues, detScore, detIssues, tier: 1 }
|
|
316
|
+
throw e
|
|
288
317
|
}
|
|
289
318
|
|
|
290
|
-
//
|
|
319
|
+
// Stage 2.5: детермінований скоринг (0 токенів) — gate перед Tier 2
|
|
320
|
+
const { score: detScore, issues: detIssues } = scoreDoc(r.md, facts)
|
|
321
|
+
|
|
291
322
|
if (detScore < threshold && env.ANTHROPIC_API_KEY) {
|
|
292
323
|
const r2 = await claudeOneShot(facts, src, cloudModel)
|
|
293
|
-
return { ...r2, ms: Date.now() - t0, score: detScore, issues: detIssues, tier: 2 }
|
|
324
|
+
return { ...r2, ms: Date.now() - t0, score: detScore, issues: detIssues, tier: 2, model: cloudModel }
|
|
294
325
|
}
|
|
295
326
|
|
|
296
|
-
return { ...r, ms: Date.now() - t0, score: detScore, issues: detIssues, tier: 1 }
|
|
327
|
+
return { ...r, ms: Date.now() - t0, score: detScore, issues: detIssues, tier: 1, model }
|
|
297
328
|
}
|
|
298
329
|
|
|
299
|
-
// CLI: node docgen-gen.mjs <file> [--oneshot] [--
|
|
330
|
+
// CLI: node docgen-gen.mjs <file> [--oneshot] [--model <m>] [--sym-threshold N] [--tier-only]
|
|
300
331
|
import { isRunAsCli } from '../../../scripts/cli-entry.mjs'
|
|
301
332
|
if (isRunAsCli(import.meta.url)) {
|
|
302
333
|
const args = process.argv.slice(2)
|
|
303
334
|
const file = args.find(a => !a.startsWith('--'))
|
|
304
335
|
const mode = args.includes('--oneshot') ? 'oneshot' : 'orchestrated'
|
|
305
|
-
const scoreCloud = args.includes('--score-cloud')
|
|
306
336
|
const tierOnly = args.includes('--tier-only')
|
|
307
|
-
const mi = args.indexOf('--model')
|
|
308
|
-
const
|
|
309
|
-
const si = args.indexOf('--sym-threshold')
|
|
337
|
+
const mi = args.indexOf('--model')
|
|
338
|
+
const model = mi >= 0 ? args[mi + 1] : 'gemma3:4b'
|
|
339
|
+
const si = args.indexOf('--sym-threshold')
|
|
340
|
+
const symThreshold = si >= 0 ? Number(args[si + 1]) : DEFAULT_SYM_THRESHOLD
|
|
310
341
|
if (!file) {
|
|
311
|
-
console.error('Usage: node docgen-gen.mjs <file> [--oneshot] [--
|
|
342
|
+
console.error('Usage: node docgen-gen.mjs <file> [--oneshot] [--model <m>] [--sym-threshold N] [--tier-only]')
|
|
312
343
|
process.exit(1)
|
|
313
344
|
}
|
|
314
345
|
if (tierOnly) {
|
|
315
346
|
const src = readFileSync(file, 'utf8')
|
|
316
347
|
const facts = extractFacts(src, file)
|
|
317
348
|
const sym = facts.internalSymbols?.length ?? 0
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
} else {
|
|
324
|
-
icon = '💻'; label = `Tier 1 local (sym=${sym} < ${BORDERLINE_SYM_LOW})`
|
|
325
|
-
}
|
|
349
|
+
const icon = sym >= symThreshold ? '☁️ ' : '💻'
|
|
350
|
+
const label =
|
|
351
|
+
sym >= symThreshold
|
|
352
|
+
? `Tier 2 cloud (sym=${sym} ≥ ${symThreshold}, pre-routed)`
|
|
353
|
+
: `Tier 1 local (sym=${sym} < ${symThreshold})`
|
|
326
354
|
process.stdout.write(`${icon} ${label} | ${file}\n`)
|
|
327
355
|
process.exit(0)
|
|
328
356
|
}
|
|
329
|
-
const r = await generateDoc(file, { model, mode,
|
|
357
|
+
const r = await generateDoc(file, { model, mode, symThreshold })
|
|
330
358
|
const issuesTxt = r.issues?.length ? ` issues=${r.issues.join(',')}` : ''
|
|
331
|
-
|
|
332
|
-
process.stderr.write(`[tier${r.tier} ${mode}] ${r.ms}ms / ${r.genTok} tok / score=${r.score}${issuesTxt}${cloudTxt}\n`)
|
|
359
|
+
process.stderr.write(`[tier${r.tier} ${mode}] ${r.ms}ms / ${r.genTok} tok / score=${r.score}${issuesTxt}\n`)
|
|
333
360
|
process.stdout.write(r.md)
|
|
334
361
|
}
|
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Глоби, які `docgen` завжди ігнорує.
|
|
3
|
-
*
|
|
4
|
-
* Це окремий snippet-модуль: список правиться тут, scanner лише читає його
|
|
5
|
-
* через predicate. Патерни пишуться в posix-формі відносно кореня проєкту.
|
|
6
|
-
*/
|
|
1
|
+
/** @see ./docs/docgen-ignore.md */
|
|
7
2
|
import picomatch from 'picomatch'
|
|
8
3
|
|
|
9
4
|
/** Базовий список glob-ів для `docgen` ignore. */
|
|
@@ -1,10 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Stage 1 docgen-конвеєра: факт-лист + код → точкові промпти на кожну секцію.
|
|
3
|
-
*
|
|
4
|
-
* v2 — СЕКЦІЙНО-МІНІМАЛЬНИЙ контекст: код іде ЛИШЕ у `Поведінку`. `Огляд` бере тільки
|
|
5
|
-
* header, `API` — лише список експортів, `Гарантії` — лише markers. Так інгест коду
|
|
6
|
-
* оплачується один раз (а не на кожну секцію), і оркестрація перестає програвати в часі.
|
|
7
|
-
*/
|
|
1
|
+
/** @see ./docs/docgen-prompts.md */
|
|
8
2
|
|
|
9
3
|
export const STYLE = [
|
|
10
4
|
'Ти технічний письменник. Пишеш лаконічну ПОВЕДІНКОВУ документацію до коду українською, чистим Markdown.',
|
|
@@ -28,7 +22,10 @@ function factsSummary(facts) {
|
|
|
28
22
|
return lines.join('\n')
|
|
29
23
|
}
|
|
30
24
|
|
|
31
|
-
const msgs = (system, user) => [
|
|
25
|
+
const msgs = (system, user) => [
|
|
26
|
+
{ role: 'system', content: system },
|
|
27
|
+
{ role: 'user', content: user }
|
|
28
|
+
]
|
|
32
29
|
|
|
33
30
|
/**
|
|
34
31
|
* Секційні набори messages з МІНІМАЛЬНИМ контекстом під кожну секцію.
|
|
@@ -42,33 +39,45 @@ export function sectionMessages(facts, src) {
|
|
|
42
39
|
|
|
43
40
|
// Огляд — лише факти (без коду)
|
|
44
41
|
out.push({
|
|
45
|
-
key: 'overview',
|
|
46
|
-
|
|
47
|
-
|
|
42
|
+
key: 'overview',
|
|
43
|
+
numPredict: 220,
|
|
44
|
+
messages: msgs(
|
|
45
|
+
`${STYLE}\n\nВІДОМІ ФАКТИ:\n${factsTxt}`,
|
|
46
|
+
'Напиши вміст секції «Огляд»: 1-3 речення — що файл робить і навіщо існує (роль у системі). Без заголовка, без переліку функцій.'
|
|
47
|
+
)
|
|
48
48
|
})
|
|
49
49
|
|
|
50
50
|
// Поведінка — ЄДИНА секція, якій потрібен код
|
|
51
51
|
out.push({
|
|
52
|
-
key: 'behavior',
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
key: 'behavior',
|
|
53
|
+
numPredict: 500,
|
|
54
|
+
messages: msgs(
|
|
55
|
+
`${STYLE}\n\nФАЙЛ ${facts.relPath}:\n\`\`\`\n${src}\n\`\`\`\n\nВІДОМІ ФАКТИ:\n${factsTxt}`,
|
|
56
|
+
`Напиши вміст секції «Поведінка»: ${multi ? 'для кожної публічної функції — один короткий пункт «що вона робить»' : 'нумерований алгоритм у бізнес-термінах'}. Якщо у фактах є свідомі пропуски шляхів — згадай їх там, де доречно (не вигадуй інших «не перевіряє»). НЕ пиши аргументи функцій у дужках, без regex.${facts.internalSymbols?.length ? ` НЕ згадуй за іменами службові функції: ${facts.internalSymbols.join(', ')}.` : ''} Без заголовка.`
|
|
57
|
+
)
|
|
55
58
|
})
|
|
56
59
|
|
|
57
60
|
// API — лише список експортів (без коду)
|
|
58
61
|
if (multi || facts.exports?.some(e => e.desc)) {
|
|
59
62
|
const list = facts.exports.map(e => `- ${e.name}: ${e.desc || '(сформулюй стисло з наміру файлу)'}`).join('\n')
|
|
60
63
|
out.push({
|
|
61
|
-
key: 'api',
|
|
62
|
-
|
|
63
|
-
|
|
64
|
+
key: 'api',
|
|
65
|
+
numPredict: 320,
|
|
66
|
+
messages: msgs(
|
|
67
|
+
STYLE,
|
|
68
|
+
`Перепиши цей список як стислі маркери «назва — що робить», СВОЇМИ словами (не копіюй дослівно), без типів і сигнатур. Використовуй РІВНО ці назви, не додавай і не прибирай:\n${list}\nБез заголовка.`
|
|
69
|
+
)
|
|
64
70
|
})
|
|
65
71
|
}
|
|
66
72
|
|
|
67
73
|
// Гарантії — лише markers (без коду)
|
|
68
74
|
out.push({
|
|
69
|
-
key: 'guarantees',
|
|
70
|
-
|
|
71
|
-
|
|
75
|
+
key: 'guarantees',
|
|
76
|
+
numPredict: 300,
|
|
77
|
+
messages: msgs(
|
|
78
|
+
`${STYLE}\n\nВІДОМІ ФАКТИ:\n${factsTxt}`,
|
|
79
|
+
'Напиши вміст секції «Гарантії поведінки» як маркери-інваріанти СУВОРО на основі ВІДОМИХ ФАКТІВ (read-only, fail-safe, пропуски). Згадуй кеш ЛИШЕ якщо у фактах прямо є «Кешує». Без сигнатур у дужках і без імен внутрішніх структур/Map-ів/кешів. Не вигадуй гарантій, яких немає у фактах. Без заголовка.'
|
|
80
|
+
)
|
|
72
81
|
})
|
|
73
82
|
|
|
74
83
|
return out
|
|
@@ -77,8 +86,10 @@ export function sectionMessages(facts, src) {
|
|
|
77
86
|
/** One-shot messages (база для порівняння). */
|
|
78
87
|
export function oneShotMessages(facts, src) {
|
|
79
88
|
const multi = (facts.exports?.length || 0) > 1
|
|
80
|
-
return msgs(
|
|
81
|
-
|
|
89
|
+
return msgs(
|
|
90
|
+
STYLE,
|
|
91
|
+
`Напиши документацію для файлу. Секції: ## Огляд (1-3 речення), ## Поведінка (нумерований/маркований алгоритм), ${multi ? '## Публічний API (назва + що робить), ' : ''}## Гарантії поведінки.\n\nФАЙЛ ${facts.relPath}:\n\`\`\`\n${src}\n\`\`\``
|
|
92
|
+
)
|
|
82
93
|
}
|
|
83
94
|
|
|
84
95
|
/** Лише текст user-промпту для one-shot (для хмарного fallback через Anthropic SDK). */
|
|
@@ -1,11 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* docgen scanner — детермінований обхід проєкту для скілу `docgen`.
|
|
3
|
-
*
|
|
4
|
-
* Друкує JSON-список кодових файлів із відносними `sourcePath`/`docPath`
|
|
5
|
-
* (тека `docs/` поряд із джерелом). Рішення про overwrite/skip приймає скіл —
|
|
6
|
-
* scanner лише лістить і ставить прапор `exists`. LLM/мережі тут немає: уся
|
|
7
|
-
* генерація доки — у субагентах скілу.
|
|
8
|
-
*/
|
|
1
|
+
/** @see ./docs/docgen-scan.md */
|
|
9
2
|
// eslint-disable-next-line unicorn/import-style
|
|
10
3
|
import path from 'node:path'
|
|
11
4
|
import { existsSync, readdirSync, statSync } from 'node:fs'
|