@nitra/cursor 10.2.0 → 11.0.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/.claude-template/hooks/normalize-decisions.sh +40 -10
- package/CHANGELOG.md +12 -0
- package/bin/n-cursor.js +9 -36
- package/package.json +1 -1
- package/rules/doc-files/js/docgen-extract.mjs +9 -3
- package/rules/doc-files/js/docgen-prompts.mjs +10 -8
- package/rules/doc-files/js/docs/docgen-extract.md +20 -18
- package/rules/doc-files/js/docs/docgen-judge-measure.md +30 -0
- package/rules/doc-files/js/docs/docgen-prompts.md +18 -19
- package/scripts/lib/adr/docs/normalize-cli.md +36 -0
- package/scripts/lib/adr/docs/normalize-pipeline.md +40 -0
- package/scripts/lib/adr/normalize-cli.mjs +73 -0
- package/scripts/lib/adr/normalize-pipeline.mjs +530 -0
|
@@ -268,18 +268,48 @@ CURSOR_MODEL="${ADR_NORMALIZE_CURSOR_MODEL:-claude-4.6-sonnet-medium}"
|
|
|
268
268
|
|
|
269
269
|
RESPONSE_FILE="$TMP_DIR/response.txt"
|
|
270
270
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
271
|
+
# Backend selection. `local` — конвеєр на малій локальній моделі (privacy + $0,
|
|
272
|
+
# `npm/scripts/lib/adr/normalize-pipeline.mjs`); `claude`/`cursor` — single-shot
|
|
273
|
+
# у хмару. Auto-default: local, якщо налаштовано `N_LOCAL_MIN_MODEL`, інакше
|
|
274
|
+
# claude → cursor. Команда local-бекенда override-иться через ADR_NORMALIZE_LOCAL_CMD
|
|
275
|
+
# (для тестів/in-repo: `node npm/bin/n-cursor.js adr-normalize-local`).
|
|
276
|
+
BACKEND="${ADR_NORMALIZE_BACKEND:-}"
|
|
277
|
+
if [ -z "$BACKEND" ]; then
|
|
278
|
+
if [ -n "${N_LOCAL_MIN_MODEL:-}" ]; then
|
|
279
|
+
BACKEND=local
|
|
280
|
+
elif command -v claude >/dev/null 2>&1; then
|
|
281
|
+
BACKEND=claude
|
|
282
|
+
elif command -v cursor-agent >/dev/null 2>&1; then
|
|
283
|
+
BACKEND=cursor
|
|
284
|
+
else
|
|
285
|
+
BACKEND=none
|
|
286
|
+
fi
|
|
281
287
|
fi
|
|
282
288
|
|
|
289
|
+
ADR_LOCAL_CMD="${ADR_NORMALIZE_LOCAL_CMD:-npx --no @nitra/cursor adr-normalize-local}"
|
|
290
|
+
|
|
291
|
+
case "$BACKEND" in
|
|
292
|
+
local)
|
|
293
|
+
log "using local pipeline backend (model: ${N_LOCAL_MIN_MODEL:-?})"
|
|
294
|
+
# local-бекенд будує власні дрібні промпти з батча — FULL_PROMPT_FILE не потрібен.
|
|
295
|
+
# shellcheck disable=SC2086
|
|
296
|
+
$ADR_LOCAL_CMD --batch "$BATCH_LIST" --clean "$CLEAN_LIST" --adr-dir "$ADR_DIR" > "$RESPONSE_FILE" 2>>"$LOG" || true
|
|
297
|
+
;;
|
|
298
|
+
claude)
|
|
299
|
+
log "using claude CLI (model: $CLAUDE_MODEL)"
|
|
300
|
+
claude -p --model "$CLAUDE_MODEL" < "$FULL_PROMPT_FILE" > "$RESPONSE_FILE" 2>>"$LOG" || true
|
|
301
|
+
;;
|
|
302
|
+
cursor)
|
|
303
|
+
log "using cursor-agent CLI (model: $CURSOR_MODEL)"
|
|
304
|
+
FULL_PROMPT=$(cat "$FULL_PROMPT_FILE")
|
|
305
|
+
cursor-agent -p --mode ask --output-format text --model "$CURSOR_MODEL" -- "$FULL_PROMPT" > "$RESPONSE_FILE" 2>>"$LOG" || true
|
|
306
|
+
;;
|
|
307
|
+
*)
|
|
308
|
+
log "no LLM backend available, skipping"
|
|
309
|
+
exit 0
|
|
310
|
+
;;
|
|
311
|
+
esac
|
|
312
|
+
|
|
283
313
|
if [ ! -s "$RESPONSE_FILE" ]; then
|
|
284
314
|
log "empty LLM response"
|
|
285
315
|
exit 0
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [11.0.0] - 2026-06-15
|
|
4
|
+
|
|
5
|
+
### Removed
|
|
6
|
+
|
|
7
|
+
- CLI: прибрано надлишкові точки входу заради мінімальної поверхні. `lint-ci` видалено — це був чистий аліас `lint --read-only --full` (CI лишається тим самим прапор-комбо). Deprecated-аліас `doc-files <sub>` (`scan|check|gen|stamp`) видалено — 0 живих callerів (hook давно на `lint-doc-files`, скіл на `lint-doc-files`/`fix-doc-files`). Заодно полагоджено застарілу документацію точок входу в `bin/n-cursor.js` (мертві згадки `fix`, опис `lint` → data-driven по `meta.json:lint`) і схему `rule-meta.json` (enum `quick|ci` → `per-file|full`, відповідає реальним значенням і `parseRuleLintSpec`).
|
|
8
|
+
|
|
9
|
+
## [10.3.0] - 2026-06-15
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- adr-normalize: локальний конвеєр-оркестратор як backend (retrieval→edge-judge→cluster→gen на малій моделі); субкоманда adr-normalize-local + backend-селектор у normalize-decisions.sh (local|claude|cursor)
|
|
14
|
+
|
|
3
15
|
## [10.2.0] - 2026-06-15
|
|
4
16
|
|
|
5
17
|
### Added
|
package/bin/n-cursor.js
CHANGED
|
@@ -26,7 +26,6 @@
|
|
|
26
26
|
* `markdownlint-cli2 --fix` → `v8r` (json/json5/yaml/yml/toml)
|
|
27
27
|
* `npx \@nitra/cursor lint-doc-files` — детермінований детектор застарілості файлових док (`stale`: `missing`|`crc-mismatch`); правило doc-files (ignore-glob у `npm/rules/doc-files/js/docgen-ignore.mjs`; тека `docs/` поряд із джерелом). Режими: повний (exit 1), `--json` (exit 0), `--missing-only`, `--hook`/`--git` (hook-протокол, exit 2), `--degraded`
|
|
28
28
|
* `npx \@nitra/cursor fix-doc-files` — JS-оркестрована генерація файлових док (роутинг local/cloud) зі штампом CRC (`--limit`/`--from`/`--overwrite`/`--retry-degraded`); `--stamp` — детерміноване перештампування CRC без LLM
|
|
29
|
-
* `npx \@nitra/cursor doc-files <sub>` — DEPRECATED-аліас (scan|check|gen|stamp) → `lint-doc-files`/`fix-doc-files`
|
|
30
29
|
* `npx \@nitra/cursor doc-aggregate modules` — JSON-лістинг логічних модулів (межі за `package.json`) для Tier 2 скілу doc-aggregate
|
|
31
30
|
* `npx \@nitra/cursor skill list` — скіли пакета без синку в проєкт
|
|
32
31
|
* `npx \@nitra/cursor skill taze` — промпт на stdout
|
|
@@ -660,7 +659,7 @@ function buildClaudeDocFilesSectionLines() {
|
|
|
660
659
|
'',
|
|
661
660
|
'## Файлова документація (`doc-files` — обовʼязковий крок, як lint)',
|
|
662
661
|
'',
|
|
663
|
-
'Після зміни чи додавання кодового файлу його файлова дока (`<dir>/docs/<stem>.md`) має бути **актуальною** — це **обовʼязковий крок кожної задачі**, нарівні з lint. Застарілість детермінується за **CRC** джерела у frontmatter доки. PostToolUse hook (`doc-files
|
|
662
|
+
'Після зміни чи додавання кодового файлу його файлова дока (`<dir>/docs/<stem>.md`) має бути **актуальною** — це **обовʼязковий крок кожної задачі**, нарівні з lint. Застарілість детермінується за **CRC** джерела у frontmatter доки. PostToolUse hook (`lint-doc-files --hook`) **сигналить** про дрейф після правки; Stop-hook (`lint-doc-files --git`) **блокує завершення** задачі за наявності застарілих док (виняток — масовий прогін понад поріг `N_CURSOR_DOC_FILES_GATE_MAX`, дефолт 50). Регенерація — `/doc-files` (JS-оркестрована, не диспатч субагентів). Агрегуюча дока (module-summary, доменні) — окремий скіл `/doc-aggregate`, за запитом.',
|
|
664
663
|
''
|
|
665
664
|
]
|
|
666
665
|
}
|
|
@@ -1506,7 +1505,7 @@ try {
|
|
|
1506
1505
|
// .n-cursor.json + bun install, а fix/lint/coverage/change/release переписують файли в CWD —
|
|
1507
1506
|
// усе це ключиться на cwd(). Запуск із піддиректорії git-репо (типово прямий
|
|
1508
1507
|
// `bun npm/bin/n-cursor.js` не з кореня) зачепив би не той каталог → STOP. Read-only та
|
|
1509
|
-
// `--root`-команди (trace, graph, lint-doc-files, fix-doc-files, doc-
|
|
1508
|
+
// `--root`-команди (trace, graph, lint-doc-files, fix-doc-files, doc-aggregate, rename-yaml-extensions) не зачіпаємо.
|
|
1510
1509
|
if (ROOT_GUARDED_COMMANDS.has(command)) {
|
|
1511
1510
|
assertCwdIsProjectRoot(cwd(), describeRootGuardedAction(command))
|
|
1512
1511
|
}
|
|
@@ -1662,38 +1661,12 @@ try {
|
|
|
1662
1661
|
|
|
1663
1662
|
break
|
|
1664
1663
|
}
|
|
1665
|
-
case '
|
|
1666
|
-
//
|
|
1667
|
-
//
|
|
1668
|
-
//
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
switch (args[0]) {
|
|
1672
|
-
case 'scan': {
|
|
1673
|
-
const { runLintDocFilesCli } = await import('../rules/doc-files/lint/lint.mjs')
|
|
1674
|
-
process.exitCode = await runLintDocFilesCli(['--json', ...rest])
|
|
1675
|
-
break
|
|
1676
|
-
}
|
|
1677
|
-
case 'check': {
|
|
1678
|
-
const { runLintDocFilesCli } = await import('../rules/doc-files/lint/lint.mjs')
|
|
1679
|
-
process.exitCode = await runLintDocFilesCli(rest)
|
|
1680
|
-
break
|
|
1681
|
-
}
|
|
1682
|
-
case 'gen': {
|
|
1683
|
-
const { runDocFilesGenCli } = await import('../rules/doc-files/js/docgen-files-batch.mjs')
|
|
1684
|
-
process.exitCode = await runDocFilesGenCli(rest)
|
|
1685
|
-
break
|
|
1686
|
-
}
|
|
1687
|
-
case 'stamp': {
|
|
1688
|
-
const { runDocFilesStampCli } = await import('../rules/doc-files/js/docgen-files-batch.mjs')
|
|
1689
|
-
process.exitCode = runDocFilesStampCli(rest)
|
|
1690
|
-
break
|
|
1691
|
-
}
|
|
1692
|
-
default: {
|
|
1693
|
-
console.error('Usage: npx @nitra/cursor lint-doc-files | fix-doc-files [--root <dir>]')
|
|
1694
|
-
process.exitCode = 1
|
|
1695
|
-
}
|
|
1696
|
-
}
|
|
1664
|
+
case 'adr-normalize-local': {
|
|
1665
|
+
// Local-backend ADR-нормалізації: викликається з .claude/hooks/normalize-decisions.sh
|
|
1666
|
+
// як заміна single-shot LLM-виклику. Проганяє конвеєр (retrieval→edge-judge→
|
|
1667
|
+
// cluster→gen) на малій локальній моделі й друкує `{operations}` JSON у stdout.
|
|
1668
|
+
const { runAdrNormalizeLocalCli } = await import('../scripts/lib/adr/normalize-cli.mjs')
|
|
1669
|
+
process.exitCode = await runAdrNormalizeLocalCli(args)
|
|
1697
1670
|
|
|
1698
1671
|
break
|
|
1699
1672
|
}
|
|
@@ -1720,7 +1693,7 @@ try {
|
|
|
1720
1693
|
default: {
|
|
1721
1694
|
console.error(`❌ Невідома команда: ${command}`)
|
|
1722
1695
|
console.error(
|
|
1723
|
-
` Очікується: (без аргументів) синхронізація правил, rename-yaml-extensions, post-tool-use-fix, lint, lint-ga, lint-rego, lint-k8s, lint-docker, lint-text, lint-doc-files, fix-doc-files, coverage, coverage-fix, taze, start-check, change, release, skill, worktree, trace, doc-
|
|
1696
|
+
` Очікується: (без аргументів) синхронізація правил, rename-yaml-extensions, post-tool-use-fix, adr-normalize-local, lint, lint-ga, lint-rego, lint-k8s, lint-docker, lint-text, lint-doc-files, fix-doc-files, coverage, coverage-fix, taze, start-check, change, release, skill, worktree, trace, doc-aggregate`
|
|
1724
1697
|
)
|
|
1725
1698
|
process.exitCode = 1
|
|
1726
1699
|
}
|
package/package.json
CHANGED
|
@@ -49,6 +49,10 @@ const FALSY_RETURN_RE = /catch[\s\S]{0,400}?return\s+(false|null|''|"")/
|
|
|
49
49
|
// octokit/.request/.query). Хибний false-negative тут = небезпечна гарантія
|
|
50
50
|
// «без мережі», тож свідомо схиляємось до over-detection (м'якший бік помилки).
|
|
51
51
|
const NETWORK_RE = /\bfetch\(|https?:\/\/|\bhttps?\.|axios|\bgot\(|graphql|\.request\(|\.query\(|\.mutate\(|octokit|node-fetch|undici|\bgrpc\b|websocket/i
|
|
52
|
+
// Будь-який `throw` назовні → НЕ можна гарантувати «fail-safe / без винятків».
|
|
53
|
+
const THROW_RE = /\bthrow\s/
|
|
54
|
+
// Запис у БД / зовнішню мутацію → НЕ read-only (навіть якщо нема ФС-запису).
|
|
55
|
+
const MUTATION_RE = /\b(insert|update|delete|upsert|drop|destroy|save)[A-Za-z]*\s*[(,]|[Mm]utation\b|\bmut[A-Z]\w*|\.(save|create|update|delete|insert|destroy|mutate)\(/
|
|
52
56
|
// Кеш — лише за ІМЕНОВАНИМ маркером (`cache`/`Cache`/`memoize`), не за будь-яким
|
|
53
57
|
// `new Map()`: акумулятор (напр. `byPath = new Map()`) — не кеш, а хибна гарантія
|
|
54
58
|
// «Кешує результати» гірша за пропуск (фабрикація > мовчання).
|
|
@@ -207,9 +211,11 @@ function extractMarkers(src) {
|
|
|
207
211
|
if (src.includes(`'${lit}`) || src.includes(`"${lit}`) || src.includes(`/${lit}`)) skips.add(lit)
|
|
208
212
|
}
|
|
209
213
|
return {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
214
|
+
// «Фабрикація > мовчання»: прапорець true лише за high-confidence; інакше
|
|
215
|
+
// guaranteesFromMarkers/factsSummary його ОПУСКАЮТЬ (не стверджують протилежне).
|
|
216
|
+
readOnly: !WRITE_FS_RE.test(src) && !MUTATION_RE.test(src), // ні ФС-запису, ні DB-мутацій
|
|
217
|
+
catchesErrors: (CATCH_RE.test(src) || TRY_RE.test(src)) && !THROW_RE.test(src), // fail-safe лише якщо НЕ кидає
|
|
218
|
+
returnsFalsyOnFail: FALSY_RETURN_RE.test(src) && !THROW_RE.test(src),
|
|
213
219
|
network: NETWORK_RE.test(src),
|
|
214
220
|
caches: CACHE_RE.test(src),
|
|
215
221
|
skips: [...skips]
|
|
@@ -30,12 +30,13 @@ function factsSummary(facts) {
|
|
|
30
30
|
if (facts.header) lines.push(`Намір файлу: ${facts.header.replaceAll('\n', ' ')}`)
|
|
31
31
|
if (facts.exports?.length) lines.push(`Публічні функції: ${facts.exports.map(e => e.name).join(', ')}`)
|
|
32
32
|
if (m.skips?.length) lines.push(`Свідомо пропускає шляхи: ${m.skips.join(', ')}`)
|
|
33
|
-
|
|
33
|
+
// «Фабрикація > мовчання»: лише ПОЗИТИВНІ high-confidence сигнали; жодних дефолтних
|
|
34
|
+
// негативів (read-only «ні», «мережа: немає») — модель echo-їть їх як хибну гарантію.
|
|
35
|
+
if (m.readOnly) lines.push('Read-only: не пише (ФС/БД)')
|
|
36
|
+
if (m.network) lines.push('Звертається до мережі')
|
|
34
37
|
if (m.catchesErrors) lines.push('Перехоплює помилки (fail-safe), не кидає винятків назовні')
|
|
35
|
-
if (m.returnsFalsyOnFail) lines.push('За
|
|
38
|
+
if (m.returnsFalsyOnFail) lines.push('За певних помилок повертає порожнє значення (напр. null) замість винятку')
|
|
36
39
|
lines.push(m.caches ? 'Кешування: так, у межах прогону' : 'Кешування: НЕМАЄ — не згадуй кеш у гарантіях')
|
|
37
|
-
if (m.network) lines.push('Звертається до мережі')
|
|
38
|
-
else lines.push('Робота з мережею: немає')
|
|
39
40
|
return lines.join('\n')
|
|
40
41
|
}
|
|
41
42
|
|
|
@@ -193,15 +194,16 @@ export function refineMessages(sectionKey, draft, issues, facts, anchors) {
|
|
|
193
194
|
export function guaranteesFromMarkers(facts) {
|
|
194
195
|
const m = facts.markers || {}
|
|
195
196
|
const lines = []
|
|
196
|
-
|
|
197
|
+
// «Фабрикація > мовчання»: лише ПОЗИТИВНІ high-confidence гарантії. Жодних
|
|
198
|
+
// негативів/дефолтів (no-network, determinism) — їх не довести file-local аналізом.
|
|
199
|
+
if (m.readOnly) lines.push('- Read-only: не виконує операцій запису (ФС/БД).')
|
|
197
200
|
if (m.catchesErrors) lines.push('- Перехоплює помилки і не пропускає винятків назовні (fail-safe).')
|
|
198
|
-
if (m.returnsFalsyOnFail) lines.push('- За
|
|
201
|
+
if (m.returnsFalsyOnFail) lines.push('- За певних помилок повертає порожнє значення (напр. `null`) замість винятку.')
|
|
199
202
|
if (m.caches) lines.push('- Кешує результати в межах одного прогону.')
|
|
200
203
|
if (m.skips?.length) {
|
|
201
204
|
lines.push(`- Свідомо пропускає шляхи: ${m.skips.map(s => '`' + s + '`').join(', ')}.`)
|
|
202
205
|
}
|
|
203
|
-
if (!
|
|
204
|
-
if (!lines.length) return '- Поведінка детермінована: результат залежить лише від вхідних даних.'
|
|
206
|
+
if (!lines.length) return '- (специфічних машинно-виведених гарантій немає)'
|
|
205
207
|
return lines.join('\n')
|
|
206
208
|
}
|
|
207
209
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
docgen:
|
|
3
3
|
source: npm/rules/doc-files/js/docgen-extract.mjs
|
|
4
|
-
crc:
|
|
4
|
+
crc: a0680e77
|
|
5
|
+
model: omlx/gemma-4-e4b-it-OptiQ-4bit
|
|
5
6
|
score: 100
|
|
6
7
|
---
|
|
7
8
|
|
|
@@ -9,31 +10,32 @@ docgen:
|
|
|
9
10
|
|
|
10
11
|
## Огляд
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
Витягує структурований факт-лист з вмісту файлів, аналізуючи їх залежно від мови. Для Rust витягує модульний опис, публічні експорти, локальні символи та класифікує імпорти й поведінкові маркери. Для JavaScript/TypeScript/MJS витягує опис, експортовані елементи з JSDoc, класифікує імпорти та визначає поведінкові маркери. При аналізі ігноруються директорії: .github, .git, node_modules, base/, ua/, .firebase. Звертається до мережі та кешує дані протягом одного прогону.
|
|
13
14
|
|
|
14
15
|
## Поведінка
|
|
15
16
|
|
|
16
|
-
1.
|
|
17
|
-
2.
|
|
18
|
-
3.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
17
|
+
1. Витягує факт-лист з вмісту файлу.
|
|
18
|
+
2. Визначає мову файлу за розширенням.
|
|
19
|
+
3. Якщо мова — Rust, виконує аналіз Rust-коду:
|
|
20
|
+
а. Витягує модульний опис з `//!`.
|
|
21
|
+
б. Визначає публічні експорти (структури, функції, енуми) на основі `pub` префікса або експозиційних атрибутів.
|
|
22
|
+
в. Визначає локальні (приватні) символи, які не є публічними.
|
|
23
|
+
г. Класифікує імпорти (`std`, зовнішні, внутрішні).
|
|
24
|
+
д. Визначає поведінкові маркери (readOnly, network, caches тощо) на основі специфічних Rust-конструкцій.
|
|
25
|
+
4. Якщо мова — JavaScript/TypeScript/MJS, виконує аналіз JS-коду:
|
|
26
|
+
а. Витягує загальний опис файлу з верхнього блоку коментарів.
|
|
27
|
+
б. Витягує експортовані функції та класи разом із їхніми JSDoc-описами.
|
|
28
|
+
в. Класифікує імпорти на стандартні бібліотеки, NPM-пакети та внутрішні модулі.
|
|
29
|
+
г. Визначає локальні (неекспортовані) функції та класи.
|
|
30
|
+
д. Визначає поведінкові маркери (readOnly, network, caches тощо) на основі евристик.
|
|
31
|
+
5. При аналізі JS-коду, свідомо ігнорує шляхи: .github, .git, node_modules, base/, ua/, .firebase.
|
|
32
|
+
6. Повертає структуру фактів, що містить метадані про файл.
|
|
30
33
|
|
|
31
34
|
## Публічний API
|
|
32
35
|
|
|
33
|
-
extractFacts —
|
|
36
|
+
extractFacts — витягує факти з вмісту файлу.
|
|
34
37
|
|
|
35
38
|
## Гарантії поведінки
|
|
36
39
|
|
|
37
|
-
- За невдачі повертає значення помилки (`false`/`null`/`Err`) замість генерування винятку чи паніки.
|
|
38
40
|
- Кешує результати в межах одного прогону.
|
|
39
41
|
- Свідомо пропускає шляхи: `.github`, `.git`, `node_modules`, `base/`, `ua/`, `.firebase`.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
docgen:
|
|
3
|
+
source: npm/rules/doc-files/js/docgen-judge-measure.mjs
|
|
4
|
+
crc: b40b626c
|
|
5
|
+
model: omlx/gemma-4-e4b-it-OptiQ-4bit
|
|
6
|
+
score: 100
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# docgen-judge-measure.mjs
|
|
10
|
+
|
|
11
|
+
## Огляд
|
|
12
|
+
|
|
13
|
+
Файл аналізує вміст визначеного списку файлів, створюючи технічну документацію на основі вихідного коду. Процес спирається на конфігурацію, визначену в report.json. Для кожного файлу відбувається кешування результатів у межах прогону. Якість згенерованої документації оцінюється хмарною моделлю шляхом порівняння з пороговими значеннями, заданими в report.json. Результати аналізу агрегуються, обчислюється відсоток хибнопозитивних спрацювань та зберігаються у report.json.
|
|
14
|
+
|
|
15
|
+
## Поведінка
|
|
16
|
+
|
|
17
|
+
1. Зчитує список файлів для аналізу.
|
|
18
|
+
2. Для кожного файлу зчитує його вміст.
|
|
19
|
+
3. Генерує технічну документацію для файлу, використовуючи кеш за вмістом вихідного коду.
|
|
20
|
+
4. Якщо документація згенерована, перевіряє її якість за встановленим порогом.
|
|
21
|
+
5. Якщо якість документації відповідає порогу, передає вміст вихідного коду та згенеровану документацію для оцінки.
|
|
22
|
+
6. Оцінює документацію за допомогою сильної хмарної моделі, використовуючи кеш за вмістом вихідного коду та документації.
|
|
23
|
+
7. Збирає результати для кожного файлу.
|
|
24
|
+
8. Агрегує результати, обчислюючи відсоток хибнопозитивних спрацювань (false-positive rate) серед документів, які пройшли порогову перевірку та були оцінені.
|
|
25
|
+
9. Зберігає повний звіт у файл `report.json` у директорії кешу.
|
|
26
|
+
10. Виводить консольний звіт про результати вимірювання.
|
|
27
|
+
|
|
28
|
+
## Гарантії поведінки
|
|
29
|
+
|
|
30
|
+
- Кешує результати в межах одного прогону.
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
docgen:
|
|
3
3
|
source: npm/rules/doc-files/js/docgen-prompts.mjs
|
|
4
|
-
crc:
|
|
4
|
+
crc: 28c9e818
|
|
5
|
+
model: omlx/gemma-4-e4b-it-OptiQ-4bit
|
|
5
6
|
score: 100
|
|
6
7
|
---
|
|
7
8
|
|
|
@@ -9,31 +10,29 @@ docgen:
|
|
|
9
10
|
|
|
10
11
|
## Огляд
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
Генерує технічну документацію на основі коду. Створює компоненти документації, такі як огляд (`overviewMessages`), повідомлення для секцій (`sectionMessages`), повідомлення для критики (`criticMessages`), повідомлення для уточнення (`refineMessages`) та гарантії, отримані з маркерів (`guaranteesFromMarkers`), застосовуючи заданий стиль (`STYLE`).
|
|
13
14
|
|
|
14
15
|
## Поведінка
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
- oneShotMessages: Створює універсальний запит для генерації повної документації.
|
|
17
|
+
STYLE: Пише лаконічну поведінкову документацію до коду українською мовою, використовуючи чистий Markdown.
|
|
18
|
+
sectionMessages: Генерує набір промптів для різних секцій документації, використовуючи метадані файлу та вміст коду.
|
|
19
|
+
overviewMessages: Створює узагальнений огляд файлу, базуючись на вже згенерованій секції «Поведінка».
|
|
20
|
+
criticMessages: Перевіряє чорнетку секції на відповідність технічним критеріям, виявляючи дефекти.
|
|
21
|
+
refineMessages: Переписує чорнетку секції, виправляючи дефекти, виявлені критиком.
|
|
22
|
+
guaranteesFromMarkers: Формує список гарантій поведінки, виходячи з машинно-виведених маркерів файлу.
|
|
23
|
+
oneShotMessages: Генерує базовий промпт для генерації повної документації за один виклик.
|
|
24
24
|
|
|
25
25
|
## Публічний API
|
|
26
26
|
|
|
27
|
-
STYLE —
|
|
28
|
-
sectionMessages —
|
|
29
|
-
overviewMessages —
|
|
30
|
-
criticMessages — Виявляє
|
|
31
|
-
refineMessages —
|
|
32
|
-
guaranteesFromMarkers — Створює
|
|
27
|
+
STYLE — Визначає загальний стиль та намір файлу.
|
|
28
|
+
sectionMessages — Містить мінімально необхідний контекст для кожної секції.
|
|
29
|
+
overviewMessages — Генерує узагальнений огляд на основі вже визначеної поведінки.
|
|
30
|
+
criticMessages — Виявляє конкретні недоліки у чорновому тексті секції, повертаючи список проблем або відсутність проблем.
|
|
31
|
+
refineMessages — Переписує чорновий текст, виправляючи виявлені недоліки.
|
|
32
|
+
guaranteesFromMarkers — Створює чіткий шаблон секції «Гарантії поведінки» на основі фактів.
|
|
33
|
+
oneShotMessages — Надає приклади для порівняння.
|
|
33
34
|
|
|
34
35
|
## Гарантії поведінки
|
|
35
36
|
|
|
36
|
-
- Read-only:
|
|
37
|
-
- За невдачі повертає значення помилки (`false`/`null`/`Err`) замість генерування винятку чи паніки.
|
|
37
|
+
- Read-only: не виконує операцій запису (ФС/БД).
|
|
38
38
|
- Кешує результати в межах одного прогону.
|
|
39
|
-
- Не звертається до мережі.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
---
|
|
2
|
+
docgen:
|
|
3
|
+
source: normalize-cli.mjs
|
|
4
|
+
crc: ce2f13af
|
|
5
|
+
model: omlx/gemma-4-e4b-it-OptiQ-4bit
|
|
6
|
+
score: 90
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# normalize-cli.mjs
|
|
10
|
+
|
|
11
|
+
## Огляд
|
|
12
|
+
|
|
13
|
+
Цей файл є CLI-обгорткою для локального ADR-нормалізатора (`n-cursor adr-normalize-local`). Він використовується скриптом `.claude/hooks/normalize-decisions.sh` як локальний бекенд для обробки батчу чернеток та списку чистих ADR. Обгортка зчитує шляхи до чернеток та параметри з аргументів командного рядка та змінних середовища, а потім проганяє `normalizePipeline`. Результатом роботи є вивід JSON-контракту з операціями у stdout, який парситься зовнішнім скриптом. Прогрес відображається у stderr.
|
|
14
|
+
|
|
15
|
+
## Поведінка
|
|
16
|
+
|
|
17
|
+
1. `runAdrNormalizeLocalCli` парсить вхідні аргументи для визначення шляху до чернеток батчу, списку чистих ADR та каталогу ADR.
|
|
18
|
+
2. Якщо не надано файл батчу, `runAdrNormalizeLocalCli` виводить повідомлення про використання та завершує роботу з кодом помилки.
|
|
19
|
+
3. `runAdrNormalizeLocalCli` зчитує шляхи до чернеток батчу з вказаного файлу.
|
|
20
|
+
4. Для кожної чернетки `runAdrNormalizeLocalCli` зчитує вміст файлу, резолвячи шлях відносно каталогу ADR, і створює об'єкт чернетки.
|
|
21
|
+
5. `runAdrNormalizeLocalCli` зчитує список назв чистих ADR з файлу, якщо він наданий.
|
|
22
|
+
6. `runAdrNormalizeLocalCli` зчитує значення змінних середовища `ADR_NORMALIZE_ALLOW_CLOUD` та `ADR_NORMALIZE_VOTES` для визначення параметрів нормалізації.
|
|
23
|
+
7. `runAdrNormalizeLocalCli` викликає логіку нормалізації, передаючи чернетки, список чистих ADR та параметри, при цьому прогрес виводиться у stderr.
|
|
24
|
+
8. `runAdrNormalizeLocalCli` виводить кількість операцій та статистику нормалізації у stderr.
|
|
25
|
+
9. `runAdrNormalizeLocalCli` виводить деталі рішень нормалізації у stderr.
|
|
26
|
+
10. `runAdrNormalizeLocalCli` друкує JSON-об'єкт, що містить операції, у stdout.
|
|
27
|
+
11. `runAdrNormalizeLocalCli` завершує роботу з кодом успіху.
|
|
28
|
+
|
|
29
|
+
## Публічний API
|
|
30
|
+
|
|
31
|
+
runAdrNormalizeLocalCli — запускає субкоманду, виводячи JSON операцій у стандартний вивід та прогрес у стандартний помилковий вивід.
|
|
32
|
+
|
|
33
|
+
## Гарантії поведінки
|
|
34
|
+
|
|
35
|
+
- Read-only: файл не виконує операцій запису у файлову систему.
|
|
36
|
+
- Не звертається до мережі.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
docgen:
|
|
3
|
+
source: normalize-pipeline.mjs
|
|
4
|
+
crc: 6619ff48
|
|
5
|
+
model: omlx/gemma-4-e4b-it-OptiQ-4bit
|
|
6
|
+
score: 100
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# normalize-pipeline.mjs
|
|
10
|
+
|
|
11
|
+
## Огляд
|
|
12
|
+
|
|
13
|
+
Файл реалізує локально-орієнтований конвеєр для нормалізації чернеток ADR. Він використовує LLM лише для вузьких, верифікованих бінарних суджень. Конвеєр працює у послідовних стадіях: JS виконує пошук кандидатів-ребер на основі лексичної схожості, LLM оцінює ці ребра (Stage 1: `same/different`) та драфти (Stage 1b: `standalone/trivial`), JS кластеризує підтверджені ребра (використовуючи `union-find`), LLM реформатує анотера (Stage 2: `gen-MADR`), а LLM генерує доповнення для злиття (Stage 3: `gen-merge`). Глобальний стан (кластери, слаги, покриття) зберігається в JS. Конвеєр повертає операції у форматі `operations[]`, сумісного з контрактом `apply-ops`.
|
|
14
|
+
|
|
15
|
+
## Поведінка
|
|
16
|
+
|
|
17
|
+
tokenize токенізує назву або слаг у множину значущих токенів, виключаючи стоп-слова.
|
|
18
|
+
jaccard обчислює Jaccard-схожість між двома множинами токенів.
|
|
19
|
+
draftTitle витягує заголовок чернетки, надаючи пріоритет заголовку ADR.
|
|
20
|
+
isNoDecision визначає, чи не прийнято рішення у чернетці, аналізуючи секцію Decision Outcome.
|
|
21
|
+
buildEdges будує кандидати-ребра між чернетками та між чернетками і чистими ADR на основі лексичної схожості.
|
|
22
|
+
validateMadr перевіряє згенерований контент на відповідність канону чистого MADR.
|
|
23
|
+
normalizePipeline виконує повний конвеєр нормалізації, групуючи чернетки, оцінюючи їх та генеруючи операції для застосування.
|
|
24
|
+
|
|
25
|
+
## Публічний API
|
|
26
|
+
|
|
27
|
+
tokenize — розбиває назву чи слаг на значущі частини, замінюючи пробіли та дефіси, і відсіюючи загальні слова.
|
|
28
|
+
jaccard — обчислює ступінь подібності між двома наборами слів.
|
|
29
|
+
draftTitle — витягує заголовок з чернетки, надаючи пріоритет заголовку у форматі `## ADR <title>`, а у відсутності такого — використовує перший не-ADR заголовок або ім'я файлу.
|
|
30
|
+
isNoDecision — визначає, чи не містить чернетка чіткого рішення, що робить її непотрібною для окремого ADR.
|
|
31
|
+
buildEdges — створює потенційні зв'язки між елементами на основі схожості слів.
|
|
32
|
+
validateMadr — перевіряє якість згенерованого документа ADR.
|
|
33
|
+
normalizePipeline — виконує повний процес обробки, повертаючи список виконаних кроків та статистику.
|
|
34
|
+
|
|
35
|
+
## Гарантії поведінки
|
|
36
|
+
|
|
37
|
+
- Read-only: файл не виконує операцій запису у файлову систему.
|
|
38
|
+
- Перехоплює помилки і не пропускає винятків назовні (fail-safe).
|
|
39
|
+
- За невдачі повертає значення помилки (`false`/`null`/`Err`) замість генерування винятку чи паніки.
|
|
40
|
+
- Не звертається до мережі.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI-обгортка локального ADR-нормалізатора (`n-cursor adr-normalize-local`).
|
|
3
|
+
*
|
|
4
|
+
* Прод-шлях `.claude/hooks/normalize-decisions.sh` викликає цю команду як
|
|
5
|
+
* local-backend замість single-shot LLM-виклику: bash готує батч і clean-список,
|
|
6
|
+
* CLI проганяє `normalizePipeline` і друкує у stdout той самий контракт
|
|
7
|
+
* `{ "operations": [...] }`, що його далі парсить і застосовує bash. Прогрес —
|
|
8
|
+
* у stderr (потрапляє в normalize-decisions.log).
|
|
9
|
+
*
|
|
10
|
+
* Аргументи:
|
|
11
|
+
* --batch <file> newline-список абсолютних шляхів до чернеток батчу
|
|
12
|
+
* --clean <file> newline-список basename-ів clean-ADR (merge-into кандидати)
|
|
13
|
+
* --adr-dir <dir> тека docs/adr (для резолву basename ↔ шлях; default cwd/docs/adr)
|
|
14
|
+
*
|
|
15
|
+
* ENV:
|
|
16
|
+
* ADR_NORMALIZE_ALLOW_CLOUD=1 дозволити хмарну ескалацію tier-каскаду (default off)
|
|
17
|
+
* ADR_NORMALIZE_VOTES=N голосів self-consistency для clean-ребер (default 2)
|
|
18
|
+
*/
|
|
19
|
+
import { readFileSync } from 'node:fs'
|
|
20
|
+
import { basename, isAbsolute, join } from 'node:path'
|
|
21
|
+
import { normalizePipeline } from './normalize-pipeline.mjs'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Парсить `--key value` пари у плоский об'єкт.
|
|
25
|
+
* @param {string[]} argv масив аргументів командного рядка
|
|
26
|
+
* @returns {Record<string, string>} мапа ключ→значення з `--key value` пар
|
|
27
|
+
*/
|
|
28
|
+
function parseArgs(argv) {
|
|
29
|
+
const out = {}
|
|
30
|
+
for (let i = 0; i < argv.length; i++) {
|
|
31
|
+
const a = argv[i]
|
|
32
|
+
if (a.startsWith('--')) { out[a.slice(2)] = argv[i + 1]; i++ }
|
|
33
|
+
}
|
|
34
|
+
return out
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const readLines = (file) =>
|
|
38
|
+
readFileSync(file, 'utf8').split('\n').map((s) => s.trim()).filter(Boolean)
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Точка входу субкоманди. Друкує `{operations}` JSON у stdout, прогрес — у stderr.
|
|
42
|
+
* @param {string[]} argv аргументи після назви команди
|
|
43
|
+
* @returns {number} exit-code (0 — успіх, 1 — помилка вводу)
|
|
44
|
+
*/
|
|
45
|
+
export function runAdrNormalizeLocalCli(argv) {
|
|
46
|
+
const args = parseArgs(argv)
|
|
47
|
+
const adrDir = args['adr-dir'] ?? join(process.cwd(), 'docs/adr')
|
|
48
|
+
if (!args.batch) {
|
|
49
|
+
console.error('Usage: n-cursor adr-normalize-local --batch <file> [--clean <file>] [--adr-dir <dir>]')
|
|
50
|
+
return 1
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const batchPaths = readLines(args.batch)
|
|
54
|
+
const drafts = batchPaths.map((p) => {
|
|
55
|
+
const abs = isAbsolute(p) ? p : join(adrDir, p)
|
|
56
|
+
return { file: basename(abs), body: readFileSync(abs, 'utf8') }
|
|
57
|
+
})
|
|
58
|
+
const cleanList = args.clean ? readLines(args.clean).map((c) => basename(c)) : []
|
|
59
|
+
|
|
60
|
+
const allowCloud = process.env.ADR_NORMALIZE_ALLOW_CLOUD === '1'
|
|
61
|
+
const votes = Number(process.env.ADR_NORMALIZE_VOTES) || 2
|
|
62
|
+
|
|
63
|
+
const { operations, stats, trace } = normalizePipeline(drafts, cleanList, {
|
|
64
|
+
allowCloud,
|
|
65
|
+
votes,
|
|
66
|
+
onProgress: (m) => console.error(`adr-normalize-local: ${m}`)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
console.error(`adr-normalize-local: ${operations.length} операцій, stats=${JSON.stringify(stats)}`)
|
|
70
|
+
console.error(`adr-normalize-local: decisions=${JSON.stringify(trace.decisions)}`)
|
|
71
|
+
process.stdout.write(JSON.stringify({ operations }))
|
|
72
|
+
return 0
|
|
73
|
+
}
|
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR normalize — локально-орієнтований конвеєр (інверсія керування: JS оркеструє,
|
|
3
|
+
* LLM відповідає лише на вузькі verifiable-питання). Альтернатива single-shot-у
|
|
4
|
+
* normalize-decisions.sh, заточена під малу локальну модель (omlx/gemma-4b).
|
|
5
|
+
*
|
|
6
|
+
* Принцип: модель НІКОЛИ не приймає глобальних рішень і не повертає великих
|
|
7
|
+
* структур. Глобальний стан (кластери, слаги, покриття) тримає JS. Модель:
|
|
8
|
+
* - судить пару записів бінарно «те саме рішення? так/ні» (Stage 1),
|
|
9
|
+
* - для ізольованого драфта каже standalone/trivial (Stage 1b),
|
|
10
|
+
* - реформатить один драфт у чистий MADR (Stage 2),
|
|
11
|
+
* - пише short merge-additions (Stage 3).
|
|
12
|
+
*
|
|
13
|
+
* Стадії:
|
|
14
|
+
* 0. retrieval (JS) — лексична схожість → кандидати-ребра draft↔draft / draft↔clean
|
|
15
|
+
* 1. edge-judge (LLM) — бінарне same/different по кожному ребру (self-consistency)
|
|
16
|
+
* 1b. kind-judge(LLM) — standalone vs trivial для драфтів без ребер
|
|
17
|
+
* ── cluster (JS) — union-find по підтверджених ребрах, вибір anchor, призначення op
|
|
18
|
+
* 2. gen-MADR (LLM) — reformat anchor/standalone → чистий MADR + validation gate
|
|
19
|
+
* 3. gen-merge (LLM) — additions для merge-драфтів
|
|
20
|
+
* ── assemble (JS) — operations[] у форматі, сумісному з apply-ops
|
|
21
|
+
*
|
|
22
|
+
* Повертає той самий operations[]-контракт, що й single-shot — apply-логіка спільна.
|
|
23
|
+
*/
|
|
24
|
+
import { z } from 'zod'
|
|
25
|
+
import { callLlm, classifyOmlxError } from '../../../lib/llm.mjs'
|
|
26
|
+
import { CLOUD_MIN, resolveModel } from '../../../lib/models.mjs'
|
|
27
|
+
|
|
28
|
+
// ─────────────────────────── Stage 0: retrieval (JS) ───────────────────────────
|
|
29
|
+
|
|
30
|
+
const STOP = new Set(['adr', 'та', 'для', 'через', 'на', 'в', 'у', 'з', 'із', 'до', 'і', 'й', 'the', 'a', 'of', 'md'])
|
|
31
|
+
|
|
32
|
+
// Module-scope regex (oxlint prefer-static-regex: без рекомпіляції на кожен виклик).
|
|
33
|
+
const RE_MD_EXT = /\.md$/
|
|
34
|
+
const RE_TS_PREFIX = /^\d{6,8}-\d{4,6}-/
|
|
35
|
+
const RE_FENCE_OPEN = /^\s*```[a-z]*\s*\n?/i
|
|
36
|
+
const RE_FENCE_CLOSE = /\n?```\s*$/i
|
|
37
|
+
const RE_SLUG_NONWORD = /[^a-zа-яіїєґ0-9]+/gi
|
|
38
|
+
const RE_LEAD_HYPHEN = /^-+/
|
|
39
|
+
const RE_TRAIL_HYPHEN = /-+$/
|
|
40
|
+
const RE_UPDATE_HEAD = /^##\s+Update/
|
|
41
|
+
const RE_DECISION_SECTION = /##\s*Decision Outcome\s*([\s\S]{0,500})/i
|
|
42
|
+
const RE_NO_DECISION = /(не\s+обрано|не\s+прийнят|рішення\s+не\s+прийн|не\s+зроблен|no\s+decision|undecided)/i
|
|
43
|
+
const RE_FENCE_LEAD = /^\s*```/
|
|
44
|
+
const RE_FENCE_TRAIL = /```\s*$/
|
|
45
|
+
const RE_FRONTMATTER = /^---\s*$/m
|
|
46
|
+
const RE_SESSION = /\bsession:\s/
|
|
47
|
+
const RE_H1 = /^#\s+\S/m
|
|
48
|
+
const RE_STATUS = /\*\*Status:\*\*/
|
|
49
|
+
const RE_DATE = /\*\*Date:\*\*\s*\d{4}-\d{2}-\d{2}/
|
|
50
|
+
const RE_TOKEN_SPLIT = /[^a-zа-яіїєґ0-9]+/i
|
|
51
|
+
const RE_DRAFT_ADR_TITLE = /^#{1,2}\s+ADR\s+(.+)$/m
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Прибирає code-fence-обгортку з LLM-відповіді.
|
|
55
|
+
* @param {string} raw сира відповідь LLM
|
|
56
|
+
* @returns {string} текст без обгортки code-fence
|
|
57
|
+
*/
|
|
58
|
+
const stripFence = (raw) => raw.replace(RE_FENCE_OPEN, '').replace(RE_FENCE_CLOSE, '').trim()
|
|
59
|
+
/**
|
|
60
|
+
* Назва clean-ADR → людський заголовок (без .md і timestamp-префікса).
|
|
61
|
+
* @param {string} s basename clean-ADR
|
|
62
|
+
* @returns {string} людський заголовок
|
|
63
|
+
*/
|
|
64
|
+
const stripAdrName = (s) => s.replace(RE_MD_EXT, '').replace(RE_TS_PREFIX, '')
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Токенізує назву/слаг у множину значущих токенів (kebab + пробіли, без стоп-слів).
|
|
68
|
+
* @param {string} s назва або слаг для токенізації
|
|
69
|
+
* @returns {Set<string>} множина значущих токенів
|
|
70
|
+
*/
|
|
71
|
+
export function tokenize(s) {
|
|
72
|
+
return new Set(
|
|
73
|
+
s
|
|
74
|
+
.toLowerCase()
|
|
75
|
+
.replace(RE_MD_EXT, '')
|
|
76
|
+
.replace(RE_TS_PREFIX, '')
|
|
77
|
+
.split(RE_TOKEN_SPLIT)
|
|
78
|
+
.filter((t) => t.length > 2 && !STOP.has(t))
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Jaccard-схожість двох множин токенів.
|
|
84
|
+
* @param {Set<string>} a перша множина токенів
|
|
85
|
+
* @param {Set<string>} b друга множина токенів
|
|
86
|
+
* @returns {number} коефіцієнт Jaccard у діапазоні 0..1
|
|
87
|
+
*/
|
|
88
|
+
export function jaccard(a, b) {
|
|
89
|
+
if (!a.size || !b.size) return 0
|
|
90
|
+
let inter = 0
|
|
91
|
+
for (const t of a) if (b.has(t)) inter++
|
|
92
|
+
return inter / (a.size + b.size - inter)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const MADR_SECTION = /^(Context and Problem|Considered Options|Decision Outcome|Consequences|More Information|report|summary|Attempt|Reason|Update)\b/i
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Витягує заголовок драфта. Капчер пише `## ADR <title>` — він у пріоритеті
|
|
99
|
+
* (чернетка може мати контент-заголовки раніше або взагалі не мати ADR-рядка).
|
|
100
|
+
* Fallback-и: перший h1, що не є MADR-секцією, інакше '' (caller бере імʼя файлу).
|
|
101
|
+
* @param {string} body тіло чернетки
|
|
102
|
+
* @returns {string} заголовок або ''
|
|
103
|
+
*/
|
|
104
|
+
export function draftTitle(body) {
|
|
105
|
+
const adr = body.match(RE_DRAFT_ADR_TITLE)
|
|
106
|
+
if (adr) return adr[1].trim()
|
|
107
|
+
for (const m of body.matchAll(/^#\s+(.+)$/gm)) {
|
|
108
|
+
if (!MADR_SECTION.test(m[1].trim())) return m[1].trim()
|
|
109
|
+
}
|
|
110
|
+
return ''
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Детермінований no-decision гейт (харднінг #1). Чернетка, де у `Decision Outcome`
|
|
115
|
+
* рішення явно НЕ прийняте (transcript обірвався) — не варта окремого ADR: gold
|
|
116
|
+
* (sonnet) такі видаляє. Ловимо без LLM, щоб не покладатися на kind-judge малої моделі.
|
|
117
|
+
* @param {string} body тіло чернетки
|
|
118
|
+
* @returns {boolean} true якщо рішення не прийняте
|
|
119
|
+
*/
|
|
120
|
+
export function isNoDecision(body) {
|
|
121
|
+
const m = body.match(RE_DECISION_SECTION)
|
|
122
|
+
if (!m) return false
|
|
123
|
+
// NB: JS \b не працює з кирилицею — покладаємось на пробіл/межі фрази без \b.
|
|
124
|
+
return RE_NO_DECISION.test(m[1])
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Будує кандидати-ребра за лексичною схожістю.
|
|
129
|
+
* @param {{file:string, body:string}[]} drafts батч чернеток
|
|
130
|
+
* @param {string[]} cleanList clean basename-и
|
|
131
|
+
* @param {{simThreshold?:number, topKClean?:number}} [opts] поріг схожості та ліміт clean-кандидатів
|
|
132
|
+
* @returns {{dd:[number,number][], dc:[number,string][]}} ребра draft↔draft (dd) і draft↔clean (dc)
|
|
133
|
+
*/
|
|
134
|
+
export function buildEdges(drafts, cleanList, opts = {}) {
|
|
135
|
+
const simThreshold = opts.simThreshold ?? 0.12
|
|
136
|
+
const topKClean = opts.topKClean ?? 3
|
|
137
|
+
const draftTok = drafts.map((d) => tokenize(`${d.file} ${draftTitle(d.body)}`))
|
|
138
|
+
const cleanTok = new Map(cleanList.map((c) => [c, tokenize(c)]))
|
|
139
|
+
|
|
140
|
+
const dd = []
|
|
141
|
+
for (let i = 0; i < drafts.length; i++) {
|
|
142
|
+
for (let j = i + 1; j < drafts.length; j++) {
|
|
143
|
+
if (jaccard(draftTok[i], draftTok[j]) >= simThreshold) dd.push([i, j])
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const dc = []
|
|
147
|
+
for (let i = 0; i < drafts.length; i++) {
|
|
148
|
+
const scored = []
|
|
149
|
+
for (const [c, tok] of cleanTok) {
|
|
150
|
+
const s = jaccard(draftTok[i], tok)
|
|
151
|
+
if (s >= simThreshold) scored.push([c, s])
|
|
152
|
+
}
|
|
153
|
+
scored.sort((a, b) => b[1] - a[1])
|
|
154
|
+
for (const [c] of scored.slice(0, topKClean)) dc.push([i, c])
|
|
155
|
+
}
|
|
156
|
+
return { dd, dc }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ─────────────────────────── LLM helper: tier cascade ──────────────────────────
|
|
160
|
+
|
|
161
|
+
const LOCAL = () => resolveModel('min')
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Виклик LLM з локальним ретраєм і (опційно) хмарною ескалацією.
|
|
165
|
+
* @param {Array<{role:string,content:string}>} messages чат-повідомлення для LLM
|
|
166
|
+
* @param {(raw:string)=>any} parse валідатор (кидає на невалідному)
|
|
167
|
+
* @param {{label:string, allowCloud:boolean, attempts?:number, stats:object, maxTokens?:number}} cfg конфіг каскаду (мітка, дозвіл на хмару, спроби, лічильники, ліміт токенів)
|
|
168
|
+
* @returns {any} результат parse
|
|
169
|
+
* @throws {Error} якщо всі спроби провалені
|
|
170
|
+
*/
|
|
171
|
+
function callWithCascade(messages, parse, cfg) {
|
|
172
|
+
const attempts = cfg.attempts ?? 2
|
|
173
|
+
const temps = [0.1, 0.4, 0.7]
|
|
174
|
+
let lastErr = null
|
|
175
|
+
for (let a = 0; a < attempts; a++) {
|
|
176
|
+
try {
|
|
177
|
+
cfg.stats.localCalls++
|
|
178
|
+
const raw = callLlm(messages, LOCAL(), {
|
|
179
|
+
timeoutMs: 120_000,
|
|
180
|
+
temperature: temps[a] ?? 0.2,
|
|
181
|
+
maxTokens: cfg.maxTokens ?? 4096,
|
|
182
|
+
caller: `adr-pipe:${cfg.label}`
|
|
183
|
+
})
|
|
184
|
+
return parse(raw)
|
|
185
|
+
} catch (error) {
|
|
186
|
+
lastErr = error
|
|
187
|
+
if (classifyOmlxError(error.message) === 'infra') break
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (cfg.allowCloud && CLOUD_MIN) {
|
|
191
|
+
try {
|
|
192
|
+
cfg.stats.cloudCalls++
|
|
193
|
+
cfg.stats.escalations++
|
|
194
|
+
const raw = callLlm(messages, CLOUD_MIN, { timeoutMs: 120_000, temperature: 0.2, maxTokens: cfg.maxTokens ?? 4096, caller: `adr-pipe:${cfg.label}:cloud` })
|
|
195
|
+
return parse(raw)
|
|
196
|
+
} catch (error) {
|
|
197
|
+
lastErr = error
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
cfg.stats.failures++
|
|
201
|
+
throw lastErr ?? new Error('callWithCascade: no result')
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Витяг першого JSON-обʼєкта з raw-тексту.
|
|
206
|
+
* @param {string} raw сирий текст відповіді LLM
|
|
207
|
+
* @returns {any} розпарсений JSON-обʼєкт
|
|
208
|
+
* @throws {Error} якщо у тексті немає JSON-обʼєкта
|
|
209
|
+
*/
|
|
210
|
+
function extractJson(raw) {
|
|
211
|
+
const s = raw.indexOf('{')
|
|
212
|
+
const e = raw.lastIndexOf('}')
|
|
213
|
+
if (s === -1 || e === -1) throw new Error('no JSON object')
|
|
214
|
+
return JSON.parse(raw.slice(s, e + 1))
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ─────────────────────────── Stage 1: edge-judge (LLM) ─────────────────────────
|
|
218
|
+
|
|
219
|
+
const EdgeSchema = z.object({
|
|
220
|
+
same: z.boolean(),
|
|
221
|
+
confidence: z.number().min(0).max(1),
|
|
222
|
+
reason: z.string().min(3).max(400)
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
const EDGE_SYS = `Ти порівнюєш два короткі записи архітектурних рішень (ADR). Визнач, чи вони описують ОДНЕ І ТЕ САМЕ рішення (одну тему/механізм, де другий лише уточнює/доповнює/продовжує перший), чи це РІЗНІ незалежні рішення.
|
|
226
|
+
|
|
227
|
+
Поверни ЛИШЕ JSON, без markdown:
|
|
228
|
+
{ "same": true|false, "confidence": 0..1, "reason": "<коротко українською>" }
|
|
229
|
+
|
|
230
|
+
same=true ЛИШЕ якщо це по суті одне рішення (дублікат, уточнення, продовження тієї самої теми). Різні аспекти однієї підсистеми, але окремі рішення → same=false. Якщо сумніваєшся — false.`
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Бінарний суддя «те саме рішення?» з self-consistency. Консервативний: `same`
|
|
234
|
+
* лише якщо ВСІ голоси кажуть same з confidence ≥ minConf. Харднінг #2: для
|
|
235
|
+
* draft↔draft (ризик over-merge) піднімаємо до 3 голосів і порога 0.6.
|
|
236
|
+
* @param {string} aTitle заголовок запису A
|
|
237
|
+
* @param {string} aBody тіло запису A
|
|
238
|
+
* @param {string} bTitle заголовок запису B
|
|
239
|
+
* @param {string} bBody тіло запису B
|
|
240
|
+
* @param {{allowCloud:boolean, votes?:number, stats:object}} cfg конфіг каскаду (дозвіл на хмару, голоси, лічильники)
|
|
241
|
+
* @param {{votes?:number, minConf?:number}} [vote] override голосів і порога на тип ребра
|
|
242
|
+
* @returns {{same:boolean, votes:object[]}} підтвердження same та сирі голоси
|
|
243
|
+
*/
|
|
244
|
+
function judgeEdge(aTitle, aBody, bTitle, bBody, cfg, vote = {}) {
|
|
245
|
+
const nVotes = vote.votes ?? cfg.votes ?? 2
|
|
246
|
+
const minConf = vote.minConf ?? 0.5
|
|
247
|
+
const user = `Запис A — "${aTitle}":\n${aBody.slice(0, 1500)}\n\n---\n\nЗапис B — "${bTitle}":\n${bBody.slice(0, 1500)}\n\nЦе одне й те саме рішення?`
|
|
248
|
+
const parse = (raw) => EdgeSchema.parse(extractJson(raw))
|
|
249
|
+
const votes = []
|
|
250
|
+
for (let v = 0; v < nVotes; v++) {
|
|
251
|
+
try {
|
|
252
|
+
votes.push(callWithCascade([{ role: 'system', content: EDGE_SYS }, { role: 'user', content: user }], parse, { label: 'edge', allowCloud: cfg.allowCloud, stats: cfg.stats, maxTokens: 300 }))
|
|
253
|
+
} catch {
|
|
254
|
+
votes.push({ same: false, confidence: 0, reason: 'judge failed → conservative different' })
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const sameCount = votes.filter((v) => v.same && v.confidence >= minConf).length
|
|
258
|
+
return { same: sameCount === votes.length, votes }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ─────────────────────────── Stage 1b: kind-judge (LLM) ────────────────────────
|
|
262
|
+
|
|
263
|
+
const KindSchema = z.object({
|
|
264
|
+
kind: z.enum(['standalone', 'trivial']),
|
|
265
|
+
reason: z.string().min(3).max(400)
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
const KIND_SYS = `Ти оцінюєш чернетку архітектурного рішення (ADR). Визнач:
|
|
269
|
+
- "standalone" — це самостійне рішення, варте збереження як decision record.
|
|
270
|
+
- "trivial" — порожнє / тривіальне / косметичне / без реального рішення, можна видалити.
|
|
271
|
+
|
|
272
|
+
Поверни ЛИШЕ JSON: { "kind": "standalone"|"trivial", "reason": "<коротко українською>" }
|
|
273
|
+
Якщо сумніваєшся — "standalone" (краще зберегти).`
|
|
274
|
+
|
|
275
|
+
function judgeKind(title, body, cfg) {
|
|
276
|
+
const user = `Чернетка — "${title}":\n${body.slice(0, 2500)}\n\nstandalone чи trivial?`
|
|
277
|
+
const parse = (raw) => KindSchema.parse(extractJson(raw))
|
|
278
|
+
try {
|
|
279
|
+
return callWithCascade([{ role: 'system', content: KIND_SYS }, { role: 'user', content: user }], parse, { label: 'kind', allowCloud: cfg.allowCloud, stats: cfg.stats, maxTokens: 200 })
|
|
280
|
+
} catch {
|
|
281
|
+
return { kind: 'standalone', reason: 'judge failed → conservative standalone' }
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ─────────────────────────── Stage 2: gen-MADR (LLM) ───────────────────────────
|
|
286
|
+
|
|
287
|
+
const MADR_HEADINGS = [
|
|
288
|
+
'## Context and Problem Statement',
|
|
289
|
+
'## Considered Options',
|
|
290
|
+
'## Decision Outcome',
|
|
291
|
+
'## More Information'
|
|
292
|
+
]
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Детермінований гейт якості згенерованого MADR.
|
|
296
|
+
* @param {string} content згенерований MADR-текст
|
|
297
|
+
* @returns {{ok:boolean, errors:string[]}} результат перевірки та перелік порушень
|
|
298
|
+
*/
|
|
299
|
+
export function validateMadr(content) {
|
|
300
|
+
const errors = []
|
|
301
|
+
if (!content || content.length < 80) errors.push('too short')
|
|
302
|
+
if (RE_FENCE_LEAD.test(content) || RE_FENCE_TRAIL.test(content.trim())) errors.push('code-fence wrapper')
|
|
303
|
+
if (RE_FRONTMATTER.test(content.split('\n').slice(0, 3).join('\n'))) errors.push('has YAML frontmatter')
|
|
304
|
+
if (RE_SESSION.test(content)) errors.push('leaked session: field')
|
|
305
|
+
if (!RE_H1.test(content)) errors.push('missing # title')
|
|
306
|
+
if (!RE_STATUS.test(content)) errors.push('missing Status')
|
|
307
|
+
if (!RE_DATE.test(content)) errors.push('missing/!ISO Date')
|
|
308
|
+
for (const h of MADR_HEADINGS) if (!content.includes(h)) errors.push(`missing heading ${h}`)
|
|
309
|
+
return { ok: errors.length === 0, errors }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const GEN_SYS = `Ти нормалізуєш чернетку ADR у чистий фінальний MADR 4.0.0. Чернетка вже містить секції — твоя задача ОЧИСТИТИ й привести до канону, НЕ вигадуючи нового.
|
|
313
|
+
|
|
314
|
+
Поверни ЛИШЕ markdown файлу (починається з "# "), без code-fence, без передмови, без YAML frontmatter.
|
|
315
|
+
|
|
316
|
+
Структура РІВНО така:
|
|
317
|
+
# <Заголовок українською>
|
|
318
|
+
|
|
319
|
+
**Status:** Accepted
|
|
320
|
+
**Date:** <YYYY-MM-DD з поля captured чернетки, перші 10 символів>
|
|
321
|
+
|
|
322
|
+
## Context and Problem Statement
|
|
323
|
+
<...>
|
|
324
|
+
|
|
325
|
+
## Considered Options
|
|
326
|
+
<bullets; якщо альтернатив не було — "Інші варіанти не обговорювалися.">
|
|
327
|
+
|
|
328
|
+
## Decision Outcome
|
|
329
|
+
Chosen option: "<...>", because <...>.
|
|
330
|
+
|
|
331
|
+
### Consequences
|
|
332
|
+
<bullets "Good, because ...", "Bad, because ...">
|
|
333
|
+
|
|
334
|
+
## More Information
|
|
335
|
+
<файли, команди, API; якщо нема — "Додаткової інформації не зафіксовано.">
|
|
336
|
+
|
|
337
|
+
Не вигадуй альтернатив, наслідків чи контексту, яких нема в чернетці. Прибери YAML frontmatter (session/captured/transcript) повністю.`
|
|
338
|
+
|
|
339
|
+
const slugify = (title) =>
|
|
340
|
+
title.toLowerCase().replace(RE_SLUG_NONWORD, '-').replace(RE_LEAD_HYPHEN, '').slice(0, 60).replace(RE_TRAIL_HYPHEN, '') || 'adr'
|
|
341
|
+
|
|
342
|
+
function genMadr(title, body, captured, cfg) {
|
|
343
|
+
const date = (captured ?? '').slice(0, 10)
|
|
344
|
+
const user = `Чернетка "${title}" (captured: ${captured ?? 'невідомо'}):\n\n${body}\n\nПоверни чистий MADR. Date = ${date || 'візьми з captured'}.`
|
|
345
|
+
const parse = (raw) => {
|
|
346
|
+
const content = stripFence(raw)
|
|
347
|
+
const v = validateMadr(content)
|
|
348
|
+
if (!v.ok) throw new Error(`MADR invalid: ${v.errors.join('; ')}`)
|
|
349
|
+
return content
|
|
350
|
+
}
|
|
351
|
+
try {
|
|
352
|
+
const content = callWithCascade([{ role: 'system', content: GEN_SYS }, { role: 'user', content: user }], parse, { label: 'gen', allowCloud: cfg.allowCloud, stats: cfg.stats, attempts: 3, maxTokens: 4096 })
|
|
353
|
+
return { content, slug: slugify(title), valid: true }
|
|
354
|
+
} catch (error) {
|
|
355
|
+
cfg.stats.madrInvalid++
|
|
356
|
+
return { content: null, slug: slugify(title), valid: false, error: error.message }
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ─────────────────────────── Stage 3: gen-merge (LLM) ──────────────────────────
|
|
361
|
+
|
|
362
|
+
const MERGE_SYS = `Ти готуєш короткий блок-доповнення до існуючого ADR. Поверни ЛИШЕ markdown-блок (без code-fence), що починається рядком "## Update <YYYY-MM-DD>", і містить ЛИШЕ новий зміст, якого ще нема в цільовому ADR (уточнення/виправлення/продовження). Стисло. Без передмови.`
|
|
363
|
+
|
|
364
|
+
function genMerge(title, body, captured, targetTitle, cfg) {
|
|
365
|
+
const date = (captured ?? '').slice(0, 10)
|
|
366
|
+
const user = `Цільовий ADR: "${targetTitle}".\nЧернетка-доповнення "${title}" (${date}):\n${body.slice(0, 2500)}\n\nДай блок "## Update ${date}" з НОВИМ змістом.`
|
|
367
|
+
const parse = (raw) => {
|
|
368
|
+
const t = stripFence(raw)
|
|
369
|
+
if (!RE_UPDATE_HEAD.test(t)) throw new Error('missing ## Update heading')
|
|
370
|
+
return t
|
|
371
|
+
}
|
|
372
|
+
try {
|
|
373
|
+
return callWithCascade([{ role: 'system', content: MERGE_SYS }, { role: 'user', content: user }], parse, { label: 'merge', allowCloud: cfg.allowCloud, stats: cfg.stats, attempts: 2, maxTokens: 1500 })
|
|
374
|
+
} catch {
|
|
375
|
+
return `## Update ${date}\n\n(доповнення з чернетки "${title}")`
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ─────────────────────────── union-find ────────────────────────────────────────
|
|
380
|
+
|
|
381
|
+
function makeDSU(n) {
|
|
382
|
+
const p = Array.from({ length: n }, (_, i) => i)
|
|
383
|
+
const find = (x) => (p[x] === x ? x : (p[x] = find(p[x])))
|
|
384
|
+
const union = (a, b) => { p[find(a)] = find(b) }
|
|
385
|
+
return { find, union }
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const captureField = (body, field) => (body.match(new RegExp(`^${field}:\\s*(.+)$`, 'm')) ?? [])[1]?.trim()
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* No-op за замовчуванням для onProgress (коли caller не передав логер).
|
|
392
|
+
* @returns {void} нічого не робить
|
|
393
|
+
*/
|
|
394
|
+
const noop = () => {
|
|
395
|
+
// навмисно порожньо: тихий fallback для onProgress
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ─────────────────────────── orchestrator ──────────────────────────────────────
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Головний конвеєр. Повертає operations[] (контракт single-shot) + stats.
|
|
402
|
+
* @param {{file:string, body:string}[]} drafts батч чернеток
|
|
403
|
+
* @param {string[]} cleanList clean basename-и
|
|
404
|
+
* @param {{allowCloud?:boolean, votes?:number, onProgress?:(m:string)=>void}} [opts] хмарна ескалація, кількість голосів і колбек прогресу
|
|
405
|
+
* @returns {{operations:object[], stats:object, trace:object}} операції apply-ops, лічильники та діагностичний trace
|
|
406
|
+
*/
|
|
407
|
+
export function normalizePipeline(drafts, cleanList, opts = {}) {
|
|
408
|
+
const allowCloud = opts.allowCloud ?? false
|
|
409
|
+
const log = opts.onProgress ?? noop
|
|
410
|
+
const stats = { localCalls: 0, cloudCalls: 0, escalations: 0, failures: 0, madrInvalid: 0 }
|
|
411
|
+
const cfg = { allowCloud, votes: opts.votes ?? 2, stats }
|
|
412
|
+
|
|
413
|
+
const titles = drafts.map((d) => draftTitle(d.body) || d.file.replace(RE_MD_EXT, ''))
|
|
414
|
+
const captured = drafts.map((d) => captureField(d.body, 'captured'))
|
|
415
|
+
|
|
416
|
+
// Харднінг #1: детермінований no-decision гейт. Такі драфти не кластеризуємо й
|
|
417
|
+
// не rewrite-имо — одразу delete (без LLM), як це робить gold.
|
|
418
|
+
const noDec = drafts.map((d) => isNoDecision(d.body))
|
|
419
|
+
if (noDec.some(Boolean)) log(`no-decision гейт: ${noDec.filter(Boolean).length} драфт(ів) → delete`)
|
|
420
|
+
|
|
421
|
+
// Stage 0: retrieval (ребра, що торкаються no-decision драфтів, відкидаємо)
|
|
422
|
+
const edges = buildEdges(drafts, cleanList)
|
|
423
|
+
const dd = edges.dd.filter(([i, j]) => !noDec[i] && !noDec[j])
|
|
424
|
+
const dc = edges.dc.filter(([i]) => !noDec[i])
|
|
425
|
+
log(`retrieval: ${dd.length} draft-draft ребер, ${dc.length} draft-clean кандидатів`)
|
|
426
|
+
|
|
427
|
+
// Stage 1: judge draft↔draft ребра (харднінг #2: 3 голоси, conf ≥ 0.6 проти over-merge)
|
|
428
|
+
const dsu = makeDSU(drafts.length)
|
|
429
|
+
const confirmedDD = []
|
|
430
|
+
for (const [i, j] of dd) {
|
|
431
|
+
const r = judgeEdge(titles[i], drafts[i].body, titles[j], drafts[j].body, cfg, { votes: 3, minConf: 0.6 })
|
|
432
|
+
if (r.same) { dsu.union(i, j); confirmedDD.push([i, j]) }
|
|
433
|
+
}
|
|
434
|
+
log(`edge-judge: ${confirmedDD.length}/${dd.length} draft-draft ребер підтверджено`)
|
|
435
|
+
|
|
436
|
+
// Stage 1: judge draft↔clean → найкращий existing-target на драфт
|
|
437
|
+
const cleanTarget = Array.from({ length: drafts.length }).fill(null)
|
|
438
|
+
const dcByDraft = new Map()
|
|
439
|
+
for (const [i, c] of dc) { if (!dcByDraft.has(i)) dcByDraft.set(i, []); dcByDraft.get(i).push(c) }
|
|
440
|
+
for (const [i, cands] of dcByDraft) {
|
|
441
|
+
for (const c of cands) {
|
|
442
|
+
const cTitle = stripAdrName(c)
|
|
443
|
+
const r = judgeEdge(titles[i], drafts[i].body, cTitle, cTitle, cfg)
|
|
444
|
+
if (r.same) { cleanTarget[i] = c; break }
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
log(`clean-match: ${cleanTarget.filter(Boolean).length} драфтів вже покриті clean-ADR`)
|
|
448
|
+
|
|
449
|
+
// Cluster (JS): групуємо за DSU
|
|
450
|
+
const clusters = new Map()
|
|
451
|
+
for (let i = 0; i < drafts.length; i++) {
|
|
452
|
+
const root = dsu.find(i)
|
|
453
|
+
if (!clusters.has(root)) clusters.set(root, [])
|
|
454
|
+
clusters.get(root).push(i)
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const decision = Array.from({ length: drafts.length }).fill(null)
|
|
458
|
+
const operations = []
|
|
459
|
+
|
|
460
|
+
for (const [, members] of clusters) {
|
|
461
|
+
if (members.length > 1) {
|
|
462
|
+
// anchor — лише серед non-noDec (no-decision не може бути канонічним rewrite)
|
|
463
|
+
const live = members.filter((m) => !noDec[m])
|
|
464
|
+
// anchor = індекс із найдовшим drafts[idx].body.length; при рівності — перший
|
|
465
|
+
// зустрінутий (еквівалент reduce з `>=`, що зберігає поточний акумулятор a).
|
|
466
|
+
const candidates = live.length ? live : members
|
|
467
|
+
let anchor = candidates[0]
|
|
468
|
+
for (let k = 1; k < candidates.length; k++) {
|
|
469
|
+
if (drafts[candidates[k]].body.length > drafts[anchor].body.length) anchor = candidates[k]
|
|
470
|
+
}
|
|
471
|
+
decision[anchor] = { op: 'rewrite' }
|
|
472
|
+
for (const m of members) {
|
|
473
|
+
if (m === anchor) continue
|
|
474
|
+
decision[m] = noDec[m] ? { op: 'delete', reason: 'рішення не прийняте (transcript обірвався)' } : { op: 'merge-anchor', anchorIdx: anchor }
|
|
475
|
+
}
|
|
476
|
+
} else {
|
|
477
|
+
const i = members[0]
|
|
478
|
+
if (noDec[i]) decision[i] = { op: 'delete', reason: 'рішення не прийняте (transcript обірвався)' }
|
|
479
|
+
else if (cleanTarget[i]) decision[i] = { op: 'merge-existing', target: cleanTarget[i] }
|
|
480
|
+
else decision[i] = { op: 'kind' }
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// одинаки без clean-target → kind-judge
|
|
485
|
+
for (let i = 0; i < drafts.length; i++) {
|
|
486
|
+
if (decision[i].op === 'kind') {
|
|
487
|
+
const k = judgeKind(titles[i], drafts[i].body, cfg)
|
|
488
|
+
decision[i] = k.kind === 'trivial' ? { op: 'delete', reason: k.reason } : { op: 'rewrite' }
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Stage 2: gen-MADR для всіх rewrite (anchors + standalones)
|
|
493
|
+
const slugByIdx = Array.from({ length: drafts.length }).fill(null)
|
|
494
|
+
for (let i = 0; i < drafts.length; i++) {
|
|
495
|
+
if (decision[i].op !== 'rewrite') continue
|
|
496
|
+
const g = genMadr(titles[i], drafts[i].body, captured[i], cfg)
|
|
497
|
+
slugByIdx[i] = g.slug
|
|
498
|
+
if (g.valid) {
|
|
499
|
+
operations.push({ op: 'rewrite', file: drafts[i].file, slug: g.slug, content: g.content })
|
|
500
|
+
} else {
|
|
501
|
+
decision[i] = { op: 'gen-failed' }
|
|
502
|
+
log(`gen-MADR FAILED для ${drafts[i].file}: ${g.error}`)
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Stage 3: merges
|
|
507
|
+
for (let i = 0; i < drafts.length; i++) {
|
|
508
|
+
const d = decision[i]
|
|
509
|
+
if (d.op === 'merge-anchor') {
|
|
510
|
+
const slug = slugByIdx[d.anchorIdx]
|
|
511
|
+
if (!slug) { log(`merge-anchor ${drafts[i].file}: anchor gen failed → skip`); continue }
|
|
512
|
+
const add = genMerge(titles[i], drafts[i].body, captured[i], titles[d.anchorIdx], cfg)
|
|
513
|
+
operations.push({ op: 'merge-into', file: drafts[i].file, target: `${slug}.md`, additions: add })
|
|
514
|
+
} else if (d.op === 'merge-existing') {
|
|
515
|
+
const cTitle = stripAdrName(d.target)
|
|
516
|
+
const add = genMerge(titles[i], drafts[i].body, captured[i], cTitle, cfg)
|
|
517
|
+
operations.push({ op: 'merge-into', file: drafts[i].file, target: d.target, additions: add })
|
|
518
|
+
} else if (d.op === 'delete') {
|
|
519
|
+
operations.push({ op: 'delete', file: drafts[i].file, reason: d.reason })
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const trace = {
|
|
524
|
+
titles,
|
|
525
|
+
clusters: Array.from(clusters.values(), (m) => m.map((i) => drafts[i].file)),
|
|
526
|
+
cleanTargets: cleanTarget.map((c, i) => (c ? [drafts[i].file, c] : null)).filter(Boolean),
|
|
527
|
+
decisions: decision.map((d, i) => [drafts[i].file, d.op])
|
|
528
|
+
}
|
|
529
|
+
return { operations, stats, trace }
|
|
530
|
+
}
|