@7n/rules 1.2.1 → 1.3.0
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 +12 -0
- package/package.json +1 -1
- package/rules/abie/clean_merged_ignore_branches/fix-clean_merged_ignore_branches.mjs +3 -0
- package/rules/doc-files/check/docs/fix-worker.md +2 -2
- package/rules/doc-files/check/fix-worker.mjs +2 -0
- package/rules/doc-files/docgen-files-batch/docs/main.md +2 -2
- package/rules/doc-files/docgen-files-batch/main.mjs +10 -6
- package/rules/doc-files/docgen-gen/docs/main.md +4 -2
- package/rules/doc-files/docgen-gen/main.mjs +39 -3
- 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/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/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/lib/fix/template-deep-merge.mjs +182 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.3.0] - 2026-07-14
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- doc-files: мʼякий дедлайн fix-pipeline тепер діє і всередині файлу — `generateDoc` ріже per-call LLM-таймаути під залишок бюджету рунга (`deadlineAt`), тож генерація, що не вкладається в рунг, обривається transient-помилкою сама, без батчу-зомбі поверх наступного rung-а і без «fix timeout» від backstop-таймера
|
|
8
|
+
|
|
9
|
+
## [1.2.2] - 2026-07-14
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- release: @7n/llm-lib@2.6.1, @7n/rules@1.1.0
|
|
14
|
+
|
|
3
15
|
## [1.2.1] - 2026-07-14
|
|
4
16
|
|
|
5
17
|
### Fixed
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@ type: JS Module
|
|
|
3
3
|
title: fix-worker.mjs
|
|
4
4
|
resource: npm/rules/doc-files/check/fix-worker.mjs
|
|
5
5
|
docgen:
|
|
6
|
-
crc:
|
|
6
|
+
crc: 27e4dca1
|
|
7
7
|
model: omlx/gemma-4-e4b-it-OptiQ-4bit
|
|
8
8
|
score: 100
|
|
9
9
|
issues: judge:inaccurate:0.98
|
|
@@ -18,7 +18,7 @@ docgen:
|
|
|
18
18
|
1. Викликається `fixWorker`.
|
|
19
19
|
2. Скануються файли для визначення застарілих документів (рукописні доки без docgen-frontmatter — не цілі).
|
|
20
20
|
3. Якщо знайдені застарілі документи, кожна цільова дока реєструється durable через `ctx.recordDurableWrite` (fallback — `ctx.recordWrite`), а потім запускається генерація батчу через `runGenerationBatch`.
|
|
21
|
-
4. Батч отримує м'який дедлайн — 80% від `ctx.timeoutMs` рунга: генерація сама зупиняється до backstop-таймауту, повертає частковий прогрес штатно й не лишає фонового батчу поверх наступного rung-а.
|
|
21
|
+
4. Батч отримує м'який дедлайн — 80% від `ctx.timeoutMs` рунга: генерація сама зупиняється до backstop-таймауту, повертає частковий прогрес штатно й не лишає фонового батчу поверх наступного rung-а. Дедлайн діє і всередині файлу: `generateDoc` ріже per-call LLM-таймаути під залишок бюджету, тож навіть перший файл, що не вкладається у рунг, обривається сам (transient), а не по backstop-таймеру runner-а.
|
|
22
22
|
5. Фільтруються порушення, що стосуються сирітських документів (вихідні файли видалені); pre-image кожного реєструється у `ctx.recordWrite`.
|
|
23
23
|
6. Викликається `purgeOrphanedDocs` для видалення сирітських документів.
|
|
24
24
|
7. Повертається результат зі списком зачеплених файлів.
|
|
@@ -36,6 +36,8 @@ export async function fixWorker(violations, ctx) {
|
|
|
36
36
|
}
|
|
37
37
|
// Deadline: батч сам зупиняється до backstop-таймауту рунга (fix timeout ×1.25) —
|
|
38
38
|
// повертає часткову роботу штатно, замість фонового батчу-зомбі поверх наступного rung-а.
|
|
39
|
+
// Дедлайн діє і всередині файлу: generateDoc ріже per-call LLM-таймаути під залишок
|
|
40
|
+
// бюджету, тож навіть перший файл, що не вкладається у рунг, обривається сам.
|
|
39
41
|
const deadlineAt = ctx.timeoutMs ? Date.now() + Math.round(ctx.timeoutMs * DEADLINE_FRACTION) : null
|
|
40
42
|
await runGenerationBatch(stale, cwd, {
|
|
41
43
|
headline: `📄 doc-files: генерація ${stale.length} доки(ів)`,
|
|
@@ -3,7 +3,7 @@ type: JS Module
|
|
|
3
3
|
title: main.mjs
|
|
4
4
|
resource: npm/rules/doc-files/docgen-files-batch/main.mjs
|
|
5
5
|
docgen:
|
|
6
|
-
crc:
|
|
6
|
+
crc: 8b00ffa9
|
|
7
7
|
model: omlx/gemma-4-e4b-it-OptiQ-4bit
|
|
8
8
|
score: 100
|
|
9
9
|
issues: judge:inaccurate:0.99
|
|
@@ -20,7 +20,7 @@ docgen:
|
|
|
20
20
|
selectTargets вибирає файли для генерації документації, які є застарілими або мають низьку якість, виключаючи ті, що вже мають потік (tier) `cloud-avg`. Рукописні доки (docPath існує без docgen-frontmatter, `foreign`) без `--overwrite` цілями не стають.
|
|
21
21
|
purgeOrphanedDocs видаляє вказівники на файли документації, для яких не існує відповідного вихідного файлу, і оновлює індекси директорій.
|
|
22
22
|
runDocFilesGenCli керує процесом генерації документації, видаляючи сирітських доків, визначаючи цілі та запускаючи пакетну генерацію; про пропущені рукописні (foreign) доки попереджає у stderr — тихого перезапису людського змісту немає, перезапис лише explicit `--overwrite`.
|
|
23
|
-
runGenerationBatch виконує генерацію документації для визначеного набору файлів, керуючи логікою циркут-брейкера при системних збоях; опційний `deadlineAt` (м'який дедлайн fix-pipeline) зупиняє батч перед стартом наступного файлу штатно — зроблене лишається на диску зі свіжими CRC, решту підбирає наступний прогін (перший файл стартує завжди).
|
|
23
|
+
runGenerationBatch виконує генерацію документації для визначеного набору файлів, керуючи логікою циркут-брейкера при системних збоях; опційний `deadlineAt` (м'який дедлайн fix-pipeline) зупиняє батч перед стартом наступного файлу штатно — зроблене лишається на диску зі свіжими CRC, решту підбирає наступний прогін (перший файл стартує завжди). Той самий `deadlineAt` прокидається у `generateDoc`, тож per-call LLM-таймаути ріжуться під залишок бюджету і файл у процесі обривається на дедлайні transient-помилкою, а не живе батчем-зомбі поверх наступного rung-а.
|
|
24
24
|
generateDirIndex (пере)генерує `index.md` директорії docs/ як OKF Directory Index без H1 у тілі — top-level заголовком лишається frontmatter `title:`, тож MD025/single-title чистий; чужий `index.md` (дока source-файлу чи людський зміст без OKF-типу) не перезаписується.
|
|
25
25
|
runDocFilesStampCli оновлює метадані (frontmatter) наявних файлів документації, додаючи хеш вихідного файлу, без повторного запуску генерації.
|
|
26
26
|
|
|
@@ -145,10 +145,10 @@ function fmtSize(bytes) {
|
|
|
145
145
|
* @param {string} root абсолютний корінь
|
|
146
146
|
* @param {{ done: number, total: number }} progress позиція у прогресі
|
|
147
147
|
* @param {{ ok: number, degraded: number, err: number, errors: string[], skipped: string[] }} stats акумулятор
|
|
148
|
-
* @param {{ model?: string, tier?: string|null, emit?: (s: string) => void }} [opts] модель/тир для штампу; emit — логер рядка
|
|
148
|
+
* @param {{ model?: string, tier?: string|null, emit?: (s: string) => void, deadlineAt?: number|null }} [opts] модель/тир для штампу; emit — логер рядка результату; deadlineAt — мʼякий дедлайн fix-pipeline для generateDoc
|
|
149
149
|
* @returns {Promise<'ok'|'permanent'|'systemic'|'transient'>} результат для керування циклом
|
|
150
150
|
*/
|
|
151
|
-
async function generateOne(file, root, progress, stats, { model, tier, emit } = {}) {
|
|
151
|
+
async function generateOne(file, root, progress, stats, { model, tier, emit, deadlineAt = null } = {}) {
|
|
152
152
|
const out = emit ?? (s => process.stdout.write(s))
|
|
153
153
|
const sourceAbs = join(root, file.sourcePath)
|
|
154
154
|
let size = 0
|
|
@@ -162,7 +162,7 @@ async function generateOne(file, root, progress, stats, { model, tier, emit } =
|
|
|
162
162
|
const docAbs = join(root, file.docPath)
|
|
163
163
|
// Варіант B: передаємо наявну доку, щоб зберегти захищену секцію «Призначення»
|
|
164
164
|
const existingMd = existsSync(docAbs) ? readFileSync(docAbs, 'utf8') : null
|
|
165
|
-
const result = await generateDoc(sourceAbs, { existingMd, model })
|
|
165
|
+
const result = await generateDoc(sourceAbs, { existingMd, model, deadlineAt })
|
|
166
166
|
const crc = crc32(readFileSync(sourceAbs))
|
|
167
167
|
mkdirSync(dirname(docAbs), { recursive: true })
|
|
168
168
|
const quality =
|
|
@@ -395,8 +395,11 @@ export function runDocFilesGenCli(argv) {
|
|
|
395
395
|
*
|
|
396
396
|
* `deadlineAt` (epoch ms): м'який дедлайн fix-pipeline — перед стартом КОЖНОГО
|
|
397
397
|
* наступного файлу (перший стартує завжди) батч звіряється з дедлайном і, коли час
|
|
398
|
-
* вийшов, завершується штатно з частковим прогресом.
|
|
399
|
-
*
|
|
398
|
+
* вийшов, завершується штатно з частковим прогресом. Той самий дедлайн прокидається
|
|
399
|
+
* у generateDoc: per-call LLM-таймаути ріжуться під залишок бюджету, тож і файл
|
|
400
|
+
* У ПРОЦЕСІ обривається на дедлайні (transient-помилка), а не живе батчем-зомбі
|
|
401
|
+
* поверх наступного rung-а. Зроблене записано по одному файлу (durable, свіжий
|
|
402
|
+
* CRC) — наступний прогін підбирає решту за CRC.
|
|
400
403
|
* @param {Array<object>} targets елементи scanForDocFiles (sourcePath/docPath)
|
|
401
404
|
* @param {string} root абсолютний корінь
|
|
402
405
|
* @param {{ headline?: string, model?: string, tier?: string, deadlineAt?: number|null }} [opts] headline — рядок-шапка прогону у stdout; model/tier — override моделі і її типу (інакше DEFAULT_LOCAL_MODEL); deadlineAt — м'який дедлайн (epoch ms)
|
|
@@ -442,7 +445,8 @@ export async function runGenerationBatch(targets, root, opts = {}) {
|
|
|
442
445
|
const status = await generateOne(file, root, { done, total: targets.length }, stats, {
|
|
443
446
|
model: opts.model,
|
|
444
447
|
tier: opts.tier,
|
|
445
|
-
emit
|
|
448
|
+
emit,
|
|
449
|
+
deadlineAt: opts.deadlineAt ?? null
|
|
446
450
|
})
|
|
447
451
|
reporter?.concernDone(file.sourcePath)
|
|
448
452
|
// Circuit-breaker: K systemic-збоїв підряд → негайний abort (середовище впало,
|
|
@@ -3,7 +3,7 @@ type: JS Module
|
|
|
3
3
|
title: main.mjs
|
|
4
4
|
resource: npm/rules/doc-files/docgen-gen/main.mjs
|
|
5
5
|
docgen:
|
|
6
|
-
crc:
|
|
6
|
+
crc: 344e711f
|
|
7
7
|
model: omlx/gemma-4-e4b-it-OptiQ-4bit
|
|
8
8
|
---
|
|
9
9
|
|
|
@@ -26,6 +26,7 @@ insertProtected розміщує захищений блок «Призначе
|
|
|
26
26
|
scoreDoc оцінює згенерований документ за набором детермінованих правил (generic-огляд, коротка поведінка, галюцинації про кеш, витік службових імен, анкор-покриття, суржик) і реєструє коди проблем. Правило R8 — refusal/чат-філер («Я готовий писати…», «Надайте мені код…», через пре-гейт docgen-judge): форсує degraded незалежно від структурної оцінки, тож LLM-суддя на такому змісті не викликається; захищене людське «Призначення» з перевірки виключене.
|
|
27
27
|
DEFAULT_LOCAL_MODEL визначає модель за замовчуванням для генерації документа, якщо не вказано інше.
|
|
28
28
|
generateDoc створює повний технічний документ з вихідного файлу, включаючи детерміновану оцінку якості; для непідтримуваних структур (one-shot шлях) оцінка не застосовується, але refusal-пре-гейт діє і там — філер отримує score 0 і позначку degraded, щоб доретрай батчу підібрав файл.
|
|
29
|
+
generateDoc приймає опційний `deadlineAt` (м'який дедлайн fix-pipeline, epoch ms): кожен LLM-виклик ріже свій таймаут під залишок бюджету (`capTimeoutToDeadline`), а вичерпаний бюджет обриває генерацію помилкою зі словом «timeout» ще до старту виклику — батч класифікує її як transient, і генерація ніколи не переживає бюджет рунга (без батчу-зомбі поверх наступного rung-а).
|
|
29
30
|
|
|
30
31
|
## Публічний API
|
|
31
32
|
|
|
@@ -33,7 +34,8 @@ splitProtected — витягує тіло захищеної секції «П
|
|
|
33
34
|
insertProtected — повертає захищений блок у фіксовану позицію одразу після заголовка документа.
|
|
34
35
|
scoreDoc — детермінований скоринг документа (0 токенів) проти факт-листа: оцінка 0–100 і коди проблем.
|
|
35
36
|
DEFAULT_LOCAL_MODEL — модель генерації за замовчуванням (env-конфігурована; без неї preflight фейлить гучно).
|
|
36
|
-
generateDoc — головний вхід: джерело → md-дока з оцінкою якості, best-of-2 повтором і опційним семантичним
|
|
37
|
+
generateDoc — головний вхід: джерело → md-дока з оцінкою якості, best-of-2 повтором і опційним семантичним суддею; опційний `deadlineAt` обмежує генерацію бюджетом рунга fix-pipeline.
|
|
38
|
+
capTimeoutToDeadline — ріже базовий per-call таймаут під залишок до дедлайну (без дедлайну — базовий ліміт; після дедлайну — 0).
|
|
37
39
|
|
|
38
40
|
## Гарантії поведінки
|
|
39
41
|
|
|
@@ -30,22 +30,50 @@ let llmMeter = { calls: 0, ms: 0 }
|
|
|
30
30
|
*/
|
|
31
31
|
let activeChain = null
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Дедлайн поточної генерації (epoch ms) — той самий lifecycle, що й activeChain:
|
|
35
|
+
* виставляється на старті generateDoc (opts.deadlineAt від fix-pipeline), скидається
|
|
36
|
+
* у finally. Ріже per-call таймаути так, що жоден LLM-виклик не переживає бюджет
|
|
37
|
+
* рунга — інакше backstop runner-а вбиває worker, а батч-зомбі продовжує дзвонити
|
|
38
|
+
* в локальну модель поверх наступного rung-а.
|
|
39
|
+
*/
|
|
40
|
+
let activeDeadlineAt = null
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Ріже базовий per-call таймаут під залишок бюджету до дедлайну.
|
|
44
|
+
* Без дедлайну — базовий ліміт; після дедлайну — 0 (виклик не має стартувати).
|
|
45
|
+
* @param {number} baseMs базовий ліміт виклику
|
|
46
|
+
* @param {number|null} deadlineAt дедлайн (epoch ms) або null
|
|
47
|
+
* @param {number} [now] поточний час (інжект для тестів)
|
|
48
|
+
* @returns {number} ефективний ліміт у мс (0 — бюджет вичерпано)
|
|
49
|
+
*/
|
|
50
|
+
export function capTimeoutToDeadline(baseMs, deadlineAt, now = Date.now()) {
|
|
51
|
+
if (!deadlineAt) return baseMs
|
|
52
|
+
return Math.min(baseMs, Math.max(0, deadlineAt - now))
|
|
53
|
+
}
|
|
54
|
+
|
|
33
55
|
/**
|
|
34
56
|
* Обгортка LLM-виклику з обліком (тепер async поверх pi-one-shot): лічить кількість
|
|
35
57
|
* викликів і сумарний час. Генерація одного файлу послідовна — лічильник без гонок.
|
|
36
58
|
* Зберігає старий інтерфейс accountant'а: повертає рядок-вміст, кидає на помилці.
|
|
59
|
+
* Таймаут виклику ріжеться під activeDeadlineAt; вичерпаний бюджет — помилка зі
|
|
60
|
+
* словом «timeout» (класифікується transient у batch, не permanent/systemic).
|
|
37
61
|
* @param {Array<{role:string,content:string}>} messages чат-повідомлення
|
|
38
62
|
* @param {string} model model-id (`provider/id`)
|
|
39
63
|
* @param {{ timeoutMs?: number, caller?: string }} [opts] ліміт/мітка (temperature/maxTokens не підтримуються pi-one-shot)
|
|
40
64
|
* @returns {Promise<string>} відповідь моделі
|
|
41
65
|
*/
|
|
42
66
|
async function callLlm(messages, model, opts = {}) {
|
|
67
|
+
const timeoutMs = capTimeoutToDeadline(opts.timeoutMs ?? LOCAL_TIMEOUT_MS, activeDeadlineAt)
|
|
68
|
+
if (timeoutMs <= 0) {
|
|
69
|
+
throw new Error('docgen deadline: бюджет рунга fix-pipeline вичерпано до старту LLM-виклику (timeout)')
|
|
70
|
+
}
|
|
43
71
|
const started = Date.now()
|
|
44
72
|
try {
|
|
45
73
|
const res = await runOneShot({
|
|
46
74
|
messages,
|
|
47
75
|
modelSpec: model,
|
|
48
|
-
timeoutMs
|
|
76
|
+
timeoutMs,
|
|
49
77
|
caller: opts.caller ?? 'docgen',
|
|
50
78
|
chain: activeChain
|
|
51
79
|
})
|
|
@@ -453,12 +481,18 @@ function finishUnsupported(r, { t0, model, chainExtra }) {
|
|
|
453
481
|
* з вищою температурою (best-of-2); якщо й він не допоміг — результат
|
|
454
482
|
* позначається `degraded`, рішення про перегенерацію приймає batch/користувач.
|
|
455
483
|
* @param {string} file абсолютний шлях джерела
|
|
456
|
-
* @param {{ model?: string, threshold?: number, existingMd?: string|null, chainFactory?: typeof startChain }} [opts] model-id, поріг degraded, наявна дока (для збереження захищеної секції), фабрика ланцюжка (інжект для тестів)
|
|
484
|
+
* @param {{ model?: string, threshold?: number, existingMd?: string|null, chainFactory?: typeof startChain, deadlineAt?: number|null }} [opts] model-id, поріг degraded, наявна дока (для збереження захищеної секції), фабрика ланцюжка (інжект для тестів), deadlineAt — мʼякий дедлайн fix-pipeline (epoch ms): per-call таймаути ріжуться під залишок бюджету, вичерпаний бюджет обриває генерацію transient-помилкою
|
|
457
485
|
* @returns {{ md: string, ms: number, llmMs: number, llmCalls: number, score: number|null, issues: string[], degraded: boolean, model: string }} документ і метадані генерації (ms — увесь файл; llmMs/llmCalls — лише LLM; решта ms — оркестрація)
|
|
458
486
|
*/
|
|
459
487
|
export async function generateDoc(
|
|
460
488
|
file,
|
|
461
|
-
{
|
|
489
|
+
{
|
|
490
|
+
model = DEFAULT_LOCAL_MODEL,
|
|
491
|
+
threshold = QUALITY_THRESHOLD,
|
|
492
|
+
existingMd = null,
|
|
493
|
+
chainFactory = startChain,
|
|
494
|
+
deadlineAt = null
|
|
495
|
+
} = {}
|
|
462
496
|
) {
|
|
463
497
|
const src = readFileSync(file, 'utf8')
|
|
464
498
|
// Pre-send guard: весь src вшивається у промпт як є (екстракт фактів його НЕ
|
|
@@ -477,6 +511,7 @@ export async function generateDoc(
|
|
|
477
511
|
llmMeter = { calls: 0, ms: 0 }
|
|
478
512
|
const chain = chainFactory({ kind: 'doc-generate', unit: facts.relPath, cwd: process.cwd() })
|
|
479
513
|
activeChain = chain
|
|
514
|
+
activeDeadlineAt = deadlineAt
|
|
480
515
|
const chainExtra = {}
|
|
481
516
|
try {
|
|
482
517
|
return await generateDocCore()
|
|
@@ -485,6 +520,7 @@ export async function generateDoc(
|
|
|
485
520
|
throw error
|
|
486
521
|
} finally {
|
|
487
522
|
activeChain = null
|
|
523
|
+
activeDeadlineAt = null
|
|
488
524
|
let outcome = 'success'
|
|
489
525
|
if (chainExtra.error) outcome = 'fail'
|
|
490
526
|
else if (chainExtra.degraded) outcome = 'partial'
|
|
@@ -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
|
+
}
|