@7n/rules 1.48.1 → 1.49.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 (34) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/bin/n-rules-cli.mjs +2045 -0
  3. package/bin/n-rules.js +4 -2026
  4. package/package.json +1 -1
  5. package/rules/changelog/.changes/260724-1500.md +5 -0
  6. package/rules/doc-files/docgen-files-batch/docs/index.md +9 -0
  7. package/rules/doc-files/docgen-files-batch/docs/main.md +59 -18
  8. package/rules/doc-files/docgen-files-batch/main.mjs +280 -29
  9. package/rules/doc-files/docgen-gen/docs/index.md +9 -0
  10. package/rules/doc-files/docgen-gen/docs/main.md +40 -29
  11. package/rules/doc-files/docgen-gen/main.mjs +79 -25
  12. package/rules/test/coverage/fix-worker.mjs +9 -1
  13. package/rules/test/coverage/lib/classify/verdict-schema.mjs +3 -1
  14. package/scripts/docs/skills-cli.md +18 -24
  15. package/scripts/lib/acp-runner.mjs +1 -1
  16. package/scripts/lib/lint-surface/collateral-veto.mjs +78 -2
  17. package/scripts/lib/lint-surface/docs/collateral-veto.md +30 -16
  18. package/scripts/lib/lint-surface/docs/index.md +1 -0
  19. package/scripts/lib/lint-surface/docs/run-fix.md +8 -23
  20. package/scripts/lib/lint-surface/docs/snapshot.md +6 -17
  21. package/scripts/lib/lint-surface/docs/test-gate.md +29 -0
  22. package/scripts/lib/lint-surface/run-fix.mjs +209 -38
  23. package/scripts/lib/lint-surface/snapshot.mjs +6 -0
  24. package/scripts/lib/lint-surface/test-gate.mjs +87 -0
  25. package/scripts/skills-cli.mjs +60 -10
  26. package/scripts/utils/docs/glob-compat.md +20 -14
  27. package/scripts/utils/glob-compat.mjs +18 -3
  28. package/skills/git-reconcile/SKILL.md +58 -0
  29. package/skills/git-reconcile/js/docs/index.md +9 -0
  30. package/skills/git-reconcile/js/docs/orchestrate.md +33 -0
  31. package/skills/git-reconcile/js/orchestrate.mjs +776 -0
  32. package/skills/git-reconcile/main.json +1 -0
  33. package/skills/taze/js/docs/orchestrate.md +40 -18
  34. package/skills/taze/js/orchestrate.mjs +6 -3
@@ -14,8 +14,8 @@
14
14
  * @typedef {import('./run-detectors.mjs').PlanItem} PlanItem
15
15
  * @typedef {import('./ladder.mjs').Rung} Rung
16
16
  */
17
- import { existsSync } from 'node:fs'
18
- import { join, relative } from 'node:path'
17
+ import { existsSync, readFileSync } from 'node:fs'
18
+ import { isAbsolute, join, relative, resolve } from 'node:path'
19
19
  import { pathToFileURL } from 'node:url'
20
20
 
21
21
  import { LOCAL_MIN, CLOUD_MIN, CLOUD_AVG, isLocalModel } from '@7n/llm-lib/model-tiers'
@@ -26,7 +26,13 @@ import { buildDetectPlan } from './run-detectors.mjs'
26
26
  import { runConcernDetector, DetectorError } from './detect.mjs'
27
27
  import { renderViolations } from './render.mjs'
28
28
  import { createSnapshot } from './snapshot.mjs'
29
- import { findCollateralEdits, realpathBestEffort } from './collateral-veto.mjs'
29
+ import {
30
+ findCollateralEdits,
31
+ findInFileCollateralEdits,
32
+ realpathBestEffort,
33
+ resolveTargetSet
34
+ } from './collateral-veto.mjs'
35
+ import { findBrokenSiblingTests } from './test-gate.mjs'
30
36
  import { createProgressReporter } from './progress.mjs'
31
37
  import { buildLadder, decideAfterFailure, DEFAULT_MAX_AVG } from './ladder.mjs'
32
38
 
@@ -174,6 +180,168 @@ async function runT0Phase(item, initialViolations, patterns, lintCtx, cwd, log,
174
180
  * @typedef {{ action: 'break'|'skip-model'|null, violations: LintViolation[], feedback: FixFeedback }} RungOutcome
175
181
  */
176
182
 
183
+ /**
184
+ * Cross-file (§12 addendum 2026-07-05) + in-file hunk-level (addendum 2026-07-24)
185
+ * collateral rung-а: наявні файли, змінені поза target-set, і наявні файли ВСЕРЕДИНІ
186
+ * target-set, змінені поза вікном навколо `violation.data.line`. Чиста функція (без
187
+ * трейсу/side-effects) — виклик trace лишається на caller-і.
188
+ * @param {{ violations: LintViolation[], item: PlanItem, snapshot: ReturnType<typeof createSnapshot>, cwd: string }} args Порушення rung-а, елемент плану, snapshot S1 і робоча директорія.
189
+ * @returns {{ targetFiles: string[], collateral: string[], inFileHunks: Array<{ file: string, start: number, end: number }>, collateralAll: string[], rejectedRel: string[], inFileHunkRel: string[] }} Обидва класи collateral (cross-file, in-file) і їхнє відносне представлення для логу/feedback.
190
+ */
191
+ function computeCollateral({ violations, item, snapshot, cwd }) {
192
+ // Semantic-collateral veto (§12 addendum 2026-07-05): clean-вердикт не приймається,
193
+ // якщо rung ЗМІНИВ наявні файли поза target-set порушення (клас «App.vue: хардкод
194
+ // версії замість getVersion»). Нові файли дозволені (scaffold/доки); порожній
195
+ // target-set (whole-repo концерни без file-атрибуції) → veto незастосовний.
196
+ const targetFiles = [...new Set([...violations.map(v => v.file).filter(Boolean), ...(item.files ?? [])])]
197
+ const collateral = findCollateralEdits({ modifiedExisting: snapshot.modifiedExisting(), targetFiles, cwd })
198
+
199
+ // In-file hunk-level veto (§12 addendum 2026-07-24): файл — легітимна ціль, але rung
200
+ // зачепив рядки поза вікном навколо порушень ЦЬОГО файлу (upsert-order.js: doc-comment
201
+ // fix + сусіднє видалення intentional-workaround-у, невидиме для cross-file veto вище).
202
+ // Без realpath: snapshot-ключі — це те, що воркер сам передав у recordWrite (як правило
203
+ // `join(cwd, relFile)`), і `resolve(cwd, v.file)` дає той самий рядок за тим самим
204
+ // (не-realpath-нормалізованим) cwd — realpath тут лише зіпсував би збіг ключів мапи.
205
+ const violationLinesByFile = new Map()
206
+ for (const v of violations) {
207
+ const line = v.data?.line
208
+ if (!v.file || typeof line !== 'number') continue
209
+ const abs = isAbsolute(v.file) ? v.file : resolve(cwd, v.file)
210
+ if (!violationLinesByFile.has(abs)) violationLinesByFile.set(abs, [])
211
+ violationLinesByFile.get(abs).push(line)
212
+ }
213
+ const modifiedAbs = new Set(snapshot.modifiedExisting())
214
+ const inFileHunks = []
215
+ for (const [abs, lines] of violationLinesByFile) {
216
+ if (!modifiedAbs.has(abs)) continue
217
+ const pre = snapshot.preImageOf(abs)
218
+ if (pre === null) continue
219
+ let current
220
+ try {
221
+ current = existsSync(abs) ? readFileSync(abs, 'utf8') : null
222
+ } catch {
223
+ continue
224
+ }
225
+ const hunk = findInFileCollateralEdits({ preImage: pre, current, violationLines: lines })
226
+ if (hunk) inFileHunks.push({ file: abs, ...hunk })
227
+ }
228
+
229
+ const collateralAll = [...collateral, ...inFileHunks.map(h => h.file)]
230
+ // relative — від так само realpath-нормалізованого cwd, інакше symlink-cwd (macOS
231
+ // /var → /private/var) дає `../../…`-шляхи у телеметрії та feedback.
232
+ const rejectedRel = collateral.map(p => relative(realpathBestEffort(cwd), p))
233
+ const inFileHunkRel = inFileHunks.map(h => `${relative(realpathBestEffort(cwd), h.file)}:${h.start}-${h.end}`)
234
+ return { targetFiles, collateral, inFileHunks, collateralAll, rejectedRel, inFileHunkRel }
235
+ }
236
+
237
+ /**
238
+ * Test-gate (addendum 2026-07-24): collateral-veto (cross-file + in-file hunk) вище
239
+ * ловить лише правки, видимі як diff проти S1. Правки ВСЕРЕДИНІ вже-таргетованого
240
+ * файлу, у ВІКНІ навколо violation.data.line (тому не зловлені in-file hunk-level
241
+ * veto), теж можуть зламати наявний проєктний тест — test-gate це третій, незалежний
242
+ * рубіж. Скоуп — лише наявні файли ВСЕРЕДИНІ target-set, реально змінені цим rung-ом
243
+ * (не колатеральні — ті вже відхилені collateral-veto); caller пропускає виклик, якщо
244
+ * collateralAll уже ветував rung (нема сенсу гонити тести на приреченому rung-у).
245
+ * Fail-open (findBrokenSiblingTests сама fail-open на відсутність test-runner-а/
246
+ * таймаут/відсутність сестринського тесту). Побічний ефект: пише trace на провал.
247
+ * @param {{ targetFiles: string[], snapshot: ReturnType<typeof createSnapshot>, cwd: string, testRunner: typeof import('./test-gate.mjs').runTestFile|undefined, ruleId: string, concernName: string, rung: Rung }} args Файли порушення, snapshot S1, робоча директорія, override test-runner-а і координати rung-а для телеметрії.
248
+ * @returns {{ file: string, testFile: string, output: string } | null} Перший зафіксований провал сестринського тесту, або null якщо test-gate не спрацював.
249
+ */
250
+ function detectBrokenTest({ targetFiles, snapshot, cwd, testRunner, ruleId, concernName, rung }) {
251
+ const targets = resolveTargetSet(targetFiles, cwd)
252
+ const modifiedInTarget = snapshot
253
+ .modifiedExisting()
254
+ .map(p => realpathBestEffort(p))
255
+ .filter(abs => targets.has(abs))
256
+ if (modifiedInTarget.length === 0) return null
257
+ // `runTest: testRunner` — default-параметр findBrokenSiblingTests спрацьовує саме
258
+ // на `undefined`, тож відсутній override прозоро падає назад на runTestFile.
259
+ const brokenTest = findBrokenSiblingTests({ files: modifiedInTarget, cwd, runTest: testRunner })
260
+ if (!brokenTest) return null
261
+ writeTrace({
262
+ caller: `fix:${ruleId}/${concernName}:${rung.tier}`,
263
+ backend: 'pi-ai',
264
+ kind: 'test-gate-veto',
265
+ rule: ruleId,
266
+ rung: rung.tier,
267
+ model: rung.model,
268
+ cwd,
269
+ brokenFile: relative(realpathBestEffort(cwd), brokenTest.file),
270
+ brokenTestFile: relative(realpathBestEffort(cwd), brokenTest.testFile),
271
+ targetFiles,
272
+ cleanDetect: true
273
+ })
274
+ return brokenTest
275
+ }
276
+
277
+ /**
278
+ * Опис відхиленого rung-а для логу (`errorSuffix`) і feedback наступному rung-у
279
+ * (`silentFailureNote`) — один пріоритет: worker-помилка → collateral-veto →
280
+ * test-gate-veto → мовчазна невдача (worker нічого не змінив / змінив, але
281
+ * порушення лишилось).
282
+ * @param {object} args Дані одного rung-а, потрібні для опису відхилення.
283
+ * @param {string|null} args.error Повідомлення worker-помилки, якщо rung кинув виняток.
284
+ * @param {string[]} args.collateralAll Об'єднаний список відхилених collateral-правок (cross-file abs).
285
+ * @param {string[]} args.rejectedRel Cross-file collateral (відносні шляхи).
286
+ * @param {string[]} args.inFileHunkRel In-file hunk-level collateral (`file:start-end`).
287
+ * @param {{ file: string, testFile: string } | null} args.brokenTest Результат test-gate.
288
+ * @param {string} args.cwd Робоча директорія (для relative()).
289
+ * @param {string[]} args.targetFiles Файли порушення rung-а.
290
+ * @param {string} args.model Модель rung-а (для тексту feedback).
291
+ * @param {string[]} args.touchedFiles Файли, торкнуті worker-ом.
292
+ * @returns {{ errorSuffix: string, silentFailureNote: string }} Суфікс для логу і нотатка для feedback наступному rung-у.
293
+ */
294
+ function describeVetoOutcome({
295
+ error,
296
+ collateralAll,
297
+ rejectedRel,
298
+ inFileHunkRel,
299
+ brokenTest,
300
+ cwd,
301
+ targetFiles,
302
+ model,
303
+ touchedFiles
304
+ }) {
305
+ if (error) return { errorSuffix: ` ❌ ${error.slice(0, 120)}`, silentFailureNote: '' }
306
+
307
+ if (collateralAll.length > 0) {
308
+ const parts = []
309
+ if (rejectedRel.length > 0) parts.push(`змінила наявні файли поза target-set (${rejectedRel.join(', ')})`)
310
+ if (inFileHunkRel.length > 0) parts.push(`зачепила рядки поза ділянкою порушення (${inFileHunkRel.join(', ')})`)
311
+ return {
312
+ errorSuffix: ` 🚫 collateral-veto: ${[...rejectedRel, ...inFileHunkRel].join(', ')}`,
313
+ silentFailureNote:
314
+ `Попередня спроба (${model}) закрила порушення, але ${parts.join('; ')} — усі правки відхилено. ` +
315
+ `Редагуй ЛИШЕ рядки порушення у файлах: ${targetFiles.join(', ')}.`
316
+ }
317
+ }
318
+
319
+ if (brokenTest) {
320
+ const brokenFileRel = relative(realpathBestEffort(cwd), brokenTest.file)
321
+ const brokenTestRel = relative(realpathBestEffort(cwd), brokenTest.testFile)
322
+ return {
323
+ errorSuffix: ` 🚫 test-gate-veto: ${brokenTestRel}`,
324
+ silentFailureNote:
325
+ `Попередня спроба (${model}) закрила порушення, але зламала наявний тест ` +
326
+ `${brokenTestRel} (файл ${brokenFileRel}) — усі правки відхилено. ` +
327
+ 'Виправ ЛИШЕ саме порушення, не чіпай навколишню логіку/коментарі-попередження.'
328
+ }
329
+ }
330
+
331
+ if (touchedFiles.length === 0) {
332
+ return {
333
+ errorSuffix: ' ❌ досі порушено',
334
+ silentFailureNote: `Попередня спроба (${model}) не внесла жодної зміни у файли; порушення досі активне.`
335
+ }
336
+ }
337
+ return {
338
+ errorSuffix: ' ❌ досі порушено',
339
+ silentFailureNote:
340
+ `Попередня спроба (${model}) торкнулась файлів (${touchedFiles.join(', ')}), ` +
341
+ 'але порушення досі активне — той самий підхід не спрацював, спробуй інакше.'
342
+ }
343
+ }
344
+
177
345
  /**
178
346
  * Проводить один rung ladder-а: worker → canonical re-detect → rollback при провалі.
179
347
  * @param {Rung} rung Поточна сходинка ladder-а.
@@ -187,10 +355,12 @@ async function runT0Phase(item, initialViolations, patterns, lintCtx, cwd, log,
187
355
  * @param {(s: string) => void} rungDeps.log Логер.
188
356
  * @param {import('./progress.mjs').ProgressReporter|null} [rungDeps.progress] Reporter прогресу.
189
357
  * @param {boolean} [rungDeps.verbose] Детальний вивід (прокидається у ctx concern-а).
358
+ * @param {typeof import('./test-gate.mjs').runTestFile} [rungDeps.testRunner] Override
359
+ * test-runner-а для test-gate (інжект для тестів).
190
360
  * @returns {Promise<{ closed: true, touchedFiles: string[] } | { closed: false, outcome: RungOutcome }>} closed=true якщо concern закрито (touchedFiles — зміни worker-а); інакше результат для наступного кроку.
191
361
  */
192
362
  async function runRung(rung, worker, violations, feedback, rungDeps) {
193
- const { item, cwd, snapshot, log, progress = null, verbose = false, chain = null } = rungDeps
363
+ const { item, cwd, snapshot, log, progress = null, verbose = false, chain = null, testRunner } = rungDeps
194
364
  const { ruleId } = item.entry
195
365
  const concernName = item.entry.concern.name
196
366
  progress?.concernStart(progressKey(item), rung.tier)
@@ -252,16 +422,13 @@ async function runRung(rung, worker, violations, feedback, rungDeps) {
252
422
  throw detectError
253
423
  }
254
424
 
255
- // Semantic-collateral veto (§12 addendum 2026-07-05): clean-вердикт не приймається,
256
- // якщо rung ЗМІНИВ наявні файли поза target-set порушення (клас «App.vue: хардкод
257
- // версії замість getVersion»). Нові файли дозволені (scaffold/доки); порожній
258
- // target-set (whole-repo концерни без file-атрибуції) → veto незастосовний.
259
- const targetFiles = [...new Set([...violations.map(v => v.file).filter(Boolean), ...(item.files ?? [])])]
260
- const collateral = findCollateralEdits({ modifiedExisting: snapshot.modifiedExisting(), targetFiles, cwd })
261
- // relative від так само realpath-нормалізованого cwd, інакше symlink-cwd (macOS
262
- // /var → /private/var) дає `../../…`-шляхи у телеметрії та feedback.
263
- const rejectedRel = collateral.map(p => relative(realpathBestEffort(cwd), p))
264
- if (collateral.length > 0) {
425
+ const { targetFiles, collateralAll, rejectedRel, inFileHunkRel } = computeCollateral({
426
+ violations,
427
+ item,
428
+ snapshot,
429
+ cwd
430
+ })
431
+ if (collateralAll.length > 0) {
265
432
  // Телеметрія відхилених правок — той самий глобальний llm-trace, що й fix-виклики.
266
433
  writeTrace({
267
434
  caller: `fix:${ruleId}/${concernName}:${rung.tier}`,
@@ -272,11 +439,20 @@ async function runRung(rung, worker, violations, feedback, rungDeps) {
272
439
  model: rung.model,
273
440
  cwd,
274
441
  rejectedFiles: rejectedRel,
442
+ rejectedHunks: inFileHunkRel,
275
443
  targetFiles,
276
444
  cleanDetect: after.length === 0
277
445
  })
278
446
  }
279
- const vetoed = after.length === 0 && !error && collateral.length > 0
447
+ // Test-gate: третій, незалежний рубіж поверх collateral-veto (cross-file + in-file
448
+ // hunk) — пропускається, якщо collateralAll уже ветував rung (нема сенсу гонити
449
+ // тести на приреченому rung-у).
450
+ const brokenTest =
451
+ after.length === 0 && !error && collateralAll.length === 0
452
+ ? detectBrokenTest({ targetFiles, snapshot, cwd, testRunner, ruleId, concernName, rung })
453
+ : null
454
+
455
+ const vetoed = after.length === 0 && !error && (collateralAll.length > 0 || brokenTest !== null)
280
456
  const touchedFiles = workerResult?.touchedFiles ?? []
281
457
 
282
458
  if (after.length === 0 && !error && !vetoed) {
@@ -301,9 +477,17 @@ async function runRung(rung, worker, violations, feedback, rungDeps) {
301
477
  return { closed: true, touchedFiles }
302
478
  }
303
479
 
304
- let errorSuffix = ' досі порушено'
305
- if (error) errorSuffix = ` ❌ ${error.slice(0, 120)}`
306
- else if (vetoed) errorSuffix = ` 🚫 collateral-veto: ${rejectedRel.join(', ')}`
480
+ const { errorSuffix, silentFailureNote } = describeVetoOutcome({
481
+ error,
482
+ collateralAll,
483
+ rejectedRel,
484
+ inFileHunkRel,
485
+ brokenTest,
486
+ cwd,
487
+ targetFiles,
488
+ model: rung.model,
489
+ touchedFiles
490
+ })
307
491
  log(` ⚡ ${rung.tier} (${rung.model}): ${ruleId}/${concernName}${errorSuffix}\n`)
308
492
 
309
493
  // Не clean → restore S1 перед наступним rung-ом (degraded не тече далі).
@@ -312,23 +496,6 @@ async function runRung(rung, worker, violations, feedback, rungDeps) {
312
496
  // canonical re-detect-ом вище — наступний rung/прогін продовжує з решти, не з нуля.
313
497
  snapshot.rollback()
314
498
 
315
- // Мовчазна невдача (worker не кинув виняток, але порушення лишилось) — без цього
316
- // наступний rung стартує без жодного знання про попередню спробу (buildFixPrompt
317
- // додає `## Попередня спроба` лише коли previousError truthy).
318
- let silentFailureNote
319
- if (vetoed) {
320
- silentFailureNote =
321
- `Попередня спроба (${rung.model}) закрила порушення, але змінила наявні файли поза ` +
322
- `target-set (${rejectedRel.join(', ')}) — усі правки відхилено. ` +
323
- `Редагуй ЛИШЕ файли порушення: ${targetFiles.join(', ')}.`
324
- } else if (touchedFiles.length === 0) {
325
- silentFailureNote = `Попередня спроба (${rung.model}) не внесла жодної зміни у файли; порушення досі активне.`
326
- } else {
327
- silentFailureNote =
328
- `Попередня спроба (${rung.model}) торкнулась файлів (${touchedFiles.join(', ')}), ` +
329
- 'але порушення досі активне — той самий підхід не спрацював, спробуй інакше.'
330
- }
331
-
332
499
  return {
333
500
  closed: false,
334
501
  outcome: {
@@ -374,6 +541,8 @@ function summarizeProblem(violations) {
374
541
  * @param {import('./progress.mjs').ProgressReporter|null} [deps.progress] Reporter прогресу.
375
542
  * @param {boolean} [deps.verbose] Детальний вивід (прокидається у ctx concern-а).
376
543
  * @param {typeof startChain} [deps.chainFactory] Фабрика ланцюжка (інжект для тестів).
544
+ * @param {typeof import('./test-gate.mjs').runTestFile} [deps.testRunner] Override
545
+ * test-runner-а для test-gate (інжект для тестів цього модуля).
377
546
  * @returns {Promise<boolean>} Чи закрито concern (усі порушення усунено).
378
547
  */
379
548
  export async function fixConcern(item, initialViolations, deps) {
@@ -520,7 +689,8 @@ async function fixConcernCore(item, initialViolations, deps, chain, chainExtra,
520
689
  log,
521
690
  progress,
522
691
  verbose,
523
- chain
692
+ chain,
693
+ testRunner: deps.testRunner
524
694
  })
525
695
  if (rung.isAvg) deps.spendAvg(1)
526
696
  chainExtra.rungs.push({
@@ -635,7 +805,7 @@ async function materializeTailToMt(remaining, cwd, log) {
635
805
  * @param {(s: string) => void} [opts.log] Логер виводу.
636
806
  * @param {boolean} [opts.isTTY] Override TTY-режиму ProgressReporter (тести); типово isTTY stdout.
637
807
  * @param {(snap: object) => void} [opts.onProgress] Публікація знімків прогресу назовні (черга lint --full).
638
- * @param {object} [opts.deps] Інжекти для тестів: { ladder, workerFor, t0For, chainFactory }.
808
+ * @param {object} [opts.deps] Інжекти для тестів: { ladder, workerFor, t0For, chainFactory, testRunner }.
639
809
  * @returns {Promise<0|1|2>} Exit code: 0 — чисто, 1 — лишились порушення, 2 — DetectorError.
640
810
  */
641
811
  export async function runFixPipeline(opts) {
@@ -709,7 +879,8 @@ export async function runFixPipeline(opts) {
709
879
  },
710
880
  workerOverride: deps.workerFor ? deps.workerFor(item.entry) : undefined,
711
881
  t0Override: patternsByItem.get(item),
712
- chainFactory: deps.chainFactory
882
+ chainFactory: deps.chainFactory,
883
+ testRunner: deps.testRunner
713
884
  })
714
885
 
715
886
  let worst = 0
@@ -34,6 +34,8 @@ const ABSENT = Symbol('absent')
34
34
  * або зроблено durable-позначку
35
35
  * @property {() => string[]} modifiedExisting наявні на момент S1 файли, чий поточний
36
36
  * вміст відрізняється від pre-image (вхід semantic-collateral veto; нові файли не входять)
37
+ * @property {(absPath: string) => string|null} preImageOf pre-image вмісту файлу на момент
38
+ * S1, або null якщо файл не було записано чи він не існував (вхід in-file hunk-level veto)
37
39
  */
38
40
 
39
41
  /**
@@ -78,6 +80,10 @@ export function createSnapshot() {
78
80
  ([abs, pre]) => pre !== ABSENT && !durable.has(abs) && (!existsSync(abs) || readFileSync(abs, 'utf8') !== pre)
79
81
  )
80
82
  .map(([abs]) => abs)
83
+ },
84
+ preImageOf(absPath) {
85
+ const pre = preImages.get(absPath)
86
+ return pre === undefined || pre === ABSENT ? null : pre
81
87
  }
82
88
  }
83
89
  }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Test-gate верифікація для non-T0 (LLM) fix-ladder rung-ів (spec addendum
3
+ * 2026-07-24, ladder-collateral-in-file).
4
+ *
5
+ * `collateral-veto.mjs` детектує колатеральні правки лише ПОЗА target-set (інші
6
+ * файли) — правки ВСЕРЕДИНІ вже-таргетованого файлу (напр. видалення навмисного,
7
+ * задокументованого workaround поряд із фіксованим порушенням) ним не покриваються,
8
+ * а canonical re-detect бачить лише той самий детектор/те саме порушення, тож теж
9
+ * не ловить. Test-gate — легша альтернатива hunk-level diff (доки не реалізовано):
10
+ * якщо rung торкнувся файлу з target-set, для якого існує сестринський тест-файл за
11
+ * конвенцією `<dir>/tests/<stem>.test.{mjs,js,ts}` (n-test.mdc), той тест
12
+ * виконується як частина verify. Провал тесту → veto (як і collateral), rollback.
13
+ *
14
+ * Fail-open за дизайном (як і collateral-veto): відсутній test-runner (`bunx`/vitest
15
+ * недоступні), таймаут чи інша інфраструктурна помилка — НЕ блокує rung. Мета —
16
+ * зловити семантичну регресію, а не стати новою точкою відмови ladder-а.
17
+ */
18
+ import { existsSync } from 'node:fs'
19
+ import { spawnSync } from 'node:child_process'
20
+ import { basename, dirname, join } from 'node:path'
21
+
22
+ const TEST_SUFFIXES = ['.test.mjs', '.test.js', '.test.ts']
23
+ const SOURCE_SUFFIXES = ['.mjs', '.js', '.ts', '.vue']
24
+
25
+ /** Таймаут одного тест-файлу (fail-open — вважається "не зламано" при перевищенні). */
26
+ const TEST_RUN_TIMEOUT_MS = 30_000
27
+
28
+ /**
29
+ * Сестринські тест-файли за конвенцією `<dir>/tests/<stem>.test.*` (n-test.mdc,
30
+ * той самий шаблон, що й зворотний `inferSourcePath` у coverage-provider).
31
+ * @param {string} sourceAbsPath Абсолютний шлях вихідного файлу.
32
+ * @returns {string[]} Наявні на диску сестринські тест-файли (може бути порожньо).
33
+ */
34
+ export function findSiblingTestFiles(sourceAbsPath) {
35
+ const base = basename(sourceAbsPath)
36
+ const suffix = SOURCE_SUFFIXES.find(s => base.endsWith(s))
37
+ if (!suffix) return []
38
+ const stem = base.slice(0, -suffix.length)
39
+ const testsDir = join(dirname(sourceAbsPath), 'tests')
40
+ return TEST_SUFFIXES.map(ext => join(testsDir, `${stem}${ext}`)).filter(existsSync)
41
+ }
42
+
43
+ /**
44
+ * Виконує один тест-файл через vitest. Fail-open на будь-яку інфраструктурну
45
+ * помилку (bunx/vitest відсутній, spawn error, таймаут) — повертає `passed: true`,
46
+ * щоб test-gate ніколи не блокував ladder через відсутність test-runner-а.
47
+ * @param {string} testAbsPath Абсолютний шлях тест-файлу.
48
+ * @param {string} cwd Робоча директорія запуску.
49
+ * @returns {{ passed: boolean, output: string }} Результат прогону.
50
+ */
51
+ export function runTestFile(testAbsPath, cwd) {
52
+ let result
53
+ try {
54
+ result = spawnSync('bunx', ['vitest', 'run', '--reporter=verbose', testAbsPath], {
55
+ cwd,
56
+ encoding: 'utf8',
57
+ timeout: TEST_RUN_TIMEOUT_MS,
58
+ env: process.env
59
+ })
60
+ } catch {
61
+ return { passed: true, output: '' }
62
+ }
63
+ // result.error — spawn сам не зміг стартувати (ENOENT тощо); status===null — вбито
64
+ // за таймаутом. Обидва — інфраструктурна невдача, не сигнал про код; fail-open.
65
+ if (result.error || result.status === null) return { passed: true, output: '' }
66
+ return { passed: result.status === 0, output: `${result.stdout ?? ''}\n${result.stderr ?? ''}`.slice(-2000) }
67
+ }
68
+
69
+ /**
70
+ * Test-gate над файлами, зміненими rung-ом В МЕЖАХ target-set (не колатеральними,
71
+ * ті вже покриті collateral-veto): перший сестринський тест, що впав, зупиняє
72
+ * пошук — цього достатньо, щоб відхилити clean-вердикт rung-а.
73
+ * @param {{ files: string[], cwd: string, runTest?: typeof runTestFile }} args files —
74
+ * абсолютні шляхи наявних файлів у target-set, змінених rung-ом; cwd — робоча
75
+ * директорія запуску тестів; runTest — override test-runner-а (тести цього модуля).
76
+ * @returns {{ file: string, testFile: string, output: string } | null} Перший
77
+ * зафіксований провал або null, якщо всі сестринські тести (за наявності) пройшли.
78
+ */
79
+ export function findBrokenSiblingTests({ files, cwd, runTest = runTestFile }) {
80
+ for (const file of files) {
81
+ for (const testFile of findSiblingTestFiles(file)) {
82
+ const { passed, output } = runTest(testFile, cwd)
83
+ if (!passed) return { file, testFile, output }
84
+ }
85
+ }
86
+ return null
87
+ }
@@ -5,15 +5,14 @@
5
5
  * Промпт збирає інструкцію скілу + контекст поточного CWD (`package.json`, `tsconfig.json`,
6
6
  * `.n-rules.json`) — далі stdout або виконання через один з раннерів: вбудований
7
7
  * pi-агент, чи зовнішній ACP-агент. `cursor`/`codex` — через `@7n/llm-lib/acp`
8
- * (napi-міст до `llm_cascade::acp`, без власного JSON-RPC у JS); deprecated
8
+ * (napi-міст до `llm_lib::acp`, без власного JSON-RPC у JS); deprecated
9
9
  * `claude` — окремий JS-шим (`./lib/acp-runner.mjs`), бо Rust-крейт його не моделює.
10
10
  *
11
- * `skill <runner> taze` — виняток із загального шляху "весь SKILL.md одним промптом":
12
- * делегує в `../skills/taze/js/orchestrate.mjs`, який детерміновано (без LLM) робить
13
- * бекап/масовий bump/diff/прибирання і лише по одному ОБМЕЖЕНОМУ виклику `<runner>`
14
- * на кожен major-пакет замість одного величезного непрозорого ходу на весь монорепо
15
- * (той, single-shot, раніше зависав без діагностики; per-пакет виклики успадковують
16
- * власний timeout раннера, і падіння одного пакета не втрачає прогрес по інших).
11
+ * `skill <runner> taze|git-reconcile` — JS-оркестровані винятки із загального
12
+ * шляху "весь SKILL.md одним промптом". `taze` детерміновано робить
13
+ * backup/bump/diff/cleanup і викликає LLM лише для major-міграцій;
14
+ * `git-reconcile` детерміновано інвентаризує Git-граф, готує worktree/PR і
15
+ * викликає LLM лише для semantic triage та conflict resolution.
17
16
  *
18
17
  * Підтримувані формати:
19
18
  * `npx \@7n/rules skill list`
@@ -36,11 +35,12 @@ import { readSkillMetaRaw, skillTier } from './lib/skill-meta.mjs'
36
35
 
37
36
  /** Виконавці скіла. `pi` — вбудований (рекомендований); `cursor`/`codex`/`claude` — зовнішні ACP-агенти (`claude` — deprecated). */
38
37
  const RUNNERS = new Set(['pi', 'cursor', 'codex', 'claude'])
38
+ const JS_ORCHESTRATED_SKILLS = new Set(['taze', 'git-reconcile'])
39
39
 
40
40
  /**
41
41
  * Раннери, що йдуть через зовнішнього ACP-агента, і чи deprecated (друкує попередження
42
42
  * перед запуском). `cursor`/`codex` — napi-міст `@7n/llm-lib/acp`; `claude` — окремий
43
- * JS-шим `runAcpRunner` (Rust-крейт `llm_cascade::acp` `claude` не моделює).
43
+ * JS-шим `runAcpRunner` (Rust-крейт `llm_lib::acp` `claude` не моделює).
44
44
  */
45
45
  const ACP_RUNNERS = {
46
46
  claude: { deprecated: true },
@@ -173,7 +173,7 @@ async function runPiRunner(prompt, rawSkillName, skillsRoot, projectDir, logErro
173
173
 
174
174
  /**
175
175
  * Делегує виконання скіла зовнішньому ACP-агенту. `cursor`/`codex` — через
176
- * `@7n/llm-lib/acp` (napi-міст до `llm_cascade::acp`: спавн, `session/prompt`,
176
+ * `@7n/llm-lib/acp` (napi-міст до `llm_lib::acp`: спавн, `session/prompt`,
177
177
  * автоапрув дозволів — усе в Rust, без JSON-RPC у JS). `claude` — deprecated
178
178
  * JS-шим `runAcpRunner` (Rust його не моделює); буде прибрано (мігруй на `skill pi`).
179
179
  * На відміну від колишнього стрімінгу по чанках, napi-шлях повертає повний текст
@@ -235,6 +235,33 @@ async function runTazeOrchestratorCli(runner, projectDir, log, logError, deps =
235
235
  }
236
236
  }
237
237
 
238
+ /**
239
+ * Виконує `git-reconcile` через JS-оркестратор: Git inventory/patch-equivalence/
240
+ * worktree/cherry-pick/gates/push/PR — детерміновано; LLM — лише semantic triage
241
+ * та conflict resolution.
242
+ * @param {'pi' | 'cursor' | 'codex'} runner LLM-раннер для bounded кроків
243
+ * @param {string} projectDir корінь проєкту
244
+ * @param {string} task додатковий намір користувача
245
+ * @param {(line: string) => void} log прогрес/звіт
246
+ * @param {(line: string) => void} logError помилки
247
+ * @param {{ runGitReconcileOrchestrator?: (opts: object) => Promise<{ok:boolean,report:string}> }} [deps] інжекти
248
+ * @returns {Promise<number>} exit code
249
+ */
250
+ async function runGitReconcileOrchestratorCli(runner, projectDir, task, log, logError, deps = {}) {
251
+ let orchestrate = deps.runGitReconcileOrchestrator
252
+ if (!orchestrate) {
253
+ const module = await import('../skills/git-reconcile/js/orchestrate.mjs')
254
+ orchestrate = module.runGitReconcileOrchestrator
255
+ }
256
+ try {
257
+ const result = await orchestrate({ cwd: projectDir, runner, task, log, deps })
258
+ return result.ok ? 0 : 1
259
+ } catch (error) {
260
+ logError(error instanceof Error ? error.message : String(error))
261
+ return 1
262
+ }
263
+ }
264
+
238
265
  /**
239
266
  * Корінь пакета `@7n/rules` (каталог з `skills/`, `rules/`, …).
240
267
  * @param {string} [fromModuleUrl] для тестів — `import.meta.url`, відносно якого шукати корінь
@@ -261,6 +288,18 @@ export function isTazeOrchestratorSkillArgs(argv) {
261
288
  return RUNNERS.has(first) && first !== 'claude' && normalizeSkillId(second) === 'taze'
262
289
  }
263
290
 
291
+ /**
292
+ * Чи аргументи ведуть у будь-який JS-оркестрований skill. Потрібно верхньому
293
+ * CLI, щоб не мутувати root package.json self-upgrade-ом до власного preflight
294
+ * оркестратора.
295
+ * @param {string[]} argv аргументи після `skill`
296
+ * @returns {boolean} true для taze/git-reconcile з pi/cursor/codex
297
+ */
298
+ export function isJsOrchestratedSkillArgs(argv) {
299
+ const [first, second] = argv
300
+ return RUNNERS.has(first) && first !== 'claude' && JS_ORCHESTRATED_SKILLS.has(normalizeSkillId(second))
301
+ }
302
+
264
303
  /**
265
304
  * @param {string[]} argv аргументи після `skill` у `n-rules`
266
305
  * @param {{ packageRoot?: string, projectDir?: string, log?: (line: string) => void, logError?: (line: string) => void, deps?: { runPiAgentSkill?: (prompt: string, opts?: object) => Promise<{ ok: boolean, error: string|null }> } }} [options] перевизначення кореня пакета, каталогу проєкту, функцій виводу та інжектів (для тестів)
@@ -295,7 +334,8 @@ export async function runSkillsCli(argv, options = {}) {
295
334
  if (!second) {
296
335
  throw new Error(`Skill name is required after "${first}"`)
297
336
  }
298
- if (first !== 'claude' && normalizeSkillId(second) === 'taze') {
337
+ const skillId = normalizeSkillId(second)
338
+ if (first !== 'claude' && skillId === 'taze') {
299
339
  return await runTazeOrchestratorCli(
300
340
  /** @type {'pi' | 'cursor' | 'codex'} */ (first),
301
341
  projectDir,
@@ -304,6 +344,16 @@ export async function runSkillsCli(argv, options = {}) {
304
344
  deps
305
345
  )
306
346
  }
347
+ if (first !== 'claude' && skillId === 'git-reconcile') {
348
+ return await runGitReconcileOrchestratorCli(
349
+ /** @type {'pi' | 'cursor' | 'codex'} */ (first),
350
+ projectDir,
351
+ rest.join(' '),
352
+ log,
353
+ logError,
354
+ deps
355
+ )
356
+ }
307
357
  const task = rest.join(' ')
308
358
  const prompt = buildSkillPrompt(skillsRoot, second, task, projectDir)
309
359
  if (first === 'pi') {
@@ -3,29 +3,35 @@ type: JS Module
3
3
  title: glob-compat.mjs
4
4
  resource: npm/scripts/utils/glob-compat.mjs
5
5
  docgen:
6
- crc: a226a048
7
- model: manual
8
- tier: manual
6
+ crc: 0c6af731
7
+ model: openai-codex/gpt-5.4-mini
8
+ tier: cloud-min
9
9
  score: 100
10
+ issues: judge-refine:kept-original,judge:inaccurate:0.98
11
+ judgeModel: openai-codex/gpt-5.4-mini
10
12
  ---
11
13
 
12
14
  ## Огляд
13
15
 
14
- Runtime-нейтральний glob-обхід для коду, що виконується і під Bun, і під Node. Потрібен, бо hook запускається через `npx` Node, де глобал `Bun` не визначений (top-level `new Bun.Glob(...)` зривав import модуля-детектора), а прямий `node:fs/promises#glob` не працює на self-hosted Linux Bun 1.3.14, де Node-compat шим не надає export `glob`. Реалізація вибирається за середовищем виконання: `Bun.Glob` під Bun, `node:fs/promises#glob` під Node (гарантовано за `engines: node >=25`).
16
+ Файл забезпечує runtime-нейтральний glob-обхід для коду, що має працювати і під Bun, і під Node. Це потрібно, щоб імпорт модуля не падав у hook-сценаріях, які запускаються через `npx` у Node, де глобал `Bun` не визначений і top-level `new Bun.Glob` ламає завантаження модуля. Вибір механізму обходу відбувається за середовищем виконання: `Bun.Glob` під Bun, `node:fs/promises#glob` під Node (`node >=25`). Публічні точки файлу — `resolveGlobScan` і `hasIgnoredPathSegment`; друга відсікає шляхи через службові теки перед подальшою обробкою.
15
17
 
16
- ## Публічний API
18
+ ## Поведінка
19
+
20
+ `resolveGlobScan` уніфікує результат сканування glob перед подальшою ітерацією: якщо `Bun.Glob.scan` повертає Promise, він дочікується розв’язання, якщо вже повертає async-iterable — передає його далі без змін. Це прибирає різницю між середовищами виконання й дозволяє наступним крокам працювати з одним форматом даних.
17
21
 
18
- - `scanGlob(pattern, cwd)` async-генератор: ітерує відносні (до `cwd`) шляхи файлів, що відповідають glob-патерну (наприклад, `cf/*/package.json`).
19
- - `hasIgnoredPathSegment(relPath, ignoredDirs)` — чи містить відносний шлях сегмент зі службових тек (наприклад, `node_modules`), які glob-обхід має ігнорувати; еквівалент ignore-патернів `**/<dir>/**` по кожній теці з `ignoredDirs`. Розділювачі `\` нормалізуються до `/`.
22
+ `hasIgnoredPathSegment` застосовує спільне правило відсікання службових тек до відносних шляхів, щоб результати glob-обходу не потрапляли в обробку, якщо шлях проходить через одну з ігнорованих тек. Перевірка працює по сегментах шляху, тож однаковий результат дає і для Unix-, і для Windows-розділювачів.
20
23
 
21
- ## Де використовується
24
+ Разом ці функції формують потік: сканування дає сирі збіги, `resolveGlobScan` стабілізує форму їх повернення, а `hasIgnoredPathSegment` відсікає небажані шляхи до передачі результатів далі. Поведінка узгоджується з очікуваннями, закладеними в `package.json`.
25
+
26
+ ## Публічний API
22
27
 
23
- - `npm/scripts/lib/workspaces.mjs`розгортання workspace-патернів із `*`.
24
- - `npm/scripts/utils/resolve-js-root.mjs` резолв JS-roots за workspace-патернами.
25
- - `npm/rules/changelog/lib/package-manifest.mjs`пошук `pyproject.toml` по репо.
26
- - `npm/rules/tauri/core_test_isolation/main.mjs` glob-члени `[workspace] members` і обхід `**/*.rs`.
28
+ - resolveGlobScanРозрізняє дві форми повернення `Bun.Glob#scan()`: async-iterable напряму
29
+ (macOS) або Promise, що резолвиться в async-iterable (спостережено на
30
+ self-hosted Linux Bun 1.3.14 — `yield*` на Promise падає з "is not async
31
+ iterable", бо в Promise немає ні `Symbol.asyncIterator`, ні `Symbol.iterator`).
32
+ - hasIgnoredPathSegment — Чи містить відносний шлях сегмент зі службових тек, які glob-обхід має ігнорувати.
33
+ Еквівалент колишніх ignore-патернів `**\/<dir>/**` по кожній теці з `ignoredDirs`.
27
34
 
28
35
  ## Гарантії поведінки
29
36
 
30
- - Read-only: не виконує операцій запису (ФС/БД).
31
- - Не фільтрує результати сам: ігнорування службових тек — відповідальність викликача (через `hasIgnoredPathSegment` або власні перевірки).
37
+ - Власних операцій запису (ФС/БД) у файлі немає; виклики імпортованих модулів можуть писати.