@7n/rules 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/package.json +1 -1
  3. package/rules/abie/clean_merged_ignore_branches/fix-clean_merged_ignore_branches.mjs +3 -0
  4. package/rules/changelog/lib/docs/package-manifest.md +6 -4
  5. package/rules/changelog/lib/package-manifest.mjs +2 -2
  6. package/rules/docker/lint_docker_yml/fix-lint_docker_yml.mjs +3 -0
  7. package/rules/ga/clean_ga_workflows/fix-clean_ga_workflows.mjs +3 -0
  8. package/rules/ga/clean_merged_branch/fix-clean_merged_branch.mjs +3 -0
  9. package/rules/ga/git_ai/fix-git_ai.mjs +3 -0
  10. package/rules/ga/lint_ga/fix-lint_ga.mjs +3 -0
  11. package/rules/ga/vscode_settings/fix-vscode_settings.mjs +3 -0
  12. package/rules/ga/zizmor_yml/fix-zizmor_yml.mjs +3 -0
  13. package/rules/js/jscpd_config/fix-jscpd_config.mjs +3 -0
  14. package/rules/js/lint_js_yml/fix-lint_js_yml.mjs +5 -0
  15. package/rules/js/package_json/fix-package_json.mjs +3 -0
  16. package/rules/k8s/lint_k8s_yml/fix-lint_k8s_yml.mjs +3 -0
  17. package/rules/npm-module/emit_types_config/fix-emit_types_config.mjs +3 -0
  18. package/rules/npm-module/npm_package_json/fix-npm_package_json.mjs +3 -0
  19. package/rules/npm-module/npm_publish_yml/fix-npm_publish_yml.mjs +5 -0
  20. package/rules/npm-module/root_package_json/fix-root_package_json.mjs +3 -0
  21. package/rules/php/lint_php_yml/fix-lint_php_yml.mjs +3 -0
  22. package/rules/python/lint_python_yml/fix-lint_python_yml.mjs +3 -0
  23. package/rules/rego/vscode_settings/fix-vscode_settings.mjs +3 -0
  24. package/rules/release/docs/release.md +4 -3
  25. package/rules/release/lib/docs/aggregate.md +18 -7
  26. package/rules/rust/lint_rust_yml/fix-lint_rust_yml.mjs +5 -0
  27. package/rules/security/lint_security_yml/fix-lint_security_yml.mjs +3 -0
  28. package/rules/style/lint_style_yml/fix-lint_style_yml.mjs +3 -0
  29. package/rules/style/package_json/fix-package_json.mjs +3 -0
  30. package/rules/style/vscode_settings/fix-vscode_settings.mjs +3 -0
  31. package/rules/text/lint_text/fix-lint_text.mjs +5 -0
  32. package/rules/text/oxfmtrc/fix-oxfmtrc.mjs +3 -0
  33. package/rules/text/run-v8r/docs/main.md +3 -1
  34. package/rules/text/run-v8r/main.mjs +18 -2
  35. package/rules/text/vscode_settings/fix-vscode_settings.mjs +3 -0
  36. package/rules/worktree/vscode_settings/fix-vscode_settings.mjs +5 -0
  37. package/rules/worktree/zed_settings/fix-zed_settings.mjs +3 -0
  38. package/scripts/docs/skills-cli.md +1 -1
  39. package/scripts/lib/adr/docs/normalize-pipeline.md +1 -1
  40. package/scripts/lib/adr/normalize-pipeline.mjs +3 -1
  41. package/scripts/lib/fix/template-deep-merge.mjs +182 -0
  42. package/scripts/skills-cli.mjs +5 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.2.2] - 2026-07-14
4
+
5
+ ### Changed
6
+
7
+ - release: @7n/llm-lib@2.6.1, @7n/rules@1.1.0
8
+
9
+ ## [1.2.1] - 2026-07-14
10
+
11
+ ### Fixed
12
+
13
+ - Bun-сумісність тестового прогону: namespace-імпорт zod (фантомний __esModule на ESM-неймспейсах ламає vitest-interop), явний env: process.env у spawnSync skills-cli (Bun дає дітям snapshot оточення), чистка bun-node-* shim-тек з PATH для дочірнього v8r (node-shebang під --bun резолвився в bun і падав на node:sea); тест run-v8r приведено до контракту verbose-виводу (#44); root scripts.test → bun run --bun vitest run
14
+ - package-manifest: VALID_MAX_BUMPS → Set (oxlint prefer-set-has), дока maxBump освіжена
15
+
3
16
  ## [1.2.0] - 2026-07-14
4
17
 
5
18
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/rules",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "CLI еталонних правил і skills (префікс n-): синк у репозиторій, дельта-lint, конформність",
5
5
  "keywords": [
6
6
  "cli",
@@ -0,0 +1,3 @@
1
+ import { createTemplateFixPattern } from '../../../scripts/lib/fix/template-deep-merge.mjs'
2
+
3
+ export const patterns = [createTemplateFixPattern({ id: 'abie-clean_merged_ignore_branches-template', targetPath: '.github/workflows/clean-merged-branch.yml' })]
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: package-manifest.mjs
4
4
  resource: npm/rules/changelog/lib/package-manifest.mjs
5
5
  docgen:
6
- crc: ced8ad49
6
+ crc: 10bad2ce
7
7
  ---
8
8
 
9
9
  Модуль `package-manifest.mjs` реалізує **уніфіковану абстракцію маніфесту пакета** для перевірок changelog у багатомовному монорепо. Він приховує відмінності між двома типами маніфестів:
@@ -17,7 +17,7 @@ docgen:
17
17
  2. **Читання маніфесту воркспейсу** в єдиній структурі `PackageManifest` (з пріоритетом `package.json` над `pyproject.toml`).
18
18
  3. **Виявлення коренів усіх пакетів монорепо**: npm-воркспейси через скрипт `workspaces.mjs` + автоматичний пошук Python-проєктів за `pyproject.toml`.
19
19
 
20
- Базова мета — дати правилам категорії `changelog` єдину модель «пакет = (kind, ws, name, version, registryPublishable)», незалежно від мови.
20
+ Базова мета — дати правилам категорії `changelog` єдину модель «пакет = (kind, ws, name, version, registryPublishable, maxBump)», незалежно від мови.
21
21
 
22
22
  ## Експорти / API
23
23
 
@@ -44,12 +44,14 @@ docgen:
44
44
  * @property {string | null} version semver-рядок
45
45
  * @property {boolean} registryPublishable чи застосовується режим порівняння з реєстром
46
46
  * @property {string[] | null} [npmFiles] лише npm: 'files' з package.json
47
+ * @property {'major' | 'minor' | 'patch' | null} maxBump стеля для 'n-rules release' (з 'package.json#release.maxBump'); null — без обмеження
47
48
  */
48
49
  ```
49
50
 
50
51
  ### Константи модуля
51
52
 
52
53
  - `PYPROJECT_GLOB_IGNORE = ['**/node_modules/**', '**/.git/**', '**/.venv/**', '**/venv/**']` — патерни, що **виключаються** під час сканування `pyproject.toml` глобом, щоб не зачіпати чужі залежності та віртуальні середовища.
54
+ - `VALID_MAX_BUMPS` — `Set` дозволених значень `release.maxBump` (`'major' | 'minor' | 'patch'`); будь-яке інше значення тихо ігнорується (`maxBump: null`).
53
55
 
54
56
  ## Функції
55
57
 
@@ -93,14 +95,14 @@ docgen:
93
95
  - Читає текст через `readFile(pkgPath, 'utf8')` і парсить як JSON.
94
96
  - Якщо JSON — не об'єкт (null, масив, примітив), повертає `null`.
95
97
  - Обчислює `registryPublishable = name є непорожнім рядком && private !== true && files є масивом`. Тобто пакет вважається «публікованим у реєстр», тільки якщо в `package.json` явно вказано непорожній `name`, не зведено `private: true`, і визначено поле `files` (whitelist того, що публікується).
96
- - Повертає об'єкт із `kind: 'npm'`, `manifestRel: 'package.json'`, з `name`/`version` (тільки якщо вони — рядки, інакше `null`), `registryPublishable`, `npmFiles = pkg.files` або `null`.
98
+ - Повертає об'єкт із `kind: 'npm'`, `manifestRel: 'package.json'`, з `name`/`version` (тільки якщо вони — рядки, інакше `null`), `registryPublishable`, `npmFiles = pkg.files` або `null`, а також `maxBump` — валідне значення `package.json#release.maxBump` (`major`/`minor`/`patch`) або `null`, якщо поле відсутнє, не рядок чи поза дозволеним списком.
97
99
  - Будь-яка помилка читання/парсингу JSON у блоці `try` → повертає `null` (catch без логування).
98
100
  2. **Гілка Python:**
99
101
  - Складає шлях `cwd/ws/pyproject.toml`.
100
102
  - `existsSync` → якщо файлу немає, повертає `null`.
101
103
  - Читає файл і викликає `parsePyprojectFields`.
102
104
  - `registryPublishable = Boolean(name && version)` — для Python публікація в PyPI потребує лише валідних `name` і `version` (PyPI не має аналога `files` whitelist).
103
- - Повертає об'єкт із `kind: 'python'`, `manifestRel: 'pyproject.toml'`, `npmFiles: null`.
105
+ - Повертає об'єкт із `kind: 'python'`, `manifestRel: 'pyproject.toml'`, `npmFiles: null`, `maxBump: null` (стеля бампа підтримується лише для npm-маніфестів).
104
106
  - **Side effects:**
105
107
  - Синхронний `existsSync` (двічі: на `package.json` і `pyproject.toml`).
106
108
  - Асинхронне читання файлу через `fs/promises.readFile`.
@@ -27,7 +27,7 @@ import { getMonorepoPackageRootDirs, isIgnoredWorkspaceRoot } from '../../../scr
27
27
  */
28
28
 
29
29
  const PYPROJECT_GLOB_IGNORE = ['**/node_modules/**', '**/.git/**', '**/.venv/**', '**/venv/**']
30
- const VALID_MAX_BUMPS = ['major', 'minor', 'patch']
30
+ const VALID_MAX_BUMPS = new Set(['major', 'minor', 'patch'])
31
31
 
32
32
  /**
33
33
  * @param {Record<string, unknown>} pkg розпарсений package.json
@@ -37,7 +37,7 @@ function maxBumpFromPackageJson(pkg) {
37
37
  const release = pkg.release
38
38
  if (!release || typeof release !== 'object' || Array.isArray(release)) return null
39
39
  const value = /** @type {Record<string, unknown>} */ (release).maxBump
40
- return typeof value === 'string' && VALID_MAX_BUMPS.includes(value)
40
+ return typeof value === 'string' && VALID_MAX_BUMPS.has(value)
41
41
  ? /** @type {'major' | 'minor' | 'patch'} */ (value)
42
42
  : null
43
43
  }
@@ -0,0 +1,3 @@
1
+ import { createTemplateFixPattern } from '../../../scripts/lib/fix/template-deep-merge.mjs'
2
+
3
+ export const patterns = [createTemplateFixPattern({ id: 'docker-lint_docker_yml-template', targetPath: '.github/workflows/lint-docker.yml' })]
@@ -0,0 +1,3 @@
1
+ import { createTemplateFixPattern } from '../../../scripts/lib/fix/template-deep-merge.mjs'
2
+
3
+ export const patterns = [createTemplateFixPattern({ id: 'ga-clean_ga_workflows-template', targetPath: '.github/workflows/clean-ga-workflows.yml' })]
@@ -0,0 +1,3 @@
1
+ import { createTemplateFixPattern } from '../../../scripts/lib/fix/template-deep-merge.mjs'
2
+
3
+ export const patterns = [createTemplateFixPattern({ id: 'ga-clean_merged_branch-template', targetPath: '.github/workflows/clean-merged-branch.yml' })]
@@ -0,0 +1,3 @@
1
+ import { createTemplateFixPattern } from '../../../scripts/lib/fix/template-deep-merge.mjs'
2
+
3
+ export const patterns = [createTemplateFixPattern({ id: 'ga-git_ai-template', targetPath: '.github/workflows/git-ai.yml' })]
@@ -0,0 +1,3 @@
1
+ import { createTemplateFixPattern } from '../../../scripts/lib/fix/template-deep-merge.mjs'
2
+
3
+ export const patterns = [createTemplateFixPattern({ id: 'ga-lint_ga-template', targetPath: '.github/workflows/lint-ga.yml' })]
@@ -0,0 +1,3 @@
1
+ import { createTemplateFixPattern } from '../../../scripts/lib/fix/template-deep-merge.mjs'
2
+
3
+ export const patterns = [createTemplateFixPattern({ id: 'ga-vscode_settings-template', targetPath: '.vscode/settings.json' })]
@@ -0,0 +1,3 @@
1
+ import { createTemplateFixPattern } from '../../../scripts/lib/fix/template-deep-merge.mjs'
2
+
3
+ export const patterns = [createTemplateFixPattern({ id: 'ga-zizmor_yml-template', targetPath: '.github/zizmor.yml' })]
@@ -0,0 +1,3 @@
1
+ import { createTemplateFixPattern } from '../../../scripts/lib/fix/template-deep-merge.mjs'
2
+
3
+ export const patterns = [createTemplateFixPattern({ id: 'js-jscpd_config-template', targetPath: '.jscpd.json' })]
@@ -0,0 +1,5 @@
1
+ import { createTemplateFixPattern } from '../../../scripts/lib/fix/template-deep-merge.mjs'
2
+
3
+ export const patterns = [
4
+ createTemplateFixPattern({ id: 'js-lint_js_yml-template', targetPath: '.github/workflows/lint-js.yml' })
5
+ ]
@@ -0,0 +1,3 @@
1
+ import { createTemplateFixPattern } from '../../../scripts/lib/fix/template-deep-merge.mjs'
2
+
3
+ export const patterns = [createTemplateFixPattern({ id: 'js-package_json-template', targetPath: 'package.json' })]
@@ -0,0 +1,3 @@
1
+ import { createTemplateFixPattern } from '../../../scripts/lib/fix/template-deep-merge.mjs'
2
+
3
+ export const patterns = [createTemplateFixPattern({ id: 'k8s-lint_k8s_yml-template', targetPath: '.github/workflows/lint-k8s.yml' })]
@@ -0,0 +1,3 @@
1
+ import { createTemplateFixPattern } from '../../../scripts/lib/fix/template-deep-merge.mjs'
2
+
3
+ export const patterns = [createTemplateFixPattern({ id: 'npm-module-emit_types_config-template', targetPath: 'npm/tsconfig.emit-types.json' })]
@@ -0,0 +1,3 @@
1
+ import { createTemplateFixPattern } from '../../../scripts/lib/fix/template-deep-merge.mjs'
2
+
3
+ export const patterns = [createTemplateFixPattern({ id: 'npm-module-npm_package_json-template', targetPath: 'npm/package.json' })]
@@ -0,0 +1,5 @@
1
+ import { createTemplateFixPattern } from '../../../scripts/lib/fix/template-deep-merge.mjs'
2
+
3
+ export const patterns = [
4
+ createTemplateFixPattern({ id: 'npm-module-npm_publish_yml-template', targetPath: '.github/workflows/npm-publish.yml' })
5
+ ]
@@ -0,0 +1,3 @@
1
+ import { createTemplateFixPattern } from '../../../scripts/lib/fix/template-deep-merge.mjs'
2
+
3
+ export const patterns = [createTemplateFixPattern({ id: 'npm-module-root_package_json-template', targetPath: 'package.json' })]
@@ -0,0 +1,3 @@
1
+ import { createTemplateFixPattern } from '../../../scripts/lib/fix/template-deep-merge.mjs'
2
+
3
+ export const patterns = [createTemplateFixPattern({ id: 'php-lint_php_yml-template', targetPath: '.github/workflows/lint-php.yml' })]
@@ -0,0 +1,3 @@
1
+ import { createTemplateFixPattern } from '../../../scripts/lib/fix/template-deep-merge.mjs'
2
+
3
+ export const patterns = [createTemplateFixPattern({ id: 'python-lint_python_yml-template', targetPath: '.github/workflows/lint-python.yml' })]
@@ -0,0 +1,3 @@
1
+ import { createTemplateFixPattern } from '../../../scripts/lib/fix/template-deep-merge.mjs'
2
+
3
+ export const patterns = [createTemplateFixPattern({ id: 'rego-vscode_settings-template', targetPath: '.vscode/settings.json' })]
@@ -3,9 +3,7 @@ type: JS Module
3
3
  title: release.mjs
4
4
  resource: npm/rules/release/release.mjs
5
5
  docgen:
6
- crc: 475bce65
7
- model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
- score: 100
6
+ crc: 3ac9ee4c
9
7
  ---
10
8
 
11
9
  Файл автоматизує процес випуску версій. Він агрегує зміна-файли для всіх робочих просторів у `version-bump` та `CHANGELOG`, комітить зміни, ставить тег `<name>@<version>` та видаляє використані зміна-файли. Цей процес виконується у CI на гілці `main` (n-rules-release-design, варіант A). Публічні функції, що надає модуль, — `release` та `runReleaseCli`.
@@ -13,6 +11,9 @@ docgen:
13
11
  ## Поведінка
14
12
 
15
13
  release агрегує change-файли для всіх робочих просторів, оновлює версії в маніфестах, додає секції до `CHANGELOG.md`, видаляє використані change-файли, створює коміт та анотовані теги для всіх зібраних релізів, а потім намагається пушити їх у апстрім з повторними спробами.
14
+
15
+ Обчислений bump обрізається зверху стелею `package.json#release.maxBump` робочого простору: наприклад, `major`-change-файл при стелі `minor` дає лише minor-реліз. Коли стеля реально спрацювала, у консоль друкується попередження з початковим bump, стелею та фінальною версією.
16
+
16
17
  runReleaseCli виконує процес релізу, викликаючи функцію release, та виводить відповідний статус у консоль.
17
18
 
18
19
  ## Публічний API
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: aggregate.mjs
4
4
  resource: npm/rules/release/lib/aggregate.mjs
5
5
  docgen:
6
- crc: 1f78a0fe
6
+ crc: ff9ff216
7
7
  ---
8
8
 
9
9
  Модуль `aggregate.mjs` забезпечує **агрегацію change-файлів одного workspace** у дві основні сутності:
@@ -23,7 +23,8 @@ docgen:
23
23
  | `maxBump(bumps)` | функція | Найвищий пріоритет бампа у списку (`major` > `minor` > `patch`) |
24
24
  | `renderChangelogSection(version, date, entries)` | функція | Рендер однієї версійної секції у markdown |
25
25
  | `prependChangelogSection(existingText, sectionBlock)` | функція | Вставка нової секції зверху наявного `CHANGELOG.md` |
26
- | `aggregateWorkspace({ currentVersion, changeFiles, date })` | функція | Високорівневе об’єднання change-файлів workspace у `newVersion` + `sectionBlock` |
26
+ | `capBump(bump, cap)` | функція | Обрізає bump зверху стелею `release.maxBump` (`major` `minor` тощо) |
27
+ | `aggregateWorkspace({ currentVersion, changeFiles, date, maxBumpCap })` | функція | Високорівневе об’єднання change-файлів workspace у `newVersion` + `sectionBlock` |
27
28
 
28
29
  Усі експорти — **іменовані** (`export function …`). Default-export відсутній.
29
30
 
@@ -76,6 +77,14 @@ docgen:
76
77
  - `maxBump(['patch'])` → `'patch'`
77
78
  - `maxBump([])` → `'patch'` (через fallback `?? 'patch'`)
78
79
 
80
+ ### `capBump(bump, cap)`
81
+
82
+ **Сигнатура:** `capBump(bump: string, cap?: string | null): string`
83
+
84
+ Обмежує обчислений bump **зверху** стелею з `package.json#release.maxBump`: якщо `bump` суворіший за `cap` (наприклад, `major` при `cap: 'minor'`), повертається `cap`; інакше — `bump` без змін. `cap` у `null`/`undefined` вимикає обмеження. Так пакет може заборонити автоматичну зміну власної major-версії, навіть якщо change-файл явно поставив `major`.
85
+
86
+ **Side effects:** немає.
87
+
79
88
  ### `renderChangelogSection(version, date, entries)`
80
89
 
81
90
  **Сигнатура:** `renderChangelogSection(version: string, date: string, entries: Array<{ section: string, description: string }>): string`
@@ -152,7 +161,7 @@ docgen:
152
161
  - `existingText = 'тут нічого корисного'`:
153
162
  - вихід: `# Changelog\n\n<sectionBlock>` (попередній вміст відкидається).
154
163
 
155
- ### `aggregateWorkspace({ currentVersion, changeFiles, date })`
164
+ ### `aggregateWorkspace({ currentVersion, changeFiles, date, maxBumpCap })`
156
165
 
157
166
  **Сигнатура:**
158
167
 
@@ -161,6 +170,7 @@ aggregateWorkspace({
161
170
  currentVersion: string,
162
171
  changeFiles: Array<{ file: string, entry: { bump: string, section: string, description: string } }>,
163
172
  date: string,
173
+ maxBumpCap?: string | null,
164
174
  }): { newVersion: string, sectionBlock: string, consumedFiles: string[] } | null
165
175
  ```
166
176
 
@@ -169,12 +179,13 @@ aggregateWorkspace({
169
179
  - `currentVersion` — поточна версія маніфесту workspace (`x.y.z`).
170
180
  - `changeFiles` — масив об’єктів, що відповідає виходу `readChangeFiles` з `change-file.mjs`. Кожен елемент має `file` (ім’я файлу `.md`) та `entry` (розпарсений frontmatter + опис).
171
181
  - `date` — рядок дати `YYYY-MM-DD` для секції CHANGELOG.
182
+ - `maxBumpCap` — стеля bump з `package.json#release.maxBump` (`'major' | 'minor' | 'patch'`); `null`/відсутній — без обмеження.
172
183
 
173
184
  **Повертає:**
174
185
 
175
186
  - `null`, якщо `changeFiles.length === 0` (явна ознака «нема чого релізити»).
176
187
  - Інакше — об’єкт:
177
- - `newVersion` — результат `bumpVersion(currentVersion, maxBump(<усі bumps>))`.
188
+ - `newVersion` — результат `bumpVersion(currentVersion, capBump(maxBump(<усі bumps>), maxBumpCap))`.
178
189
  - `sectionBlock` — результат `renderChangelogSection(newVersion, date, <усі entries>)`.
179
190
  - `consumedFiles` — імена change-файлів (`c.file`), які мають бути видалені викликачем після успішного запису маніфесту й `CHANGELOG.md`.
180
191
 
@@ -211,7 +222,7 @@ aggregateWorkspace({
211
222
  1. **Збір change-файлів.** Викликач звертається до `readChangeFiles(ws, cwd)` з `change-file.mjs`, отримуючи `Array<{ file, entry }>` — усі `.md` із `<ws>/.changes/`, відсортовані за іменем (тобто де-факто за `timestamp`).
212
223
  2. **Отримання поточної версії.** Викликач читає `package.json` (або інший маніфест) і дістає `currentVersion`.
213
224
  3. **Дата.** Викликач формує `date = new Date().toISOString().slice(0, 10)` або еквівалент.
214
- 4. **Агрегація.** Виклик `aggregateWorkspace({ currentVersion, changeFiles, date })`:
225
+ 4. **Агрегація.** Виклик `aggregateWorkspace({ currentVersion, changeFiles, date, maxBumpCap })`:
215
226
  - Якщо повертає `null` — викликач пропускає workspace (нема `change-файлів`).
216
227
  - Якщо повертає об’єкт — отримуємо `newVersion`, `sectionBlock`, `consumedFiles`.
217
228
  5. **Оновлення `CHANGELOG.md`.** Викликач читає поточний `CHANGELOG.md` (або порожній рядок, якщо файлу нема), застосовує `prependChangelogSection(existingText, sectionBlock)` і записує результат на диск.
@@ -244,7 +255,7 @@ aggregateWorkspace({
244
255
  5. Експортує функцію `prependChangelogSection(existingText, sectionBlock)`, яка:
245
256
  - Якщо `existingText.trimStart()` не починається з `# Changelog`, повертає `# Changelog\n\n<sectionBlock>`.
246
257
  - Інакше відокремлює перший рядок (`head`) і решту (`rest`, із `trimStart`) і повертає `head + '\n\n' + sectionBlock + '\n' + rest`.
247
- 6. Експортує функцію `aggregateWorkspace({ currentVersion, changeFiles, date })`, яка:
258
+ 6. Експортує функцію `aggregateWorkspace({ currentVersion, changeFiles, date, maxBumpCap })`, яка:
248
259
  - Повертає `null`, якщо `changeFiles` порожній.
249
- - Інакше повертає `{ newVersion, sectionBlock, consumedFiles }`, де `newVersion = bumpVersion(currentVersion, maxBump(<усі c.entry.bump>))`, `sectionBlock = renderChangelogSection(newVersion, date, <усі c.entry>)`, `consumedFiles = <усі c.file>`.
260
+ - Інакше повертає `{ newVersion, sectionBlock, consumedFiles }`, де `newVersion = bumpVersion(currentVersion, capBump(maxBump(<усі c.entry.bump>), maxBumpCap))`, `sectionBlock = renderChangelogSection(newVersion, date, <усі c.entry>)`, `consumedFiles = <усі c.file>`.
250
261
  7. Не виконує жодного I/O й не залежить від `node:*`.
@@ -0,0 +1,5 @@
1
+ import { createTemplateFixPattern } from '../../../scripts/lib/fix/template-deep-merge.mjs'
2
+
3
+ export const patterns = [
4
+ createTemplateFixPattern({ id: 'rust-lint_rust_yml-template', targetPath: '.github/workflows/lint-rust.yml' })
5
+ ]
@@ -0,0 +1,3 @@
1
+ import { createTemplateFixPattern } from '../../../scripts/lib/fix/template-deep-merge.mjs'
2
+
3
+ export const patterns = [createTemplateFixPattern({ id: 'security-lint_security_yml-template', targetPath: '.github/workflows/lint-security.yml' })]
@@ -0,0 +1,3 @@
1
+ import { createTemplateFixPattern } from '../../../scripts/lib/fix/template-deep-merge.mjs'
2
+
3
+ export const patterns = [createTemplateFixPattern({ id: 'style-lint_style_yml-template', targetPath: '.github/workflows/lint-style.yml' })]
@@ -0,0 +1,3 @@
1
+ import { createTemplateFixPattern } from '../../../scripts/lib/fix/template-deep-merge.mjs'
2
+
3
+ export const patterns = [createTemplateFixPattern({ id: 'style-package_json-template', targetPath: 'package.json' })]
@@ -0,0 +1,3 @@
1
+ import { createTemplateFixPattern } from '../../../scripts/lib/fix/template-deep-merge.mjs'
2
+
3
+ export const patterns = [createTemplateFixPattern({ id: 'style-vscode_settings-template', targetPath: '.vscode/settings.json' })]
@@ -0,0 +1,5 @@
1
+ import { createTemplateFixPattern } from '../../../scripts/lib/fix/template-deep-merge.mjs'
2
+
3
+ export const patterns = [
4
+ createTemplateFixPattern({ id: 'text-lint_text-template', targetPath: '.github/workflows/lint-text.yml' })
5
+ ]
@@ -0,0 +1,3 @@
1
+ import { createTemplateFixPattern } from '../../../scripts/lib/fix/template-deep-merge.mjs'
2
+
3
+ export const patterns = [createTemplateFixPattern({ id: 'text-oxfmtrc-template', targetPath: '.oxfmtrc.json' })]
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: main.mjs
4
4
  resource: npm/rules/text/run-v8r/main.mjs
5
5
  docgen:
6
- crc: f6f78d26
6
+ crc: 804dce1c
7
7
  ---
8
8
 
9
9
  ## Огляд
@@ -23,6 +23,7 @@ docgen:
23
23
  - `runV8rWithGlobs(globs?)` — послідовно запускає `v8r` для кожного glob-у з `V8R_CONFIG_FILE`, вказаним на резольвнутий конфіг; при коді 0/98 вивід приховано, інакше друкується й повертається код першого невдалого прогону.
24
24
  - `runV8rWithFiles(files)` — delta-режим: один запуск `v8r` по конкретних існуючих шляхах (код 98 неможливий), порожній список — одразу 0.
25
25
  - `warnAboutRemoteSchemaFallback(stderrText)` — парсить stderr `v8r` і на кожен файл, чию схему знайдено мережевим fallback-ом (schemastore, не customCatalog), друкує в stdout пораду додати схему в каталог.
26
+ - `stripBunNodeShimDirs(pathValue)` — прибирає з PATH shim-теки `bun-node-*`, які `bun run --bun` додає з підміненим `node`: дочірній `v8r` (node-shebang) інакше виконувався б під bun і падав на непідтримуваному `node:sea`. Дочірній процес `v8r` завжди отримує очищений PATH.
26
27
  - `lint(ctx)` — detector `text/run-v8r`: `ctx.files` → delta по відфільтрованих розширеннях, без `ctx.files` → full за `DEFAULT_V8R_GLOBS`.
27
28
 
28
29
  ## Публічний API
@@ -32,6 +33,7 @@ docgen:
32
33
  - `writeResolvedV8rConfig()` — матеріалізація тимчасового конфігу, повертає його шлях.
33
34
  - `runV8rWithGlobs(globs?)`, `runV8rWithFiles(files)` — точки входу перевірки (full/delta).
34
35
  - `warnAboutRemoteSchemaFallback(stderrText)` — попередження про мережевий fallback схем.
36
+ - `stripBunNodeShimDirs(pathValue)` — PATH без shim-тек `bun-node-*` (для дочірнього v8r).
35
37
  - `lint(ctx)` — інтеграція в lint-пайплайн.
36
38
 
37
39
  ## Гарантії поведінки
@@ -47,7 +47,8 @@
47
47
  import { spawnSync } from 'node:child_process'
48
48
  import { existsSync, readFileSync, writeFileSync } from 'node:fs'
49
49
  import { tmpdir } from 'node:os'
50
- import { dirname, isAbsolute, join } from 'node:path'
50
+ import { basename, delimiter, dirname, isAbsolute, join } from 'node:path'
51
+ import { env } from 'node:process'
51
52
  import { fileURLToPath } from 'node:url'
52
53
 
53
54
  import { isRunAsCli } from '../../../scripts/cli-entry.mjs'
@@ -150,6 +151,21 @@ export function warnAboutRemoteSchemaFallback(stderrText) {
150
151
  }
151
152
  }
152
153
 
154
+ /**
155
+ * Прибирає з PATH shim-теки `bun-node-*`: їх додає `bun run --bun`, підміняючи `node` через
156
+ * symlink на bun. `bun x v8r` поважає node-shebang і бере `node` з PATH — під shim v8r виконується bun-ом
157
+ * і падає на непідтримуваному `node:sea`, тому дочірній v8r має бачити справжній node.
158
+ * @param {string | undefined} pathValue значення PATH батьківського процесу
159
+ * @returns {string | undefined} PATH без shim-тек (undefined — якщо PATH не задано)
160
+ */
161
+ export function stripBunNodeShimDirs(pathValue) {
162
+ if (!pathValue) return pathValue
163
+ return pathValue
164
+ .split(delimiter)
165
+ .filter(entry => !basename(entry).startsWith('bun-node-'))
166
+ .join(delimiter)
167
+ }
168
+
153
169
  /**
154
170
  * Один виклик `bun x v8r <targets...>` з підготовленим `customCatalog`-конфігом.
155
171
  * @param {string[]} targets glob-и або конкретні шляхи файлів
@@ -165,7 +181,7 @@ function runOneV8rInvocation(targets, configPath, verbose = false) {
165
181
  maxBuffer: 50 * 1024 * 1024,
166
182
  shell: false,
167
183
  stdio: ['ignore', 'pipe', 'pipe'],
168
- env: { ...process.env, V8R_CONFIG_FILE: configPath }
184
+ env: { ...env, PATH: stripBunNodeShimDirs(env.PATH), V8R_CONFIG_FILE: configPath }
169
185
  })
170
186
 
171
187
  if (result.error) {
@@ -0,0 +1,3 @@
1
+ import { createTemplateFixPattern } from '../../../scripts/lib/fix/template-deep-merge.mjs'
2
+
3
+ export const patterns = [createTemplateFixPattern({ id: 'text-vscode_settings-template', targetPath: '.vscode/settings.json' })]
@@ -0,0 +1,5 @@
1
+ import { createTemplateFixPattern } from '../../../scripts/lib/fix/template-deep-merge.mjs'
2
+
3
+ export const patterns = [
4
+ createTemplateFixPattern({ id: 'worktree-vscode_settings-template', targetPath: '.vscode/settings.json' })
5
+ ]
@@ -0,0 +1,3 @@
1
+ import { createTemplateFixPattern } from '../../../scripts/lib/fix/template-deep-merge.mjs'
2
+
3
+ export const patterns = [createTemplateFixPattern({ id: 'worktree-zed_settings-template', targetPath: '.zed/settings.json' })]
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: skills-cli.mjs
4
4
  resource: npm/scripts/skills-cli.mjs
5
5
  docgen:
6
- crc: 3a6aab39
6
+ crc: 30b6209c
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  ---
9
9
 
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: normalize-pipeline.mjs
4
4
  resource: npm/scripts/lib/adr/normalize-pipeline.mjs
5
5
  docgen:
6
- crc: 9c2cbb81
6
+ crc: f8bd18e2
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  ---
9
9
 
@@ -23,7 +23,9 @@
23
23
  *
24
24
  * Повертає той самий operations[]-контракт, що й single-shot — apply-логіка спільна.
25
25
  */
26
- import { z } from 'zod'
26
+ // Namespace-імпорт замість `import { z }`: Bun показує фантомний `__esModule` на ESM-неймспейсах,
27
+ // через що interop у vitest приймає default-експорт zod за CJS-обгортку і губить named-експорт `z`.
28
+ import * as z from 'zod'
27
29
  import { runOneShot } from '@7n/llm-lib/one-shot'
28
30
  import { startChain } from '@7n/llm-lib/chain'
29
31
  import { CLOUD_MIN, resolveModel } from '@7n/llm-lib/model-tiers'
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Спільний T0-autofix writer для policy-концернів "один target-файл + один канонічний
3
+ * `template/*.snippet.{json,jsonc,yml,yaml}`" (`engine:"template"` і `engine:"rego"`
4
+ * з тим самим snippet-шаблоном). Deep-merge snippet → target: об'єкти мерджаться по
5
+ * ключах, масиви — union за структурним підмножинним збігом (`checkSnippet`-семантика,
6
+ * як у детекторі — жодного окремого визначення "збігу"), листя — перезаписується
7
+ * канонічним значенням. Файл відсутній → копіюється сам snippet (без merge).
8
+ *
9
+ * JSON/JSONC — plain-object merge + `JSON.stringify`. YAML — `yaml` Document API
10
+ * (`setIn`/`addIn`/`hasIn`), щоб зберегти коментарі й форматування наявного файлу;
11
+ * створюється лише те, чого бракує.
12
+ *
13
+ * Кожен викличний concern передає лише `{ id, targetPath }` — сам writer резолвить
14
+ * snippet-файл у `template/` свого concern-а через `ctx.concernDir` (той самий
15
+ * механізм, що й `vscode-ext-add.mjs`).
16
+ */
17
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
18
+ import { basename, dirname, extname, join } from 'node:path'
19
+
20
+ import { checkSnippet } from '../template.mjs'
21
+
22
+ const SNIPPET_EXTS = ['yml', 'yaml', 'json', 'jsonc']
23
+
24
+ /**
25
+ * Чи `needle` структурно вже присутній у якомусь елементі `actualArray`
26
+ * (та сама subset-семантика, що й у детекторі — жодного окремого визначення "збігу").
27
+ * @param {unknown[]} actualArray наявний масив
28
+ * @param {unknown} needle елемент снippета, наявність якого перевіряємо
29
+ * @returns {boolean} true — вже присутній структурно
30
+ */
31
+ function containedIn(actualArray, needle) {
32
+ return actualArray.some(a => checkSnippet(a, needle, { targetPath: '', source: '' }).length === 0)
33
+ }
34
+
35
+ /**
36
+ * Шукає `<basename>.snippet.<ext>` у `template/` concern-а (перебір відомих розширень).
37
+ * @param {string} templateDir абсолютний шлях до `template/` concern-а
38
+ * @param {string} targetBasename basename цільового файлу (напр. `npm-publish.yml`)
39
+ * @returns {string|null} абсолютний шлях до snippet-файлу або null
40
+ */
41
+ function findSnippetFile(templateDir, targetBasename) {
42
+ if (!existsSync(templateDir)) return null
43
+ for (const ext of SNIPPET_EXTS) {
44
+ const p = join(templateDir, `${targetBasename}.snippet.${ext}`)
45
+ if (existsSync(p)) return p
46
+ }
47
+ return null
48
+ }
49
+
50
+ /**
51
+ * Рекурсивний deep-merge snippet у plain JS-значення (JSON/JSONC-гілка).
52
+ * @param {unknown} actual наявне значення (або undefined)
53
+ * @param {unknown} snippet канонічний фрагмент
54
+ * @returns {unknown} злите значення
55
+ */
56
+ function mergeJsonValue(actual, snippet) {
57
+ if (Array.isArray(snippet)) {
58
+ const arr = Array.isArray(actual) ? [...actual] : []
59
+ for (const needle of snippet) {
60
+ if (!containedIn(arr, needle)) arr.push(needle)
61
+ }
62
+ return arr
63
+ }
64
+ if (snippet !== null && typeof snippet === 'object') {
65
+ const obj = actual !== null && typeof actual === 'object' && !Array.isArray(actual) ? { ...actual } : {}
66
+ for (const [k, v] of Object.entries(snippet)) obj[k] = mergeJsonValue(obj[k], v)
67
+ return obj
68
+ }
69
+ return snippet // leaf — перезаписуємо канонічним значенням
70
+ }
71
+
72
+ /**
73
+ * Deep-merge snippet у YAML `Document` за шляхом (мутує `doc`). Масиви — `addIn` лише
74
+ * відсутніх (структурно) елементів; об'єкти — рекурсія по ключах (`setIn` створює
75
+ * проміжні мапи автоматично); листя — `setIn`.
76
+ * @param {import('yaml').Document} doc YAML-документ (мутується)
77
+ * @param {unknown} snippet канонічний фрагмент на цьому шляху
78
+ * @param {Array<string|number>} path шлях у документі
79
+ * @returns {void}
80
+ */
81
+ function mergeYamlDoc(doc, snippet, path) {
82
+ if (Array.isArray(snippet)) {
83
+ // `doc.createNode([])` — примусово YAMLSeq-вузол; голий `setIn(path, [])` іноді
84
+ // лишає сирий JS-масив (коли батьківська мапа вже існує), і подальший `addIn` кидає
85
+ // `Expected YAML collection at …` — createNode гарантує коректний тип вузла завжди.
86
+ if (!doc.hasIn(path)) doc.setIn(path, doc.createNode([]))
87
+ const existing = doc.getIn(path)
88
+ const existingJs = existing && typeof existing.toJS === 'function' ? existing.toJS(doc) : []
89
+ for (const needle of snippet) {
90
+ if (!containedIn(existingJs, needle)) doc.addIn(path, needle)
91
+ }
92
+ return
93
+ }
94
+ if (snippet !== null && typeof snippet === 'object') {
95
+ for (const [k, v] of Object.entries(snippet)) mergeYamlDoc(doc, v, [...path, k])
96
+ return
97
+ }
98
+ doc.setIn(path, snippet) // leaf — перезаписуємо канонічним значенням
99
+ }
100
+
101
+ /**
102
+ * Створює T0-патерн, що приводить `targetPath` у відповідність `template/*.snippet.*`
103
+ * свого concern-а (deep-merge, idempotent). Один writer — для будь-якого single-target
104
+ * snippet-концерну (`engine:"template"` чи `engine:"rego"` з тим самим snippet-шаблоном).
105
+ * @param {{ id: string, targetPath: string }} opts `id` — унікальний id T0-патерну; `targetPath` — posix-relative шлях цільового файлу від cwd
106
+ * @returns {import('../lint-surface/types.mjs').T0Pattern} T0-патерн для `fix-<concern>.mjs`
107
+ */
108
+ export function createTemplateFixPattern({ id, targetPath }) {
109
+ return {
110
+ id,
111
+ test: violations => violations.some(v => v.file === targetPath),
112
+ apply: async (violations, ctx) => {
113
+ if (!violations.some(v => v.file === targetPath)) return { touchedFiles: [] }
114
+ if (!ctx.concernDir) return { touchedFiles: [] }
115
+
116
+ const snippetPath = findSnippetFile(join(ctx.concernDir, 'template'), basename(targetPath))
117
+ if (!snippetPath) return { touchedFiles: [] }
118
+
119
+ const absTarget = join(ctx.cwd, targetPath)
120
+ const ext = extname(snippetPath).toLowerCase()
121
+ const isJson = ext === '.json' || ext === '.jsonc'
122
+
123
+ const prevText = existsSync(absTarget) ? readFileSync(absTarget, 'utf8') : null
124
+
125
+ // Файл відсутній → копіюємо snippet як є (без merge — немає з чим мерджити).
126
+ if (prevText === null) {
127
+ const rawSnippet = readFileSync(snippetPath, 'utf8')
128
+ ctx.recordWrite?.(absTarget)
129
+ mkdirSync(dirname(absTarget), { recursive: true })
130
+ writeFileSync(absTarget, rawSnippet, 'utf8')
131
+ return { touchedFiles: [absTarget], message: `${targetPath}: створено зі snippet` }
132
+ }
133
+
134
+ let nextText
135
+ if (isJson) {
136
+ let snippet
137
+ let actual
138
+ try {
139
+ snippet = JSON.parse(readFileSync(snippetPath, 'utf8'))
140
+ actual = JSON.parse(prevText)
141
+ } catch {
142
+ return { touchedFiles: [] } // невалідний JSON — не чіпаємо детермінованим фіксом
143
+ }
144
+ // Уже відповідає snippet-у (та сама перевірка, що й детектор) → не чіпаємо файл
145
+ // взагалі: без цієї гейтки JSON.stringify переформатував би вже коректний файл
146
+ // (напр. компактний однорядковий snippet → pretty-print) без жодної реальної зміни.
147
+ if (checkSnippet(actual, snippet, { targetPath: '', source: '' }).length === 0) {
148
+ return { touchedFiles: [] }
149
+ }
150
+ const merged = mergeJsonValue(actual, snippet)
151
+ nextText = JSON.stringify(merged, null, 2) + '\n'
152
+ } else {
153
+ const { parse, parseDocument } = await import('yaml')
154
+ let snippet
155
+ let actualPlain
156
+ try {
157
+ snippet = parse(readFileSync(snippetPath, 'utf8'))
158
+ actualPlain = parse(prevText)
159
+ } catch {
160
+ return { touchedFiles: [] } // невалідний YAML — не чіпаємо детермінованим фіксом
161
+ }
162
+ // Уже відповідає snippet-у (та сама перевірка, що й детектор) → не чіпаємо файл
163
+ // взагалі: Document.toString() не завжди byte-identical на вже коректному вмісті
164
+ // (напр. folded block scalars переформатовуються при round-trip без потреби).
165
+ if (checkSnippet(actualPlain, snippet, { targetPath: '', source: '' }).length === 0) {
166
+ return { touchedFiles: [] }
167
+ }
168
+ const doc = parseDocument(prevText)
169
+ if (doc.errors.length > 0) return { touchedFiles: [] }
170
+ if (snippet !== null && typeof snippet === 'object' && !Array.isArray(snippet)) {
171
+ for (const [k, v] of Object.entries(snippet)) mergeYamlDoc(doc, v, [k])
172
+ }
173
+ nextText = doc.toString()
174
+ }
175
+
176
+ if (nextText === prevText) return { touchedFiles: [] }
177
+ ctx.recordWrite?.(absTarget)
178
+ writeFileSync(absTarget, nextText, 'utf8')
179
+ return { touchedFiles: [absTarget], message: `${targetPath}: приведено у відповідність snippet` }
180
+ }
181
+ }
182
+ }
@@ -55,7 +55,9 @@ const USAGE_LINES = [
55
55
  * @returns {boolean} чи знайдено бінарник у PATH
56
56
  */
57
57
  function isBinaryInPath(name) {
58
- const probe = spawnSync('command', ['-v', name], { shell: true, encoding: 'utf8' })
58
+ // Явний `env: process.env` Bun без нього дає дітям snapshot оточення зі старту процесу
59
+ // і не бачить runtime-змін `process.env.PATH` (та сама причина, що в resolve-cmd.mjs).
60
+ const probe = spawnSync('command', ['-v', name], { shell: true, encoding: 'utf8', env: process.env })
59
61
  return probe.status === 0
60
62
  }
61
63
 
@@ -196,7 +198,8 @@ function runLlmCli(kind, prompt, projectDir, logError) {
196
198
  input: prompt,
197
199
  cwd: projectDir,
198
200
  stdio: ['pipe', 'inherit', 'inherit'],
199
- encoding: 'utf8'
201
+ encoding: 'utf8',
202
+ env: process.env
200
203
  })
201
204
  return result.status ?? 1
202
205
  }