@7n/rules 1.49.21 → 1.49.23

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.23] - 2026-07-27
4
+
5
+ ### Fixed
6
+
7
+ - Treat reconcile diffs containing only release entries as patch-equivalent
8
+
9
+ ## [1.49.22] - 2026-07-27
10
+
11
+ ### Fixed
12
+
13
+ - Retry initial PR check discovery and prune rebuildable forensic dependencies
14
+
3
15
  ## [1.49.21] - 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.21",
3
+ "version": "1.49.23",
4
4
  "description": "CLI еталонних правил і skills (префікс n-): синк у репозиторій, дельта-lint, конформність",
5
5
  "keywords": [
6
6
  "cli",
@@ -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: f87f22fc
6
+ crc: 69749e90
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  score: 100
9
9
  ---
@@ -20,11 +20,13 @@ 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
- `runAsync` запускає install, tests, lint і PR checks без блокування event loop. Після push та `gh pr create` функція `verifyPullRequestReadiness` очікує terminal checks і передає набори PR/base checks у `classifyPullRequestChecks`. Успішний outcome `pr-created` повертається лише для `ready`; regression, baseline-red, pending, timeout або unreadable checks зберігають branch, URL і worktree та блокують cleanup.
23
+ `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
24
 
25
25
  `commitPendingChanges` створює додатковий commit лише для staged remediation. Чистий index після перенесення branch-source є валідним, коли корисні commits уже присутні в `HEAD`. Порожній PR check rollup вважається непідтвердженим, а не успішним.
26
26
 
27
- `cleanupSource` видаляє лише точний доказово безпечний branch/stash. Cleanup review-source дозволений після `drop` або коли всі його групи завершились `pr-created` чи `patch-equivalent`; `failed` і всі `pr-checks-*` outcomes лишають source та forensic worktree.
27
+ `hasOnlyChangeEntries` відсікає tree diff, у якому лишилися тільки `*/.changes/*.md`: такий результат вважається `patch-equivalent`, не створює PR і дозволяє cleanup source branch.
28
+
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.
28
30
 
29
31
  ## Публічний API
30
32
 
@@ -36,6 +38,8 @@ docgen:
36
38
  - `captureBehaviorBaseline`, `captureCachedBehaviorBaseline` — фіксують і кешують test baseline.
37
39
  - `validateBehaviorState`, `validateFinalProjectGates`, `remediateBehaviorState` — виконують behavioral та canonical gates.
38
40
  - `commitPendingChanges` — комітить лише staged remediation, не вимагаючи порожнього commit для вже перенесених commits.
41
+ - `hasOnlyChangeEntries` — розпізнає release-metadata-only diff, який не потребує PR.
42
+ - `pruneForensicDependencies` — звільняє rebuildable dependencies без втрати Git evidence.
39
43
  - `classifyPullRequestChecks`, `verifyPullRequestReadiness` — класифікують CI відносно base commit.
40
44
  - `formatOutcomeCounts`, `formatReport` — формують точний deterministic summary.
41
45
  - `cleanupSource`, `runGitReconcileOrchestrator` — виконують безпечний cleanup і координують повний flow.
@@ -47,6 +51,8 @@ docgen:
47
51
  - Canonical fixers охоплюють code/non-code scope, після чого gates повторюються без fix.
48
52
  - PR вважається створеним успішно лише після terminal green checks.
49
53
  - Порожній PR check rollup fail-closed зберігає forensic worktree.
54
+ - Forensic worktree зберігає Git evidence без накопичення `node_modules`.
55
+ - Diff лише з `.changes` не створює PR і не блокує cleanup source branch.
50
56
  - CI regression, baseline-red або непідтверджений стан завершують orchestration non-zero і зберігають forensic refs/worktree.
51
57
  - Cleanup не видаляє protected, open-PR, kept, failed або `pr-checks-*` sources.
52
58
  - `spawnSync.error`, зокрема `ENOENT`, зберігається в command diagnostics.
@@ -1,10 +1,11 @@
1
1
  /** @see ./docs/orchestrate.md */
2
2
  import { spawn, spawnSync } from 'node:child_process'
3
3
  import { once } from 'node:events'
4
- import { appendFileSync, existsSync, readFileSync } from 'node:fs'
4
+ import { appendFileSync, existsSync, readFileSync, rmSync } from 'node:fs'
5
5
  import { dirname, isAbsolute, join } from 'node:path'
6
6
  import { performance } from 'node:perf_hooks'
7
7
  import { env } from 'node:process'
8
+ import { setTimeout as delay } from 'node:timers/promises'
8
9
 
9
10
  import { renderProgressLine } from '../../../scripts/lib/lint-surface/progress.mjs'
10
11
  import { readGitPolicy } from '../../../scripts/lib/git-policy.mjs'
@@ -24,6 +25,7 @@ const REF_HEADS_RE = /^refs\/heads\//
24
25
  const REF_ORIGIN_RE = /^refs\/remotes\/origin\//
25
26
  const RENAME_DELETE_CONFLICT_RE = /^CONFLICT \(rename\/delete\): .+? renamed to (.+?) in .+?, but deleted/
26
27
  const SOURCE_CODE_RE = /\.(?:js|mjs|ts|vue|rs|py)$/
28
+ const CHANGE_ENTRY_RE = /(^|\/)\.changes\/[^/]+\.md$/
27
29
  const WHITESPACE_RE = /\s+/
28
30
  const ACP_PROGRESS_ENV = 'N_LLM_ACP_PROGRESS'
29
31
  const REF_INVENTORY_FORMAT = ['%(refname)', '%00', '%(object', 'name)', '%00', '%(committer', 'date:iso-strict)'].join(
@@ -1007,6 +1009,16 @@ export function sourceDirectories(paths) {
1007
1009
  return [...new Set(paths.filter(path => SOURCE_CODE_RE.test(path)).map(path => dirname(path)))].toSorted()
1008
1010
  }
1009
1011
 
1012
+ /**
1013
+ * Визначає технічний залишок, який містить лише release entries. Такі файли
1014
+ * не доводять окремої корисної поведінки й не мають породжувати PR.
1015
+ * @param {string[]} paths змінені шляхи відносно policy base
1016
+ * @returns {boolean} чи всі зміни лежать безпосередньо у `.changes/`
1017
+ */
1018
+ export function hasOnlyChangeEntries(paths) {
1019
+ return paths.length > 0 && paths.every(path => CHANGE_ENTRY_RE.test(path))
1020
+ }
1021
+
1010
1022
  /**
1011
1023
  * Збирає tracked і untracked code directories відносно policy base ref.
1012
1024
  * @param {string} cwd worktree
@@ -1021,6 +1033,40 @@ function changedPaths(cwd, spawnFn) {
1021
1033
  return [...new Set([...tracked, ...untracked])]
1022
1034
  }
1023
1035
 
1036
+ /**
1037
+ * @param {string} cwd worktree
1038
+ * @param {typeof spawnSync} spawnFn інжект
1039
+ * @returns {boolean} чи tree diff складається лише з release entries
1040
+ */
1041
+ function hasOnlyChangeEntriesFromBase(cwd, spawnFn) {
1042
+ return hasOnlyChangeEntries(changedPaths(cwd, spawnFn))
1043
+ }
1044
+
1045
+ /**
1046
+ * Прибирає no-op або change-only worktree до дорогих behavioral/CI gates.
1047
+ * @param {object} args контекст materialized worktree
1048
+ * @returns {{status:string,branch:string,rationale?:string}|null} terminal outcome
1049
+ */
1050
+ function discardPatchEquivalentWorktree(args) {
1051
+ const { worktree, rootCwd, spawnFn, onProgress, validated = false } = args
1052
+ if (!hasChangesFromBase(worktree.cwd, spawnFn)) {
1053
+ onProgress('remove no-op worktree')
1054
+ removeReconcileWorktree(worktree, rootCwd, spawnFn)
1055
+ return { status: 'patch-equivalent', branch: worktree.branch }
1056
+ }
1057
+ if (!hasOnlyChangeEntriesFromBase(worktree.cwd, spawnFn)) return null
1058
+
1059
+ onProgress('remove change-only worktree')
1060
+ removeReconcileWorktree(worktree, rootCwd, spawnFn)
1061
+ return {
1062
+ status: 'patch-equivalent',
1063
+ branch: worktree.branch,
1064
+ rationale: validated
1065
+ ? 'Після Git validation лишилися тільки release entries у .changes/'
1066
+ : 'Після перенесення лишилися тільки release entries у .changes/'
1067
+ }
1068
+ }
1069
+
1024
1070
  /**
1025
1071
  * Повертає директорії зміненого коду.
1026
1072
  * @param {string} cwd worktree
@@ -1456,19 +1502,36 @@ export function classifyPullRequestChecks(prChecks, baseChecks) {
1456
1502
  }
1457
1503
  }
1458
1504
 
1505
+ /**
1506
+ * Видаляє лише rebuildable dependencies зі збереженого forensic worktree.
1507
+ * Git metadata, commits і tracked/untracked зміни не зачіпаються.
1508
+ * @param {string} worktreeCwd шлях worktree
1509
+ * @returns {boolean} чи було що прибрати
1510
+ */
1511
+ export function pruneForensicDependencies(worktreeCwd) {
1512
+ const dependencies = join(worktreeCwd, 'node_modules')
1513
+ if (!existsSync(dependencies)) return false
1514
+ try {
1515
+ rmSync(dependencies, { recursive: true, force: true })
1516
+ return true
1517
+ } catch {
1518
+ return false
1519
+ }
1520
+ }
1521
+
1459
1522
  /**
1460
1523
  * Чекає terminal CI state й порівнює failed checks з base commit.
1461
1524
  * @param {object} args PR context
1462
1525
  * @returns {Promise<{status:string,error?:string}>} readiness
1463
1526
  */
1464
1527
  export async function verifyPullRequestReadiness(args) {
1465
- const { url, cwd, spawnFn = spawnSync, asyncSpawnFn } = args
1528
+ const { url, cwd, spawnFn = spawnSync, asyncSpawnFn, delayFn = delay } = args
1466
1529
  const longRunner = resolveAsyncSpawn(spawnFn, asyncSpawnFn)
1467
1530
  await runAsync('gh', ['pr', 'checks', url, '--watch', '--interval', '10'], cwd, longRunner, {
1468
1531
  allowFailure: true,
1469
1532
  timeoutMs: PR_CHECK_TIMEOUT_MS
1470
1533
  })
1471
- const view = await runAsync(
1534
+ let view = await runAsync(
1472
1535
  'gh',
1473
1536
  ['pr', 'view', url, '--json', 'statusCheckRollup,baseRefOid'],
1474
1537
  cwd,
@@ -1478,7 +1541,28 @@ export async function verifyPullRequestReadiness(args) {
1478
1541
  if (view.status !== 0) {
1479
1542
  return { status: 'pr-checks-unverified', error: `Не вдалося прочитати PR checks: ${view.stderr || view.error}` }
1480
1543
  }
1481
- const pr = parseJson(view.stdout, null)
1544
+ let pr = parseJson(view.stdout, null)
1545
+ if (pr && Array.isArray(pr.statusCheckRollup) && pr.statusCheckRollup.length === 0) {
1546
+ await delayFn(10_000)
1547
+ await runAsync('gh', ['pr', 'checks', url, '--watch', '--interval', '10'], cwd, longRunner, {
1548
+ allowFailure: true,
1549
+ timeoutMs: PR_CHECK_TIMEOUT_MS
1550
+ })
1551
+ view = await runAsync(
1552
+ 'gh',
1553
+ ['pr', 'view', url, '--json', 'statusCheckRollup,baseRefOid'],
1554
+ cwd,
1555
+ longRunner,
1556
+ { allowFailure: true }
1557
+ )
1558
+ if (view.status !== 0) {
1559
+ return {
1560
+ status: 'pr-checks-unverified',
1561
+ error: `Не вдалося повторно прочитати PR checks: ${view.stderr || view.error}`
1562
+ }
1563
+ }
1564
+ pr = parseJson(view.stdout, null)
1565
+ }
1482
1566
  if (!pr || !Array.isArray(pr.statusCheckRollup) || !pr.baseRefOid) {
1483
1567
  return { status: 'pr-checks-unverified', error: 'GitHub повернув неповний PR check rollup' }
1484
1568
  }
@@ -1596,11 +1680,8 @@ async function createPullRequest(args) {
1596
1680
  log,
1597
1681
  onProgress
1598
1682
  })
1599
- if (!hasChangesFromBase(worktree.cwd, spawnFn)) {
1600
- onProgress('remove no-op worktree')
1601
- removeReconcileWorktree(worktree, rootCwd, spawnFn)
1602
- return { status: 'patch-equivalent', branch: worktree.branch }
1603
- }
1683
+ const appliedOutcome = discardPatchEquivalentWorktree({ worktree, rootCwd, spawnFn, onProgress })
1684
+ if (appliedOutcome) return appliedOutcome
1604
1685
  const verification =
1605
1686
  changedSourceDirectories(worktree.cwd, spawnFn).length > 0
1606
1687
  ? await finalizeBehavior({
@@ -1620,11 +1701,14 @@ async function createPullRequest(args) {
1620
1701
  if (unresolved.length > 0) throw new Error(`Нерозв'язані конфлікти: ${unresolved.join(', ')}`)
1621
1702
  git(['diff', '--check'], worktree.cwd, spawnFn)
1622
1703
  git(['add', '-A'], worktree.cwd, spawnFn)
1623
- if (!hasChangesFromBase(worktree.cwd, spawnFn)) {
1624
- onProgress('remove no-op worktree')
1625
- removeReconcileWorktree(worktree, rootCwd, spawnFn)
1626
- return { status: 'patch-equivalent', branch: worktree.branch }
1627
- }
1704
+ const validatedOutcome = discardPatchEquivalentWorktree({
1705
+ worktree,
1706
+ rootCwd,
1707
+ spawnFn,
1708
+ onProgress,
1709
+ validated: true
1710
+ })
1711
+ if (validatedOutcome) return validatedOutcome
1628
1712
  onProgress('delta lint')
1629
1713
  const finalGates = await passFinalProjectGates({
1630
1714
  cwd: worktree.cwd,
@@ -1669,6 +1753,8 @@ async function createPullRequest(args) {
1669
1753
  asyncSpawnFn: deps.asyncSpawnFn
1670
1754
  })
1671
1755
  if (readiness.status !== 'ready') {
1756
+ onProgress('prune forensic dependencies')
1757
+ pruneForensicDependencies(worktree.cwd)
1672
1758
  return {
1673
1759
  status: readiness.status,
1674
1760
  error: readiness.error,
@@ -1682,6 +1768,10 @@ async function createPullRequest(args) {
1682
1768
  return { status: 'pr-created', url: createdPr, branch: worktree.branch }
1683
1769
  } catch (error) {
1684
1770
  const setup = /** @type {{branch?:string,worktree?:string}} */ (error)
1771
+ if (worktree?.cwd) {
1772
+ onProgress('prune forensic dependencies')
1773
+ pruneForensicDependencies(worktree.cwd)
1774
+ }
1685
1775
  return {
1686
1776
  status: 'failed',
1687
1777
  error: error instanceof Error ? error.message : String(error),