@nitra/cursor 3.10.0 → 3.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.11.0] - 2026-06-02
4
+
5
+ ### Changed
6
+
7
+ - coverage: scoped режим --changed — flow-турнікет (DEFAULT_GATES) перевіряє лише змінені від base_commit файли (vitest --changed + Stryker --mutate), однаково для закомічених і незакомічених змін; повний coverage лишається для bun run coverage / n-coverage-fix
8
+
3
9
  ## [3.10.0] - 2026-06-01
4
10
 
5
11
  ### Added
package/bin/n-cursor.js CHANGED
@@ -1527,9 +1527,10 @@ try {
1527
1527
  }
1528
1528
  case 'coverage': {
1529
1529
  // n-cursor coverage — оркестратор покриття + мутаційного тестування з discovery
1530
- // провайдерів через .n-cursor.json#rules (test.mdc).
1530
+ // провайдерів через .n-cursor.json#rules (test.mdc). --changed звужує scope до
1531
+ // змінених від base файлів (flow-турнікет: лише vitest/Stryker по diff).
1531
1532
  const { runCoverageCli } = await import('../rules/test/coverage/coverage.mjs')
1532
- process.exitCode = await runCoverageCli({ fix: args.includes('--fix') })
1533
+ process.exitCode = await runCoverageCli({ fix: args.includes('--fix'), changed: args.includes('--changed') })
1533
1534
 
1534
1535
  break
1535
1536
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "3.10.0",
3
+ "version": "3.11.0",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -78,9 +78,12 @@ Composer, Claude Code) працює **Пасивний Турнікет**: ти,
78
78
  npx @nitra/cursor flow verify
79
79
  ```
80
80
 
81
- Проганяє Quality Gate (lint). Повертає `0` (pass) або `1` із виводом
82
- проваленого gate. Coverage (тести + Stryker-мутації) **поза** turnstile —
83
- запускай окремо `npx @nitra/cursor coverage` або в CI.
81
+ Проганяє Quality Gates (lint + coverage). Повертає `0` (pass) або `1` із
82
+ виводом проваленого gate. **Обидва гейти перевіряють лише змінені файли**
83
+ (diff від `base_commit`): `lint` — quick-режим, `coverage --changed` vitest
84
+ `--changed` + Stryker `--mutate` по diff. Працює однаково, незалежно від того,
85
+ чи зміни вже закомічені у worktree. Повний coverage (увесь проєкт) — окремо:
86
+ `bun run coverage` або `/n-coverage-fix`.
84
87
 
85
88
  6. **Review (adversarial)** — рекомендовано перед release:
86
89
 
@@ -10,13 +10,36 @@ import { spawnSync } from 'node:child_process'
10
10
  import { existsSync, readFileSync } from 'node:fs'
11
11
  import { mkdtemp, readFile, rm } from 'node:fs/promises'
12
12
  import { tmpdir } from 'node:os'
13
- import { join, relative } from 'node:path'
13
+ import { isAbsolute, join, relative } from 'node:path'
14
14
 
15
15
  import { resolveAllJsRoots } from '../../../scripts/utils/resolve-js-root.mjs'
16
16
  import { addCoverage, addMutation } from '../../test/coverage/coverage.mjs'
17
17
 
18
18
  const TEST_BLOCK_START = /^\s*(it|test)\(/
19
19
  const FILE_EXTENSION = /\.[^.]+$/
20
+ /** JS/TS-розширення — файли, які мутує Stryker і покриває vitest. */
21
+ const JS_FILE = /\.(c|m)?[jt]sx?$/
22
+ /** Тест-файли (`*.test.*` / `*.spec.*`) — НЕ production-код, не йдуть у Stryker `--mutate`. */
23
+ const TEST_FILE = /\.(test|spec)\.[^.]+$/
24
+
25
+ /**
26
+ * Звужує список змінених файлів (relative до cwd) до тих, що лежать під `jsRoot`,
27
+ * мають JS/TS-розширення, і рібейзить їх відносно `jsRoot`.
28
+ * @param {string[]} changedFiles relative-до-cwd шляхи змінених файлів
29
+ * @param {string} cwd корінь проєкту
30
+ * @param {string} jsRoot абсолютний шлях workspace-кореня
31
+ * @returns {string[]} JS-файли під jsRoot, шляхи relative до jsRoot
32
+ */
33
+ export function scopeToRoot(changedFiles, cwd, jsRoot) {
34
+ const out = []
35
+ for (const f of changedFiles) {
36
+ if (!JS_FILE.test(f)) continue
37
+ const rel = relative(jsRoot, join(cwd, f))
38
+ if (rel.startsWith('..') || isAbsolute(rel)) continue
39
+ out.push(rel)
40
+ }
41
+ return out
42
+ }
20
43
  const VITEST_HINT =
21
44
  'js-lint coverage: vitest відсутній у package.json — додай `vitest`, `@vitest/coverage-v8` та `@stryker-mutator/vitest-runner` у devDependencies (див. test.mdc)'
22
45
 
@@ -217,7 +240,11 @@ export function parseStrykerReport(report, jsRoot) {
217
240
  * у такому випадку сигналізує "no tests" → collectOneRoot пропускає workspace.
218
241
  */
219
242
  const defaultRunner = {
220
- runJsCoverage({ cwd, lcovDir }) {
243
+ runJsCoverage({ cwd, lcovDir, base }) {
244
+ // base !== undefined ⇔ --changed-режим: vitest сам рахує зачеплені змінами тести
245
+ // через граф імпортів. `--changed <base>` порівнює base↔робоче дерево (committed і
246
+ // uncommitted разом); `--changed` без аргументу — uncommitted vs HEAD.
247
+ const changedArgs = base === undefined ? [] : base === null ? ['--changed'] : ['--changed', base]
221
248
  const r = spawnSync(
222
249
  'bunx',
223
250
  [
@@ -226,13 +253,14 @@ const defaultRunner = {
226
253
  '--passWithNoTests',
227
254
  '--coverage',
228
255
  '--coverage.reporter=lcov',
229
- `--coverage.reportsDirectory=${lcovDir}`
256
+ `--coverage.reportsDirectory=${lcovDir}`,
257
+ ...changedArgs
230
258
  ],
231
259
  { cwd, stdio: 'inherit', env: process.env }
232
260
  )
233
261
  return r.status ?? 1
234
262
  },
235
- runStryker({ cwd }) {
263
+ runStryker({ cwd, mutate }) {
236
264
  // `npx`, не `bunx`: bunx завжди ставить пакет у `T/bunx-<uid>-<pkg>@latest` і запускає
237
265
  // Stryker звідти. Плагін-discovery у Stryker (`@stryker-mutator/*`) globится відносно
238
266
  // CORE-install-каталогу (`core/dist/src/di/plugin-loader.js` → `../../../../../@stryker-mutator/*`),
@@ -240,30 +268,48 @@ const defaultRunner = {
240
268
  // встановлений `@stryker-mutator/vitest-runner` залишається невидимим, і workers падають з
241
269
  // `Cannot find TestRunner plugin "vitest"`. `npx` ходить угору по `node_modules/.bin/` і
242
270
  // запускає Stryker з локального hoisted-install, де поряд лежить vitest-runner.
243
- const r = spawnSync('npx', ['@stryker-mutator/core', 'run'], { cwd, stdio: 'inherit', env: process.env })
271
+ // mutate (непорожній) --changed-режим: мутуємо лише змінені production-файли цього root.
272
+ const mutateArgs = mutate && mutate.length > 0 ? ['--mutate', mutate.join(',')] : []
273
+ const r = spawnSync('npx', ['@stryker-mutator/core', 'run', ...mutateArgs], {
274
+ cwd,
275
+ stdio: 'inherit',
276
+ env: process.env
277
+ })
244
278
  return r.status ?? 1
245
279
  }
246
280
  }
247
281
 
248
282
  /**
249
283
  * Збирає метрики покриття + мутаційного тестування для **одного** JS-root.
250
- * Пропускає workspace без тестів (повертає `null`): vitest у такому випадку
251
- * пройшов з `--passWithNoTests`, але lcov порожній нема сенсу запускати
252
- * Stryker. Реальні помилки (vitest exit 0, mutation.json відсутній попри
253
- * наявні тести) кидаютьсяу multi-root режимі це не маскує справжній збій.
284
+ *
285
+ * Full-режим (`scope === null`): vitest на всьому suite + Stryker на всіх файлах
286
+ * config-глоба. Пропускає workspace без тестів (повертає `null`): vitest пройшов з
287
+ * `--passWithNoTests`, але lcov порожній нема сенсу запускати Stryker.
288
+ *
289
+ * Changed-режим (`scope = { files, base }`): vitest `--changed <base>` (лише
290
+ * зачеплені тести) + Stryker `--mutate` лише по змінених production-файлах. Тут
291
+ * **не** пропускаємо на порожньому lcov — змінений src без тестів має дати
292
+ * NoCoverage-мутанти (gate впаде, як і має). Якщо змінено лише тест-файли (нема
293
+ * production-src) — Stryker не запускаємо (мутувати нічого), повертаємо лише coverage.
294
+ *
295
+ * Реальні помилки (vitest exit ≠ 0, відсутній mutation.json попри запуск Stryker)
296
+ * кидаються — у multi-root режимі це не маскує справжній збій.
254
297
  * @param {string} jsRoot абсолютний шлях до workspace-кореня
255
298
  * @param {string} cwd корінь проєкту (для рібейзингу `survived[].file`)
256
299
  * @param {{runJsCoverage:Function, runStryker:Function}} runner spawn-ін'єкція
257
- * @returns {Promise<{coverage:object, mutation:{caught:number,total:number}, survived:Array<object>} | null>} результати або null коли workspace без тестів
300
+ * @param {{files:string[], base:string|null}|null} [scope] changed-scope (null = full-режим)
301
+ * @returns {Promise<{coverage:object, mutation:{caught:number,total:number}, survived:Array<object>} | null>} результати або null коли full-режим і workspace без тестів
258
302
  */
259
- async function collectOneRoot(jsRoot, cwd, runner) {
303
+ async function collectOneRoot(jsRoot, cwd, runner, scope = null) {
260
304
  const wsRel = relative(cwd, jsRoot)
305
+ // У changed-режимі production-файли для мутації = змінені JS цього root без тест-файлів.
306
+ const mutateSrc = scope ? scope.files.filter(f => !TEST_FILE.test(f)) : null
261
307
 
262
- // 1. Coverage через vitest run --passWithNoTests --coverage
308
+ // 1. Coverage через vitest run --passWithNoTests --coverage (+ --changed у changed-режимі)
263
309
  const lcovDir = await mkdtemp(join(tmpdir(), 'js-lint-cov-'))
264
310
  let coverage
265
311
  try {
266
- const code = await runner.runJsCoverage({ cwd: jsRoot, lcovDir })
312
+ const code = await runner.runJsCoverage(scope ? { cwd: jsRoot, lcovDir, base: scope.base } : { cwd: jsRoot, lcovDir })
267
313
  if (code !== 0) throw new Error(`JS coverage exit ${code}`)
268
314
  const lcovPath = join(lcovDir, 'lcov.info')
269
315
  coverage = existsSync(lcovPath)
@@ -273,13 +319,20 @@ async function collectOneRoot(jsRoot, cwd, runner) {
273
319
  await rm(lcovDir, { recursive: true, force: true })
274
320
  }
275
321
 
276
- // Порожній lcov ⇔ vitest з --passWithNoTests не знайшов тестів. Пропускаємо
277
- // workspace, щоб не запускати Stryker марно і не псувати агрегат.
278
- const hasTests = coverage.lines.total > 0 || coverage.functions.total > 0
279
- if (!hasTests) return null
322
+ // Full-режим: порожній lcov ⇔ vitest не знайшов тестів → пропускаємо workspace,
323
+ // щоб не ганяти Stryker марно. У changed-режимі НЕ пропускаємо (див. JSDoc).
324
+ if (!scope) {
325
+ const hasTests = coverage.lines.total > 0 || coverage.functions.total > 0
326
+ if (!hasTests) return null
327
+ }
280
328
 
281
- // 2. Mutation через Stryker
282
- await runner.runStryker({ cwd: jsRoot })
329
+ // Changed-режим без production-src (змінено лише тест-файли) → мутувати нічого.
330
+ if (scope && mutateSrc.length === 0) {
331
+ return { coverage, mutation: { caught: 0, total: 0 }, survived: [] }
332
+ }
333
+
334
+ // 2. Mutation через Stryker (у changed-режимі — лише по mutateSrc)
335
+ await runner.runStryker(scope ? { cwd: jsRoot, mutate: mutateSrc } : { cwd: jsRoot })
283
336
  const mutationPath = join(jsRoot, 'reports', 'stryker', 'mutation.json')
284
337
  if (!existsSync(mutationPath)) {
285
338
  throw new Error(
@@ -317,22 +370,36 @@ async function collectOneRoot(jsRoot, cwd, runner) {
317
370
  * з зрозумілим повідомленням ("Жодного провайдера покриття не знайдено").
318
371
  * Шляхи у `survived` рібейзяться відносно `cwd`, щоб `coverage-fix.mjs`
319
372
  * знаходив джерела через `join(projectRoot, file)`.
373
+ *
374
+ * Changed-режим (`opts.changedFiles` задано): кожен root отримує лише свої змінені
375
+ * JS-файли (`scopeToRoot`); roots без змінених JS пропускаються повністю (ні vitest,
376
+ * ні Stryker). Якщо змін нема ніде — повертає `[]` без error-логу (оркестратор
377
+ * трактує порожній changed-scope як pass).
320
378
  * @param {string} cwd корінь проєкту
321
- * @param {{runner?: typeof defaultRunner}} [opts] runner-ін'єкція для тестів
322
- * @returns {Promise<Array<{area:string, coverage:object, mutation:{caught:number,total:number}, survived:Array<object>}>>} рядок `JS` або `[]` коли тестів нема ніде
379
+ * @param {{runner?: typeof defaultRunner, changedFiles?: string[], base?: string|null}} [opts] runner-ін'єкція + changed-scope
380
+ * @returns {Promise<Array<{area:string, coverage:object, mutation:{caught:number,total:number}, survived:Array<object>}>>} рядок `JS` або `[]` коли тестів/змін нема ніде
323
381
  */
324
382
  export async function collect(cwd, opts = {}) {
325
383
  const runner = opts.runner ?? defaultRunner
384
+ const changed = Array.isArray(opts.changedFiles)
326
385
  const jsRoots = await resolveAllJsRoots(cwd)
327
386
  if (jsRoots.length === 0) throw new Error('js-lint coverage: package.json не знайдено')
328
387
 
329
388
  const results = []
330
389
  for (const jsRoot of jsRoots) {
331
- const r = await collectOneRoot(jsRoot, cwd, runner)
390
+ let scope = null
391
+ if (changed) {
392
+ const files = scopeToRoot(opts.changedFiles, cwd, jsRoot)
393
+ if (files.length === 0) continue // root без змінених JS — пропускаємо
394
+ scope = { files, base: opts.base ?? null }
395
+ }
396
+ const r = await collectOneRoot(jsRoot, cwd, runner, scope)
332
397
  if (r !== null) results.push(r)
333
398
  }
334
399
 
335
400
  if (results.length === 0) {
401
+ // Changed-режим: нема змінених JS у жодному root → тихо порожньо (це pass, не помилка).
402
+ if (changed) return []
336
403
  console.error(
337
404
  'js-lint coverage: жоден workspace не має тестів ' +
338
405
  '(`*.test.{js,mjs}` у `tests/` або поряд із джерелом) — ' +
@@ -16,6 +16,8 @@ import { hasCargoTomlInTree } from '../lib/has-cargo-toml.mjs'
16
16
  import { resolveCargoManifest } from '../../../scripts/utils/resolve-cargo-manifest.mjs'
17
17
 
18
18
  const IGNORED_DIR_NAMES = new Set(['node_modules', '.git', '.next', '.turbo', 'target'])
19
+ /** Rust-релевантні зміни: `.rs`-джерела або маніфести Cargo. */
20
+ const RUST_CHANGE = /(\.rs$)|((^|\/)Cargo\.(toml|lock)$)/
19
21
 
20
22
  /**
21
23
  * Чи провайдер застосовний у поточному cwd.
@@ -33,7 +35,7 @@ export function detect(cwd) {
33
35
  * на ≤2 ядрах = 1, на 4 = 2, на 8+ = 4. Стеля 4 — Rust linker bottleneck:
34
36
  * вище практичного приросту не дає навіть на 16+ ядрах.
35
37
  * @param {string | undefined} envValue значення `process.env.CARGO_MUTANTS_JOBS`
36
- * @returns {number}
38
+ * @returns {number} кількість паралельних воркерів (>= 1)
37
39
  */
38
40
  export function resolveJobs(envValue) {
39
41
  if (envValue !== undefined && envValue !== '') {
@@ -119,12 +121,21 @@ const defaultRunner = {
119
121
 
120
122
  /**
121
123
  * Збирає Rust-метрики покриття + мутаційного тестування.
124
+ *
125
+ * Changed-режим (`opts.changedFiles` задано): якщо серед змінених немає Rust-релевантних
126
+ * файлів (`.rs` / `Cargo.toml` / `Cargo.lock`) — повертає `[]` (skip), щоб JS-only крок
127
+ * турнікета не ганяв повний `cargo mutants`. Якщо Rust змінено — наразі прогін повний по
128
+ * crate (per-file scoping cargo-mutants — окремий крок).
122
129
  * @param {string} cwd корінь проєкту
123
- * @param {{runner?: typeof defaultRunner}} [opts] ін'єкція runner-а для тестів
130
+ * @param {{runner?: typeof defaultRunner, changedFiles?: string[]}} [opts] ін'єкція runner-а + changed-scope
124
131
  * @returns {Promise<Array<{area:string, coverage:object, mutation:{caught:number,total:number}}>>} рядки для COVERAGE.md
125
132
  */
126
133
  export async function collect(cwd, opts = {}) {
127
134
  const runner = opts.runner ?? defaultRunner
135
+ // Changed-режим без Rust-релевантних змін → не запускаємо повний crate-прогін.
136
+ if (Array.isArray(opts.changedFiles) && !opts.changedFiles.some(f => RUST_CHANGE.test(f))) {
137
+ return []
138
+ }
128
139
  const manifestPath = await resolveCargoManifest(cwd)
129
140
  if (manifestPath === null) {
130
141
  throw new Error('rust coverage: Cargo.toml не знайдено (cwd + workspaces)')
@@ -19,6 +19,8 @@ import { fileURLToPath, pathToFileURL } from 'node:url'
19
19
 
20
20
  import { applyVerdicts } from '../../../scripts/coverage-classify/apply.mjs'
21
21
  import { classify } from '../../../scripts/coverage-classify/index.mjs'
22
+ import { flowStatePath, readState } from '../../../scripts/dispatcher/lib/state-store.mjs'
23
+ import { collectChangedFilesSince } from '../../../scripts/lib/changed-files.mjs'
22
24
  import { readNCursorConfigLite } from '../../../scripts/lib/read-n-cursor-config-lite.mjs'
23
25
  import { withLock } from '../../../scripts/utils/with-lock.mjs'
24
26
 
@@ -194,18 +196,46 @@ async function readClassifyThreshold(cwd) {
194
196
  }
195
197
  }
196
198
 
199
+ /**
200
+ * Резолвить scope змінених файлів для `--changed`-режиму.
201
+ *
202
+ * Base — `metadata.base_commit` зі стану flow (sibling-файл `.flow.json` поруч із
203
+ * worktree-checkout). `git diff <base>` проти робочого дерева ловить committed і
204
+ * uncommitted однаково — тож scope не залежить від того, чи executor уже закомітив
205
+ * крок. Поза flow (нема/пошкоджений стан) — fallback на робоче дерево vs HEAD.
206
+ * @param {string} cwd корінь проєкту (= worktree-checkout у межах flow)
207
+ * @returns {{base: string|null, files: string[]}} base-ref і relative-posix список змінених файлів
208
+ */
209
+ function resolveChangedScope(cwd) {
210
+ let base = null
211
+ try {
212
+ const state = readState(flowStatePath(cwd))
213
+ base = state?.metadata?.base_commit ?? null
214
+ } catch {
215
+ // пошкоджений/несумісний стан — попереджаємо й падаємо на HEAD (working-tree scope).
216
+ console.error('coverage --changed: стан flow нечитабельний — scope визначається від HEAD робочого дерева')
217
+ base = null
218
+ }
219
+ return { base, files: collectChangedFilesSince(base, cwd) }
220
+ }
221
+
197
222
  /**
198
223
  * Виконує coverage-pipeline: discovery провайдерів за `.n-cursor.json#rules`,
199
224
  * detect+collect для кожного, агрегація, запис COVERAGE.md.
200
225
  * При `opts.fix === true` після запису COVERAGE.md запускає агента (coverage-fix.mjs)
201
226
  * для написання тестів по вцілілих мутантах.
202
- * @param {{cwd?:string, rulesDir?:string, fix?:boolean}} [opts] ін'єкція cwd/rulesDir для тестів; fix --fix режим
203
- * @returns {Promise<number>} exit code (0 OK, 1 коли жоден провайдер не дав даних)
227
+ * При `opts.changed === true` провайдери звужують scope до змінених від base файлів
228
+ * (для flow-турнікета). Порожній scope (нема релевантних змін) це pass (exit 0)
229
+ * без перезапису наявного COVERAGE.md, а НЕ помилка «жодного провайдера».
230
+ * @param {{cwd?:string, rulesDir?:string, fix?:boolean, changed?:boolean}} [opts] ін'єкція cwd/rulesDir для тестів; fix — --fix режим; changed — scope лише змінених
231
+ * @returns {Promise<number>} exit code (0 OK, 1 коли жоден провайдер не дав даних у full-режимі)
204
232
  */
205
233
  export async function runCoverageSteps(opts = {}) {
206
234
  const cwd = opts.cwd ?? process.cwd()
207
235
  const rulesDir = opts.rulesDir ?? RULES_DIR
208
236
  const config = await readNCursorConfigLite(cwd)
237
+ const scope = opts.changed ? resolveChangedScope(cwd) : null
238
+ const collectOpts = scope ? { changedFiles: scope.files, base: scope.base } : {}
209
239
  const rows = []
210
240
 
211
241
  for (const ruleId of config.rules) {
@@ -214,14 +244,27 @@ export async function runCoverageSteps(opts = {}) {
214
244
  if (!provider) continue
215
245
  if (!(await provider.detect(cwd))) continue
216
246
  console.log(`→ ${ruleId} coverage…`)
217
- rows.push(...(await provider.collect(cwd)))
247
+ rows.push(...(await provider.collect(cwd, collectOpts)))
218
248
  }
219
249
 
220
250
  if (rows.length === 0) {
251
+ // --changed: порожній scope = «нема релевантних змін» → pass, не чіпаємо COVERAGE.md.
252
+ if (opts.changed) {
253
+ console.log('✓ coverage --changed: немає змінених файлів у scope провайдерів — пропускаю')
254
+ return 0
255
+ }
221
256
  console.error('✗ Жодного провайдера покриття не знайдено для активних правил у .n-cursor.json#rules')
222
257
  return 1
223
258
  }
224
259
 
260
+ // --changed (турнікет): рішення гейту визначає лише exit-код — vitest/Stryker кинули б помилку
261
+ // під час collect, якби тести/прогін упали. Тут НЕ перезаписуємо повний COVERAGE.md частковим
262
+ // scoped-звітом і не ганяємо LLM-класифікацію (зайвий кошт у per-step циклі).
263
+ if (opts.changed) {
264
+ console.log('✓ coverage --changed: змінені файли перевірено')
265
+ return 0
266
+ }
267
+
225
268
  // LLM-класифікація survived мутантів (graceful skip без API key)
226
269
  const allSurvived = rows.flatMap(r => r.survived ?? [])
227
270
  let augmentedRows = rows
@@ -256,18 +299,19 @@ export async function runCoverageSteps(opts = {}) {
256
299
  }
257
300
 
258
301
  /**
259
- * CLI entrypoint для `n-cursor coverage [--fix]`.
302
+ * CLI entrypoint для `n-cursor coverage [--fix] [--changed]`.
260
303
  * Із `--fix`: збирає метрики → запускає агента → повторно збирає метрики.
261
304
  * Без `--fix`: лише збирає метрики.
305
+ * Із `--changed`: звужує scope до змінених від base файлів (flow-турнікет).
262
306
  * Лок охоплює кожен coverage-прогін окремо.
263
- * @param {{fix?:boolean}} [opts] прапор --fix
307
+ * @param {{fix?:boolean, changed?:boolean}} [opts] прапори --fix / --changed
264
308
  * @returns {Promise<number>} exit code
265
309
  */
266
310
  export async function runCoverageCli(opts = {}) {
267
311
  const code = await withLock('coverage', () => runCoverageSteps(opts))
268
312
  if (code === 0 && opts.fix) {
269
313
  console.log('\n♻️ Повторний coverage після агента…\n')
270
- return withLock('coverage', () => runCoverageSteps({ fix: false }))
314
+ return withLock('coverage', () => runCoverageSteps({ fix: false, changed: opts.changed }))
271
315
  }
272
316
  return code
273
317
  }
@@ -130,6 +130,8 @@ test.skipIf(env.STRYKER_MUTATOR_WORKER)('узгоджені з поточним
130
130
 
131
131
  Канонічна команда — `n-cursor coverage`: збирає метрики покриття (`vitest run --coverage`, `cargo llvm-cov` тощо) і мутаційного тестування (Stryker з vitest-runner + `coverageAnalysis: 'perTest'`, `cargo-mutants`) з усіх активних провайдерів у `.n-cursor.json#rules` і пише `COVERAGE.md` у корінь проєкту. Лок і дедуп — `withLock('coverage', ...)`.
132
132
 
133
+ **Scoped-режим `--changed`** (для flow-турнікета): `n-cursor coverage --changed` звужує scope до файлів, змінених від `base_commit` (зі стану flow `.flow.json`; поза flow — робоче дерево vs HEAD). `git diff <base>` проти робочого дерева ловить committed і uncommitted однаково, тож результат не залежить від того, чи крок уже закомічено. Недосяжний `base` (rebase/force-update) — fail-closed (помилка, не тихий pass). JS-провайдер ганяє `vitest --changed <base>` (лише зачеплені тести) і Stryker `--mutate` по змінених production-файлах (тест-файли відкидаються); roots без змінених JS пропускаються. Rust-провайдер пропускається, якщо не змінено `.rs`/`Cargo.*` (інакше — повний crate-прогін; per-file scoping cargo-mutants — окремий крок). Порожній scope (нема релевантних змін) — pass. У changed-режимі `COVERAGE.md` **не** перезаписується (рішення гейту — лише exit-код) і LLM-класифікація не запускається. `DEFAULT_GATES` турнікета викликає саме `coverage --changed`; повний coverage (увесь проєкт, запис `COVERAGE.md`) лишається для `bun run coverage` / `/n-coverage-fix`.
134
+
133
135
  Провайдери живуть у `npm/rules/<rule>/coverage/coverage.mjs` (постачаються правилами мови/рантайму: `js-lint`, `rust`, у майбутньому `python` тощо). Оркестратор — у `npm/rules/test/coverage/coverage.mjs`.
134
136
 
135
137
  У `package.json` (корінь) має бути `scripts.coverage` із викликом `n-cursor coverage`:
@@ -11,12 +11,16 @@
11
11
  import { worktreeFingerprint } from '../../utils/worktree-fingerprint.mjs'
12
12
 
13
13
  /**
14
- * Канонічний gate verify лише `lint`. Coverage (vitest-покриття + Stryker-
15
- * мутації) навмисно ПОЗА turnstile: повний прогін надто довгий і ламкий у
16
- * worktree, тож тести/мутації запускаються окремо (`npx \@nitra/cursor coverage`)
17
- * або в CI, а не на кожному `flow verify`.
14
+ * Канонічні gate verify (lint + coverage; coverage включає тести+мутації).
15
+ * Обидва scoped до змінених файлів: `lint` через quick-режим (`changed-files.mjs`),
16
+ * `coverage --changed` через vitest `--changed`/Stryker `--mutate` по diff від base.
17
+ * Турнікет (`flow verify`/per-step) перевіряє лише змінене; повний coverage
18
+ * окремо (`bun run coverage`, `/n-coverage-fix`).
18
19
  */
19
- export const DEFAULT_GATES = [{ name: 'lint', cmd: ['npx', '@nitra/cursor', 'lint'] }]
20
+ export const DEFAULT_GATES = [
21
+ { name: 'lint', cmd: ['npx', '@nitra/cursor', 'lint'] },
22
+ { name: 'coverage', cmd: ['npx', '@nitra/cursor', 'coverage', '--changed'] }
23
+ ]
20
24
 
21
25
  /**
22
26
  * Проганяє gate-и й повертає verdict.
@@ -28,3 +28,31 @@ export function collectChangedFiles(cwd = process.cwd()) {
28
28
  const untracked = gitLines(['ls-files', '--others', '--exclude-standard'], cwd)
29
29
  return [...new Set([...modified, ...untracked])]
30
30
  }
31
+
32
+ /**
33
+ * Список змінених + untracked файлів **відносно базового комміту**.
34
+ *
35
+ * `git diff <base>` (без `..`/`...`, без `HEAD`) порівнює base-комміт із поточним
36
+ * **робочим деревом** — тобто однаково ловить і закомічене від base, і staged, і
37
+ * незакомічені модифікації. Це гарантує однакову поведінку незалежно від того, чи
38
+ * зміни вже закомічені у worktree (потрібно для flow-турнікета, де executor комітить
39
+ * кожен крок). Без `base` — fallback на `collectChangedFiles` (робоче дерево vs HEAD).
40
+ * @param {string|null} [base] базовий комміт (`metadata.base_commit` зі стану flow)
41
+ * @param {string} [cwd] корінь репо
42
+ * @returns {string[]} унікальні шляхи (без видалених)
43
+ */
44
+ export function collectChangedFilesSince(base, cwd = process.cwd()) {
45
+ if (!base) return collectChangedFiles(cwd)
46
+ // Fail-closed: недосяжний base (rebase/force-update/shallow prune) інакше дав би `git diff`
47
+ // exit 128 → порожній список → gate мовчки пройшов би без перевірки. Краще явна помилка.
48
+ const verify = spawnSync('git', ['rev-parse', '--verify', '--quiet', `${base}^{commit}`], { cwd, encoding: 'utf8' })
49
+ if (verify.status !== 0 || verify.error) {
50
+ throw new Error(
51
+ `collectChangedFilesSince: base-комміт «${base}» недосяжний у ${cwd} ` +
52
+ '(rebase/force-update?) — coverage --changed не може визначити scope'
53
+ )
54
+ }
55
+ const changed = gitLines(['diff', base, '--name-only', '--diff-filter=ACMR'], cwd)
56
+ const untracked = gitLines(['ls-files', '--others', '--exclude-standard'], cwd)
57
+ return [...new Set([...changed, ...untracked])]
58
+ }