@7n/rules 1.49.23 → 1.49.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.49.25] - 2026-07-27
4
+
5
+ ### Fixed
6
+
7
+ - Збережено --path scope у doc-files fixer
8
+
9
+ ## [1.49.24] - 2026-07-27
10
+
11
+ ### Changed
12
+
13
+ - git-reconcile формує PR descriptions із пріоритетом бізнес-контексту та архітектури за validated final diff
14
+
3
15
  ## [1.49.23] - 2026-07-27
4
16
 
5
17
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/rules",
3
- "version": "1.49.23",
3
+ "version": "1.49.25",
4
4
  "description": "CLI еталонних правил і skills (префікс n-): синк у репозиторій, дельта-lint, конформність",
5
5
  "keywords": [
6
6
  "cli",
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: fix-worker.mjs
4
4
  resource: npm/rules/doc-files/check/fix-worker.mjs
5
5
  docgen:
6
- crc: 452ab360
6
+ crc: 444c817d
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  score: 100
9
9
  issues: judge:inaccurate:0.98
@@ -18,7 +18,7 @@ docgen:
18
18
  ## Поведінка
19
19
 
20
20
  1. Викликається `fixWorker`.
21
- 2. Скануються файли для визначення застарілих документів (рукописні доки без docgen-frontmatter — не цілі).
21
+ 2. З порушень, знайдених detector-ом, відновлюються повні цілі застарілих документів; fixer не робить нового повного обходу checkout. Тому hook і `lint doc-files --path` генерують docs лише для власного explicit scope (рукописні доки без docgen-frontmatter — не цілі).
22
22
  3. Якщо знайдені застарілі документи, кожна цільова дока реєструється durable через `ctx.recordDurableWrite` (fallback — `ctx.recordWrite`), а потім запускається генерація батчу через `runGenerationBatch`.
23
23
  4. Батч отримує м'який дедлайн — 80% від `ctx.timeoutMs` рунга: генерація сама зупиняється до backstop-таймауту, повертає частковий прогрес штатно й не лишає фонового батчу поверх наступного rung-а. Дедлайн діє і всередині файлу: `generateDoc` ріже per-call LLM-таймаути під залишок бюджету, тож навіть перший файл, що не вкладається у рунг, обривається сам (transient), а не по backstop-таймеру runner-а.
24
24
  5. Фільтруються порушення, що стосуються сирітських документів (вихідні файли видалені); pre-image кожного реєструється у `ctx.recordWrite`.
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: main.mjs
4
4
  resource: npm/rules/doc-files/check/main.mjs
5
5
  docgen:
6
- crc: dd3f8c2a
6
+ crc: b5921123
7
7
  ---
8
8
 
9
9
  ## Огляд
@@ -12,7 +12,7 @@ Lint-детектор doc-files: знаходить застарілі файл
12
12
 
13
13
  ## Поведінка
14
14
 
15
- `lint(ctx)` збирає застарілі доки через `collectStale` (для конкретного набору змінених файлів або, якщо файлів не передано, повним скануванням дерева) і додає для кожної окреме порушення з причиною (`missing`/`crc-mismatch`/`degraded`) та шляхом джерела. Окремо сканує `docs/`-теки на сирітські доки (`scanOrphanedDocs`) джерело яких більше не існує.
15
+ `lint(ctx)` збирає застарілі доки через `collectStale` (для конкретного набору змінених файлів або, якщо файлів не передано, повним скануванням дерева) і додає для кожної окреме порушення з причиною (`missing`/`crc-mismatch`/`degraded`) та шляхом джерела. Сирітські доки (`scanOrphanedDocs`) перевіряються лише за повного repo-wide скану: explicit files від hook або `--path` не мають безпечної межі для такого обходу, тому не можуть зачіпати docs поза своїм scope.
16
16
 
17
17
  Реверс-мапінг: якщо серед змінених файлів трапляється сама `.md`-дока (а не її джерело), `sourceForDoc` шукає відповідний вихідний файл поруч (той самий basename, легальне розширення) і саме його передає далі в перевірку застарілості — так зміна доки теж тригерить звірку CRC її джерела.
18
18
 
@@ -24,16 +24,19 @@ const DEADLINE_FRACTION = 0.8
24
24
  /** @type {FixWorkerFn} */
25
25
  export async function fixWorker(violations, ctx) {
26
26
  const { cwd } = ctx
27
- const { scanForDocFiles } = await import('../docgen-scan/main.mjs')
27
+ const { describeFile } = await import('../docgen-scan/main.mjs')
28
28
  const { runGenerationBatch, purgeOrphanedDocs } = await import('../docgen-files-batch/main.mjs')
29
29
 
30
30
  /** @type {string[]} */
31
31
  const touchedFiles = []
32
32
  const recordDoc = ctx.recordDurableWrite ?? ctx.recordWrite
33
33
 
34
- // Re-scan дає повні target-обʼєкти {sourcePath, docPath, reason, …}, що їх потребує
35
- // runGenerationBatch (violation несе лише file+docPath).
36
- const stale = scanForDocFiles(cwd).filter(f => f.stale)
34
+ // Відновлюємо повні target-обʼєкти лише для порушень detector-а. Повний re-scan
35
+ // тут ігнорував би explicit files від hook/`lint --path` і генерував би docs
36
+ // для всього checkout.
37
+ const stale = [...new Set(violations.filter(v => v.reason !== 'orphaned-doc' && v.file).map(v => v.file))]
38
+ .map(sourcePath => describeFile(cwd, sourcePath))
39
+ .filter(f => f.stale)
37
40
  if (stale.length > 0) {
38
41
  for (const f of stale) {
39
42
  const docAbs = join(cwd, f.docPath)
@@ -86,14 +86,19 @@ export function lint(ctx) {
86
86
  })
87
87
  )
88
88
  }
89
- for (const orphan of scanOrphanedDocs(cwd)) {
90
- violations.push(
91
- /** @type {Partial<import('../../../scripts/lib/lint-surface/types.mjs').LintViolation>} */ ({
92
- reason: 'orphaned-doc',
93
- message: `сирітський док (source видалено): ${orphan}`,
94
- file: orphan
95
- })
96
- )
89
+ // Явний files-набір (hook/--path) не містить межі дерева для безпечного
90
+ // orphan-скану. Повний скан тут порушив би scope і міг би видалити доки поза
91
+ // сервісом, тому orphan-cleanup лишається лише repo-wide прогоном.
92
+ if (files === undefined) {
93
+ for (const orphan of scanOrphanedDocs(cwd)) {
94
+ violations.push(
95
+ /** @type {Partial<import('../../../scripts/lib/lint-surface/types.mjs').LintViolation>} */ ({
96
+ reason: 'orphaned-doc',
97
+ message: `сирітський док (source видалено): ${orphan}`,
98
+ file: orphan
99
+ })
100
+ )
101
+ }
97
102
  }
98
103
 
99
104
  const unavailable = unavailableDocFilesPlugins(cwd)
@@ -31,7 +31,8 @@ LLM отримує лише bounded-завдання:
31
31
 
32
32
  1. semantic triage кандидатів, які JS не може оцінити за Git-фактами;
33
33
  2. розв'язання змістових конфліктів і перевірку перенесеної поведінки у вже
34
- підготовленому worktree.
34
+ підготовленому worktree;
35
+ 3. бізнесовий та архітектурний опис PR за bounded-фактами фінального diff.
35
36
 
36
37
  LLM не видаляє refs, не створює worktree, не push-ить і не відкриває PR.
37
38
  LLM виконує лише narrow tests, потрібні під час правок; full repository tests,
@@ -55,6 +56,8 @@ heartbeat з elapsed time. Формат однаковий у TTY/CI, тому c
55
56
 
56
57
  - triage — повноту verdicts, schema, groups і commit OID;
57
58
  - worktree — відсутність conflict markers та `git diff --check`;
59
+ - PR description — JSON schema, evidence paths із реального diff і перевагу
60
+ business/architecture змісту над behavior/risk details;
58
61
  - поведінку — repository test script відносно test baseline чистої
59
62
  `origin/<baseBranch>` і changelog gate. Red baseline приймається лише для
60
63
  розпізнаних Vitest failures, якщо після перенесення не додалось нових.
@@ -91,6 +94,13 @@ transport. Після провалу `max` джерело fail-closed лишає
91
94
  - Перед push обов'язково проходять фінальний tree-diff guard, domain lint для
92
95
  non-code paths, changelog і `git diff --check`; code changes додатково
93
96
  проходять scoped docs/lint та tests.
97
+ - Перед push JS передає min-моделі bounded final diff, commit metadata,
98
+ triage rationale і behavioral verification. Модель повертає лише validated
99
+ JSON, а JS рендерить PR body із видимими секціями «Навіщо»,
100
+ «Бізнес-результат», «Архітектура», «Поведінка» та «Ризики та сумісність»;
101
+ source і evidence paths лишаються у collapsed technical details.
102
+ - Невалідний PR description повторюється на max; повторний провал fail-closed
103
+ зберігає worktree і не push-ить гілку з misleading описом.
94
104
  - Canonical fixers охоплюють code і non-code directories; після механічного
95
105
  виправлення фінальні gates обов'язково запускаються повторно без fix.
96
106
  - Behavioral LLM не викликається для змін без code paths; test baseline
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: orchestrate.mjs
4
4
  resource: npm/skills/git-reconcile/js/orchestrate.mjs
5
5
  docgen:
6
- crc: 69749e90
6
+ crc: fc877b0d
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  score: 100
9
9
  ---
@@ -20,13 +20,15 @@ docgen:
20
20
 
21
21
  Для корисної групи JS створює ізольований worktree від policy base, застосовує commits або stash і відсікає порожній tree diff. `captureCachedBehaviorBaseline` кешує Promise baseline за base OID, щоб concurrent PR-групи не дублювали test run. `validateBehaviorState` перевіряє Git state, scoped docs/lint, tests відносно baseline і changelog. `remediateBehaviorState` запускає canonical fixers для code та non-code directories; після remediation final gates повторюються у read-only режимі.
22
22
 
23
+ Після фінальних gates `collectPullRequestFacts` збирає bounded final diff, commit metadata, changed paths, triage rationale та behavioral verification. `describePullRequest` викликає min-модель, а `validatePullRequestDescription` перевіряє JSON schema, factual evidence paths і перевагу business/architecture змісту; residual failure повторюється на max. `renderPullRequestBody` детерміновано ставить секції «Навіщо», «Бізнес-результат» та «Архітектура» перед поведінковими деталями, а source і evidence paths ховає в технічний `<details>`.
24
+
23
25
  `runAsync` запускає install, tests, lint і PR checks без блокування event loop. Після push та `gh pr create` функція `verifyPullRequestReadiness` очікує terminal checks і передає набори PR/base checks у `classifyPullRequestChecks`. Якщо initial rollup порожній, orchestration дає GitHub коротке вікно на реєстрацію checks і повторює watch. Успішний outcome `pr-created` повертається лише для `ready`; regression, baseline-red, pending, timeout або unreadable checks зберігають branch, URL і worktree та блокують cleanup.
24
26
 
25
27
  `commitPendingChanges` створює додатковий commit лише для staged remediation. Чистий index після перенесення branch-source є валідним, коли корисні commits уже присутні в `HEAD`. Порожній PR check rollup вважається непідтвердженим, а не успішним.
26
28
 
27
29
  `hasOnlyChangeEntries` відсікає tree diff, у якому лишилися тільки `*/.changes/*.md`: такий результат вважається `patch-equivalent`, не створює PR і дозволяє cleanup source branch.
28
30
 
29
- `pruneForensicDependencies` прибирає лише rebuildable `node_modules` зі збереженого forensic worktree. `cleanupSource` видаляє лише точний доказово безпечний branch/stash. Cleanup review-source дозволений після `drop` або коли всі його групи завершились `pr-created` чи `patch-equivalent`; `failed` і всі `pr-checks-*` outcomes лишають source та forensic worktree.
31
+ `pruneForensicDependencies` прибирає лише відновлюваний `node_modules` зі збереженого forensic worktree. `cleanupSource` видаляє лише точний доказово безпечний branch/stash. Cleanup review-source дозволений після `drop` або коли всі його групи завершились `pr-created` чи `patch-equivalent`; `failed` і всі `pr-checks-*` outcomes лишають source та forensic worktree.
30
32
 
31
33
  ## Публічний API
32
34
 
@@ -34,12 +36,13 @@ docgen:
34
36
  - `createPhaseProgress` — формує ANSI-free progress snapshots і heartbeat.
35
37
  - `parseWorktrees`, `dedupeRefs`, `conflictFiles`, `inventoryRepository` — збирають і нормалізують Git inventory.
36
38
  - `buildTriagePrompt`, `parseDecisionEnvelope`, `validateTriageOutcome` — задають і перевіряють bounded triage contract.
39
+ - `collectPullRequestFacts`, `validatePullRequestDescription`, `describePullRequest`, `renderPullRequestBody` — готують, перевіряють і формують business/architecture narrative фінального PR.
37
40
  - `callRunner`, `callWithValidatedFallback` — викликають runner за схемою `min → validation → max`.
38
41
  - `captureBehaviorBaseline`, `captureCachedBehaviorBaseline` — фіксують і кешують test baseline.
39
42
  - `validateBehaviorState`, `validateFinalProjectGates`, `remediateBehaviorState` — виконують behavioral та canonical gates.
40
43
  - `commitPendingChanges` — комітить лише staged remediation, не вимагаючи порожнього commit для вже перенесених commits.
41
44
  - `hasOnlyChangeEntries` — розпізнає release-metadata-only diff, який не потребує PR.
42
- - `pruneForensicDependencies` — звільняє rebuildable dependencies без втрати Git evidence.
45
+ - `pruneForensicDependencies` — звільняє відновлювані dependencies без втрати Git evidence.
43
46
  - `classifyPullRequestChecks`, `verifyPullRequestReadiness` — класифікують CI відносно base commit.
44
47
  - `formatOutcomeCounts`, `formatReport` — формують точний deterministic summary.
45
48
  - `cleanupSource`, `runGitReconcileOrchestrator` — виконують безпечний cleanup і координують повний flow.
@@ -49,6 +52,8 @@ docgen:
49
52
  - Довгі child processes не блокують event loop і progress heartbeat.
50
53
  - Baseline test Promise дедуплікується між concurrent PR-групами одного base OID.
51
54
  - Canonical fixers охоплюють code/non-code scope, після чого gates повторюються без fix.
55
+ - PR description спирається лише на bounded final facts, а evidence paths завжди належать реальному diff.
56
+ - Business/architecture narrative має не меншу вагу, ніж behavior/risk details; повторно невалідний опис блокує push.
52
57
  - PR вважається створеним успішно лише після terminal green checks.
53
58
  - Порожній PR check rollup fail-closed зберігає forensic worktree.
54
59
  - Forensic worktree зберігає Git evidence без накопичення `node_modules`.
@@ -13,6 +13,7 @@ import { readGitPolicy } from '../../../scripts/lib/git-policy.mjs'
13
13
  const LLM_TIERS = ['min', 'max']
14
14
  const REVIEW_BATCH_SIZE = 10
15
15
  const PROMPT_TEXT_LIMIT = 12_000
16
+ const PR_DIFF_TEXT_LIMIT = 24_000
16
17
  const PROGRESS_HEARTBEAT_MS = 30_000
17
18
  const PR_CHECK_TIMEOUT_MS = 15 * 60_000
18
19
  const DEFAULT_PR_CONCURRENCY = 3
@@ -27,6 +28,12 @@ const RENAME_DELETE_CONFLICT_RE = /^CONFLICT \(rename\/delete\): .+? renamed to
27
28
  const SOURCE_CODE_RE = /\.(?:js|mjs|ts|vue|rs|py)$/
28
29
  const CHANGE_ENTRY_RE = /(^|\/)\.changes\/[^/]+\.md$/
29
30
  const WHITESPACE_RE = /\s+/
31
+ const PR_DESCRIPTION_ARRAY_FIELDS = [
32
+ 'businessOutcomes',
33
+ 'architectureChanges',
34
+ 'behaviorChanges',
35
+ 'risksAndCompatibility'
36
+ ]
30
37
  const ACP_PROGRESS_ENV = 'N_LLM_ACP_PROGRESS'
31
38
  const REF_INVENTORY_FORMAT = ['%(refname)', '%00', '%(object', 'name)', '%00', '%(committer', 'date:iso-strict)'].join(
32
39
  ''
@@ -767,6 +774,184 @@ export function validateTriageOutcome(outcome, candidates) {
767
774
  return { ok: true, value: envelope }
768
775
  }
769
776
 
777
+ /**
778
+ * Збирає bounded факти з фінального diff для grounded бізнесового й
779
+ * архітектурного опису PR без повторного repository exploration моделлю.
780
+ * @param {object} args контекст готового PR
781
+ * @returns {object} факти опису PR
782
+ */
783
+ export function collectPullRequestFacts(args) {
784
+ const { cwd, baseRef, source, title, rationale = '', verification = '', spawnFn = spawnSync } = args
785
+ const range = `${baseRef}...HEAD`
786
+ const changedPaths = git(['diff', '--name-only', range], cwd, spawnFn).stdout.split('\n').filter(Boolean)
787
+ const diff = git(['diff', '--no-ext-diff', '--unified=2', range], cwd, spawnFn).stdout
788
+ return {
789
+ source,
790
+ title,
791
+ rationale,
792
+ verification,
793
+ baseRef,
794
+ commits: git(['log', '--format=%h%x09%s', range], cwd, spawnFn).stdout.split('\n').filter(Boolean),
795
+ changedPaths,
796
+ diffStat: git(['diff', '--stat', range], cwd, spawnFn).stdout.trim(),
797
+ diff: diff.length > PR_DIFF_TEXT_LIMIT ? `${diff.slice(0, PR_DIFF_TEXT_LIMIT)}\n[diff truncated by JS]` : diff
798
+ }
799
+ }
800
+
801
+ /**
802
+ * Формує bounded prompt, який забороняє implementation-changelog і вимагає
803
+ * business/architecture narrative лише з підготовлених JS-фактів.
804
+ * @param {object} facts фінальні Git та behavioral факти
805
+ * @returns {string} промпт
806
+ */
807
+ export function buildPullRequestDescriptionPrompt(facts) {
808
+ return [
809
+ 'Ти формуєш лише зміст PR description за вже зібраними JS-фактами.',
810
+ 'Не запускай команди, не редагуй файли, не створюй PR і не вигадуй відсутній контекст.',
811
+ 'Поясни передусім: навіщо зміна потрібна, який дає продуктово-операційний результат, як змінює responsibilities, boundaries, contracts або data/control flow.',
812
+ 'Не переказуй diff по функціях і рядках. Називай implementation detail лише коли він є важливим architecture contract.',
813
+ 'Не вигадуй клієнтів, фінансові метрики, deployment status або гарантії. Якщо бізнес-контекст обмежений, чесно сформулюй підтверджений operational/developer outcome.',
814
+ 'Кожне твердження обґрунтуй facts; evidencePaths мають бути точними changedPaths.',
815
+ 'Business context разом із businessOutcomes та architectureChanges має бути не коротшим за behaviorChanges разом із risksAndCompatibility.',
816
+ 'Поверни лише JSON object без markdown:',
817
+ '{"businessContext":"...","businessOutcomes":["..."],"architectureChanges":["..."],"behaviorChanges":["..."],"risksAndCompatibility":["..."],"evidencePaths":["path/from/changedPaths"]}',
818
+ JSON.stringify(facts)
819
+ ].join('\n\n')
820
+ }
821
+
822
+ /**
823
+ * Перевіряє структуру, factual anchors і перевагу business/architecture
824
+ * змісту перед дрібними деталями реалізації.
825
+ * @param {{text:string}} outcome LLM output
826
+ * @param {{changedPaths:string[]}} facts фінальні Git-факти
827
+ * @returns {{ok:boolean,error?:string,value?:object}} validation
828
+ */
829
+ export function validatePullRequestDescription(outcome, facts) {
830
+ const description = parseDecisionEnvelope(outcome.text)
831
+ if (!description) return { ok: false, error: 'відсутній JSON object опису PR' }
832
+ if (
833
+ typeof description.businessContext !== 'string' ||
834
+ description.businessContext.trim().length < 40 ||
835
+ description.businessContext.length > 1200 ||
836
+ description.businessContext.includes('\n')
837
+ ) {
838
+ return { ok: false, error: 'businessContext має бути змістовним однорядковим текстом' }
839
+ }
840
+ for (const field of PR_DESCRIPTION_ARRAY_FIELDS) {
841
+ const values = description[field]
842
+ if (
843
+ !Array.isArray(values) ||
844
+ values.length === 0 ||
845
+ values.length > 5 ||
846
+ values.some(
847
+ value => typeof value !== 'string' || value.trim().length < 15 || value.length > 600 || value.includes('\n')
848
+ )
849
+ ) {
850
+ return { ok: false, error: `${field} має містити 1-5 змістовних однорядкових пунктів` }
851
+ }
852
+ }
853
+ const changedPaths = new Set(facts.changedPaths)
854
+ if (
855
+ !Array.isArray(description.evidencePaths) ||
856
+ description.evidencePaths.length === 0 ||
857
+ description.evidencePaths.length > 12 ||
858
+ description.evidencePaths.some(path => typeof path !== 'string' || !changedPaths.has(path))
859
+ ) {
860
+ return { ok: false, error: 'evidencePaths мають бути непорожнім subset фактичних changedPaths' }
861
+ }
862
+ const focusedLength =
863
+ description.businessContext.length +
864
+ description.businessOutcomes.join('').length +
865
+ description.architectureChanges.join('').length
866
+ const detailLength = description.behaviorChanges.join('').length + description.risksAndCompatibility.join('').length
867
+ if (focusedLength < detailLength) {
868
+ return { ok: false, error: 'business та architecture зміст коротший за behavior/risk details' }
869
+ }
870
+ return { ok: true, value: description }
871
+ }
872
+
873
+ /**
874
+ * Форматує validated narrative items як Markdown bullets.
875
+ * @param {string[]} values пункти секції
876
+ * @returns {string} Markdown
877
+ */
878
+ function markdownBullets(values) {
879
+ return values.map(value => `- ${value.trim()}`).join('\n')
880
+ }
881
+
882
+ /**
883
+ * Рендерить стабільний PR body: business та architecture секції видимі
884
+ * першими, а source/evidence залишаються у forensic details.
885
+ * @param {object} args validated description та deterministic facts
886
+ * @param {object} args.description validated narrative
887
+ * @param {object} args.facts deterministic Git facts
888
+ * @returns {string} Markdown PR body
889
+ */
890
+ export function renderPullRequestBody({ description, facts }) {
891
+ const inline = value => String(value).trim().split(WHITESPACE_RE).join(' ')
892
+ const evidence = description.evidencePaths.map(path => `- \`${path.replaceAll('`', '\\`')}\``).join('\n')
893
+ return [
894
+ '## Навіщо',
895
+ '',
896
+ description.businessContext.trim(),
897
+ '',
898
+ '## Бізнес-результат',
899
+ '',
900
+ markdownBullets(description.businessOutcomes),
901
+ '',
902
+ '## Архітектура',
903
+ '',
904
+ markdownBullets(description.architectureChanges),
905
+ '',
906
+ '## Поведінка',
907
+ '',
908
+ markdownBullets(description.behaviorChanges),
909
+ '',
910
+ '## Ризики та сумісність',
911
+ '',
912
+ markdownBullets(description.risksAndCompatibility),
913
+ '',
914
+ '## Перевірки',
915
+ '',
916
+ `- \`git diff --check ${facts.baseRef}...HEAD\``,
917
+ '- scoped code lint/tests та domain lint для non-code paths',
918
+ '- `npx @7n/rules lint changelog --no-fix`',
919
+ ...(facts.verification ? [`- LLM behavioral verification: ${inline(facts.verification).slice(0, 1000)}`] : []),
920
+ '',
921
+ '<details>',
922
+ '<summary>Технічні докази перенесення</summary>',
923
+ '',
924
+ `Джерело: \`${facts.source.replaceAll('`', '\\`')}\`.`,
925
+ '',
926
+ evidence,
927
+ '',
928
+ '</details>'
929
+ ].join('\n')
930
+ }
931
+
932
+ /**
933
+ * Генерує validated PR narrative через min→validation→max над фінальним diff.
934
+ * @param {object} args контекст готового worktree
935
+ * @returns {Promise<string>} deterministic Markdown body
936
+ */
937
+ export async function describePullRequest(args) {
938
+ const { runner, cwd, baseRef, source, title, rationale, verification, deps, spawnFn, log, onProgress = noop } = args
939
+ const collectFacts = deps.collectPullRequestFacts ?? collectPullRequestFacts
940
+ const facts = collectFacts({ cwd, baseRef, source, title, rationale, verification, spawnFn })
941
+ const outcome = await callWithValidatedFallback({
942
+ runner,
943
+ prompt: buildPullRequestDescriptionPrompt(facts),
944
+ cwd,
945
+ deps,
946
+ log,
947
+ label: `PR description ${source}`,
948
+ onAttempt: ({ tier }) => onProgress('PR description', tier),
949
+ validate: result => validatePullRequestDescription(result, facts)
950
+ })
951
+ if (!outcome.ok) throw new Error(`LLM PR description: ${outcome.error}`)
952
+ return renderPullRequestBody({ description: outcome.validation.value, facts })
953
+ }
954
+
770
955
  /**
771
956
  * Перетворює довільний title/ref на branch slug.
772
957
  * @param {string} value title/ref
@@ -1153,8 +1338,9 @@ export async function remediateBehaviorState(
1153
1338
  if (validation.remediation !== 'canonical-fixers') return { attempted: false, ok: false }
1154
1339
 
1155
1340
  const longRunner = resolveAsyncSpawn(spawnFn, asyncSpawnFn)
1156
- const changedDirectories = [...new Set([...changedSourceDirectories(cwd, spawnFn), ...changedNonCodeDirectories(cwd, spawnFn)])]
1157
- .toSorted()
1341
+ const changedDirectories = [
1342
+ ...new Set([...changedSourceDirectories(cwd, spawnFn), ...changedNonCodeDirectories(cwd, spawnFn)])
1343
+ ].toSorted()
1158
1344
  for (const path of changedDirectories) {
1159
1345
  onProgress(`deterministic fix (${path})`)
1160
1346
  const fixed = await runAsync('npx', ['@7n/rules', 'lint', '--path', path], cwd, longRunner, {
@@ -1503,7 +1689,7 @@ export function classifyPullRequestChecks(prChecks, baseChecks) {
1503
1689
  }
1504
1690
 
1505
1691
  /**
1506
- * Видаляє лише rebuildable dependencies зі збереженого forensic worktree.
1692
+ * Видаляє лише відновлювані dependencies зі збереженого forensic worktree.
1507
1693
  * Git metadata, commits і tracked/untracked зміни не зачіпаються.
1508
1694
  * @param {string} worktreeCwd шлях worktree
1509
1695
  * @returns {boolean} чи було що прибрати
@@ -1531,13 +1717,9 @@ export async function verifyPullRequestReadiness(args) {
1531
1717
  allowFailure: true,
1532
1718
  timeoutMs: PR_CHECK_TIMEOUT_MS
1533
1719
  })
1534
- let view = await runAsync(
1535
- 'gh',
1536
- ['pr', 'view', url, '--json', 'statusCheckRollup,baseRefOid'],
1537
- cwd,
1538
- longRunner,
1539
- { allowFailure: true }
1540
- )
1720
+ let view = await runAsync('gh', ['pr', 'view', url, '--json', 'statusCheckRollup,baseRefOid'], cwd, longRunner, {
1721
+ allowFailure: true
1722
+ })
1541
1723
  if (view.status !== 0) {
1542
1724
  return { status: 'pr-checks-unverified', error: `Не вдалося прочитати PR checks: ${view.stderr || view.error}` }
1543
1725
  }
@@ -1548,13 +1730,9 @@ export async function verifyPullRequestReadiness(args) {
1548
1730
  allowFailure: true,
1549
1731
  timeoutMs: PR_CHECK_TIMEOUT_MS
1550
1732
  })
1551
- view = await runAsync(
1552
- 'gh',
1553
- ['pr', 'view', url, '--json', 'statusCheckRollup,baseRefOid'],
1554
- cwd,
1555
- longRunner,
1556
- { allowFailure: true }
1557
- )
1733
+ view = await runAsync('gh', ['pr', 'view', url, '--json', 'statusCheckRollup,baseRefOid'], cwd, longRunner, {
1734
+ allowFailure: true
1735
+ })
1558
1736
  if (view.status !== 0) {
1559
1737
  return {
1560
1738
  status: 'pr-checks-unverified',
@@ -1721,23 +1899,24 @@ async function createPullRequest(args) {
1721
1899
  const baseRef = policyBaseRef(worktree.cwd)
1722
1900
  const baseBranch = readGitPolicy(worktree.cwd).baseBranch
1723
1901
  git(['diff', '--check', `${baseRef}...HEAD`], worktree.cwd, spawnFn)
1902
+ const describePr = deps.describePullRequest ?? describePullRequest
1903
+ const body = await describePr({
1904
+ runner,
1905
+ cwd: worktree.cwd,
1906
+ baseRef,
1907
+ source,
1908
+ title: group.title,
1909
+ rationale: group.rationale ?? candidate.rationale ?? '',
1910
+ verification,
1911
+ deps,
1912
+ spawnFn,
1913
+ log,
1914
+ onProgress
1915
+ })
1724
1916
  onProgress('push')
1725
1917
  git(['push', '-u', 'origin', worktree.branch], worktree.cwd, spawnFn)
1726
1918
 
1727
1919
  onProgress('create PR')
1728
- const body = [
1729
- `Джерело: \`${source}\`.`,
1730
- '',
1731
- group.rationale ?? candidate.rationale ?? `Корисну поведінку перенесено на актуальний ${baseBranch}.`,
1732
- '',
1733
- 'Перевірки:',
1734
- `- \`git diff --check ${baseRef}...HEAD\``,
1735
- '- scoped code lint/tests та domain lint для non-code paths',
1736
- '- `npx @7n/rules lint changelog --no-fix`',
1737
- verification ? `- LLM behavioral verification: ${verification.slice(0, 1000)}` : ''
1738
- ]
1739
- .filter(Boolean)
1740
- .join('\n')
1741
1920
  createdPr = run(
1742
1921
  'gh',
1743
1922
  ['pr', 'create', '--base', baseBranch, '--head', worktree.branch, '--title', group.title, '--body', body],
@@ -2268,12 +2447,7 @@ export async function runGitReconcileOrchestrator(options = {}) {
2268
2447
 
2269
2448
  const report = formatReport({ inventory, results })
2270
2449
  log(report)
2271
- const blockingStatuses = new Set([
2272
- 'failed',
2273
- 'pr-checks-regressed',
2274
- 'pr-checks-baseline-red',
2275
- 'pr-checks-unverified'
2276
- ])
2450
+ const blockingStatuses = new Set(['failed', 'pr-checks-regressed', 'pr-checks-baseline-red', 'pr-checks-unverified'])
2277
2451
  const ok = results.every(result => !blockingStatuses.has(result.status) && result.incomplete !== true)
2278
2452
  return { ok, report, inventory, results }
2279
2453
  }