@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.
- package/CHANGELOG.md +13 -0
- package/package.json +1 -1
- package/rules/abie/clean_merged_ignore_branches/fix-clean_merged_ignore_branches.mjs +3 -0
- package/rules/changelog/lib/docs/package-manifest.md +6 -4
- package/rules/changelog/lib/package-manifest.mjs +2 -2
- package/rules/docker/lint_docker_yml/fix-lint_docker_yml.mjs +3 -0
- package/rules/ga/clean_ga_workflows/fix-clean_ga_workflows.mjs +3 -0
- package/rules/ga/clean_merged_branch/fix-clean_merged_branch.mjs +3 -0
- package/rules/ga/git_ai/fix-git_ai.mjs +3 -0
- package/rules/ga/lint_ga/fix-lint_ga.mjs +3 -0
- package/rules/ga/vscode_settings/fix-vscode_settings.mjs +3 -0
- package/rules/ga/zizmor_yml/fix-zizmor_yml.mjs +3 -0
- package/rules/js/jscpd_config/fix-jscpd_config.mjs +3 -0
- package/rules/js/lint_js_yml/fix-lint_js_yml.mjs +5 -0
- package/rules/js/package_json/fix-package_json.mjs +3 -0
- package/rules/k8s/lint_k8s_yml/fix-lint_k8s_yml.mjs +3 -0
- package/rules/npm-module/emit_types_config/fix-emit_types_config.mjs +3 -0
- package/rules/npm-module/npm_package_json/fix-npm_package_json.mjs +3 -0
- package/rules/npm-module/npm_publish_yml/fix-npm_publish_yml.mjs +5 -0
- package/rules/npm-module/root_package_json/fix-root_package_json.mjs +3 -0
- package/rules/php/lint_php_yml/fix-lint_php_yml.mjs +3 -0
- package/rules/python/lint_python_yml/fix-lint_python_yml.mjs +3 -0
- package/rules/rego/vscode_settings/fix-vscode_settings.mjs +3 -0
- package/rules/release/docs/release.md +4 -3
- package/rules/release/lib/docs/aggregate.md +18 -7
- package/rules/rust/lint_rust_yml/fix-lint_rust_yml.mjs +5 -0
- package/rules/security/lint_security_yml/fix-lint_security_yml.mjs +3 -0
- package/rules/style/lint_style_yml/fix-lint_style_yml.mjs +3 -0
- package/rules/style/package_json/fix-package_json.mjs +3 -0
- package/rules/style/vscode_settings/fix-vscode_settings.mjs +3 -0
- package/rules/text/lint_text/fix-lint_text.mjs +5 -0
- package/rules/text/oxfmtrc/fix-oxfmtrc.mjs +3 -0
- package/rules/text/run-v8r/docs/main.md +3 -1
- package/rules/text/run-v8r/main.mjs +18 -2
- package/rules/text/vscode_settings/fix-vscode_settings.mjs +3 -0
- package/rules/worktree/vscode_settings/fix-vscode_settings.mjs +5 -0
- package/rules/worktree/zed_settings/fix-zed_settings.mjs +3 -0
- package/scripts/docs/skills-cli.md +1 -1
- package/scripts/lib/adr/docs/normalize-pipeline.md +1 -1
- package/scripts/lib/adr/normalize-pipeline.mjs +3 -1
- package/scripts/lib/fix/template-deep-merge.mjs +182 -0
- 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
|
@@ -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:
|
|
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.
|
|
40
|
+
return typeof value === 'string' && VALID_MAX_BUMPS.has(value)
|
|
41
41
|
? /** @type {'major' | 'minor' | 'patch'} */ (value)
|
|
42
42
|
: null
|
|
43
43
|
}
|
|
@@ -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:
|
|
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:
|
|
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
|
-
| `
|
|
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:*`.
|
|
@@ -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:
|
|
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: { ...
|
|
184
|
+
env: { ...env, PATH: stripBunNodeShimDirs(env.PATH), V8R_CONFIG_FILE: configPath }
|
|
169
185
|
})
|
|
170
186
|
|
|
171
187
|
if (result.error) {
|
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
*
|
|
24
24
|
* Повертає той самий operations[]-контракт, що й single-shot — apply-логіка спільна.
|
|
25
25
|
*/
|
|
26
|
-
import { z }
|
|
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
|
+
}
|
package/scripts/skills-cli.mjs
CHANGED
|
@@ -55,7 +55,9 @@ const USAGE_LINES = [
|
|
|
55
55
|
* @returns {boolean} чи знайдено бінарник у PATH
|
|
56
56
|
*/
|
|
57
57
|
function isBinaryInPath(name) {
|
|
58
|
-
|
|
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
|
}
|