@7n/rules 1.48.2 → 1.49.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 +29 -0
- package/bin/n-rules-cli.mjs +5 -8
- package/package.json +1 -1
- package/rules/changelog/.changes/260724-1500.md +5 -0
- package/rules/doc-files/docgen-files-batch/docs/index.md +9 -0
- package/rules/doc-files/docgen-files-batch/docs/main.md +59 -18
- package/rules/doc-files/docgen-files-batch/main.mjs +280 -29
- package/rules/doc-files/docgen-gen/docs/index.md +9 -0
- package/rules/doc-files/docgen-gen/docs/main.md +40 -29
- package/rules/doc-files/docgen-gen/main.mjs +79 -25
- package/rules/test/coverage/fix-worker.mjs +9 -1
- package/rules/test/coverage/lib/classify/verdict-schema.mjs +3 -1
- package/scripts/docs/skills-cli.md +18 -24
- package/scripts/lib/acp-runner.mjs +1 -1
- package/scripts/lib/lint-surface/collateral-veto.mjs +78 -2
- package/scripts/lib/lint-surface/docs/collateral-veto.md +30 -16
- package/scripts/lib/lint-surface/docs/index.md +1 -0
- package/scripts/lib/lint-surface/docs/run-fix.md +8 -23
- package/scripts/lib/lint-surface/docs/snapshot.md +6 -17
- package/scripts/lib/lint-surface/docs/test-gate.md +29 -0
- package/scripts/lib/lint-surface/run-fix.mjs +209 -38
- package/scripts/lib/lint-surface/snapshot.mjs +6 -0
- package/scripts/lib/lint-surface/test-gate.mjs +87 -0
- package/scripts/skills-cli.mjs +60 -10
- package/scripts/utils/docs/glob-compat.md +20 -14
- package/scripts/utils/glob-compat.mjs +18 -3
- package/skills/git-reconcile/SKILL.md +58 -0
- package/skills/git-reconcile/js/docs/index.md +9 -0
- package/skills/git-reconcile/js/docs/orchestrate.md +33 -0
- package/skills/git-reconcile/js/orchestrate.mjs +776 -0
- package/skills/git-reconcile/main.json +1 -0
- package/skills/taze/js/docs/orchestrate.md +40 -18
- package/skills/taze/js/orchestrate.mjs +6 -3
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: JS Module
|
|
3
|
+
title: test-gate.mjs
|
|
4
|
+
resource: npm/scripts/lib/lint-surface/test-gate.mjs
|
|
5
|
+
docgen:
|
|
6
|
+
crc: 212d387b
|
|
7
|
+
model: manual
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Огляд
|
|
11
|
+
|
|
12
|
+
Test-gate верифікація для non-T0 (LLM) fix-ladder rung-ів (spec addendum 2026-07-24, ladder-collateral-in-file). Закриває клас колатеральних правок, які [collateral-veto.mjs](./collateral-veto.md) не бачить: правки ВСЕРЕДИНІ вже-таргетованого файлу (напр. видалення задокументованого workaround поруч із фіксованим порушенням) не зачіпають жодного детектора й не торкаються файлів поза target-set, тож проходять і collateral-veto, і canonical re-detect. Якщо для зміненого файлу з target-set існує сестринський тест-файл за конвенцією `<dir>/tests/<stem>.test.{mjs,js,ts}` (n-test.mdc), той тест виконується як частина verify — провал тесту відхиляє clean-вердикт rung-а так само, як і collateral-veto.
|
|
13
|
+
|
|
14
|
+
## Поведінка
|
|
15
|
+
|
|
16
|
+
1. `findSiblingTestFiles(sourceAbsPath)` конструює кандидатів `<dir>/tests/<stem>.test.{mjs,js,ts}` для джерела з розширенням `.mjs`/`.js`/`.ts`/`.vue` і повертає лише наявні на диску файли.
|
|
17
|
+
2. `runTestFile(testAbsPath, cwd)` виконує один тест-файл через `bunx vitest run --reporter=verbose <file>` (`spawnSync`, таймаут 30s). Fail-open: spawn-помилка, відсутній `bunx`/vitest, чи таймаут (`status === null`) — повертає `passed: true`, а не блокує rung.
|
|
18
|
+
3. `findBrokenSiblingTests({ files, cwd, runTest })` перебирає файли (наявні, у target-set, реально змінені rung-ом), для кожного шукає сестринські тести й запускає перший знайдений; перший провал зупиняє пошук і повертається як veto-кандидат. `runTest` — override test-runner-а для юніт-тестів цього модуля (типово `runTestFile`).
|
|
19
|
+
|
|
20
|
+
## Публічний API
|
|
21
|
+
|
|
22
|
+
- `findSiblingTestFiles(sourceAbsPath)` — наявні сестринські тест-файли джерела (може бути порожньо).
|
|
23
|
+
- `runTestFile(testAbsPath, cwd)` — `{ passed, output }`, fail-open на інфраструктурну помилку.
|
|
24
|
+
- `findBrokenSiblingTests({ files, cwd, runTest? })` — `{ file, testFile, output } | null`, перший зафіксований провал.
|
|
25
|
+
|
|
26
|
+
## Гарантії поведінки
|
|
27
|
+
|
|
28
|
+
- Fail-open за дизайном: відсутній test-runner, таймаут чи відсутність сестринського тесту ніколи не блокує fix-ladder.
|
|
29
|
+
- Не редагує жодних файлів — лише читає (пошук тест-файлів) і запускає сторонній процес (vitest).
|
|
@@ -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 {
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
+
}
|
package/scripts/skills-cli.mjs
CHANGED
|
@@ -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-міст до `
|
|
8
|
+
* (napi-міст до `llm_lib::acp`, без власного JSON-RPC у JS); deprecated
|
|
9
9
|
* `claude` — окремий JS-шим (`./lib/acp-runner.mjs`), бо Rust-крейт його не моделює.
|
|
10
10
|
*
|
|
11
|
-
* `skill <runner> taze` —
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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-крейт `
|
|
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-міст до `
|
|
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
|
-
|
|
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') {
|