@nitra/cursor 3.9.0 → 3.10.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,16 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.10.0] - 2026-06-01
4
+
5
+ ### Added
6
+
7
+ - rust coverage: incremental mutation через CARGO_MUTANTS_BASE_REF → cargo-mutants --in-diff (мутуємо лише змінене у <ref>...HEAD)
8
+ - rust coverage: CARGO_MUTANTS_BASELINE=skip → cargo-mutants --baseline skip (пропуск немутованого baseline коли тести вже зелені); rust.mdc — гайд по CI-кешу target/ для coverage-job
9
+
10
+ ### Removed
11
+
12
+ - internal: видалено мертвий JS-код — 19 експортованих функцій/констант, що викликались лише з тестів (k8s manifests: 5 предикатів, мігрованих у rego; gha-workflow: 9 предикатів, мігрованих у rego ga.workflow_common + 1 каскадний; abie/text/dispatcher: kustomizationHasAbieNginxRunHttpRoutePatch, getV8rCatalogPath, SUBCOMMANDS, resolveFlow) разом з їхніми тестами й осиротілими хелперами
13
+
3
14
  ## [3.9.0] - 2026-06-01
4
15
 
5
16
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "3.9.0",
3
+ "version": "3.10.0",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -211,14 +211,3 @@ export function validateAbieNginxRunHttpRoutePatches(
211
211
  }
212
212
  return null
213
213
  }
214
-
215
- /**
216
- * Чи kustomization містить валідні patch для HTTPRoute (ua).
217
- * @param {string} raw повний текст kustomization.yaml
218
- * @param {'ua'} mode опис.
219
- * @returns {boolean} результат
220
- */
221
- export function kustomizationHasAbieNginxRunHttpRoutePatch(raw, mode) {
222
- const combined = getCombinedNginxRunPatchTextFromKustomization(raw)
223
- return validateAbieNginxRunHttpRoutePatches(combined, mode, raw) === null
224
- }
@@ -78,8 +78,9 @@ Composer, Claude Code) працює **Пасивний Турнікет**: ти,
78
78
  npx @nitra/cursor flow verify
79
79
  ```
80
80
 
81
- Проганяє Quality Gates (lint + coverage). Повертає `0` (pass) або `1` із
82
- виводом проваленого gate.
81
+ Проганяє Quality Gate (lint). Повертає `0` (pass) або `1` із виводом
82
+ проваленого gate. Coverage (тести + Stryker-мутації) **поза** turnstile —
83
+ запускай окремо `npx @nitra/cursor coverage` або в CI.
83
84
 
84
85
  6. **Review (adversarial)** — рекомендовано перед release:
85
86
 
@@ -431,56 +431,6 @@ function pushStringPaths(arr, acc) {
431
431
  /** Префікс `apiVersion` для маніфесту Kustomize **Kustomization**. */
432
432
  const KUSTOMIZE_CONFIG_API_PREFIX = 'kustomize.config.k8s.io/'
433
433
 
434
- /**
435
- * Чи послідовність непорожніх рядків відсортована за `localeCompare` (en, ascending).
436
- * @param {string[]} paths рядки для перевірки
437
- * @returns {boolean} `true` якщо послідовність відсортована
438
- */
439
- function stringPathsAreSortedEn(paths) {
440
- for (let i = 1; i < paths.length; i++) {
441
- if (paths[i - 1].localeCompare(paths[i], 'en', { sensitivity: 'base' }) > 0) {
442
- return false
443
- }
444
- }
445
- return true
446
- }
447
-
448
- /**
449
- * Порушення сорту **`resources`**: лише для **`kustomize.config.k8s.io/…`**, **`kind: Kustomization`**.
450
- * Порожні рядки в списку ігноруються (як у `pushStringPaths`).
451
- * @param {unknown} obj корінь першого YAML-документа
452
- * @returns {string | null} причина або `null`, якщо обмеження не застосовується
453
- */
454
- export function kustomizationResourcesSortedAlphabeticallyViolation(obj) {
455
- if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) return null
456
- const rec = /** @type {Record<string, unknown>} */ (obj)
457
- if (rec.kind !== 'Kustomization') return null
458
- const av = rec.apiVersion
459
- if (typeof av !== 'string' || !av.startsWith(KUSTOMIZE_CONFIG_API_PREFIX)) return null
460
- const res = rec.resources
461
- if (res === undefined) return null
462
- if (!Array.isArray(res)) {
463
- return 'Kustomization.resources має бути масивом (k8s.mdc)'
464
- }
465
- /**
466
- @type {string[]}
467
- */
468
- const paths = []
469
- for (const [i, item] of res.entries()) {
470
- if (typeof item !== 'string') {
471
- return `Kustomization.resources[${i}] — очікується рядок-шлях (k8s.mdc)`
472
- }
473
- const t = item.trim()
474
- if (t !== '') paths.push(t)
475
- }
476
- if (paths.length < 2) return null
477
- if (!stringPathsAreSortedEn(paths)) {
478
- const want = paths.toSorted((a, b) => a.localeCompare(b, 'en', { sensitivity: 'base' }))
479
- return `Kustomization.resources має бути за алфавітом (en). Зараз: ${paths.join(', ')}; очікувано: ${want.join(', ')} (k8s.mdc)`
480
- }
481
- return null
482
- }
483
-
484
434
  // Plan B: per-document `resources[]` sort у Kustomization — у rego-пакеті
485
435
  // `k8s.kustomization`, викликається з `runAllK8sRego` на початку `check()`.
486
436
  // JS-orchestrator validateKustomizationResourcesSortedAlphabetically видалено.
@@ -2159,36 +2109,6 @@ export function collectJson6902OperationsFromPatchText(patchText) {
2159
2109
  return []
2160
2110
  }
2161
2111
 
2162
- /**
2163
- * Шляхи JSON Patch, де в одному наборі операцій є і **remove**, і **add** (k8s.mdc: краще **replace**).
2164
- * @param {Array<{ op: string, path: string }>} ops нормалізовані **op**
2165
- * @returns {string[]} унікальні **path** з порушенням (відсортовано)
2166
- */
2167
- export function json6902PathsWithRemoveAndAddOnSamePath(ops) {
2168
- /**
2169
- @type {Map<string, Set<string>>}
2170
- */
2171
- const byPath = new Map()
2172
- for (const { op, path } of ops) {
2173
- if (path) {
2174
- if (!byPath.has(path)) {
2175
- byPath.set(path, new Set())
2176
- }
2177
- byPath.get(path).add(op)
2178
- }
2179
- }
2180
- /**
2181
- @type {string[]}
2182
- */
2183
- const out = []
2184
- for (const [path, set] of byPath) {
2185
- if (set.has('remove') && set.has('add')) {
2186
- out.push(path)
2187
- }
2188
- }
2189
- return out.toSorted((a, b) => a.localeCompare(b))
2190
- }
2191
-
2192
2112
  // Plan B: вся audit-ланка JSON6902 (failIfJson6902RemoveAddConflictOnSamePath,
2193
2113
  // auditJson6902PatchExternalFile, auditOneKustomizationJson6902Patch,
2194
2114
  // auditKustomizationPatchesJson6902) видалена. Per-document inline JSON6902
@@ -2673,34 +2593,9 @@ export function collectDeploymentConfigMapRefs(deployment) {
2673
2593
  return names
2674
2594
  }
2675
2595
 
2676
- /**
2677
- * Чи **Service** містить заборонені анотації GKE у **`metadata.annotations`** (k8s.mdc).
2678
- * @param {unknown} manifest корінь YAML-документа
2679
- * @returns {string | null} текст порушення або null, якщо не Service / анотацій немає / ок
2680
- */
2681
- export function serviceForbiddenGcpAnnotationsViolation(manifest) {
2682
- if (manifest === null || manifest === undefined || typeof manifest !== 'object' || Array.isArray(manifest))
2683
- return null
2684
- const rec = /** @type {Record<string, unknown>} */ (manifest)
2685
- if (rec.kind !== 'Service') return null
2686
- const meta = rec.metadata
2687
- if (meta === null || meta === undefined || typeof meta !== 'object' || Array.isArray(meta)) return null
2688
- const m = /** @type {Record<string, unknown>} */ (meta)
2689
- const ann = m.annotations
2690
- if (ann === null || ann === undefined || typeof ann !== 'object' || Array.isArray(ann)) return null
2691
- const a = /** @type {Record<string, unknown>} */ (ann)
2692
- /**
2693
- @type {string[]}
2694
- */
2695
- const found = []
2696
- for (const key of SERVICE_FORBIDDEN_GCP_ANNOTATION_KEYS) {
2697
- if (Object.hasOwn(a, key)) {
2698
- found.push(key)
2699
- }
2700
- }
2701
- if (found.length === 0) return null
2702
- return `metadata.annotations: прибери заборонені ключі GKE: ${found.join(', ')} (див. k8s.mdc)`
2703
- }
2596
+ // Plan B: заборонені GKE-анотації на Service — у rego-пакеті k8s.* (per-document).
2597
+ // JS-функцію serviceForbiddenGcpAnnotationsViolation видалено; константа
2598
+ // SERVICE_FORBIDDEN_GCP_ANNOTATION_KEYS лишається експортованою (власний тест).
2704
2599
 
2705
2600
  /** Суфікс **`metadata.name`** headless-сервісу поруч із **`svc.yaml`** (див. k8s.mdc). */
2706
2601
  const SVC_HL_NAME_SUFFIX = '-hl'
@@ -4235,15 +4130,6 @@ export function snippetNameForKind(kind) {
4235
4130
  return name
4236
4131
  }
4237
4132
 
4238
- /**
4239
- * Читає deployment.snippet.yaml і повертає розпарсений spec.
4240
- * @deprecated Використовуй loadSnippetSpec('deployment')
4241
- * @returns {{ podSelector: Record<string, unknown>, policyTypes: string[], ingress: unknown[], egress: unknown[] }} розпарсений spec deployment snippet
4242
- */
4243
- export function readNetworkPolicySnippet() {
4244
- return /** @type {any} */ (loadSnippetSpec('deployment'))
4245
- }
4246
-
4247
4133
  /**
4248
4134
  * No-op fail-callback (повертає аргумент). Використовується як дефолт у `regenerateLegacyNetworkPolicyDocsInFile`,
4249
4135
  * коли caller не передає власний `fail` — щоб `collectHttpRouteIngressForWorkload` не падав.
@@ -5047,17 +4933,6 @@ export async function prodOverlayHpaPdbOverrideNeeds(rootNorm, kustAbs) {
5047
4933
  }
5048
4934
  }
5049
4935
 
5050
- /**
5051
- * Чи прод-оверлей потребує **будь-яких** overrides HPA/PDB у **patches[]** (зведений прапорець).
5052
- * @param {string} rootNorm нормалізований корінь репозиторію
5053
- * @param {string} kustAbs абсолютний шлях до kustomization.yaml
5054
- * @returns {Promise<boolean>} true, якщо потрібен хоча б один тип оверрайду
5055
- */
5056
- export async function prodOverlayNeedsHpaPdbOverrides(rootNorm, kustAbs) {
5057
- const n = await prodOverlayHpaPdbOverrideNeeds(rootNorm, kustAbs)
5058
- return n.needsHpaReplicaPatches || n.needsPdbMinAvailablePatch
5059
- }
5060
-
5061
4936
  /**
5062
4937
  * Для прод kustomization.yaml вимагає **patches[]** за потреби: **`/spec/minReplicas`** і **`/spec/maxReplicas`**
5063
4938
  * для **HorizontalPodAutoscaler** (якщо в успадкованому base лишився HPA без delete-patch), **`/spec/minAvailable`**
@@ -8,9 +8,9 @@
8
8
  */
9
9
  import { spawnSync } from 'node:child_process'
10
10
  import { existsSync } from 'node:fs'
11
- import { mkdtemp, readFile, rm } from 'node:fs/promises'
11
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
12
12
  import { cpus, tmpdir } from 'node:os'
13
- import { join } from 'node:path'
13
+ import { dirname, join } from 'node:path'
14
14
 
15
15
  import { hasCargoTomlInTree } from '../lib/has-cargo-toml.mjs'
16
16
  import { resolveCargoManifest } from '../../../scripts/utils/resolve-cargo-manifest.mjs'
@@ -43,14 +43,48 @@ export function resolveJobs(envValue) {
43
43
  return Math.min(4, Math.max(1, Math.floor(cpus().length / 2)))
44
44
  }
45
45
 
46
+ /**
47
+ * Резолвить базовий git-ref для incremental mutation через cargo-mutants `--in-diff`.
48
+ * Порожнє/відсутнє значення → `null` = повний прогін усіх мутантів (дефолт для `main`).
49
+ * Непорожнє (напр. `origin/main`) → мутуємо лише змінене у `<ref>...HEAD` (для feature-гілки).
50
+ * cargo-mutants не має persistent-кешу вердиктів (як Stryker `incremental.json`) — scoping
51
+ * за git-diff це його штатний аналог «не передивляйся незмінений код».
52
+ * @param {string | undefined} envValue значення `process.env.CARGO_MUTANTS_BASE_REF`
53
+ * @returns {string | null} trimmed ref або null
54
+ */
55
+ export function resolveBaseRef(envValue) {
56
+ if (envValue === undefined) return null
57
+ const trimmed = envValue.trim()
58
+ return trimmed === '' ? null : trimmed
59
+ }
60
+
61
+ /**
62
+ * Резолвить режим baseline для cargo-mutants. `CARGO_MUTANTS_BASELINE=skip`
63
+ * (case-insensitive) → `'skip'` = пропустити немутований baseline build+test:
64
+ * фіксована економія в один повний `cargo test`, безпечна ЛИШЕ коли тести вже
65
+ * зелені у попередньому CI-степі (інакше всі вердикти сміттєві). Будь-що інше →
66
+ * `null` = дефолтний baseline-прогін. Цінність найбільша разом з `--in-diff`,
67
+ * де baseline — більша частка дрібного прогону.
68
+ * @param {string | undefined} envValue значення `process.env.CARGO_MUTANTS_BASELINE`
69
+ * @returns {'skip' | null} режим або null для дефолту
70
+ */
71
+ export function resolveBaseline(envValue) {
72
+ return envValue !== undefined && envValue.trim().toLowerCase() === 'skip' ? 'skip' : null
73
+ }
74
+
46
75
  /**
47
76
  * Будує argv для `cargo mutants`. `--in-place` навмисно відсутній: cargo-mutants
48
77
  * створює власну sandbox-копію в `target/mutants.<i>/`, що обов'язкове для `--jobs > 1`.
49
- * @param {{ manifestPath: string, outDir: string, jobs: number }} opts
50
- * @returns {string[]}
78
+ * `diffPath` (опційно) вмикає `--in-diff` мутуються лише рядки з цього unified-diff.
79
+ * `baseline === 'skip'` (опційно) додає `--baseline skip` — без немутованого baseline-прогону.
80
+ * @param {{ manifestPath: string, outDir: string, jobs: number, diffPath?: string, baseline?: 'skip' | null }} opts параметри запуску
81
+ * @returns {string[]} argv для cargo
51
82
  */
52
- export function buildCargoMutantsArgs({ manifestPath, outDir, jobs }) {
53
- return ['mutants', '--jobs', String(jobs), '-o', outDir, '--manifest-path', manifestPath]
83
+ export function buildCargoMutantsArgs({ manifestPath, outDir, jobs, diffPath, baseline }) {
84
+ const args = ['mutants', '--jobs', String(jobs), '-o', outDir, '--manifest-path', manifestPath]
85
+ if (diffPath) args.push('--in-diff', diffPath)
86
+ if (baseline === 'skip') args.push('--baseline', 'skip')
87
+ return args
54
88
  }
55
89
 
56
90
  const defaultRunner = {
@@ -61,13 +95,25 @@ const defaultRunner = {
61
95
  })
62
96
  return { exitCode: r.status ?? 1, stdout: r.stdout?.toString('utf8') ?? '' }
63
97
  },
64
- runCargoMutants({ manifestPath, outDir }) {
98
+ runCargoMutants({ manifestPath, outDir, diffPath }) {
65
99
  const jobs = resolveJobs(process.env.CARGO_MUTANTS_JOBS)
66
- const r = spawnSync('cargo', buildCargoMutantsArgs({ manifestPath, outDir, jobs }), {
100
+ const baseline = resolveBaseline(process.env.CARGO_MUTANTS_BASELINE)
101
+ const r = spawnSync('cargo', buildCargoMutantsArgs({ manifestPath, outDir, jobs, diffPath, baseline }), {
67
102
  stdio: 'inherit',
68
103
  env: process.env
69
104
  })
70
105
  return r.status ?? 1
106
+ },
107
+ runGitDiff({ manifestPath, baseRef }) {
108
+ // `--relative` + cwd = каталог crate → шляхи в diff збігаються з тим, що
109
+ // cargo-mutants мутує (relative до package), навіть у monorepo з src-tauri/.
110
+ // Three-dot `<ref>...HEAD` = зміни гілки від merge-base, а не «з того часу в ref».
111
+ const r = spawnSync('git', ['diff', '--relative', `${baseRef}...HEAD`], {
112
+ cwd: dirname(manifestPath),
113
+ stdio: ['inherit', 'pipe', 'inherit'],
114
+ env: process.env
115
+ })
116
+ return { exitCode: r.status ?? 1, stdout: r.stdout?.toString('utf8') ?? '' }
71
117
  }
72
118
  }
73
119
 
@@ -95,13 +141,31 @@ export async function collect(cwd, opts = {}) {
95
141
  functions: { covered: totals.functions.covered, total: totals.functions.count }
96
142
  }
97
143
 
98
- // 2. Mutation через cargo mutants
144
+ // 2. Mutation через cargo mutants.
145
+ // CARGO_MUTANTS_BASE_REF (напр. `origin/main`) вмикає incremental-режим: мутуємо
146
+ // лише рядки, змінені у `<baseRef>...HEAD` (`git diff --relative` → cargo-mutants
147
+ // `--in-diff`). Env не задано — повний прогін усіх мутантів (дефолт для `main`).
148
+ const baseRef = resolveBaseRef(process.env.CARGO_MUTANTS_BASE_REF)
99
149
  const outDir = await mkdtemp(join(tmpdir(), 'rust-mutants-'))
100
150
  let mutation
101
151
  try {
152
+ let diffPath
153
+ if (baseRef !== null) {
154
+ const { exitCode: diffCode, stdout: diff } = await runner.runGitDiff({ manifestPath, baseRef })
155
+ if (diffCode !== 0) {
156
+ // Невідомий ref / не git-репо — не валимо прогін, відкочуємось до повного.
157
+ process.stderr.write(`rust coverage: git diff проти '${baseRef}' упав — повний mutation-прогін\n`)
158
+ } else if (diff.trim() === '') {
159
+ // У `<baseRef>...HEAD` немає змін під цим crate — мутувати нічого.
160
+ return [{ area: 'Rust', coverage, mutation: { caught: 0, total: 0 } }]
161
+ } else {
162
+ diffPath = join(outDir, 'in-diff.patch')
163
+ await writeFile(diffPath, diff)
164
+ }
165
+ }
102
166
  // cargo-mutants exit ≠ 0 коли є missed — це нормально, не помилка.
103
167
  // Реальний крах — відсутній outcomes.json.
104
- await runner.runCargoMutants({ manifestPath, outDir })
168
+ await runner.runCargoMutants({ manifestPath, outDir, diffPath })
105
169
  let outcomes
106
170
  try {
107
171
  outcomes = JSON.parse(await readFile(join(outDir, 'mutants.out', 'outcomes.json'), 'utf8'))
@@ -2,7 +2,7 @@
2
2
  description: Перевірка Rust коду
3
3
  globs: "**/{Cargo.toml,Cargo.lock,rustfmt.toml,clippy.toml,.vscode/extensions.json,package.json},**/*.rs"
4
4
  alwaysApply: false
5
- version: '1.2'
5
+ version: '1.4'
6
6
  ---
7
7
 
8
8
  **rustfmt** ([rust-lang/rustfmt](https://github.com/rust-lang/rustfmt)) — форматер; **clippy** ([rust-lang/rust-clippy](https://github.com/rust-lang/rust-clippy)) — лінтер. У скрипті **`lint-rust`** локально йдуть три кроки в одному рядку: `cargo fmt --all` → `cargo clippy --fix --allow-staged --allow-dirty --all-targets --all-features` → фінальний `cargo clippy --all-targets --all-features -- -D warnings`. У CI — без `--fix`: `cargo fmt --all -- --check` і `cargo clippy ... -- -D warnings` (див. `lint-rust.yml`).
@@ -29,3 +29,22 @@ Tauri-проєкт завжди має `src-tauri/Cargo.toml`, тому прав
29
29
  ## Покриття + мутаційне тестування Rust
30
30
 
31
31
  Покриття + мутаційне тестування Rust постачаються через `n-cursor coverage` (правило `test.mdc`). Реалізація провайдера — у `npm/rules/rust/coverage/coverage.mjs`: `cargo llvm-cov --json --summary-only` + `cargo mutants --jobs N` (паралельні воркери, дефолт `min(4, cpus/2)`; override через env `CARGO_MUTANTS_JOBS`). Прапорець `--in-place` прибраний — cargo-mutants створює власну sandbox-копію в `target/mutants.<i>/`, що сумісне з `--jobs > 1`. Бінарники: `cargo install cargo-llvm-cov && cargo install cargo-mutants`.
32
+
33
+ ### Incremental mutation через `--in-diff`
34
+
35
+ cargo-mutants **не** має persistent-кешу вердиктів між прогонами (на відміну від Stryker `incremental.json`). Штатний аналог «не передивляйся незмінений код» — scoping за git-diff. Вмикається env-змінною **`CARGO_MUTANTS_BASE_REF`**:
36
+
37
+ - **не задано** (дефолт, типово для `main`) — повний прогін усіх мутантів;
38
+ - задано (напр. `origin/main`, типово для feature-гілки в CI) — мутуються лише рядки, змінені у `<baseRef>...HEAD`. Провайдер бере `git diff --relative <baseRef>...HEAD` з каталогу crate (шляхи в diff збігаються з тим, що мутує cargo-mutants навіть у monorepo з `src-tauri/`), пише його у sandbox і передає cargo-mutants `--in-diff`.
39
+
40
+ Краєві випадки: порожній diff (немає змін під crate) → mutation `0/0` без запуску cargo-mutants; невідомий ref / не git-репо → попередження у stderr і fallback до повного прогону.
41
+
42
+ ### Пропуск baseline через `CARGO_MUTANTS_BASELINE=skip`
43
+
44
+ cargo-mutants спершу ганяє немутований baseline (повний build+test), щоб переконатися, що suite зелений. Це **фіксована** вартість, незалежна від кількості мутантів — а отже більша частка дрібного `--in-diff`-прогону. **`CARGO_MUTANTS_BASELINE=skip`** прибирає цей крок (cargo-mutants `--baseline skip`), економлячи один повний `cargo test`.
45
+
46
+ Безпечно **лише** коли тести вже зелені у попередньому CI-степі (типовий порядок: `cargo test` → потім `n-cursor coverage` зі `skip`). Без цієї гарантії всі вердикти стають сміттєвими, тому дефолт — baseline-прогін. Найкорисніше в парі з `--in-diff`.
47
+
48
+ ### CI-кеш `target/` — множник, без якого scoping невидимий
49
+
50
+ `--in-diff` ріже **кількість** мутантів, кеш `target/` — **вартість кожної** компіляції; вони множаться. Без кешу холодний CI щоразу перебудовує всі залежності, і baseline-build (для Tauri — хвилини) затьмарює економію від меншої кількості мутантів. У workflow, що викликає `n-cursor coverage` для Rust, став `Swatinem/rust-cache@v2` (кеш `~/.cargo` + `target/`) після `dtolnay/rust-toolchain@stable` — так само, як у `lint-rust.yml`. Sandbox-копії `target/mutants.<i>/` самі не кешуються, але деривуються з кешованих залежностей.
@@ -27,14 +27,6 @@ export const DEFAULT_V8R_GLOBS = ['**/*.json', '**/*.json5', '**/*.yml', '**/*.y
27
27
  /** Абсолютний шлях до `schemas/v8r-catalog.json` у корені пакета `@nitra/cursor` (`npm/schemas/`). */
28
28
  export const V8R_CATALOG_PATH = join(dirname(fileURLToPath(import.meta.url)), '../../../schemas/v8r-catalog.json')
29
29
 
30
- /**
31
- * Повертає шлях до каталогу схем v8r для пакета (для тестів і діагностики).
32
- * @returns {string} абсолютний шлях до v8r-catalog.json
33
- */
34
- export function getV8rCatalogPath() {
35
- return V8R_CATALOG_PATH
36
- }
37
-
38
30
  /**
39
31
  * Запускає послідовні виклики v8r по glob-ам; не змінює process.exitCode (лише повертає код).
40
32
  * @param {string[]} [globs] патерни; за замовчуванням DEFAULT_V8R_GLOBS
@@ -29,9 +29,6 @@ const USAGE = [
29
29
  ' npx @nitra/cursor flow repair [--discard-step-work] # відновлення пошкодженого стану'
30
30
  ].join('\n')
31
31
 
32
- /** Підкоманди flow. */
33
- export const SUBCOMMANDS = ['init', 'spec', 'plan', 'verify', 'review', 'gate', 'release', 'run', 'resume', 'cancel', 'repair']
34
-
35
32
  /**
36
33
  * Усі handler-и реальні (Ф1 Spec/Plan + Ф2 Турнікет + Ф4 Активний Раннер).
37
34
  * @type {Record<string, (rest: string[], deps: object) => Promise<number>>}
@@ -57,25 +57,3 @@ export function orchestrationFor(model, matrix) {
57
57
  export function polyfillStartable({ hasRunner }) {
58
58
  return hasRunner === true
59
59
  }
60
-
61
- /**
62
- * Повний резолв: оголошена модель + режим. Кидає, якщо polyfill без runner-а.
63
- * @param {{ args?: string[], env?: Record<string, string | undefined>, config?: { flow?: { model?: string } }, matrix: object, hasRunner: boolean }} input джерела
64
- * @returns {{ model: string | null, mode: 'native' | 'polyfill' }} оголошена модель і режим
65
- */
66
- export function resolveFlow({ args = [], env = {}, config = {}, matrix, hasRunner }) {
67
- const model = declaredModel({
68
- cliModel: parseModelFlag(args),
69
- envModel: env.N_CURSOR_FLOW_MODEL ?? null,
70
- configModel: (config && config.flow && config.flow.model) ?? null
71
- })
72
- const mode = orchestrationFor(model, matrix)
73
- if (mode === 'polyfill' && !polyfillStartable({ hasRunner })) {
74
- throw new Error(
75
- 'n-cursor flow: режим polyfill потребує доступного SubagentRunner ' +
76
- '(`claude` або `cursor-agent` у PATH), але жодного не знайдено. ' +
77
- 'Оголосіть модель із native_workflows (--model) або встановіть CLI-runner.'
78
- )
79
- }
80
- return { model, mode }
81
- }
@@ -10,11 +10,13 @@
10
10
  */
11
11
  import { worktreeFingerprint } from '../../utils/worktree-fingerprint.mjs'
12
12
 
13
- /** Канонічні gate-и verify (lint + coverage; coverage включає тести+мутації). */
14
- export const DEFAULT_GATES = [
15
- { name: 'lint', cmd: ['npx', '@nitra/cursor', 'lint'] },
16
- { name: 'coverage', cmd: ['npx', '@nitra/cursor', 'coverage'] }
17
- ]
13
+ /**
14
+ * Канонічний gate verify — лише `lint`. Coverage (vitest-покриття + Stryker-
15
+ * мутації) навмисно ПОЗА turnstile: повний прогін надто довгий і ламкий у
16
+ * worktree, тож тести/мутації запускаються окремо (`npx \@nitra/cursor coverage`)
17
+ * або в CI, а не на кожному `flow verify`.
18
+ */
19
+ export const DEFAULT_GATES = [{ name: 'lint', cmd: ['npx', '@nitra/cursor', 'lint'] }]
18
20
 
19
21
  /**
20
22
  * Проганяє gate-и й повертає verdict.
@@ -8,7 +8,6 @@
8
8
  */
9
9
  import { parse } from 'yaml'
10
10
 
11
- const CHECKOUT_USES_MARKER = 'actions/checkout@'
12
11
  const CHECKOUT_V6_USES = 'actions/checkout@v6'
13
12
  const LOCAL_SETUP_BUN_DEPS_MARKER = './.github/actions/setup-bun-deps'
14
13
  const BUNX_OXLINT_FIX_RE = /bunx\s+oxlint[^\n]*--fix/u
@@ -69,97 +68,6 @@ export function getStepRun(step) {
69
68
  return ''
70
69
  }
71
70
 
72
- /** У тексті `run:` зіставляє `\\` одразу перед переносом рядка (типове shell-продовження в bash). */
73
- const RUN_SHELL_LINE_CONTINUATION_BACKSLASH_RE = /\\\r?\n/
74
-
75
- /**
76
- * Чи містить значення `run:` shell-продовження рядка через зворотний сліш перед переносом (`… \\` + NL).
77
- * У workflow такі конструкції замінюють на folded block `>-` без зворотних слішів (ga.mdc).
78
- * @param {string} runText текст з `getStepRun`
79
- * @returns {boolean} `true`, якщо знайдено `\\` перед новим рядком
80
- */
81
- export function runTextHasShellLineContinuationBackslash(runText) {
82
- return typeof runText === 'string' && runText.length > 0 && RUN_SHELL_LINE_CONTINUATION_BACKSLASH_RE.test(runText)
83
- }
84
-
85
- /**
86
- * Повертає кроки, у яких `run:` містить заборонене shell-продовження через `\\`.
87
- * @param {Record<string, unknown>} root корінь workflow
88
- * @returns {{ jobId: string, stepIndex: number }[]} список кроків із порушенням
89
- */
90
- export function findRunStepsWithShellLineContinuationBackslash(root) {
91
- /** @type {{ jobId: string, stepIndex: number }[]} */
92
- const out = []
93
- for (const { jobId, stepIndex, step } of flattenWorkflowSteps(root)) {
94
- const run = getStepRun(step)
95
- if (runTextHasShellLineContinuationBackslash(run)) {
96
- out.push({ jobId, stepIndex })
97
- }
98
- }
99
- return out
100
- }
101
-
102
- /**
103
- * Чи є крок, у якого `uses` містить будь-який з підрядків.
104
- * @param {Record<string, unknown>} root корінь workflow
105
- * @param {string[]} substrings підрядки для пошуку в `uses`
106
- * @returns {boolean} `true`, якщо знайдено хоча б один збіг
107
- */
108
- export function hasAnyStepUsesContaining(root, substrings) {
109
- for (const { step } of flattenWorkflowSteps(root)) {
110
- const uses = getStepUses(step)
111
- if (substrings.some(s => uses.includes(s))) {
112
- return true
113
- }
114
- }
115
- return false
116
- }
117
-
118
- /**
119
- * Чи перед першим кроком з локальним `setup-bun-deps` у кожному job є `actions/checkout@`.
120
- * Якщо `setup-bun-deps` у файлі немає — `true`.
121
- * @param {Record<string, unknown>} root корінь workflow
122
- * @param {string[]} setupPathSubstrings підрядки `uses`, що означають локальний composite (наприклад `./.github/actions/setup-bun-deps`)
123
- * @returns {boolean} `false`, якщо є setup без попереднього checkout
124
- */
125
- export function hasCheckoutBeforeLocalSetupBunDeps(root, setupPathSubstrings) {
126
- for (const [, job] of workflowJobsEntries(root)) {
127
- let hasCheckoutStep = false
128
- for (const step of workflowJobSteps(job)) {
129
- const uses = getStepUses(step)
130
- if (uses.includes(CHECKOUT_USES_MARKER)) {
131
- hasCheckoutStep = true
132
- }
133
- if (setupPathSubstrings.some(s => uses.includes(s)) && !hasCheckoutStep) {
134
- return false
135
- }
136
- }
137
- }
138
- return true
139
- }
140
-
141
- /**
142
- * Шукає заборонені підрядки лише в `uses` та `run` кроків (не в коментарях YAML поза кроками).
143
- * @param {Record<string, unknown>} root корінь workflow
144
- * @param {{ pattern: string, msg: string }[]} forbidden список заборонених фрагментів і повідомлень
145
- * @returns {{ jobId: string, stepIndex: number, pattern: string, msg: string }[]} знайдені збіги
146
- */
147
- export function findForbiddenUsesOrRunPatterns(root, forbidden) {
148
- /** @type {{ jobId: string, stepIndex: number, pattern: string, msg: string }[]} */
149
- const hits = []
150
- for (const { jobId, stepIndex, step } of flattenWorkflowSteps(root)) {
151
- const uses = getStepUses(step)
152
- const run = getStepRun(step)
153
- const blob = `${uses}\n${run}`
154
- for (const { pattern, msg } of forbidden) {
155
- if (blob.includes(pattern)) {
156
- hits.push({ jobId, stepIndex, pattern, msg })
157
- }
158
- }
159
- }
160
- return hits
161
- }
162
-
163
71
  /**
164
72
  * Чи є в `on.push.paths` (або `on.pull_request.paths`) елемент з точним значенням.
165
73
  * @param {Record<string, unknown>} root корінь workflow
@@ -183,87 +91,6 @@ export function eventPathsIncludeExact(root, event, exact) {
183
91
  return paths.includes(exact)
184
92
  }
185
93
 
186
- /**
187
- * Чи містить `on.push.paths` підрядок `npm/**` (npm-module).
188
- * @param {Record<string, unknown>} root корінь workflow
189
- * @returns {boolean} `true`, якщо серед `paths` є рядок з `npm/**`
190
- */
191
- export function pushPathsIncludeNpmGlob(root) {
192
- const on = root?.on
193
- if (!on || typeof on !== 'object') {
194
- return false
195
- }
196
- const push = /** @type {Record<string, unknown>} */ (on).push
197
- if (!push || typeof push !== 'object') {
198
- return false
199
- }
200
- const paths = push.paths
201
- if (!Array.isArray(paths)) {
202
- return false
203
- }
204
- return paths.some(p => typeof p === 'string' && p.includes('npm/**'))
205
- }
206
-
207
- /**
208
- * Перевіряє наявність `branches` з `main` у `on.push`.
209
- * @param {Record<string, unknown>} root корінь workflow
210
- * @returns {boolean} `true`, якщо `main` є в `on.push.branches`
211
- */
212
- export function pushHasMainBranch(root) {
213
- const on = root?.on
214
- if (!on || typeof on !== 'object') {
215
- return false
216
- }
217
- const push = /** @type {Record<string, unknown>} */ (on).push
218
- if (!push || typeof push !== 'object') {
219
- return false
220
- }
221
- const branches = push.branches
222
- if (!Array.isArray(branches)) {
223
- return false
224
- }
225
- return branches.includes('main')
226
- }
227
-
228
- /**
229
- * Чи є крок з `uses: JS-DevTools/npm-publish` та `with.package` для npm-пакета.
230
- * @param {Record<string, unknown>} root корінь workflow
231
- * @returns {boolean} `true`, якщо знайдено крок publish з `package: npm/package.json`
232
- */
233
- export function hasNpmPublishStepWithPackage(root) {
234
- for (const { step } of flattenWorkflowSteps(root)) {
235
- const uses = getStepUses(step)
236
- if (uses.includes('JS-DevTools/npm-publish')) {
237
- const w = step.with
238
- if (w && typeof w === 'object' && /** @type {Record<string, unknown>} */ (w).package === 'npm/package.json') {
239
- return true
240
- }
241
- }
242
- }
243
- return false
244
- }
245
-
246
- /**
247
- * Чи є у job `permissions.id-token: write`.
248
- * @param {Record<string, unknown>} root корінь workflow
249
- * @returns {boolean} `true`, якщо OIDC-дозвіл для npm publish налаштований
250
- */
251
- export function hasIdTokenWritePermission(root) {
252
- const jobs = root?.jobs
253
- if (!jobs || typeof jobs !== 'object') {
254
- return false
255
- }
256
- for (const job of Object.values(jobs)) {
257
- if (job && typeof job === 'object') {
258
- const perm = /** @type {Record<string, unknown>} */ (job).permissions
259
- if (perm && typeof perm === 'object' && /** @type {Record<string, unknown>} */ (perm)['id-token'] === 'write') {
260
- return true
261
- }
262
- }
263
- }
264
- return false
265
- }
266
-
267
94
  /**
268
95
  * Перевірки для `lint-js.yml`: checkout@v6, persist-credentials, setup-bun-deps, run-команди.
269
96
  * @param {Record<string, unknown> | null} root корінь workflow або `null` якщо parse не вдався
@@ -322,15 +149,6 @@ export function anyRunStepIncludes(root, needle) {
322
149
  return false
323
150
  }
324
151
 
325
- /**
326
- * Чи викликається stylelint у workflow через `npx stylelint` у кроці `run` (вимога для CI).
327
- * @param {Record<string, unknown>} root корінь workflow
328
- * @returns {boolean} `true`, якщо умова виконана
329
- */
330
- export function anyRunStepIncludesStylelint(root) {
331
- return anyRunStepIncludes(root, 'npx stylelint')
332
- }
333
-
334
152
  /**
335
153
  * Повертає jobs як список пар [jobId, job], якщо структура валідна.
336
154
  * @param {Record<string, unknown>} root корінь workflow