@7n/rules 1.2.2 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.3.1] - 2026-07-14
4
+
5
+ ### Changed
6
+
7
+ - docs(adr): merge pending ADR normalization batch
8
+
9
+ ## [1.3.0] - 2026-07-14
10
+
11
+ ### Changed
12
+
13
+ - doc-files: мʼякий дедлайн fix-pipeline тепер діє і всередині файлу — `generateDoc` ріже per-call LLM-таймаути під залишок бюджету рунга (`deadlineAt`), тож генерація, що не вкладається в рунг, обривається transient-помилкою сама, без батчу-зомбі поверх наступного rung-а і без «fix timeout» від backstop-таймера
14
+
3
15
  ## [1.2.2] - 2026-07-14
4
16
 
5
17
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/rules",
3
- "version": "1.2.2",
3
+ "version": "1.3.1",
4
4
  "description": "CLI еталонних правил і skills (префікс n-): синк у репозиторій, дельта-lint, конформність",
5
5
  "keywords": [
6
6
  "cli",
@@ -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'
@@ -25,7 +25,7 @@ const SNIPPET_EXTS = ['yml', 'yaml', 'json', 'jsonc']
25
25
  * Чи `needle` структурно вже присутній у якомусь елементі `actualArray`
26
26
  * (та сама subset-семантика, що й у детекторі — жодного окремого визначення "збігу").
27
27
  * @param {unknown[]} actualArray наявний масив
28
- * @param {unknown} needle елемент снippета, наявність якого перевіряємо
28
+ * @param {unknown} needle елемент snippet-а, наявність якого перевіряємо
29
29
  * @returns {boolean} true — вже присутній структурно
30
30
  */
31
31
  function containedIn(actualArray, needle) {
@@ -98,6 +98,57 @@ function mergeYamlDoc(doc, snippet, path) {
98
98
  doc.setIn(path, snippet) // leaf — перезаписуємо канонічним значенням
99
99
  }
100
100
 
101
+ /**
102
+ * Рахує наступний вміст JSON/JSONC-target. `null` — невалідний вхід (не чіпаємо);
103
+ * незмінений `prevText` — уже відповідає snippet-у (idempotent, без reformat).
104
+ * @param {string} prevText наявний вміст target-файлу
105
+ * @param {string} snippetPath абсолютний шлях до snippet-файлу
106
+ * @returns {string|null} новий вміст, незмінений `prevText`, або `null`
107
+ */
108
+ function computeJsonNextText(prevText, snippetPath) {
109
+ let snippet
110
+ let actual
111
+ try {
112
+ snippet = JSON.parse(readFileSync(snippetPath, 'utf8'))
113
+ actual = JSON.parse(prevText)
114
+ } catch {
115
+ return null // невалідний JSON — не чіпаємо детермінованим фіксом
116
+ }
117
+ // Уже відповідає snippet-у (та сама перевірка, що й детектор) → без reformat:
118
+ // JSON.stringify інакше переформатував би вже коректний файл (напр. компактний
119
+ // однорядковий snippet → pretty-print) без жодної реальної зміни.
120
+ if (checkSnippet(actual, snippet, { targetPath: '', source: '' }).length === 0) return prevText
121
+ return JSON.stringify(mergeJsonValue(actual, snippet), null, 2) + '\n'
122
+ }
123
+
124
+ /**
125
+ * Рахує наступний вміст YAML-target через `yaml` Document API. `null` — невалідний
126
+ * вхід; незмінений `prevText` — уже відповідає snippet-у (idempotent, без reformat).
127
+ * @param {string} prevText наявний вміст target-файлу
128
+ * @param {string} snippetPath абсолютний шлях до snippet-файлу
129
+ * @returns {Promise<string|null>} новий вміст, незмінений `prevText`, або `null`
130
+ */
131
+ async function computeYamlNextText(prevText, snippetPath) {
132
+ const { parse, parseDocument } = await import('yaml')
133
+ let snippet
134
+ let actualPlain
135
+ try {
136
+ snippet = parse(readFileSync(snippetPath, 'utf8'))
137
+ actualPlain = parse(prevText)
138
+ } catch {
139
+ return null // невалідний YAML — не чіпаємо детермінованим фіксом
140
+ }
141
+ // Уже відповідає snippet-у → без reformat: Document.toString() не завжди
142
+ // byte-identical на вже коректному вмісті (напр. folded block scalars).
143
+ if (checkSnippet(actualPlain, snippet, { targetPath: '', source: '' }).length === 0) return prevText
144
+ const doc = parseDocument(prevText)
145
+ if (doc.errors.length > 0) return null
146
+ if (snippet !== null && typeof snippet === 'object' && !Array.isArray(snippet)) {
147
+ for (const [k, v] of Object.entries(snippet)) mergeYamlDoc(doc, v, [k])
148
+ }
149
+ return doc.toString()
150
+ }
151
+
101
152
  /**
102
153
  * Створює T0-патерн, що приводить `targetPath` у відповідність `template/*.snippet.*`
103
154
  * свого concern-а (deep-merge, idempotent). Один writer — для будь-якого single-target
@@ -110,16 +161,13 @@ export function createTemplateFixPattern({ id, targetPath }) {
110
161
  id,
111
162
  test: violations => violations.some(v => v.file === targetPath),
112
163
  apply: async (violations, ctx) => {
113
- if (!violations.some(v => v.file === targetPath)) return { touchedFiles: [] }
164
+ if (violations.every(v => v.file !== targetPath)) return { touchedFiles: [] }
114
165
  if (!ctx.concernDir) return { touchedFiles: [] }
115
166
 
116
167
  const snippetPath = findSnippetFile(join(ctx.concernDir, 'template'), basename(targetPath))
117
168
  if (!snippetPath) return { touchedFiles: [] }
118
169
 
119
170
  const absTarget = join(ctx.cwd, targetPath)
120
- const ext = extname(snippetPath).toLowerCase()
121
- const isJson = ext === '.json' || ext === '.jsonc'
122
-
123
171
  const prevText = existsSync(absTarget) ? readFileSync(absTarget, 'utf8') : null
124
172
 
125
173
  // Файл відсутній → копіюємо snippet як є (без merge — немає з чим мерджити).
@@ -131,49 +179,12 @@ export function createTemplateFixPattern({ id, targetPath }) {
131
179
  return { touchedFiles: [absTarget], message: `${targetPath}: створено зі snippet` }
132
180
  }
133
181
 
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
- }
182
+ const isJson = ['.json', '.jsonc'].includes(extname(snippetPath).toLowerCase())
183
+ const nextText = isJson
184
+ ? computeJsonNextText(prevText, snippetPath)
185
+ : await computeYamlNextText(prevText, snippetPath)
175
186
 
176
- if (nextText === prevText) return { touchedFiles: [] }
187
+ if (nextText === null || nextText === prevText) return { touchedFiles: [] }
177
188
  ctx.recordWrite?.(absTarget)
178
189
  writeFileSync(absTarget, nextText, 'utf8')
179
190
  return { touchedFiles: [absTarget], message: `${targetPath}: приведено у відповідність snippet` }