@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.
Files changed (125) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/package.json +1 -1
  3. package/rules/abie/js/applies.mjs +1 -5
  4. package/rules/abie/js/env_dns.mjs +1 -9
  5. package/rules/abie/js/firebase_hosting.mjs +1 -5
  6. package/rules/abie/js/hc_pairing.mjs +1 -8
  7. package/rules/abie/js/ua_http_route.mjs +1 -10
  8. package/rules/abie/js/ua_node_selector.mjs +1 -8
  9. package/rules/adr/js/hooks.mjs +1 -20
  10. package/rules/bun/js/layout.mjs +1 -19
  11. package/rules/capacitor/js/platforms.mjs +1 -23
  12. package/rules/changelog/js/consistency.mjs +1 -29
  13. package/rules/ci4/js/marksman_config.mjs +1 -19
  14. package/rules/docker/js/lint.mjs +1 -34
  15. package/rules/ga/docs/fix.md +4 -4
  16. package/rules/ga/js/docs/lint.md +3 -3
  17. package/rules/ga/js/docs/workflows.md +14 -14
  18. package/rules/ga/js/workflows.mjs +1 -16
  19. package/rules/ga/lint/docs/lint.md +9 -9
  20. package/rules/graphql/js/tooling.mjs +1 -9
  21. package/rules/hasura/js/internal_urls.mjs +1 -24
  22. package/rules/image-avif/js/avif_generation.mjs +1 -27
  23. package/rules/image-compress/js/package_setup.mjs +1 -18
  24. package/rules/js-bun-db/js/safety.mjs +1 -31
  25. package/rules/js-bun-redis/js/imports.mjs +1 -12
  26. package/rules/js-lint/js/docs/lint-findings.md +30 -0
  27. package/rules/js-lint/js/lint-findings.mjs +1 -7
  28. package/rules/js-lint/js/lint.mjs +1 -10
  29. package/rules/js-lint/js/tooling.mjs +1 -13
  30. package/rules/js-lint/js/utils_imports.mjs +1 -18
  31. package/rules/js-lint-ci/js/lint.mjs +1 -6
  32. package/rules/js-mssql/js/deps.mjs +1 -10
  33. package/rules/js-run/js/runtime.mjs +1 -37
  34. package/rules/js-run/lib/docs/temporal-scan.md +25 -0
  35. package/rules/k8s/js/manifests.mjs +1 -137
  36. package/rules/nginx-default-tpl/js/template.mjs +1 -18
  37. package/rules/npm-module/js/docs/header_doc_pointer.md +25 -0
  38. package/rules/npm-module/js/header_doc_pointer.mjs +82 -0
  39. package/rules/npm-module/js/package_structure.mjs +1 -28
  40. package/rules/npm-module/js/rule_meta.mjs +1 -10
  41. package/rules/npm-module/js/skill_meta.mjs +1 -13
  42. package/rules/php/js/tooling.mjs +1 -11
  43. package/rules/python/js/applies.mjs +1 -8
  44. package/rules/python/js/tooling.mjs +1 -21
  45. package/rules/rego/js/applies.mjs +1 -11
  46. package/rules/rust/js/applies.mjs +1 -7
  47. package/rules/security/js/sample_secret.mjs +1 -28
  48. package/rules/security/js/trufflehog.mjs +1 -8
  49. package/rules/style-lint/js/lint.mjs +1 -5
  50. package/rules/style-lint/js/tooling.mjs +1 -19
  51. package/rules/tauri/js/cargo_mutants_config.mjs +1 -20
  52. package/rules/tauri/js/tooling.mjs +1 -21
  53. package/rules/test/js/cargo_mutants_config.mjs +1 -12
  54. package/rules/test/js/location.mjs +1 -9
  55. package/rules/test/js/no-process-chdir.mjs +1 -21
  56. package/rules/test/js/no-relative-fs-path.mjs +1 -23
  57. package/rules/test/js/stryker_config.mjs +4 -25
  58. package/rules/test/js/vitest-config-pool-forks.mjs +1 -17
  59. package/rules/text/js/forbidden-prettier.mjs +1 -10
  60. package/rules/text/js/formatting.mjs +1 -31
  61. package/rules/vue/js/packages.mjs +1 -24
  62. package/scripts/docs/coverage-fix-extract.md +32 -0
  63. package/scripts/docs/lint-cli.md +25 -0
  64. package/scripts/docs/post-tool-use-fix.md +27 -0
  65. package/scripts/docs/rename-yaml-extensions.md +36 -0
  66. package/scripts/docs/skills-cli.md +35 -0
  67. package/scripts/docs/sync-claude-config.md +52 -0
  68. package/scripts/docs/sync-setup-bun-deps-action.md +26 -0
  69. package/scripts/docs/upgrade-nitra-cursor-and-install.md +29 -0
  70. package/scripts/docs/worktree-cli.md +46 -0
  71. package/scripts/lib/docs/assert-project-root.md +28 -0
  72. package/scripts/lib/docs/diff-added-lines.md +34 -0
  73. package/scripts/lib/docs/read-n-cursor-config-lite.md +28 -0
  74. package/scripts/lib/docs/resolve-target-files.md +34 -0
  75. package/scripts/lib/docs/root-notice.md +28 -0
  76. package/scripts/lib/docs/rule-meta-helpers.md +34 -0
  77. package/scripts/lib/docs/rule-meta.md +34 -0
  78. package/scripts/lib/docs/rule-predicates.md +30 -0
  79. package/scripts/lib/docs/run-conftest-batch.md +26 -0
  80. package/scripts/lib/docs/run-lint-step.md +25 -0
  81. package/scripts/lib/docs/run-rule-cli.md +27 -0
  82. package/scripts/lib/docs/run-rule.md +32 -0
  83. package/scripts/lib/docs/run-standard-lint.md +22 -0
  84. package/scripts/lib/docs/run-standard-rule.md +24 -0
  85. package/scripts/lib/docs/skill-meta.md +31 -0
  86. package/scripts/lib/docs/sync-gitignore-worktree.md +31 -0
  87. package/scripts/lib/docs/template.md +40 -0
  88. package/scripts/lib/docs/timing-summary.md +24 -0
  89. package/scripts/lib/docs/workspaces.md +30 -0
  90. package/scripts/lib/docs/worktree-notice.md +27 -0
  91. package/scripts/lib/docs/worktree.md +38 -0
  92. package/scripts/utils/docs/ast-scan-utils.md +50 -0
  93. package/scripts/utils/docs/ensure-gitignore-entries.md +28 -0
  94. package/scripts/utils/docs/find-package-json-paths.md +26 -0
  95. package/scripts/utils/docs/lock-cache-dir.md +25 -0
  96. package/scripts/utils/docs/pass.md +25 -0
  97. package/scripts/utils/docs/resolve-cargo-manifest.md +23 -0
  98. package/scripts/utils/docs/resolve-cmd.md +29 -0
  99. package/scripts/utils/docs/resolve-js-root.md +25 -0
  100. package/scripts/utils/docs/test-helpers.md +36 -0
  101. package/scripts/utils/docs/walk-cache.md +27 -0
  102. package/scripts/utils/docs/walkDir.md +32 -0
  103. package/scripts/utils/docs/with-lock.md +25 -0
  104. package/scripts/utils/docs/worktree-fingerprint.md +27 -0
  105. package/skills/docgen/js/docgen-batch.mjs +95 -0
  106. package/skills/docgen/js/docgen-extract.mjs +33 -18
  107. package/skills/docgen/js/docgen-gen.mjs +125 -98
  108. package/skills/docgen/js/docgen-ignore.mjs +1 -6
  109. package/skills/docgen/js/docgen-prompts.mjs +33 -22
  110. package/skills/docgen/js/docgen-scan.mjs +1 -8
  111. package/skills/docgen/js/docs/docgen-extract.md +28 -0
  112. package/skills/docgen/js/docs/docgen-gen.md +41 -0
  113. package/skills/docgen/js/docs/docgen-ignore.md +24 -0
  114. package/skills/docgen/js/docs/docgen-prompts.md +24 -0
  115. package/skills/docgen/js/docs/docgen-scan.md +48 -0
  116. package/skills/fix/js/docs/llm-worker.md +27 -0
  117. package/skills/fix/js/docs/orchestrator.md +32 -0
  118. package/skills/fix/js/docs/t0.md +29 -0
  119. package/skills/fix/js/llm-worker.mjs +64 -29
  120. package/skills/fix/js/orchestrator.mjs +45 -54
  121. package/skills/fix/js/t0.mjs +16 -32
  122. package/skills/start-check/js/check.mjs +1 -16
  123. package/skills/start-check/js/docs/check.md +34 -0
  124. package/skills/taze/js/diff.mjs +1 -15
  125. 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', 'path', 'crypto', 'os', 'util', 'stream', 'events', 'http', 'https',
13
- 'url', 'child_process', 'process', 'assert', 'buffer', 'zlib', 'readline'
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) { ret = rm[1].trim(); continue }
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(), npm = new Set(), internal = 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]) for (const n of m[2].split(',')) {
104
- const name = n.replace(/\s+as\s+.*/, '').trim()
105
- if (name) out.add(name)
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) { console.error('Usage: node docgen-extract.mjs <file>'); process.exit(1) }
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, messages, stream: true, think: false,
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
- { hostname: 'localhost', port: 11434, path: '/api/chat', method: 'POST',
37
- headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } },
38
- (res) => {
39
- let text = '', genTok = 0, buf = ''
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 { /* partial line */ }
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.replace(/^```[a-z]*\n?/, '').replace(/\n?```\s*$/, '').trim()
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) { cur = m[1].toLowerCase().replace(/[^а-яіїєґa-z0-9]/gi, ''); result[cur] = '' }
91
- else if (cur) result[cur] += line + '\n'
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
- { score -= 25; issues.push('no-overview') }
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
- { score -= 20; issues.push('short-behavior') }
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
- { score -= 20; issues.push('cache-hallucination') }
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) { score -= 10; issues.push(`internal-name:${sym}`) }
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
- ].filter(Boolean).join('\n')
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
- role: 'user',
160
- content: [
161
- { type: 'text', text: `ФАКТИ:\n${factsTxt}`, cache_control: { type: 'ephemeral' } },
162
- { type: 'text', text: `КОД:\n\`\`\`\n${src.slice(0, 4000)}\n\`\`\``, cache_control: { type: 'ephemeral' } },
163
- { type: 'text', text: `ДОКУМЕНТАЦІЯ:\n${md}` }
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
- /** Файли з sym цього значення отримують хмарного рефері (Haiku) після локального проходу. */
233
- const BORDERLINE_SYM_LOW = 2
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 < BORDERLINE_SYM_LOW → Tier 1 (без хмарного рефері)
240
- * sym [BORDERLINE_SYM_LOW, symThreshold) → Tier 1 + cloudScoreDoc (Haiku) як рефері
241
- * sym >= symThreshold → Pre-routing одразу Tier 2
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(file, {
249
- model = 'gemma3:4b',
250
- mode = 'orchestrated',
251
- scoreModel = 'claude-haiku-4-5-20251001',
252
- cloudModel = 'claude-sonnet-4-6',
253
- threshold = QUALITY_THRESHOLD,
254
- scoreCloud = false,
255
- symThreshold = DEFAULT_SYM_THRESHOLD
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 { ...r2, ms: Date.now() - t0, score: null, issues: [`pre-routed:sym=${complexity}`], tier: 2 }
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
- let r = facts.unsupported
269
- ? await generateOneShot(facts, src, model)
270
- : mode === 'oneshot'
271
- ? await generateOneShot(facts, src, model)
272
- : await generateOrchestrated(facts, src, model)
273
-
274
- // Stage 2.5a: детермінований скоринг (0 токенів)
275
- const { score: detScore, issues: detIssues } = scoreDoc(r.md, facts)
276
-
277
- // Stage 2.5b: cloudScoreDoc (Haiku) як рефері для borderline-файлів або при scoreCloud=true
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 { ...r2, ms: Date.now() - t0, score: cs.score, cloudScores: cs.scores,
284
- issues: cs.issues, detScore, detIssues, tier: 2 }
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
- return { ...r, ms: Date.now() - t0, score: cs.score, cloudScores: cs.scores,
287
- issues: cs.issues, detScore, detIssues, tier: 1 }
316
+ throw e
288
317
  }
289
318
 
290
- // Детермінований fallback (без хмарного рефері)
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] [--score-cloud] [--model <m>] [--score-model <m>] [--sym-threshold N] [--tier-only]
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'); const model = mi >= 0 ? args[mi + 1] : 'gemma3:4b'
308
- const smi = args.indexOf('--score-model'); const scoreModel = smi >= 0 ? args[smi + 1] : 'claude-haiku-4-5-20251001'
309
- const si = args.indexOf('--sym-threshold'); const symThreshold = si >= 0 ? Number(args[si + 1]) : DEFAULT_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] [--score-cloud] [--model <m>] [--score-model <m>] [--sym-threshold N] [--tier-only]')
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
- let label, icon
319
- if (sym >= symThreshold) {
320
- icon = '☁️ '; label = `Tier 2 cloud (sym=${sym} ${symThreshold}, pre-routed)`
321
- } else if (sym >= BORDERLINE_SYM_LOW) {
322
- icon = '🔀'; label = `Tier 1+judge (sym=${sym} [${BORDERLINE_SYM_LOW},${symThreshold}), Haiku рефері)`
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, scoreCloud, scoreModel, symThreshold })
357
+ const r = await generateDoc(file, { model, mode, symThreshold })
330
358
  const issuesTxt = r.issues?.length ? ` issues=${r.issues.join(',')}` : ''
331
- const cloudTxt = r.cloudScores ? ` cloud-scores=${JSON.stringify(r.cloudScores)}` : ''
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) => [{ role: 'system', content: system }, { role: 'user', content: 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', numPredict: 220,
46
- messages: msgs(`${STYLE}\n\nВІДОМІ ФАКТИ:\n${factsTxt}`,
47
- 'Напиши вміст секції «Огляд»: 1-3 речення — що файл робить і навіщо існує (роль у системі). Без заголовка, без переліку функцій.')
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', numPredict: 500,
53
- messages: msgs(`${STYLE}\n\nФАЙЛ ${facts.relPath}:\n\`\`\`\n${src}\n\`\`\`\n\nВІДОМІ ФАКТИ:\n${factsTxt}`,
54
- `Напиши вміст секції «Поведінка»: ${multi ? 'для кожної публічної функції — один короткий пункт «що вона робить»' : 'нумерований алгоритм у бізнес-термінах'}. Якщо у фактах є свідомі пропуски шляхів — згадай їх там, де доречно (не вигадуй інших «не перевіряє»). НЕ пиши аргументи функцій у дужках, без regex.${facts.internalSymbols?.length ? ` НЕ згадуй за іменами службові функції: ${facts.internalSymbols.join(', ')}.` : ''} Без заголовка.`)
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', numPredict: 320,
62
- messages: msgs(STYLE,
63
- `Перепиши цей список як стислі маркери «назва — що робить», СВОЇМИ словами (не копіюй дослівно), без типів і сигнатур. Використовуй РІВНО ці назви, не додавай і не прибирай:\n${list}\nБез заголовка.`)
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', numPredict: 300,
70
- messages: msgs(`${STYLE}\n\nВІДОМІ ФАКТИ:\n${factsTxt}`,
71
- 'Напиши вміст секції «Гарантії поведінки» як маркери-інваріанти СУВОРО на основі ВІДОМИХ ФАКТІВ (read-only, fail-safe, пропуски). Згадуй кеш ЛИШЕ якщо у фактах прямо є «Кешує». Без сигнатур у дужках і без імен внутрішніх структур/Map-ів/кешів. Не вигадуй гарантій, яких немає у фактах. Без заголовка.')
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(STYLE,
81
- `Напиши документацію для файлу. Секції: ## Огляд (1-3 речення), ## Поведінка (нумерований/маркований алгоритм), ${multi ? '## Публічний API (назва + що робить), ' : ''}## Гарантії поведінки.\n\nФАЙЛ ${facts.relPath}:\n\`\`\`\n${src}\n\`\`\``)
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'