@7n/rules 1.49.8 → 1.49.9

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,11 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.49.9] - 2026-07-26
4
+
5
+ ### Changed
6
+
7
+ - Add configurable Git delta policy
8
+
3
9
  ## [1.49.8] - 2026-07-26
4
10
 
5
11
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/rules",
3
- "version": "1.49.8",
3
+ "version": "1.49.9",
4
4
  "description": "CLI еталонних правил і skills (префікс n-): синк у репозиторій, дельта-lint, конформність",
5
5
  "keywords": [
6
6
  "cli",
@@ -15,14 +15,14 @@
15
15
 
16
16
  ### local-only
17
17
 
18
- **npm:** `private: true` або без `files`. **Python:** без пари name+version для реєстру. База залежить від гілки:
18
+ **npm:** `private: true` або без `files`. **Python:** без пари name+version для реєстру. Гілки визначає `.n-rules.json:git`: `baseBranch` (за замовчуванням `main`) і `releaseBranches` (за замовчуванням `["main"]`). На обох значеннях за замовчуванням блок `git` у конфіг не пишуть.
19
19
 
20
- 1. На **`dev`** local-only не активний (крім незакомічених registry-published).
21
- 2. На **`main`** — diff від **`origin/main`** (попередній опублікований `main`); без remote — від `HEAD~1`. **`dev` не використовується** як база на `main`.
22
- 3. На **feature-гілці** — merge-base з **`dev`**, якщо є; інакше з **`main`** (репо без `dev`). За наявності `origin/*` беремо новішу з двох баз (локальна гілка-кандидат vs `origin/`-версія) застарілий локальний `main`/`dev` не має перекривати вже інтегровану в origin історію, і навпаки.
20
+ 1. На **`baseBranch`** local-only не активний (крім незакомічених registry-published).
21
+ 2. На гілці з **`releaseBranches`** — diff від її `origin/<branch>`; без remote — від `HEAD~1`.
22
+ 3. На feature-гілці — merge-base з кандидатами `baseBranch` і `releaseBranches`. Для кожного кандидата локальна та `origin/`-версії порівнюються, береться новіша спільна база; порядок кандидатів детермінований конфігом.
23
23
  4. Drift `version` від бази → **fail** (ручний bump заборонено). Зміни фіксуй change-файлом; bump зробить CI.
24
24
 
25
- Якщо немає git або немає `dev`/`main`/`origin/main` — local-only пропускається.
25
+ Якщо немає git або refs із Git policy — local-only пропускається.
26
26
 
27
27
  Merge-коміт (готовий, з другим предком, або `MERGE_HEAD` під час незавершеного `git commit`) пропускається цілком — changeset документують feature-коміти, а не інтеграційний merge.
28
28
 
@@ -0,0 +1,10 @@
1
+ ---
2
+ type: Directory Index
3
+ title: npm/rules/changelog/consistency
4
+ resource: npm/rules/changelog/consistency/
5
+ ---
6
+
7
+ | Файл | Тип |
8
+ | ----------------------------------------- | --------- |
9
+ | [fix-consistency.mjs](fix-consistency.md) | JS Module |
10
+ | [main.mjs](main.md) | JS Module |
@@ -3,39 +3,31 @@ type: JS Module
3
3
  title: main.mjs
4
4
  resource: npm/rules/changelog/consistency/main.mjs
5
5
  docgen:
6
- crc: 59dac7bd
7
- model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
- score: 75
9
- issues: internal-name:checkPublishedWorkspace,internal-name:runLocalOnlyChecks,anchor-miss:(n-changelog.mdc),judge:inaccurate:0.99
6
+ crc: 40857795
7
+ model: openai-codex/gpt-5.4-mini
8
+ tier: cloud-min
9
+ score: 95
10
+ issues: anchor-miss:(n-changelog.mdc),judge-refine:kept-original,judge:inaccurate:0.99
11
+ judgeModel: openai-codex/gpt-5.4-mini
10
12
  ---
11
13
 
12
14
  ## Огляд
13
15
 
14
- Огляд:
15
- Цей файл гарантує готовність пакетів у монорепозиторії до публікації. Він сканує кореневі директорії, визначає локальні та зареєстровані пакети, використовуючи інформацію з конфігураційного файлу res.json. Далі він порівнює версію кожного пакета з даними, опублікованими на https://pypi.org/pypi/, та виконує локальні перевірки змін за допомогою Git.
16
-
17
- Поведінка:
18
- Ініціалізує процес перевірки, який аналізує кореневі директорії проєктів монорепозиторію. Проходиться по знайдених директоріях, визначаючись пакети з реєстру та локальні. Для кожного пакета, які можуть бути опубліковані в реєстрі, здійснюється перевірка відповідності `CHANGELOG.md` та порівняння версії з опублікованою через звернення до мережі. Якщо пакети не є опублікованими, виконується перевірка локальних змін на основі інформації з Git. Якщо виявлені зміни, і пакет не має файлу `.changes/CHANGELOG.md`, створюється цей файл з дефолтними даними. При виявленні розбіжностей версій виводиться повідомлення про заборону. Усі операції перехоплюють помилки (fail-safe), не генеруючи зовнішніх винятків, і повертають порожнє значення замість них за певних умов. Фінальний код виходу визначається на основі результатів перевірок.
16
+ `lint` перевіряє, чи workspace справді потребує changelog-запису або bump, щоб не реагувати на merge-сценарії, застарілу базу та шум від ігнорованих шляхів. Для published workspace він звіряє локальний стан із уже опублікованою версією. Функція працює fail-safe: перехоплює помилки, не кидає їх назовні та за окремих збоїв повертає порожнє значення замість винятку.
19
17
 
20
18
  ## Поведінка
21
19
 
22
- Поведінка:
23
-
24
- 1. Викликається `main`.
25
- 2. Merge пропускається цілком: і готовий merge-коміт (HEAD із другим предком), і merge У ПРОЦЕСІ (маркер `MERGE_HEAD` під час `git commit` у pre-commit hook, коли `HEAD^2` ще не існує) changeset документують feature-коміти, autofix шумного «Merge…»-changeset не створює.
26
- 3. Знаходяться кореневі директорії проєктів монорепо.
27
- 4. Проходиться по знайдених директоріях, визначаються пакети з реєстру та ті, що є локальними.
28
- 5. База порівняння feature-гілки НОВІШИЙ merge-base серед локальної та `origin/`-версії базової гілки (dev/main): застарілий локальний `main` не видає реліз-коміти, вже інтегровані в origin, за «ручний bump поза CI»; локальний main попереду origin так само валідний; репо без remote працює на локальних гілках.
29
- 6. Для кожного пакета, які можуть бути опубліковані в реєстрі, викликається `checkPublishedWorkspace`.
30
- 7. `checkPublishedWorkspace` перевіряє наявність та відповідність `CHANGELOG.md`, а також порівнює версію пакета з опублікованою версією через мережевий запит до https://pypi.org/pypi/.
31
- 8. Якщо пакети не є опублікованими, викликається `runLocalOnlyChecks` для перевірки змін.
32
- 9. `runLocalOnlyChecks` використовує інформацію з Git для порівняння поточного стану з базою гілки.
33
- 10. Якщо виявлені зміни, і пакет не має файлу `.changes/CHANGELOG.md`, то в режимі автоматичного виправлення створюється цей файл з дефолтними даними.
34
- 11. При виявленні різниці версій, яка свідчить про ручний збій (version вищий, ніж опублікований), виводиться повідомлення про заборону.
35
- 12. `main` повертає код виходу на основі результатів перевірок.
20
+ 1. Перевіряє, чи репозиторій перебуває в merge-сценарії, і якщо так — не вважає це дефектом changelog-дисципліни: merge вже несе задокументовані зміни, тому окремий change-файл тут не потрібен.
21
+ 2. Визначає базу порівняння для changelog-навігації так, щоб брати найновішу з локальної та remote-версії базової гілки; це зменшує хибні спрацювання на застарілому локальному `main`/`dev` і коректно працює, коли remote відсутній.
22
+ 3. Для кожного workspace оцінює, чи є релевантні зміни відносно цієї бази, і відсікає шум від ігнорованих changelog-шляхів та ignore-правил. Це потрібно, щоб перевірка реагувала лише на зміни, які справді потребують bump або change-файлу.
23
+ 4. Для published workspace перевіряє, що changelog присутній і має базовий формат, а також що опублікована версія не відстає від локальної. Якщо версія в дереві випереджає опубліковану це вважається ручним bump поза CI, а не штатним станом.
24
+ 5. Якщо published workspace має релевантні Git-зміни без change-файлу, або фіксує це як помилку, або в autofix-режимі створює change-файл із автоматичним описом і ставить його в індекс, щоб коміт не падав. Для published-пакетів додатково вимагається, щоб `files` у маніфесті містив `CHANGELOG.md`, бо релізний changelog має публікуватися разом із пакетом.
25
+ 6. Для workspace, які змінюються лише локально, звіряє версію з базою порівняння, перевіряє наявність незрелізних change-файлів і, якщо треба, також пропонує або створює change-файл. Повідомлення формуються в стилі, сумісному з правилами ``.
26
+ 7. Резолвить опубліковану версію залежно від типу пакета: для Python орієнтується на PyPI, для інших на npm. Пошук публікації йде через `https://pypi.org/pypi/`, а також через конфігурацію `res.json`, якщо вона визначає очікувану поведінку workspace.
27
+ 8. Працює fail-safe: мережеві чи git-помилки не пробиваються назовні як винятки, а перетворюються на м’який результат або перевірочний фейл, щоб один збій не ламав увесь lint-прогін.
36
28
 
37
29
  ## Гарантії поведінки
38
30
 
39
- - Read-only: не виконує операцій запису (ФС/БД).
31
+ - Власних операцій запису (ФС/БД) у файлі немає; виклики імпортованих модулів можуть писати.
40
32
  - Перехоплює помилки і не пропускає винятків назовні (fail-safe).
41
33
  - За певних помилок повертає порожнє значення (напр. `null`) замість винятку.
@@ -6,6 +6,7 @@ import { join } from 'node:path'
6
6
  import { promisify } from 'node:util'
7
7
 
8
8
  import { createViolationReporter } from '../../../scripts/lib/lint-surface/violation-reporter.mjs'
9
+ import { readGitPolicy } from '../../../scripts/lib/git-policy.mjs'
9
10
  import {
10
11
  getMonorepoProjectRootDirs,
11
12
  manifestFilePath,
@@ -35,12 +36,6 @@ const AUTOFIX_SECTION = 'Changed'
35
36
  /** Fallback-опис, коли subject останнього коміту порожній (напр. порожній репозиторій). */
36
37
  const AUTOFIX_FALLBACK_MESSAGE = 'оновлення'
37
38
 
38
- /** Кандидати інтеграційними тести гілки для feature-гілок (перша наявна; див. n-changelog.mdc). */
39
- const FEATURE_BASE_BRANCH_CANDIDATES = Object.freeze(['dev', 'main'])
40
-
41
- /** Гілка `dev`: local-only не активний (крім незакомічених registry-published). */
42
- const LOCAL_ONLY_SKIP_BRANCH = 'dev'
43
-
44
39
  /**
45
40
  * Префікси шляхів (posix), які не вважаються релізними змінами — інверсія glob (n-changelog.mdc):
46
41
  * документація (`docs/`, `doc/`) та синхронізований із `@7n/rules` інструментарій
@@ -191,28 +186,30 @@ async function resolveMergeBase(baseRef, cwd) {
191
186
  * @returns {Promise<{ ref: string, label: string } | null>} результат
192
187
  */
193
188
  async function resolveChangelogComparisonPoint(branch, cwd) {
194
- if (branch === LOCAL_ONLY_SKIP_BRANCH) {
189
+ const policy = readGitPolicy(cwd)
190
+ if (branch === policy.baseBranch) {
195
191
  return null
196
192
  }
197
193
 
198
- if (branch === 'main') {
199
- const originMainRaw = await gitOrNull(['rev-parse', '--verify', '--quiet', 'origin/main'], cwd)
200
- const originMainSha = originMainRaw?.trim()
194
+ if (policy.releaseBranches.includes(branch ?? '')) {
195
+ const originRef = `origin/${branch}`
196
+ const originRaw = await gitOrNull(['rev-parse', '--verify', '--quiet', originRef], cwd)
197
+ const originSha = originRaw?.trim()
201
198
  const headRaw = await gitOrNull(['rev-parse', 'HEAD'], cwd)
202
199
  const headSha = headRaw?.trim()
203
- if (originMainSha && headSha && (originMainSha === headSha || (await isGitAncestor('origin/main', 'HEAD', cwd)))) {
204
- return { ref: 'origin/main', label: 'main' }
200
+ if (originSha && headSha && (originSha === headSha || (await isGitAncestor(originRef, 'HEAD', cwd)))) {
201
+ return { ref: originRef, label: branch }
205
202
  }
206
203
  const parent = await gitOrNull(['rev-parse', '--verify', '--quiet', 'HEAD~1'], cwd)
207
204
  if (typeof parent === 'string' && parent.trim().length > 0) {
208
- return { ref: parent.trim(), label: 'main~1' }
205
+ return { ref: parent.trim(), label: `${branch}~1` }
209
206
  }
210
207
  return null
211
208
  }
212
209
 
213
210
  // Feature-гілка: база — новіший merge-base серед локальної та origin-версії кандидата
214
211
  // (див. resolveNewestMergeBase; застарілий локальний main не має перекривати origin).
215
- for (const name of FEATURE_BASE_BRANCH_CANDIDATES) {
212
+ for (const name of policy.integrationBranches) {
216
213
  const mergeBase = await resolveNewestMergeBase(name, cwd)
217
214
  if (!mergeBase) {
218
215
  continue
@@ -591,7 +588,8 @@ async function checkPublishedWorkspacePendingGitChanges(manifest, _Vcurrent, sub
591
588
 
592
589
  const branch = await currentBranchName(cwd)
593
590
 
594
- if (branch === LOCAL_ONLY_SKIP_BRANCH) {
591
+ const policy = readGitPolicy(cwd)
592
+ if (branch === policy.baseBranch) {
595
593
  if (await workspaceHasRelevantChangesAgainstBase('HEAD', manifest.ws, subWorkspaces, cwd)) {
596
594
  await fixOrFailPublishedWorkspace(manifest, label, mf, autofix, pass, fail, cwd)
597
595
  }
@@ -604,7 +602,7 @@ async function checkPublishedWorkspacePendingGitChanges(manifest, _Vcurrent, sub
604
602
  return
605
603
  }
606
604
 
607
- if (branch === 'main' && (await workspaceHasRelevantChangesAgainstBase('HEAD', manifest.ws, subWorkspaces, cwd))) {
605
+ if (policy.releaseBranches.includes(branch ?? '') && (await workspaceHasRelevantChangesAgainstBase('HEAD', manifest.ws, subWorkspaces, cwd))) {
608
606
  await fixOrFailPublishedWorkspace(manifest, label, mf, autofix, pass, fail, cwd)
609
607
  }
610
608
  }
@@ -732,13 +730,14 @@ async function runLocalOnlyChecks(localOnly, subWorkspaces, autofix, pass, fail,
732
730
  return
733
731
  }
734
732
  const branch = await currentBranchName(cwd)
735
- if (branch === LOCAL_ONLY_SKIP_BRANCH) {
736
- pass('changelog: поточна гілка = dev — local-only перевірку пропущено')
733
+ const policy = readGitPolicy(cwd)
734
+ if (branch === policy.baseBranch) {
735
+ pass(`changelog: поточна гілка = ${policy.baseBranch} — local-only перевірку пропущено`)
737
736
  return
738
737
  }
739
738
  const comparison = await resolveChangelogComparisonPoint(branch, cwd)
740
739
  if (!comparison) {
741
- pass('changelog: ref dev/main (та origin/*) не знайдено — local-only перевірку пропущено')
740
+ pass('changelog: Git policy refs (та origin/*) не знайдено — local-only перевірку пропущено')
742
741
  return
743
742
  }
744
743
 
@@ -59,6 +59,24 @@
59
59
  "minLength": 1
60
60
  }
61
61
  },
62
+ "git": {
63
+ "type": "object",
64
+ "additionalProperties": false,
65
+ "description": "Git policy проєкту. Відсутній блок еквівалентний baseBranch/releaseBranches = main.",
66
+ "properties": {
67
+ "baseBranch": {
68
+ "type": "string",
69
+ "minLength": 1,
70
+ "description": "Головна integration-гілка і перший кандидат бази delta; за замовчуванням main."
71
+ },
72
+ "releaseBranches": {
73
+ "type": "array",
74
+ "minItems": 1,
75
+ "description": "Довгоживучі release/integration гілки; разом з baseBranch утворюють delta та protected набір. За замовчуванням [main].",
76
+ "items": { "type": "string", "minLength": 1 }
77
+ }
78
+ }
79
+ },
62
80
  "version": {
63
81
  "type": "string",
64
82
  "description": "Застаріле поле, ігнорується CLI. Правила завжди копіюються з каталогу mdc/ установленого пакету (node_modules або кеш npx); змініть версію через оновлення залежності."
@@ -8,6 +8,7 @@
8
8
  import { spawnSync } from 'node:child_process'
9
9
 
10
10
  import { isWorktreeCheckoutPath } from '../utils/walkDir.mjs'
11
+ import { readGitPolicy } from './git-policy.mjs'
11
12
 
12
13
  /**
13
14
  * @param {string[]} args аргументи git
@@ -47,18 +48,16 @@ function dropWorktreeCheckouts(paths) {
47
48
 
48
49
  /**
49
50
  * Визначає git base для scoped-перевірок без зовнішнього runtime-стану.
50
- * Кандидати — `origin/main` і локальна `main`: рахуємо merge-base з HEAD для обох
51
- * і беремо **новіший** (descendant) з двох. Це захищає від stale-ref з будь-якого
52
- * боку: у git-worktree локальна `main` закріплена за іншим деревом і часто відстає
53
- * (застаріла база фантомні «змінені» файли з давно влитих PR), а без свіжого
54
- * fetch може відставати вже `origin/main`. Якщо доступний лише один ref (офлайн,
55
- * без remote) — його merge-base; якщо жодного — null, і caller порівнює лише
51
+ * Кандидати — effective Git policy: `baseBranch` + `releaseBranches`, кожна у
52
+ * `origin/` та локальній формах. Беремо **найновіший** сумісний merge-base; це
53
+ * захищає від stale-ref і вже інтегрованих змін між довгоживучими середовищами.
54
+ * Якщо жодного ref немає null, і caller порівнює лише
56
55
  * робоче дерево з HEAD. Повернений sha завжди досяжний (це merge-base існуючого
57
56
  * ref), тож fail-closed перевірка в `collectChangedFilesSince` не спрацює хибно.
58
- * Явний `baseRef` (CI: `--base origin/main` після fetch) вимикає вибір —
57
+ * Явний `baseRef` (CI: `--base origin/dev` після fetch) вимикає вибір —
59
58
  * merge-base рахується лише проти нього.
60
59
  * @param {string} [cwd] корінь репо
61
- * @param {string|null} [baseRef] явний ref бази замість вибору origin/main|main
60
+ * @param {string|null} [baseRef] явний ref бази замість Git policy
62
61
  * @returns {string|null} merge-base commit або null
63
62
  */
64
63
  export function resolveChangedBase(cwd = process.cwd(), baseRef = null) {
@@ -67,12 +66,16 @@ export function resolveChangedBase(cwd = process.cwd(), baseRef = null) {
67
66
  return result.status === 0 && !result.error ? result.stdout.trim() : ''
68
67
  }
69
68
  if (baseRef) return mergeBaseWith(baseRef) || null
70
- const [primary, secondary] = ['origin/main', 'main'].map(ref => mergeBaseWith(ref)).filter(Boolean)
71
- if (!primary || !secondary || primary === secondary) return primary ?? null
72
- // Обидва ref-и дали різні merge-base: новіший — той, що є нащадком іншого.
73
- // Якщо гілки merge-base розійшлися (екзотика), лишаємо пріоритет origin/main.
74
- const ancestry = spawnSync('git', ['merge-base', '--is-ancestor', primary, secondary], { cwd })
75
- return ancestry.status === 0 && !ancestry.error ? secondary : primary
69
+ const { integrationBranches } = readGitPolicy(cwd)
70
+ const bases = integrationBranches.flatMap(name => [`origin/${name}`, name]).map(mergeBaseWith).filter(Boolean)
71
+ if (bases.length === 0) return null
72
+ let newest = bases[0]
73
+ for (const candidate of bases.slice(1)) {
74
+ if (candidate === newest) continue
75
+ const ancestry = spawnSync('git', ['merge-base', '--is-ancestor', newest, candidate], { cwd })
76
+ if (ancestry.status === 0 && !ancestry.error) newest = candidate
77
+ }
78
+ return newest
76
79
  }
77
80
 
78
81
  /**
@@ -3,33 +3,50 @@ type: JS Module
3
3
  title: changed-files.mjs
4
4
  resource: npm/scripts/lib/changed-files.mjs
5
5
  docgen:
6
- crc: 1c7d9027
6
+ crc: 0482c036
7
+ model: openai-codex/gpt-5.5
8
+ tier: cloud-avg
7
9
  score: 100
10
+ issues: judge-refine:kept-original,judge:inaccurate:0.98
11
+ judgeModel: openai-codex/gpt-5.4-mini
8
12
  ---
9
13
 
10
- Файл збирає змінені та незакомічені файли з робочого дерева. Визначає базовий комміт для перевірок у межах зміненого діапазону. Збирає змінені та незакомічені файли відносно базового комміту.
14
+ ## Огляд
15
+
16
+ Файл збирає перелік файлів для quick-режиму lint-оркестратора: змінені та staged через `git diff HEAD`, а також нові untracked через `git ls-files --others --exclude-standard`. Видалені файли не потрапляють у результат.
17
+
18
+ `collectChangedFiles`, `resolveChangedBase` і `collectChangedFilesSince` потрібні, щоб визначати базу порівняння та повертати лише актуально змінені робочі файли для перевірки. Поза git-репо або при помилці git повертається порожній список.
11
19
 
12
20
  ## Поведінка
13
21
 
14
- collectChangedFiles Збирає змінені та незакомічені файли з робочого дерева.
15
- resolveChangedBase Визначає базовий комміт для scoped-перевірок.
16
- collectChangedFilesSince Збирає змінені та незакомічені файли відносно базового комміту.
22
+ `collectChangedFiles` формує scope quick-перевірки з поточного робочого дерева: бере змінені tracked/staged файли відносно `HEAD`, додає нові untracked файли та повертає унікальні relative-posix шляхи без видалених файлів.
23
+
24
+ `resolveChangedBase` потрібна перед scoped-перевірками, коли scope має рахуватися не лише від `HEAD`, а від інтеграційної бази. Вона визначає досяжний merge-base для поточної гілки: або за явно заданим base ref, або за політикою git-гілок репозиторію. Якщо придатної бази немає, результатом є відсутність бази, і подальший збір змін має перейти до quick-поведінки.
25
+
26
+ `collectChangedFilesSince` споживає базу з `resolveChangedBase` або її відсутність. За наявної бази вона збирає всі файли, змінені від цієї бази до поточного робочого дерева, включно із закоміченими після бази, staged і незакоміченими змінами, та додає untracked файли. За відсутньої бази делегує збір до `collectChangedFiles`.
27
+
28
+ Усі результати є списками унікальних шляхів для наступних lint-етапів. Для обох режимів діє спільне правило: видалені файли не потрапляють у scope, а файли всередині службових worktree-чекаутів відкидаються, щоб сесійні копії репозиторію не лінтилися як робочий код. Поза git-репозиторієм або при помилці git звичайний quick-збір повертає порожній список, але scoped-збір із недосяжною базою завершується явною помилкою, щоб перевірка не пройшла з порожнім scope помилково.
17
29
 
18
30
  ## Публічний API
19
31
 
20
- - collectChangedFiles: Збирає список змінених та невідстежених файлів з робочого дерева відносно робочого дерева.
21
- - resolveChangedBase: Визначає базовий комміт для обмежених перевірок без використання зовнішнього стану.
22
- - Кандидати — `origin/main` і локальна `main`: merge-base з HEAD рахується для обох, береться новіший (descendant). Це захищає від stale-ref: у git-worktree локальна `main` часто відстає, без свіжого fetch може відставати `origin/main`.
23
- - Явний другий аргумент `baseRef` (CI: `--base origin/main`) вимикає вибір merge-base рахується лише проти нього.
24
- - Якщо доступний лише один ref (офлайн/без remote) — його merge-base; якщо ref відсутні, повертає null, і викликаючий порівнює лише робоче дерево з HEAD.
25
- - collectChangedFilesSince: Збирає список змінених та невідстежених файлів відносно базового комміту.
26
- - git diff <base>: Порівнює комміт `base` з поточним робочим деревом (включаючи закомічені та незакомічені модифікації).
27
- - Якщо `base` відсутній, використовується `collectChangedFiles`.
32
+ - collectChangedFiles Relative-posix список змінених + untracked файлів робочого дерева.
33
+ - resolveChangedBase Визначає git base для scoped-перевірок без зовнішнього runtime-стану.
34
+ Кандидати — effective Git policy: `baseBranch` + `releaseBranches`, кожна у
35
+ `origin/` та локальній формах. Беремо **найновіший** сумісний merge-base; це
36
+ захищає від stale-ref і вже інтегрованих змін між довгоживучими середовищами.
37
+ Якщо жодного ref немає null, і caller порівнює лише
38
+ робоче дерево з HEAD. Повернений sha завжди досяжний (це merge-base існуючого
39
+ ref), тож fail-closed перевірка в `collectChangedFilesSince` не спрацює хибно.
40
+ Явний `baseRef` (CI: `--base origin/dev` після fetch) вимикає вибір —
41
+ merge-base рахується лише проти нього.
42
+ - collectChangedFilesSince — Список змінених + untracked файлів **відносно базового комміту**.
43
+
44
+ `git diff <base>` (без `..`/`...`, без `HEAD`) порівнює base-комміт із поточним
45
+ **робочим деревом** — тобто однаково ловить і закомічене від base, і staged, і
46
+ незакомічені модифікації. Це гарантує однакову поведінку незалежно від того, чи
47
+ зміни вже закомічені у worktree. Без `base` — fallback на `collectChangedFiles`
48
+ (робоче дерево vs HEAD).
28
49
 
29
50
  ## Гарантії поведінки
30
51
 
31
- - Обидва збирачі відкидають шляхи всередині worktree-чекаутів (`.worktrees/`, `.claude/worktrees/`): це повні копії репо (сесійні git-worktree Claude/агентів), а не робочий код; у споживацьких репо вони можуть бути не gitignored і інакше потрапляли б у delta-список як untracked.
32
-
33
- - Read-only: файл не виконує операцій запису у файлову систему.
34
- - За невдачі повертає значення помилки (`false`/`null`/`Err`) замість генерування винятку чи паніки.
35
- - Не звертається до мережі.
52
+ - (специфічних машинно-виведених гарантій немає)
@@ -0,0 +1,34 @@
1
+ ---
2
+ type: JS Module
3
+ title: git-policy.mjs
4
+ resource: npm/scripts/lib/git-policy.mjs
5
+ docgen:
6
+ crc: 5809ac19
7
+ model: openai-codex/gpt-5.5
8
+ tier: cloud-avg
9
+ score: 100
10
+ judgeModel: openai-codex/gpt-5.4-mini
11
+ ---
12
+
13
+ ## Огляд
14
+
15
+ `readGitPolicy` читає Git policy проєкту з `.n-rules.json` або `.n-cursor.json`, щоб delta-перевірки, changelog і branch tooling користувалися спільним набором базової, release, integration і protected гілок. Якщо політика відсутня, неповна або непридатна, повертає історичний канон `main`, не зупиняючи роботу інструментів. Помилки перехоплюються і не кидаються назовні.
16
+
17
+ ## Поведінка
18
+
19
+ 1. `readGitPolicy` шукає компактну політику Git у конфігах `.n-rules.json`, `.n-cursor.json`, щоб узгодити базову, release, integration і protected гілки для delta-перевірок, changelog і branch tooling.
20
+ 2. Якщо конфіг відсутній, неповний або не може бути прочитаний як валідні дані, функція безпечно повертається до історичного канону `main` і не перериває роботу інструментів винятком.
21
+ 3. Базова гілка береться з політики лише тоді, коли вона задана змістовним іменем; інакше використовується `main`.
22
+ 4. Release-гілки беруться з політики як набір змістовних унікальних назв; якщо придатних значень немає, release-набором стає `main`.
23
+ 5. Integration-гілки формуються як об’єднання базової гілки та release-гілок без дублювання, щоб downstream-інструменти мали єдине джерело правди для порівнянь.
24
+ 6. Protected-гілки збігаються з integration-гілками, бо ці довгоживучі гілки мають однаково враховуватися як цільові й захищені.
25
+
26
+ ## Публічний API
27
+
28
+ - readGitPolicy — Повертає effective policy. `releaseBranches` одночасно є integration-кандидатами
29
+ delta та захищеними довгоживучими гілками; `baseBranch` додається до обох наборів.
30
+
31
+ ## Гарантії поведінки
32
+
33
+ - Власних операцій запису (ФС/БД) у файлі немає; виклики імпортованих модулів можуть писати.
34
+ - Перехоплює помилки і не пропускає винятків назовні (fail-safe).
@@ -20,6 +20,7 @@ resource: npm/scripts/lib/
20
20
  | [ensure-tool.mjs](ensure-tool.md) | JS Module |
21
21
  | [generated-markdown.mjs](generated-markdown.md) | JS Module |
22
22
  | [gha-workflow.mjs](gha-workflow.md) | JS Module |
23
+ | [git-policy.mjs](git-policy.md) | JS Module |
23
24
  | [glob-to-regex.mjs](glob-to-regex.md) | JS Module |
24
25
  | [inline-template-links.mjs](inline-template-links.md) | JS Module |
25
26
  | [js-source-signals.mjs](js-source-signals.md) | JS Module |
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Читає компактну Git policy проєкту для delta, changelog і branch tooling.
3
+ * Відсутній або неповний конфіг зберігає історичний канон `main`.
4
+ */
5
+ import { existsSync, readFileSync } from 'node:fs'
6
+ import { join } from 'node:path'
7
+
8
+ const CONFIG_FILES = ['.n-rules.json', '.n-cursor.json']
9
+ const DEFAULT_BASE_BRANCH = 'main'
10
+
11
+ /**
12
+ * @param {unknown} value потенційне ім'я гілки
13
+ * @returns {string|null} нормалізоване ім'я або null
14
+ */
15
+ function branchName(value) {
16
+ if (typeof value !== 'string') return null
17
+ const name = value.trim()
18
+ return name.length > 0 ? name : null
19
+ }
20
+
21
+ /**
22
+ * Повертає effective policy. `releaseBranches` одночасно є integration-кандидатами
23
+ * delta та захищеними довгоживучими гілками; `baseBranch` додається до обох наборів.
24
+ * @param {string} [cwd] корінь репозиторію
25
+ * @returns {{baseBranch:string,releaseBranches:string[],integrationBranches:string[],protectedBranches:string[]}}
26
+ */
27
+ export function readGitPolicy(cwd = process.cwd()) {
28
+ let raw = null
29
+ for (const file of CONFIG_FILES) {
30
+ const path = join(cwd, file)
31
+ if (!existsSync(path)) continue
32
+ try {
33
+ raw = JSON.parse(readFileSync(path, 'utf8'))
34
+ } catch {
35
+ // Повний CLI повідомить про невалідний JSON; standalone helpers лишаються safe-by-default.
36
+ }
37
+ break
38
+ }
39
+ const git = raw && typeof raw.git === 'object' && !Array.isArray(raw.git) ? raw.git : {}
40
+ const baseBranch = branchName(git.baseBranch) ?? DEFAULT_BASE_BRANCH
41
+ const configuredRelease = Array.isArray(git.releaseBranches) ? git.releaseBranches.map(branchName).filter(Boolean) : []
42
+ const releaseBranches = configuredRelease.length > 0 ? [...new Set(configuredRelease)] : [DEFAULT_BASE_BRANCH]
43
+ const integrationBranches = [...new Set([baseBranch, ...releaseBranches])]
44
+ return { baseBranch, releaseBranches, integrationBranches, protectedBranches: integrationBranches }
45
+ }
@@ -6,8 +6,8 @@ import { performance } from 'node:perf_hooks'
6
6
  import { env } from 'node:process'
7
7
 
8
8
  import { createProgressReporter } from '../../../scripts/lib/lint-surface/progress.mjs'
9
+ import { readGitPolicy } from '../../../scripts/lib/git-policy.mjs'
9
10
 
10
- const BASE_REF = 'origin/main'
11
11
  const LLM_TIERS = ['min', 'max']
12
12
  const REVIEW_BATCH_SIZE = 10
13
13
  const PROMPT_TEXT_LIMIT = 12_000
@@ -30,6 +30,11 @@ function noop() {
30
30
  // Навмисно порожньо: caller не запросив progress output.
31
31
  }
32
32
 
33
+ /** @param {string} cwd корінь репозиторію @returns {string} remote ref базової гілки */
34
+ function policyBaseRef(cwd) {
35
+ return `origin/${readGitPolicy(cwd).baseBranch}`
36
+ }
37
+
33
38
  /**
34
39
  * Форматує elapsed time без удаваної точності.
35
40
  * @param {number} startedAt початок у мілісекундах
@@ -178,10 +183,10 @@ function branchName(ref) {
178
183
  * @param {Map<string,string>} worktreeCommits checkout HEAD OID→path
179
184
  * @returns {Array<{ref:string, oid:string, date:string, worktree:string|null,aliases:string[]}>} refs
180
185
  */
181
- export function dedupeRefs(refs, worktrees, worktreeCommits = new Map()) {
186
+ export function dedupeRefs(refs, worktrees, worktreeCommits = new Map(), protectedBranches = ['main']) {
182
187
  const byOid = new Map()
183
188
  for (const item of refs) {
184
- if (item.ref === 'refs/remotes/origin/HEAD' || branchName(item.ref) === 'main') continue
189
+ if (item.ref === 'refs/remotes/origin/HEAD' || protectedBranches.includes(branchName(item.ref))) continue
185
190
  const existing = byOid.get(item.oid)
186
191
  const worktree = worktrees.get(item.ref) ?? worktreeCommits.get(item.oid) ?? existing?.worktree ?? null
187
192
  const isRemote = item.ref.startsWith('refs/remotes/origin/')
@@ -282,8 +287,10 @@ export function conflictFiles(text) {
282
287
  */
283
288
  export function inventoryRepository(cwd, deps = {}) {
284
289
  const spawnFn = deps.spawnFn ?? spawnSync
290
+ const policy = readGitPolicy(cwd)
291
+ const baseRef = policyBaseRef(cwd)
285
292
  git(['fetch', '--prune', 'origin'], cwd, spawnFn)
286
- git(['rev-parse', '--verify', BASE_REF], cwd, spawnFn)
293
+ git(['rev-parse', '--verify', baseRef], cwd, spawnFn)
287
294
 
288
295
  const worktreeState = parseWorktreeState(git(['worktree', 'list', '--porcelain'], cwd, spawnFn).stdout)
289
296
  const refLines = git(
@@ -299,7 +306,8 @@ export function inventoryRepository(cwd, deps = {}) {
299
306
  return { ref, oid, date }
300
307
  }),
301
308
  worktreeState.branches,
302
- worktreeState.commits
309
+ worktreeState.commits,
310
+ policy.protectedBranches
303
311
  )
304
312
  const prInventory = openPullRequests(cwd, spawnFn)
305
313
  const prs = prInventory.items
@@ -308,16 +316,16 @@ export function inventoryRepository(cwd, deps = {}) {
308
316
  const branches = refs.map(item => {
309
317
  const name = branchName(item.ref)
310
318
  const merged =
311
- git(['merge-base', '--is-ancestor', item.ref, BASE_REF], cwd, spawnFn, {
319
+ git(['merge-base', '--is-ancestor', item.ref, baseRef], cwd, spawnFn, {
312
320
  allowFailure: true
313
321
  }).status === 0
314
322
  const novelCommitIds = merged
315
323
  ? []
316
- : git(['rev-list', '--right-only', '--cherry-pick', '--no-merges', `${BASE_REF}...${item.ref}`], cwd, spawnFn)
324
+ : git(['rev-list', '--right-only', '--cherry-pick', '--no-merges', `${baseRef}...${item.ref}`], cwd, spawnFn)
317
325
  .stdout.split('\n')
318
326
  .filter(Boolean)
319
327
  .toReversed()
320
- const counts = git(['rev-list', '--left-right', '--count', `${BASE_REF}...${item.ref}`], cwd, spawnFn)
328
+ const counts = git(['rev-list', '--left-right', '--count', `${baseRef}...${item.ref}`], cwd, spawnFn)
321
329
  .stdout.trim()
322
330
  .split(WHITESPACE_RE)
323
331
  .map(Number)
@@ -325,13 +333,13 @@ export function inventoryRepository(cwd, deps = {}) {
325
333
  const state = branchState({ merged, novelCommitIds, pr, worktree: item.worktree })
326
334
  const changedFiles =
327
335
  state === 'review'
328
- ? git(['diff', '--name-status', `${BASE_REF}...${item.ref}`], cwd, spawnFn)
336
+ ? git(['diff', '--name-status', `${baseRef}...${item.ref}`], cwd, spawnFn)
329
337
  .stdout.split('\n')
330
338
  .filter(Boolean)
331
339
  .slice(0, 200)
332
340
  : []
333
341
  const mergeTree =
334
- state === 'review' ? git(['merge-tree', BASE_REF, item.ref], cwd, spawnFn, { allowFailure: true }).stdout : ''
342
+ state === 'review' ? git(['merge-tree', baseRef, item.ref], cwd, spawnFn, { allowFailure: true }).stdout : ''
335
343
  return {
336
344
  source: `${SOURCE_BRANCH_PREFIX}${item.ref}`,
337
345
  ref: item.ref,
@@ -364,7 +372,7 @@ export function inventoryRepository(cwd, deps = {}) {
364
372
  }
365
373
  })
366
374
 
367
- return { base: BASE_REF, branches, stashes, warnings }
375
+ return { base: baseRef, baseBranch: policy.baseBranch, branches, stashes, warnings }
368
376
  }
369
377
 
370
378
  /**
@@ -629,7 +637,7 @@ function chooseBranch(title, cwd, spawnFn) {
629
637
 
630
638
  /**
631
639
  * Створює керований worktree та детерміновано пересаджує його branch на
632
- * origin/main без зміни вихідного checkout.
640
+ * policy base ref без зміни вихідного checkout.
633
641
  * @param {string} title PR title
634
642
  * @param {string} source source id
635
643
  * @param {string} cwd корінь
@@ -637,11 +645,12 @@ function chooseBranch(title, cwd, spawnFn) {
637
645
  * @returns {{branch:string,cwd:string}} worktree
638
646
  */
639
647
  function createReconcileWorktree(title, source, cwd, spawnFn) {
648
+ const baseRef = policyBaseRef(cwd)
640
649
  const branch = chooseBranch(title, cwd, spawnFn)
641
650
  run('npx', ['@7n/mt', 'worktree', 'create', branch, `git-reconcile: ${source}`], cwd, spawnFn)
642
651
  const worktreeCwd = join(cwd, '.worktrees', branch.replaceAll('/', '-'))
643
- git(['switch', '--detach', BASE_REF], worktreeCwd, spawnFn)
644
- git(['branch', '-f', branch, BASE_REF], worktreeCwd, spawnFn)
652
+ git(['switch', '--detach', baseRef], worktreeCwd, spawnFn)
653
+ git(['branch', '-f', branch, baseRef], worktreeCwd, spawnFn)
645
654
  git(['switch', branch], worktreeCwd, spawnFn)
646
655
  return { branch, cwd: worktreeCwd }
647
656
  }
@@ -718,11 +727,12 @@ export function finishCherryPick(cwd, spawnFn = spawnSync) {
718
727
  * @returns {boolean} чи є що переносити в PR
719
728
  */
720
729
  export function hasChangesFromBase(cwd, spawnFn = spawnSync) {
730
+ const baseRef = policyBaseRef(cwd)
721
731
  return (
722
- git(['diff', '--quiet', `${BASE_REF}...HEAD`], cwd, spawnFn, {
732
+ git(['diff', '--quiet', `${baseRef}...HEAD`], cwd, spawnFn, {
723
733
  allowFailure: true
724
734
  }).status !== 0 ||
725
- git(['diff', '--quiet', BASE_REF, '--'], cwd, spawnFn, {
735
+ git(['diff', '--quiet', baseRef, '--'], cwd, spawnFn, {
726
736
  allowFailure: true
727
737
  }).status !== 0 ||
728
738
  git(['ls-files', '--others', '--exclude-standard'], cwd, spawnFn).stdout.trim().length > 0
@@ -795,13 +805,13 @@ export function sourceDirectories(paths) {
795
805
  }
796
806
 
797
807
  /**
798
- * Збирає tracked і untracked code directories відносно origin/main.
808
+ * Збирає tracked і untracked code directories відносно policy base ref.
799
809
  * @param {string} cwd worktree
800
810
  * @param {typeof spawnSync} spawnFn інжект
801
811
  * @returns {string[]} directories
802
812
  */
803
813
  function changedPaths(cwd, spawnFn) {
804
- const tracked = git(['diff', '--name-only', BASE_REF, '--'], cwd, spawnFn).stdout.split('\n').filter(Boolean)
814
+ const tracked = git(['diff', '--name-only', policyBaseRef(cwd), '--'], cwd, spawnFn).stdout.split('\n').filter(Boolean)
805
815
  const untracked = git(['ls-files', '--others', '--exclude-standard'], cwd, spawnFn).stdout.split('\n').filter(Boolean)
806
816
  return [...new Set([...tracked, ...untracked])]
807
817
  }
@@ -874,7 +884,7 @@ function ensureWorktreeDependencies(cwd, spawnFn) {
874
884
  }
875
885
 
876
886
  /**
877
- * Фіксує test baseline на чистому origin/main до перенесення source.
887
+ * Фіксує test baseline на чистій policy base гілці до перенесення source.
878
888
  * @param {string} cwd worktree
879
889
  * @param {typeof spawnSync} spawnFn інжект
880
890
  * @returns {{tests:{status:number,stdout:string,stderr:string}|null}} baseline
@@ -889,7 +899,7 @@ export function captureBehaviorBaseline(cwd, spawnFn = spawnSync) {
889
899
  }
890
900
 
891
901
  /**
892
- * Повторно використовує test baseline одного origin/main між PR-групами.
902
+ * Повторно використовує test baseline однієї policy base гілки між PR-групами.
893
903
  * Залежності все одно встановлюються в кожному окремому worktree.
894
904
  * @param {string} cwd worktree
895
905
  * @param {Map<string,object>} cache кеш за OID бази
@@ -898,7 +908,7 @@ export function captureBehaviorBaseline(cwd, spawnFn = spawnSync) {
898
908
  */
899
909
  export function captureCachedBehaviorBaseline(cwd, cache, spawnFn = spawnSync) {
900
910
  ensureWorktreeDependencies(cwd, spawnFn)
901
- const baseOid = git(['rev-parse', BASE_REF], cwd, spawnFn).stdout.trim()
911
+ const baseOid = git(['rev-parse', policyBaseRef(cwd)], cwd, spawnFn).stdout.trim()
902
912
  if (cache.has(baseOid)) return { baseline: cache.get(baseOid), cached: true }
903
913
  const baseline = captureBehaviorBaseline(cwd, spawnFn)
904
914
  cache.set(baseOid, baseline)
@@ -910,7 +920,7 @@ export function captureCachedBehaviorBaseline(cwd, cache, spawnFn = spawnSync) {
910
920
  * Саме ці докази вирішують, чи приймати min-результат або ескалювати на max.
911
921
  * @param {string} cwd worktree
912
922
  * @param {typeof spawnSync} spawnFn інжект
913
- * @param {{tests:{status:number,stdout:string,stderr:string}|null}|null} [baseline] стан origin/main
923
+ * @param {{tests:{status:number,stdout:string,stderr:string}|null}|null} [baseline] стан policy base гілки
914
924
  * @param {(stage:string)=>void} [onProgress] stage callback
915
925
  * @returns {{ok:boolean,error?:string}} validation
916
926
  */
@@ -972,7 +982,7 @@ async function resolveConflict(args) {
972
982
  const { runner, source, worktreeCwd, deps, spawnFn, log, onProgress = noop } = args
973
983
  const unresolved = unresolvedFiles(worktreeCwd, spawnFn)
974
984
  const prompt = [
975
- `У worktree ${worktreeCwd} JS уже застосував ${source} до свіжого origin/main.`,
985
+ `У worktree ${worktreeCwd} JS уже застосував ${source} до свіжої policy base гілки.`,
976
986
  `Розв'яжи лише змістові конфлікти: ${unresolved.join(', ')}.`,
977
987
  'Порівняй current main та намір перенесеної зміни; не використовуй ours/theirs механічно.',
978
988
  'Збережи актуальну поведінку main, перенеси лише відсутню корисну частину.',
@@ -1036,7 +1046,7 @@ async function applySource(args) {
1036
1046
  async function finalizeBehavior(args) {
1037
1047
  const { runner, source, rationale, worktreeCwd, baseline, deps, spawnFn, log, onProgress = noop } = args
1038
1048
  const prompt = [
1039
- `JS переніс ${source} на свіжий origin/main у ${worktreeCwd}.`,
1049
+ `JS переніс ${source} на свіжу policy base гілку у ${worktreeCwd}.`,
1040
1050
  `Очікувана користь: ${rationale}`,
1041
1051
  'Перевір реальний diff і call sites. Доведи лише перенесену поведінку до готовності:',
1042
1052
  '- додай/онови regression test, якщо це bug fix;',
@@ -1140,7 +1150,9 @@ async function createPullRequest(args) {
1140
1150
  onProgress('delta lint')
1141
1151
  const finalGates = validateFinalProjectGates(worktree.cwd, spawnFn)
1142
1152
  if (!finalGates.ok) throw new Error(finalGates.error)
1143
- git(['diff', '--check', `${BASE_REF}...HEAD`], worktree.cwd, spawnFn)
1153
+ const baseRef = policyBaseRef(worktree.cwd)
1154
+ const baseBranch = readGitPolicy(worktree.cwd).baseBranch
1155
+ git(['diff', '--check', `${baseRef}...HEAD`], worktree.cwd, spawnFn)
1144
1156
  onProgress('push')
1145
1157
  git(['push', '-u', 'origin', worktree.branch], worktree.cwd, spawnFn)
1146
1158
 
@@ -1148,10 +1160,10 @@ async function createPullRequest(args) {
1148
1160
  const body = [
1149
1161
  `Джерело: \`${source}\`.`,
1150
1162
  '',
1151
- group.rationale ?? candidate.rationale ?? 'Корисну поведінку перенесено на актуальний main.',
1163
+ group.rationale ?? candidate.rationale ?? `Корисну поведінку перенесено на актуальний ${baseBranch}.`,
1152
1164
  '',
1153
1165
  'Перевірки:',
1154
- '- `git diff --check origin/main...HEAD`',
1166
+ `- \`git diff --check ${baseRef}...HEAD\``,
1155
1167
  '- scoped code lint/tests та domain lint для non-code paths',
1156
1168
  '- `npx @7n/rules lint changelog --no-fix`',
1157
1169
  verification ? `- LLM behavioral verification: ${verification.slice(0, 1000)}` : ''
@@ -1160,7 +1172,7 @@ async function createPullRequest(args) {
1160
1172
  .join('\n')
1161
1173
  createdPr = run(
1162
1174
  'gh',
1163
- ['pr', 'create', '--base', 'main', '--head', worktree.branch, '--title', group.title, '--body', body],
1175
+ ['pr', 'create', '--base', baseBranch, '--head', worktree.branch, '--title', group.title, '--body', body],
1164
1176
  worktree.cwd,
1165
1177
  spawnFn
1166
1178
  ).stdout.trim()