@nitra/cursor 4.1.0 → 4.1.2

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 (135) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/bin/n-cursor.js +25 -13
  3. package/docs/flow.MD +1364 -0
  4. package/docs/stryker.config.md +37 -0
  5. package/docs/vitest.config.md +23 -0
  6. package/lib/models.mjs +1 -2
  7. package/package.json +2 -1
  8. package/rules/abie/fix.mjs +1 -1
  9. package/rules/bun/docs/fix.md +3 -0
  10. package/rules/bun/fix.mjs +1 -1
  11. package/rules/capacitor/fix.mjs +1 -1
  12. package/rules/changelog/docs/fix.md +3 -0
  13. package/rules/changelog/fix.mjs +1 -1
  14. package/rules/ci4/fix.mjs +1 -1
  15. package/rules/ci4/js/docs/marksman_config.md +1 -0
  16. package/rules/docker/docs/fix.md +1 -1
  17. package/rules/docker/fix.mjs +1 -1
  18. package/rules/docker/lint/docs/lint.md +1 -0
  19. package/rules/efes/docs/fix.md +2 -1
  20. package/rules/efes/fix.mjs +1 -1
  21. package/rules/feedback/fix.mjs +1 -1
  22. package/rules/ga/fix.mjs +1 -1
  23. package/rules/ga/js/lint.mjs +1 -1
  24. package/rules/graphql/docs/fix.md +4 -1
  25. package/rules/graphql/fix.mjs +1 -1
  26. package/rules/graphql/lib/docs/graphql-gql-scan.md +3 -0
  27. package/rules/hasura/fix.mjs +1 -1
  28. package/rules/image-avif/docs/fix.md +4 -1
  29. package/rules/image-avif/fix.mjs +1 -1
  30. package/rules/image-avif/js/docs/avif_generation.md +1 -0
  31. package/rules/image-compress/fix.mjs +1 -1
  32. package/rules/js-bun-db/fix.mjs +1 -1
  33. package/rules/js-bun-db/lib/docs/bun-sql-scan.md +6 -0
  34. package/rules/js-bun-redis/fix.mjs +1 -1
  35. package/rules/js-lint/fix.mjs +1 -1
  36. package/rules/js-lint/js/docs/utils_imports.md +1 -0
  37. package/rules/js-lint-ci/docs/fix.md +4 -1
  38. package/rules/js-lint-ci/fix.mjs +1 -1
  39. package/rules/js-mssql/docs/fix.md +3 -0
  40. package/rules/js-mssql/fix.mjs +1 -1
  41. package/rules/js-mssql/lib/docs/mssql-pool-scan.md +9 -0
  42. package/rules/js-run/docs/fix.md +3 -0
  43. package/rules/js-run/fix.mjs +1 -1
  44. package/rules/js-run/lib/docs/check-env-scan.md +2 -1
  45. package/rules/js-run/lib/docs/promise-settimeout-scan.md +4 -0
  46. package/rules/k8s/docs/fix.md +3 -0
  47. package/rules/k8s/fix.mjs +1 -1
  48. package/rules/nginx-default-tpl/docs/fix.md +3 -0
  49. package/rules/nginx-default-tpl/fix.mjs +1 -1
  50. package/rules/npm-module/fix.mjs +1 -1
  51. package/rules/npm-module/js/header_doc_pointer.mjs +14 -3
  52. package/rules/php/docs/fix.md +2 -1
  53. package/rules/php/fix.mjs +1 -1
  54. package/rules/python/docs/fix.md +4 -1
  55. package/rules/python/fix.mjs +1 -1
  56. package/rules/rego/fix.mjs +1 -1
  57. package/rules/rego/js/lint.mjs +1 -1
  58. package/rules/release/docs/fix.md +4 -1
  59. package/rules/release/fix.mjs +1 -1
  60. package/rules/rust/fix.mjs +1 -1
  61. package/rules/security/docs/fix.md +1 -1
  62. package/rules/security/fix.mjs +1 -1
  63. package/rules/style-lint/docs/fix.md +3 -0
  64. package/rules/style-lint/fix.mjs +1 -1
  65. package/rules/tauri/docs/fix.md +4 -1
  66. package/rules/tauri/fix.mjs +1 -1
  67. package/rules/test/docs/fix.md +3 -0
  68. package/rules/test/fix.mjs +1 -1
  69. package/rules/test/js/no-relative-fs-path.mjs +2 -1
  70. package/rules/text/docs/fix.md +3 -0
  71. package/rules/text/fix.mjs +1 -1
  72. package/rules/text/js/lint.mjs +1 -1
  73. package/rules/vue/fix.mjs +1 -1
  74. package/rules/worktree/fix.mjs +1 -1
  75. package/scripts/auto-rules.mjs +1 -1
  76. package/scripts/coverage-classify/index.mjs +10 -10
  77. package/scripts/coverage-fix.mjs +2 -2
  78. package/scripts/dispatcher/graph/lib/cmd-init.mjs +112 -0
  79. package/scripts/dispatcher/graph/lib/cmd-invalidate.mjs +96 -0
  80. package/scripts/dispatcher/graph/lib/cmd-kill.mjs +141 -0
  81. package/scripts/dispatcher/graph/lib/cmd-plan.mjs +142 -0
  82. package/scripts/dispatcher/graph/lib/cmd-run.mjs +328 -0
  83. package/scripts/dispatcher/graph/lib/cmd-scan.mjs +115 -0
  84. package/scripts/dispatcher/graph/lib/cmd-setup.mjs +111 -0
  85. package/scripts/dispatcher/graph/lib/cmd-signals.mjs +328 -0
  86. package/scripts/dispatcher/graph/lib/cmd-status.mjs +131 -0
  87. package/scripts/dispatcher/graph/lib/cmd-verify.mjs +100 -0
  88. package/scripts/dispatcher/graph/lib/cmd-watch.mjs +128 -0
  89. package/scripts/dispatcher/graph/lib/config.mjs +103 -0
  90. package/scripts/dispatcher/graph/lib/frontmatter.mjs +224 -0
  91. package/scripts/dispatcher/graph/lib/nnn.mjs +127 -0
  92. package/scripts/dispatcher/graph/lib/node-state.mjs +157 -0
  93. package/scripts/dispatcher/graph/lib/scanner.mjs +235 -0
  94. package/scripts/dispatcher/graph/lib/worktree-ops.mjs +193 -0
  95. package/scripts/dispatcher/graph-tasks.mjs +92 -0
  96. package/scripts/dispatcher/index.mjs +3 -3
  97. package/scripts/dispatcher/lib/docs/events.md +1 -0
  98. package/scripts/dispatcher/lib/executor.mjs +1 -1
  99. package/scripts/dispatcher/lib/subagent-runner.mjs +9 -9
  100. package/scripts/dispatcher/trace.mjs +6 -2
  101. package/scripts/docs/build-agents-commands.md +1 -0
  102. package/scripts/docs/cli-entry.md +6 -0
  103. package/scripts/graph/index.mjs +115 -0
  104. package/scripts/graph/lib/config.mjs +62 -0
  105. package/scripts/graph/lib/dag.mjs +161 -0
  106. package/scripts/graph/lib/frontmatter.mjs +70 -0
  107. package/scripts/graph/lib/nnn.mjs +77 -0
  108. package/scripts/graph/lib/state.mjs +110 -0
  109. package/scripts/graph/scan.mjs +64 -0
  110. package/scripts/graph/status.mjs +86 -0
  111. package/scripts/lib/docs/load-cursor-config.md +3 -0
  112. package/scripts/lib/root-notice.mjs +4 -2
  113. package/scripts/lib/rule-predicates.mjs +1 -1
  114. package/scripts/lib/worktree-notice.mjs +14 -7
  115. package/scripts/lib/worktree.mjs +3 -2
  116. package/scripts/utils/resolve-js-root.mjs +2 -1
  117. package/scripts/utils/with-lock.mjs +1 -1
  118. package/skills/docgen/js/docgen-batch.mjs +7 -7
  119. package/skills/docgen/js/docgen-extract.mjs +80 -37
  120. package/skills/docgen/js/docgen-ignore.mjs +1 -1
  121. package/skills/docgen/js/docgen-prompts.mjs +21 -5
  122. package/skills/fix/js/llm-worker.mjs +19 -22
  123. package/skills/fix/js/orchestrator.mjs +6 -7
  124. package/skills/fix/js/t0.mjs +14 -13
  125. package/types/bin/n-cursor.d.ts +1 -1
  126. package/rules/flow/docs/fix.md +0 -152
  127. package/rules/flow/fix.mjs +0 -18
  128. package/rules/flow/flow.mdc +0 -127
  129. package/rules/flow/meta.json +0 -1
  130. package/scripts/dispatcher/lib/docs/flow-lock.md +0 -161
  131. package/scripts/dispatcher/lib/docs/flow-resolve.md +0 -267
  132. package/scripts/dispatcher/lib/flow-plan.mjs +0 -153
  133. package/scripts/dispatcher/lib/flow-resolve.mjs +0 -156
  134. package/scripts/dispatcher/lib/flow-signals.mjs +0 -235
  135. package/scripts/dispatcher/lib/flow-verify.mjs +0 -127
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Визначення стану вузла за наявністю файлів (O(1), без читання вмісту).
3
+ * Пріоритет: invalidated > resolved > pending-audit > stalled > running > waiting/blocked > failed > needs-plan
4
+ */
5
+ import { existsSync, readdirSync } from 'node:fs'
6
+ import { join } from 'node:path'
7
+
8
+ /**
9
+ * @typedef {'needs-plan'|'waiting'|'blocked'|'running'|'stalled'|'pending-audit'|'resolved'|'failed'|'invalidated'} NodeState
10
+ */
11
+
12
+ /**
13
+ * Визначає стан атомарного вузла за файлами у директорії.
14
+ * @param {string} dir абсолютний шлях до директорії вузла
15
+ * @param {{ depsResolved: boolean }} opts
16
+ * @returns {NodeState}
17
+ */
18
+ export function deriveAtomicState(dir, { depsResolved = true } = {}) {
19
+ if (existsSync(join(dir, 'invalidated'))) return 'invalidated'
20
+
21
+ const files = listFiles(dir)
22
+
23
+ if (hasFact(files) && !files.includes('invalidated')) return 'resolved'
24
+
25
+ const pendingNNN = findPendingAudit(files)
26
+ if (pendingNNN !== null) return 'pending-audit'
27
+
28
+ const runningUntil = findRunningUntil(files)
29
+ if (runningUntil !== null) {
30
+ const ts = Number(runningUntil)
31
+ const now = Math.floor(Date.now() / 1000)
32
+ return ts > now ? 'running' : 'stalled'
33
+ }
34
+
35
+ const hasPlan = files.some(f => /^plan_\d{3}\.md$/u.test(f))
36
+ const hasRun = files.some(f => /^run_\d{3}\.md$/u.test(f))
37
+
38
+ if (!hasPlan) return 'needs-plan'
39
+ if (hasRun && !hasFact(files)) return 'failed'
40
+ if (hasPlan && !depsResolved) return 'blocked'
41
+ return 'waiting'
42
+ }
43
+
44
+ /**
45
+ * Визначає стан composite вузла за станами дітей.
46
+ * @param {string} dir
47
+ * @param {NodeState[]} childStates
48
+ * @returns {NodeState}
49
+ */
50
+ export function deriveCompositeState(dir, childStates) {
51
+ if (existsSync(join(dir, 'invalidated'))) return 'invalidated'
52
+
53
+ if (childStates.length === 0) return 'needs-plan'
54
+ if (childStates.every(s => s === 'resolved')) return 'resolved'
55
+ if (childStates.some(s => s === 'running' || s === 'pending-audit')) return 'running'
56
+ if (childStates.some(s => s === 'stalled')) return 'stalled'
57
+ if (childStates.some(s => s === 'failed') && !childStates.some(s => s === 'running')) return 'failed'
58
+ return 'waiting'
59
+ }
60
+
61
+ /**
62
+ * Перевіряє наявність orphan worktree для вузла (resolved + worktree exists).
63
+ * @param {string} dir
64
+ * @param {string} worktreesDir
65
+ * @returns {boolean}
66
+ */
67
+ export function hasOrphanWorktree(dir, worktreesDir) {
68
+ const nodeName = dir.split('/').at(-1)
69
+ try {
70
+ return readdirSync(worktreesDir).some(d => d.startsWith(nodeName))
71
+ } catch {
72
+ return false
73
+ }
74
+ }
75
+
76
+ // --- helpers ---
77
+
78
+ /** @param {string} dir @returns {string[]} */
79
+ function listFiles(dir) {
80
+ try { return readdirSync(dir) } catch { return [] }
81
+ }
82
+
83
+ /** @param {string[]} files @returns {boolean} */
84
+ function hasFact(files) {
85
+ return files.some(f => /^fact_\d{3}\.md$/u.test(f))
86
+ }
87
+
88
+ /**
89
+ * Знаходить перший pending-audit_NNN без audit-result_NNN.
90
+ * @param {string[]} files
91
+ * @returns {string | null} NNN або null
92
+ */
93
+ function findPendingAudit(files) {
94
+ const pending = files.filter(f => /^pending-audit_\d{3}\.md$/u.test(f)).sort()
95
+ for (const p of pending) {
96
+ const nnn = p.replace('pending-audit_', '').replace('.md', '')
97
+ if (!files.includes(`audit-result_${nnn}.md`)) return nnn
98
+ }
99
+ return null
100
+ }
101
+
102
+ /**
103
+ * Знаходить `running_until_<ts>` файл і повертає ts як рядок.
104
+ * @param {string[]} files
105
+ * @returns {string | null}
106
+ */
107
+ function findRunningUntil(files) {
108
+ const f = files.find(f => /^running_until_\d+$/u.test(f))
109
+ return f ? f.replace('running_until_', '') : null
110
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `graph scan` — повне сканування граф: відновлює стан з файлів, виводить результат.
3
+ * exit 0 — граф чистий; exit 1 — є вузли у стані failed.
4
+ */
5
+ import { resolve } from 'node:path'
6
+ import { cwd } from 'node:process'
7
+
8
+ import { loadConfig } from './lib/config.mjs'
9
+ import { buildDag } from './lib/dag.mjs'
10
+
11
+ /**
12
+ * @param {string[]} args
13
+ * @param {{ json?: boolean }} opts
14
+ */
15
+ export async function runScan(args, opts = {}) {
16
+ const root = cwd()
17
+ const config = loadConfig(root)
18
+ const tasksDir = resolve(root, config.tasks_dir)
19
+ const worktreesDir = resolve(root, config.worktrees_dir)
20
+
21
+ const nodes = buildDag(tasksDir, worktreesDir)
22
+
23
+ if (opts.json) {
24
+ const out = {}
25
+ for (const [id, node] of nodes) out[id] = { state: node.state, deps: node.deps }
26
+ process.stdout.write(JSON.stringify(out, null, 2) + '\n')
27
+ } else {
28
+ printScanTable(nodes)
29
+ }
30
+
31
+ const hasFailed = [...nodes.values()].some(n => n.state === 'failed')
32
+ return hasFailed ? 1 : 0
33
+ }
34
+
35
+ /** @param {Map<string, import('./lib/dag.mjs').GraphNode>} nodes */
36
+ function printScanTable(nodes) {
37
+ const STATE_ICON = {
38
+ 'needs-plan': '⏳',
39
+ waiting: '○',
40
+ blocked: '○',
41
+ running: '◉',
42
+ stalled: '⚠',
43
+ 'pending-audit': '🔍',
44
+ resolved: '✓',
45
+ failed: '✗',
46
+ invalidated: '⊘',
47
+ }
48
+
49
+ const counts = {}
50
+ for (const { state } of nodes.values()) counts[state] = (counts[state] ?? 0) + 1
51
+
52
+ const summary = Object.entries(counts).map(([s, n]) => `${s}:${n}`).join(' ')
53
+ console.log(`graph — ${summary}\n`)
54
+
55
+ for (const [id, node] of nodes) {
56
+ const icon = STATE_ICON[node.state] ?? '?'
57
+ let detail = ''
58
+ if (node.state === 'needs-plan') detail = `run: graph plan tasks/${id}/`
59
+ else if (node.state === 'blocked') detail = `blocked: ${node.deps.join(', ')}`
60
+ else if (node.state === 'stalled') detail = 'stalled — deadline passed'
61
+ const suffix = detail ? ` [${detail}]` : ''
62
+ console.log(` ${icon} ${id.padEnd(30)} [${node.state}]${suffix}`)
63
+ }
64
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * `graph status [path]` — стан одного вузла або всього графу.
3
+ */
4
+ import { resolve } from 'node:path'
5
+ import { cwd } from 'node:process'
6
+
7
+ import { loadConfig } from './lib/config.mjs'
8
+ import { buildDag } from './lib/dag.mjs'
9
+
10
+ /**
11
+ * @param {string | undefined} path відносний шлях до вузла або undefined для всього графу
12
+ * @param {{ json?: boolean }} opts
13
+ */
14
+ export async function runStatus(path, opts = {}) {
15
+ const root = cwd()
16
+ const config = loadConfig(root)
17
+ const tasksDir = resolve(root, config.tasks_dir)
18
+ const worktreesDir = resolve(root, config.worktrees_dir)
19
+
20
+ const nodes = buildDag(tasksDir, worktreesDir)
21
+
22
+ if (path) {
23
+ // Normalize: tasks/foo → foo, tasks/foo/ → foo
24
+ const id = path.replace(/^tasks\//u, '').replace(/\/$/u, '')
25
+ const node = nodes.get(id)
26
+ if (!node) {
27
+ console.error(`Node not found: ${id}`)
28
+ return 1
29
+ }
30
+ if (opts.json) {
31
+ process.stdout.write(JSON.stringify({ id, ...node }, null, 2) + '\n')
32
+ } else {
33
+ printNode(id, node, nodes)
34
+ }
35
+ return 0
36
+ }
37
+
38
+ // Full graph
39
+ if (opts.json) {
40
+ const out = {}
41
+ for (const [id, node] of nodes) {
42
+ out[id] = { state: node.state, deps: node.deps, mode: node.mode, executor: node.executor }
43
+ }
44
+ process.stdout.write(JSON.stringify(out, null, 2) + '\n')
45
+ } else {
46
+ // Print as tree
47
+ const roots = [...nodes.values()].filter(n => !n.parentId)
48
+ const counts = {}
49
+ for (const { state } of nodes.values()) counts[state] = (counts[state] ?? 0) + 1
50
+ const summary = Object.entries(counts).map(([s, n]) => `${s}:${n}`).join(' ')
51
+ console.log(`graph — ${summary}\n`)
52
+ for (const root of roots) printTree(root.id, nodes, 0)
53
+ }
54
+ return 0
55
+ }
56
+
57
+ const ICON = {
58
+ 'needs-plan': '⏳', waiting: '○', blocked: '○', running: '◉',
59
+ stalled: '⚠', 'pending-audit': '🔍', resolved: '✓', failed: '✗', invalidated: '⊘',
60
+ }
61
+
62
+ /** @param {string} id @param {Map<string, import('./lib/dag.mjs').GraphNode>} nodes @param {number} depth */
63
+ function printTree(id, nodes, depth) {
64
+ const node = nodes.get(id)
65
+ if (!node) return
66
+ const icon = ICON[node.state] ?? '?'
67
+ const indent = ' '.repeat(depth)
68
+ let detail = ''
69
+ if (node.state === 'needs-plan') detail = ` run: graph plan tasks/${id}/`
70
+ else if (node.state === 'blocked') detail = ` blocked: ${node.deps.join(', ')}`
71
+ console.log(`${indent}${icon} ${id} [${node.state}]${detail}`)
72
+ for (const child of node.children) printTree(child, nodes, depth + 1)
73
+ }
74
+
75
+ /**
76
+ * @param {string} id
77
+ * @param {import('./lib/dag.mjs').GraphNode} node
78
+ * @param {Map<string, import('./lib/dag.mjs').GraphNode>} nodes
79
+ */
80
+ function printNode(id, node, nodes) {
81
+ const icon = ICON[node.state] ?? '?'
82
+ console.log(`${icon} ${id} [${node.state}]`)
83
+ if (node.deps.length) console.log(` deps: ${node.deps.join(', ')}`)
84
+ if (node.children.length) console.log(` children: ${node.children.join(', ')}`)
85
+ console.log(` mode: ${node.mode} executor: ${node.executor.type}/${node.executor.model_tier}`)
86
+ }
@@ -101,6 +101,9 @@ const ignored = await loadCursorIgnorePaths(root)
101
101
  // ignored: ['/abs/path/.worktrees', '/abs/path/node_modules', ...]
102
102
 
103
103
  // Далі при обході файлів пропускаємо ті, що під будь-яким із ignored:
104
+ /**
105
+ *
106
+ */
104
107
  function isIgnored(absPosixPath) {
105
108
  return ignored.some(ig => absPosixPath === ig || absPosixPath.startsWith(ig + '/'))
106
109
  }
@@ -40,6 +40,8 @@ git rev-parse --show-toplevel
40
40
  /** Канонічний блок root-інструкції (з маркерами). */
41
41
  const BLOCK = `${ROOT_START}\n${NOTICE_BODY}\n${ROOT_END}`
42
42
 
43
+ const LEADING_NEWLINES_RE = /^\n+/u
44
+
43
45
  /**
44
46
  * Вставляє / оновлює / видаляє root-guard блок у вмісті `SKILL.md`.
45
47
  * @param {string} content вміст `SKILL.md`
@@ -57,8 +59,8 @@ export function injectRootNotice(content, enabled) {
57
59
  const fm = withoutBlock.match(FRONTMATTER_RE)
58
60
  if (fm) {
59
61
  const head = fm[1]
60
- const rest = withoutBlock.slice(head.length).replace(/^\n+/u, '')
62
+ const rest = withoutBlock.slice(head.length).replace(LEADING_NEWLINES_RE, '')
61
63
  return `${head}\n${BLOCK}\n\n${rest}`
62
64
  }
63
- return `${BLOCK}\n\n${withoutBlock.replace(/^\n+/u, '')}`
65
+ return `${BLOCK}\n\n${withoutBlock.replace(LEADING_NEWLINES_RE, '')}`
64
66
  }
@@ -133,7 +133,7 @@ export const RULE_PREDICATES = {
133
133
  * @param {{ hasBunSqlImport: boolean }} facts факти
134
134
  * @returns {Promise<boolean>} true, якщо deps pg/pg-format/mysql2 або import sql з bun
135
135
  */
136
- async jsBunDbSignal(cwd, facts) {
136
+ jsBunDbSignal(cwd, facts) {
137
137
  if (facts.hasBunSqlImport === true) return true
138
138
  return anyDepInTree(cwd, ['pg', 'pg-format', 'mysql2'])
139
139
  },
@@ -36,6 +36,13 @@ const NAME_RE = /^name:\s*["']?([^"'\n]+)["']?\s*$/mu
36
36
  /** Перший H1 як fallback, якщо frontmatter не містить `name`. */
37
37
  const H1_RE = /^#\s+(.+)$/mu
38
38
 
39
+ const N_PREFIX_RE = /^n-/u
40
+ const COMBINING_DIACRITICS_RE = /[̀-ͯ]/gu
41
+ const NON_ALPHANUM_RE = /[^a-z0-9]+/gu
42
+ const TRAILING_DASHES_RE = /^-+|-+$/gu
43
+ const TRAILING_DASH_RE = /-+$/u
44
+ const LEADING_NEWLINES_RE = /^\n+/u
45
+
39
46
  const CYRILLIC_TRANSLIT = new Map(
40
47
  Object.entries({
41
48
  а: 'a',
@@ -96,13 +103,13 @@ function deriveSuffix(content) {
96
103
  const raw = content.match(NAME_RE)?.[1] ?? content.match(H1_RE)?.[1] ?? FALLBACK_SUFFIX
97
104
  const slug = transliterate(raw)
98
105
  .trim()
99
- .replace(/^n-/u, '')
106
+ .replace(N_PREFIX_RE, '')
100
107
  .normalize('NFKD')
101
- .replaceAll(/[\u0300-\u036F]/gu, '')
102
- .replaceAll(/[^a-z0-9]+/gu, '-')
103
- .replaceAll(/^-+|-+$/gu, '')
108
+ .replaceAll(COMBINING_DIACRITICS_RE, '')
109
+ .replaceAll(NON_ALPHANUM_RE, '-')
110
+ .replaceAll(TRAILING_DASHES_RE, '')
104
111
 
105
- return (slug || FALLBACK_SUFFIX).slice(0, 10).replace(/-+$/u, '') || FALLBACK_SUFFIX
112
+ return (slug || FALLBACK_SUFFIX).slice(0, 10).replace(TRAILING_DASH_RE, '') || FALLBACK_SUFFIX
106
113
  }
107
114
 
108
115
  /**
@@ -202,8 +209,8 @@ export function injectWorktreeNotice(content, enabled) {
202
209
  const fm = withoutBlock.match(FRONTMATTER_RE)
203
210
  if (fm) {
204
211
  const head = fm[1]
205
- const rest = withoutBlock.slice(head.length).replace(/^\n+/u, '')
212
+ const rest = withoutBlock.slice(head.length).replace(LEADING_NEWLINES_RE, '')
206
213
  return `${head}\n${block}\n\n${rest}`
207
214
  }
208
- return `${block}\n\n${withoutBlock.replace(/^\n+/u, '')}`
215
+ return `${block}\n\n${withoutBlock.replace(LEADING_NEWLINES_RE, '')}`
209
216
  }
@@ -14,6 +14,7 @@ import { basename, join } from 'node:path'
14
14
 
15
15
  /** Символи, безпечні для імені каталогу/файла; решта → дефіс. */
16
16
  const UNSAFE_PATH_CHARS_RE = /[^a-zA-Z0-9._-]+/gu
17
+ const MD_EXTENSION_RE = /\.md$/u
17
18
 
18
19
  /**
19
20
  * Перетворює імʼя git-гілки на безпечне імʼя каталогу/файла для `.worktrees/`.
@@ -82,7 +83,7 @@ export function buildDescription({ branch, task, baseCommit, date }) {
82
83
  `**Дата:** ${date}`,
83
84
  `**База (коміт):** ${baseCommit}`,
84
85
  '',
85
- 'Прибрати: ' + '`' + `npx @nitra/cursor worktree remove ${branch}` + '`',
86
+ `Прибрати: \`npx @nitra/cursor worktree remove ${branch}\``,
86
87
  ''
87
88
  ].join('\n')
88
89
  }
@@ -121,5 +122,5 @@ export function buildDirtyNotice(porcelain, limit = DIRTY_LIST_LIMIT) {
121
122
  */
122
123
  export function findOrphanDescFiles(descFiles, registeredCheckouts) {
123
124
  const checkoutBasenames = new Set(registeredCheckouts.map(c => basename(c)))
124
- return descFiles.filter(md => !checkoutBasenames.has(basename(md).replace(/\.md$/u, '')))
125
+ return descFiles.filter(md => !checkoutBasenames.has(basename(md).replace(MD_EXTENSION_RE, '')))
125
126
  }
@@ -8,6 +8,7 @@ import { glob, readFile } from 'node:fs/promises'
8
8
  import { join } from 'node:path'
9
9
 
10
10
  const WORKSPACE_GLOB_IGNORE = ['**/node_modules/**', '**/.git/**']
11
+ const PACKAGE_JSON_SUFFIX_RE = /[/\\]package\.json$/
11
12
 
12
13
  /**
13
14
  * Розгортає один workspace-патерн у список абсолютних шляхів каталогів з package.json.
@@ -23,7 +24,7 @@ async function expandWorkspacePattern(cwd, pattern) {
23
24
  }
24
25
  const results = []
25
26
  for await (const rel of glob(`${pattern}/package.json`, { cwd, exclude: WORKSPACE_GLOB_IGNORE })) {
26
- const wsRel = rel.replace(/[/\\]package\.json$/, '')
27
+ const wsRel = rel.replace(PACKAGE_JSON_SUFFIX_RE, '')
27
28
  results.push(join(cwd, wsRel))
28
29
  }
29
30
  return results.sort()
@@ -128,7 +128,7 @@ export async function withLock(key, runFn, opts = {}) {
128
128
 
129
129
  const onSignal = () => {
130
130
  release()
131
- // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit -- SIGINT/SIGTERM мають завершити процес із кодом 130
131
+ // eslint-disable-next-line n/no-process-exit -- SIGINT/SIGTERM мають завершити процес із кодом 130
132
132
  process.exit(130)
133
133
  }
134
134
  process.once('SIGINT', onSignal)
@@ -20,8 +20,8 @@ const missing = allFiles.filter(x => !x.exists)
20
20
  console.log(`\n📋 Файлів для генерації: ${missing.length}`)
21
21
 
22
22
  // 2. Розкласти по тирах
23
- const local = [],
24
- cloud = []
23
+ const cloud = [],
24
+ local = []
25
25
  for (const f of missing) {
26
26
  try {
27
27
  const src = readFileSync(join(ROOT, f.sourcePath), 'utf8')
@@ -51,10 +51,10 @@ for (const f of cloud) {
51
51
  stats.ok++
52
52
  stats.cloudOk++
53
53
  console.log(` ✓ ${f.sourcePath} (sym=${f.sym}, ${Math.round((Date.now() - t0) / 1000)}s)`)
54
- } catch (e) {
54
+ } catch (error) {
55
55
  stats.err++
56
56
  stats.errors.push(f.sourcePath)
57
- console.error(` ✗ ${f.sourcePath}: ${e.message}`)
57
+ console.error(` ✗ ${f.sourcePath}: ${error.message}`)
58
58
  }
59
59
  }
60
60
 
@@ -77,10 +77,10 @@ for (const f of local) {
77
77
  stats.ok++
78
78
  stats.localOk++
79
79
  process.stdout.write(`✓ ${Math.round((Date.now() - t0) / 1000)}s score=${result.score ?? '?'}\n`)
80
- } catch (e) {
80
+ } catch (error) {
81
81
  stats.err++
82
82
  stats.errors.push(f.sourcePath)
83
- process.stdout.write(`✗ ${e.message}\n`)
83
+ process.stdout.write(`✗ ${error.message}\n`)
84
84
  }
85
85
  }
86
86
 
@@ -91,5 +91,5 @@ console.log(` 💻 Local (gemma3:4b): ${stats.localOk} файлів`)
91
91
  console.log(` ☁️ Cloud (Claude/pi): ${stats.cloudOk} файлів`)
92
92
  if (stats.errors.length > 0) {
93
93
  console.log('Помилки:')
94
- stats.errors.forEach(e => console.log(` - ${e}`))
94
+ for (const e of stats.errors) console.log(` - ${e}`)
95
95
  }
@@ -1,5 +1,8 @@
1
1
  /** @see ./docs/docgen-extract.md */
2
2
 
3
+ import { isRunAsCli } from '../../../scripts/cli-entry.mjs'
4
+ import { readFileSync } from 'node:fs'
5
+
3
6
  const BUILTIN_MODULES = new Set([
4
7
  'fs',
5
8
  'path',
@@ -19,18 +22,45 @@ const BUILTIN_MODULES = new Set([
19
22
  'readline'
20
23
  ])
21
24
 
22
- /** Прибирає `/** *​/`-обрамлення й `*`-префікси, повертає чистий текст рядками. */
25
+ const JSDOC_OPEN_RE = /^\s*\/\*\*?/
26
+ const JSDOC_CLOSE_RE = /\*\/\s*$/
27
+ const STAR_PREFIX_RE = /^\s*\*?\s?/
28
+ const PARAM_LINE_RE = /^@param\s+(?:\{[^}]*\}\s+)?\[?([A-Za-z0-9_.]+)\]?\s*(.*)$/
29
+ const RETURNS_LINE_RE = /^@returns?\s+(?:\{[^}]*\}\s+)?(.*)$/
30
+ const FILE_HEADER_RE = /^\s*\/\*\*([\s\S]*?)\*\//
31
+ const PRECEDING_JSDOC_RE = /\/\*\*(?:(?!\*\/)[\s\S])*\*\/\s*$/
32
+ const EXPORT_DECL_RE = /export\s+(?:async\s+)?(function|const|class)\s+([A-Za-z0-9_]+)/g
33
+ const IMPORT_FROM_RE = /^import\s+[\s\S]*?from\s+['"]([^'"]+)['"]/gm
34
+ const NODE_PREFIX_RE = /^node:/
35
+ const INTERNAL_IMPORT_RE = /import\s+(?:([A-Za-z0-9_$]+)\s*,?\s*)?(?:\{([^}]+)\})?\s+from\s+['"](\.[^'"]+)['"]/g
36
+ const IMPORT_AS_RE = /\s+as\s+.*/
37
+ const WRITE_FS_RE = /\b(writeFile|mkdir|rmdir|unlink|appendFile|createWriteStream|rm\()/
38
+ const CATCH_RE = /catch\s*\(/
39
+ const TRY_RE = /\btry\s*\{/
40
+ const FALSY_RETURN_RE = /return\s+(false|null|''|"")/
41
+ const NETWORK_RE = /\bfetch\(|https?\.|axios|got\(/
42
+ const CACHE_RE = /new Map\(\)|Cache|cache/
43
+
44
+ /**
45
+ * Прибирає `/** *​/`-обрамлення й `*`-префікси, повертає чистий текст рядками.
46
+ * @param {string} raw сирий JSDoc-блок з обрамленням
47
+ * @returns {string} очищений текст без обрамлення й префіксів
48
+ */
23
49
  function cleanJsDoc(raw) {
24
50
  return raw
25
- .replace(/^\s*\/\*\*?/, '')
26
- .replace(/\*\/\s*$/, '')
51
+ .replace(JSDOC_OPEN_RE, '')
52
+ .replace(JSDOC_CLOSE_RE, '')
27
53
  .split('\n')
28
- .map(l => l.replace(/^\s*\*?\s?/, '').trimEnd())
54
+ .map(l => l.replace(STAR_PREFIX_RE, '').trimEnd())
29
55
  .join('\n')
30
56
  .trim()
31
57
  }
32
58
 
33
- /** Опис (без @-тегів) + параметри з @param як «name — опис». */
59
+ /**
60
+ * Опис (без @-тегів) + параметри з @param як «name — опис».
61
+ * @param {string} raw сирий JSDoc-блок
62
+ * @returns {{desc:string, params:Array<{name:string, desc:string}>, ret:string}} розпарсений опис, параметри й опис повернення
63
+ */
34
64
  function parseJsDoc(raw) {
35
65
  const text = cleanJsDoc(raw)
36
66
  const lines = text.split('\n')
@@ -38,8 +68,8 @@ function parseJsDoc(raw) {
38
68
  const params = []
39
69
  let ret = ''
40
70
  for (const l of lines) {
41
- const pm = l.match(/^@param\s+(?:\{[^}]*\}\s+)?\[?([A-Za-z0-9_.]+)\]?\s*(.*)$/)
42
- const rm = l.match(/^@returns?\s+(?:\{[^}]*\}\s+)?(.*)$/)
71
+ const pm = l.match(PARAM_LINE_RE)
72
+ const rm = l.match(RETURNS_LINE_RE)
43
73
  if (pm) {
44
74
  const desc = pm[2].trim()
45
75
  // «опис.» — JSDoc-заглушка без сенсу; не тягнемо її як факт
@@ -56,9 +86,13 @@ function parseJsDoc(raw) {
56
86
  return { desc: descLines.join('\n').trim(), params, ret }
57
87
  }
58
88
 
59
- /** Провідний блок-коментар файлу (намір), якщо він перед першим import/кодом. */
89
+ /**
90
+ * Провідний блок-коментар файлу (намір), якщо він перед першим import/кодом.
91
+ * @param {string} src вміст файлу
92
+ * @returns {string} текст header-коментаря або порожній рядок
93
+ */
60
94
  function extractFileHeader(src) {
61
- const m = src.match(/^\s*\/\*\*([\s\S]*?)\*\//)
95
+ const m = src.match(FILE_HEADER_RE)
62
96
  if (!m) return ''
63
97
  // має бути на самому початку (до import/код)
64
98
  if (src.slice(0, m.index).trim() !== '') return ''
@@ -69,18 +103,22 @@ function extractFileHeader(src) {
69
103
  * Блок-коментар, що стоїть ВПРИТУЛ перед позицією (лише пробіли між ними).
70
104
  * `(?:(?!\*​/)[\s\S])*` гарантує, що тіло не містить `*​/`, тож захоплюється рівно один
71
105
  * найближчий блок — без жадібного «перестрибування» через імпорти/код.
106
+ * @param {string} prefix вміст файлу до позиції експорту
107
+ * @returns {string|null} JSDoc-блок або null якщо немає
72
108
  */
73
109
  function precedingJsDoc(prefix) {
74
- const m = prefix.match(/\/\*\*(?:(?!\*\/)[\s\S])*\*\/\s*$/)
110
+ const m = prefix.match(PRECEDING_JSDOC_RE)
75
111
  return m ? m[0] : null
76
112
  }
77
113
 
78
- /** Експорти + JSDoc, що безпосередньо передує кожному. */
114
+ /**
115
+ * Експорти + JSDoc, що безпосередньо передує кожному.
116
+ * @param {string} src вміст файлу
117
+ * @returns {Array<object>} список експортів із метаданими
118
+ */
79
119
  function extractExports(src) {
80
120
  const out = []
81
- const re = /export\s+(?:async\s+)?(function|const|class)\s+([A-Za-z0-9_]+)/g
82
- let m
83
- while ((m = re.exec(src))) {
121
+ for (const m of src.matchAll(EXPORT_DECL_RE)) {
84
122
  const [, kind, name] = m
85
123
  const jsdocRaw = precedingJsDoc(src.slice(0, m.index))
86
124
  out.push({ name, kind, ...(jsdocRaw ? parseJsDoc(jsdocRaw) : { desc: '', params: [], ret: '' }) })
@@ -88,39 +126,47 @@ function extractExports(src) {
88
126
  return out
89
127
  }
90
128
 
91
- /** Імпорти, класифіковані на stdlib / npm / internal. */
129
+ /**
130
+ * Імпорти, класифіковані на stdlib / npm / internal.
131
+ * @param {string} src вміст файлу
132
+ * @returns {{stdlib:Array<string>, npm:Array<string>, internal:Array<string>}} розкласифіковані шляхи імпортів
133
+ */
92
134
  function extractImports(src) {
93
- const stdlib = new Set(),
135
+ const internal = new Set(),
94
136
  npm = new Set(),
95
- internal = new Set()
96
- const re = /^import\s+[\s\S]*?from\s+['"]([^'"]+)['"]/gm
97
- let m
98
- while ((m = re.exec(src))) {
137
+ stdlib = new Set()
138
+ for (const m of src.matchAll(IMPORT_FROM_RE)) {
99
139
  const s = m[1]
100
- if (s.startsWith('node:') || BUILTIN_MODULES.has(s.split('/')[0])) stdlib.add(s.replace(/^node:/, ''))
140
+ if (s.startsWith('node:') || BUILTIN_MODULES.has(s.split('/')[0])) stdlib.add(s.replace(NODE_PREFIX_RE, ''))
101
141
  else if (s.startsWith('.') || s.startsWith('/')) internal.add(s)
102
142
  else npm.add(s)
103
143
  }
104
144
  return { stdlib: [...stdlib], npm: [...npm], internal: [...internal] }
105
145
  }
106
146
 
107
- /** Імена символів, імпортованих із внутрішніх модулів — їх модель не має згадувати. */
147
+ /**
148
+ * Імена символів, імпортованих із внутрішніх модулів — їх модель не має згадувати.
149
+ * @param {string} src вміст файлу
150
+ * @returns {Array<string>} список імен внутрішніх символів
151
+ */
108
152
  function extractInternalSymbols(src) {
109
153
  const out = new Set()
110
- const re = /import\s+(?:([A-Za-z0-9_$]+)\s*,?\s*)?(?:\{([^}]+)\})?\s+from\s+['"](\.[^'"]+)['"]/g
111
- let m
112
- while ((m = re.exec(src))) {
154
+ for (const m of src.matchAll(INTERNAL_IMPORT_RE)) {
113
155
  if (m[1]) out.add(m[1].trim())
114
156
  if (m[2])
115
157
  for (const n of m[2].split(',')) {
116
- const name = n.replace(/\s+as\s+.*/, '').trim()
158
+ const name = n.replace(IMPORT_AS_RE, '').trim()
117
159
  if (name) out.add(name)
118
160
  }
119
161
  }
120
162
  return [...out]
121
163
  }
122
164
 
123
- /** Поведінкові маркери — евристики регулярками. */
165
+ /**
166
+ * Поведінкові маркери — евристики регулярками.
167
+ * @param {string} src вміст файлу
168
+ * @returns {object} набір прапорців-евристик
169
+ */
124
170
  function extractMarkers(src) {
125
171
  // помітні «пропуски»: dir/segment-літерали у фільтрах
126
172
  const skips = new Set()
@@ -128,11 +174,11 @@ function extractMarkers(src) {
128
174
  if (src.includes(`'${lit}`) || src.includes(`"${lit}`) || src.includes(`/${lit}`)) skips.add(lit)
129
175
  }
130
176
  return {
131
- readOnly: !/\b(writeFile|mkdir|rmdir|unlink|appendFile|createWriteStream|rm\()/.test(src),
132
- catchesErrors: /catch\s*\(/.test(src) || /\btry\s*\{/.test(src),
133
- returnsFalsyOnFail: /return\s+(false|null|''|"")/.test(src),
134
- network: /\bfetch\(|https?\.|axios|got\(/.test(src),
135
- caches: /new Map\(\)|Cache|cache/.test(src),
177
+ readOnly: !WRITE_FS_RE.test(src),
178
+ catchesErrors: CATCH_RE.test(src) || TRY_RE.test(src),
179
+ returnsFalsyOnFail: FALSY_RETURN_RE.test(src),
180
+ network: NETWORK_RE.test(src),
181
+ caches: CACHE_RE.test(src),
136
182
  skips: [...skips]
137
183
  }
138
184
  }
@@ -141,7 +187,7 @@ function extractMarkers(src) {
141
187
  * Головний екстрактор: код файлу → факт-лист.
142
188
  * @param {string} src вміст файлу
143
189
  * @param {string} relPath шлях (для контексту/мови екстрактора)
144
- * @returns {{relPath:string, lang:string, header:string, exports:Array, imports:object, markers:object}}
190
+ * @returns {{relPath:string, lang:string, header:string, exports:Array, imports:object, markers:object}} структура фактів про файл
145
191
  */
146
192
  export function extractFacts(src, relPath) {
147
193
  const lang = relPath.split('.').pop()
@@ -160,13 +206,10 @@ export function extractFacts(src, relPath) {
160
206
  }
161
207
 
162
208
  // CLI для інспекції: node docgen-extract.mjs <file>
163
- import { isRunAsCli } from '../../../scripts/cli-entry.mjs'
164
- import { readFileSync } from 'node:fs'
165
209
  if (isRunAsCli(import.meta.url)) {
166
210
  const file = process.argv[2]
167
211
  if (!file) {
168
- console.error('Usage: node docgen-extract.mjs <file>')
169
- process.exit(1)
212
+ throw new Error('Usage: node docgen-extract.mjs <file>')
170
213
  }
171
214
  const facts = extractFacts(readFileSync(file, 'utf8'), file)
172
215
  console.log(JSON.stringify(facts, null, 2))
@@ -36,7 +36,7 @@ function toPosixRelPath(relPath) {
36
36
  * Для `kind = 'dir'` це працює і на піддерево каталогу, тож glob на кшталт
37
37
  * `**\\/demo/**` спрацьовує на `demo/x` під час рекурсивного обходу.
38
38
  * @param {string} relPath відносний шлях від кореня проєкту
39
- * @param {'path'|'dir'} [kind='path'] тип перевірки
39
+ * @param {'path'|'dir'} [kind] тип перевірки (за замовчуванням `'path'`)
40
40
  * @returns {boolean} `true`, якщо шлях ігнорується
41
41
  */
42
42
  export function isDocgenIgnored(relPath, kind = 'path') {