@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +12 -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/doc-files/check/docs/fix-worker.md +2 -2
  5. package/rules/doc-files/check/fix-worker.mjs +2 -0
  6. package/rules/doc-files/docgen-files-batch/docs/main.md +2 -2
  7. package/rules/doc-files/docgen-files-batch/main.mjs +10 -6
  8. package/rules/doc-files/docgen-gen/docs/main.md +4 -2
  9. package/rules/doc-files/docgen-gen/main.mjs +39 -3
  10. package/rules/docker/lint_docker_yml/fix-lint_docker_yml.mjs +3 -0
  11. package/rules/ga/clean_ga_workflows/fix-clean_ga_workflows.mjs +3 -0
  12. package/rules/ga/clean_merged_branch/fix-clean_merged_branch.mjs +3 -0
  13. package/rules/ga/git_ai/fix-git_ai.mjs +3 -0
  14. package/rules/ga/lint_ga/fix-lint_ga.mjs +3 -0
  15. package/rules/ga/vscode_settings/fix-vscode_settings.mjs +3 -0
  16. package/rules/ga/zizmor_yml/fix-zizmor_yml.mjs +3 -0
  17. package/rules/js/jscpd_config/fix-jscpd_config.mjs +3 -0
  18. package/rules/js/lint_js_yml/fix-lint_js_yml.mjs +5 -0
  19. package/rules/js/package_json/fix-package_json.mjs +3 -0
  20. package/rules/k8s/lint_k8s_yml/fix-lint_k8s_yml.mjs +3 -0
  21. package/rules/npm-module/emit_types_config/fix-emit_types_config.mjs +3 -0
  22. package/rules/npm-module/npm_package_json/fix-npm_package_json.mjs +3 -0
  23. package/rules/npm-module/npm_publish_yml/fix-npm_publish_yml.mjs +5 -0
  24. package/rules/npm-module/root_package_json/fix-root_package_json.mjs +3 -0
  25. package/rules/php/lint_php_yml/fix-lint_php_yml.mjs +3 -0
  26. package/rules/python/lint_python_yml/fix-lint_python_yml.mjs +3 -0
  27. package/rules/rego/vscode_settings/fix-vscode_settings.mjs +3 -0
  28. package/rules/rust/lint_rust_yml/fix-lint_rust_yml.mjs +5 -0
  29. package/rules/security/lint_security_yml/fix-lint_security_yml.mjs +3 -0
  30. package/rules/style/lint_style_yml/fix-lint_style_yml.mjs +3 -0
  31. package/rules/style/package_json/fix-package_json.mjs +3 -0
  32. package/rules/style/vscode_settings/fix-vscode_settings.mjs +3 -0
  33. package/rules/text/lint_text/fix-lint_text.mjs +5 -0
  34. package/rules/text/oxfmtrc/fix-oxfmtrc.mjs +3 -0
  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/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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/rules",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
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: fix-worker.mjs
4
4
  resource: npm/rules/doc-files/check/fix-worker.mjs
5
5
  docgen:
6
- crc: 7611b2f9
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: 6237a73f
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
- * файлу (durable, свіжий CRC) наступний прогін підбирає решту за CRC.
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: bef08f19
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: opts.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
- { model = DEFAULT_LOCAL_MODEL, threshold = QUALITY_THRESHOLD, existingMd = null, chainFactory = startChain } = {}
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,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' })]
@@ -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' })]
@@ -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' })]
@@ -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
+ }