@nitra/cursor 3.8.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 +21 -0
- package/bin/n-cursor.js +7 -2
- package/package.json +1 -1
- package/rules/abie/lib/kustomization-patches.mjs +0 -11
- package/rules/flow/flow.mdc +3 -2
- package/rules/k8s/js/manifests.mjs +5 -212
- package/rules/k8s/k8s.mdc +1 -1
- package/rules/k8s/policy/manifest/manifest.rego +7 -6
- package/rules/rust/coverage/coverage.mjs +74 -10
- package/rules/rust/rust.mdc +20 -1
- package/rules/text/lint/run-v8r.mjs +0 -8
- package/scripts/dispatcher/index.mjs +0 -3
- package/scripts/dispatcher/lib/capability.mjs +0 -22
- package/scripts/dispatcher/lib/reviewer.mjs +7 -5
- package/scripts/lib/gha-workflow.mjs +0 -182
- package/scripts/lib/sync-gitignore-worktree.mjs +28 -0
- package/scripts/lib/worktree-notice.mjs +4 -5
- package/scripts/lib/worktree.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
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
|
+
|
|
14
|
+
## [3.9.0] - 2026-06-01
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- k8s: канонічний образ hasura/graphql-engine → v2.49.0.ubuntu.amd64 (ubuntu-база містить pg_dump 18, на відміну від ubi-варіанту)
|
|
19
|
+
|
|
20
|
+
### Removed
|
|
21
|
+
|
|
22
|
+
- k8s: видалено мертву JS-перевірку образа hasura (deploymentHasuraGraphqlEngineImageViolation + набір HASURA_GRAPHQL_ENGINE_ALLOWED_IMAGES) — пер-документна перевірка делегована rego-пакету k8s.manifest, тег тепер має єдине джерело істини (allowed_hasura_images)
|
|
23
|
+
|
|
3
24
|
## [3.8.0] - 2026-06-01
|
|
4
25
|
|
|
5
26
|
### Added
|
package/bin/n-cursor.js
CHANGED
|
@@ -100,6 +100,7 @@ import { runLintK8s } from '../rules/k8s/lint/lint.mjs'
|
|
|
100
100
|
import { runLintRego } from '../rules/rego/lint/lint.mjs'
|
|
101
101
|
import { runLintTextCli } from '../rules/text/lint/lint.mjs'
|
|
102
102
|
import { syncClaudeConfig } from '../scripts/sync-claude-config.mjs'
|
|
103
|
+
import { syncGitignoreWorktree } from '../scripts/lib/sync-gitignore-worktree.mjs'
|
|
103
104
|
import { upgradeNitraCursorToLatestAndBunInstall } from '../scripts/upgrade-nitra-cursor-and-install.mjs'
|
|
104
105
|
import { runRenameYamlExtensionsCli } from './rename-yaml-extensions.mjs'
|
|
105
106
|
import { runSkillsCli } from '../scripts/skills-cli.mjs'
|
|
@@ -715,8 +716,7 @@ async function syncClaudeMd(ignore) {
|
|
|
715
716
|
lines.push(`@${RULES_DIR}/${mdcFile}`)
|
|
716
717
|
}
|
|
717
718
|
|
|
718
|
-
lines.push(...buildClaudeLintParallelismSectionLines())
|
|
719
|
-
lines.push(...buildClaudeWorktreeEnforcementSectionLines())
|
|
719
|
+
lines.push(...buildClaudeLintParallelismSectionLines(), ...buildClaudeWorktreeEnforcementSectionLines())
|
|
720
720
|
|
|
721
721
|
const skillsSectionLines = await buildClaudeSkillsSectionLines()
|
|
722
722
|
lines.push(...skillsSectionLines)
|
|
@@ -1437,6 +1437,11 @@ async function runSync() {
|
|
|
1437
1437
|
}
|
|
1438
1438
|
})
|
|
1439
1439
|
|
|
1440
|
+
await runSyncStep('❌ Не вдалося оновити .gitignore (worktree): ', async () => {
|
|
1441
|
+
const { written } = await syncGitignoreWorktree(cwd())
|
|
1442
|
+
if (written) console.log('🌳 .gitignore (worktree): додано .worktrees/')
|
|
1443
|
+
})
|
|
1444
|
+
|
|
1440
1445
|
console.log(`\n✨ Готово: ${successCount} завантажено, ${failCount} з помилками\n`)
|
|
1441
1446
|
if (failCount > 0) {
|
|
1442
1447
|
throw new Error(`Не вдалося завантажити ${failCount} з ${rules.length} правил`)
|
package/package.json
CHANGED
|
@@ -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
|
-
}
|
package/rules/flow/flow.mdc
CHANGED
|
@@ -78,8 +78,9 @@ Composer, Claude Code) працює **Пасивний Турнікет**: ти,
|
|
|
78
78
|
npx @nitra/cursor flow verify
|
|
79
79
|
```
|
|
80
80
|
|
|
81
|
-
Проганяє Quality
|
|
82
|
-
|
|
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
|
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
* = **`"512Mi"`**. Поле **`imagePullPolicy`**
|
|
20
20
|
* не перевіряється — діють типові правила Kubernetes (`:latest` або коли тег не вказано → **Always**,
|
|
21
21
|
* інші теги → **IfNotPresent**). Якщо серед **`containers`** / **`initContainers`** є образ
|
|
22
|
-
* **`hasura/graphql-engine`**, дозволено лише
|
|
22
|
+
* **`hasura/graphql-engine`**, дозволено лише канонічний тег зі списку `allowed_hasura_images`
|
|
23
|
+
* у rego-пакеті `k8s.manifest` (`policy/manifest/manifest.rego`) — пер-документна перевірка делегована rego.
|
|
23
24
|
*
|
|
24
25
|
* **Namespace і Kustomize:** YAML у **`…/k8s/base/`** (окрім імені **`kustomization.yaml`**)
|
|
25
26
|
* завжди має **непорожній** **`metadata.namespace`** у відповідних документах (узгоджено з dev у репозиторії),
|
|
@@ -151,20 +152,6 @@ const YAML_LS_MODELINE_RE = /^# yaml-language-server: \$schema=.*\n/
|
|
|
151
152
|
|
|
152
153
|
const YANNH_PIN = 'v1.33.9-standalone-strict'
|
|
153
154
|
|
|
154
|
-
/**
|
|
155
|
-
* Дозволений образ **hasura/graphql-engine** у Deployment (узгоджено з k8s.mdc).
|
|
156
|
-
* Еквівалент **`docker.io/…`** також приймається.
|
|
157
|
-
*/
|
|
158
|
-
export const HASURA_GRAPHQL_ENGINE_IMAGE = 'hasura/graphql-engine:v2.48.15.ubi.amd64'
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
Набір прийнятних рядків `image` без digest (`@sha256:…`).
|
|
162
|
-
*/
|
|
163
|
-
const HASURA_GRAPHQL_ENGINE_ALLOWED_IMAGES = new Set([
|
|
164
|
-
HASURA_GRAPHQL_ENGINE_IMAGE,
|
|
165
|
-
`docker.io/${HASURA_GRAPHQL_ENGINE_IMAGE}`
|
|
166
|
-
])
|
|
167
|
-
|
|
168
155
|
/**
|
|
169
156
|
* Чи відносний POSIX-шлях від кореня репо вказує на YAML під **`…/k8s/…/base/…`** (після сегмента **`k8s`** у шляху
|
|
170
157
|
* є каталог **`base`**). Тут очікуються маніфести шару **base**, включно з будь-яким файлом із **`kind: Deployment`**
|
|
@@ -444,56 +431,6 @@ function pushStringPaths(arr, acc) {
|
|
|
444
431
|
/** Префікс `apiVersion` для маніфесту Kustomize **Kustomization**. */
|
|
445
432
|
const KUSTOMIZE_CONFIG_API_PREFIX = 'kustomize.config.k8s.io/'
|
|
446
433
|
|
|
447
|
-
/**
|
|
448
|
-
* Чи послідовність непорожніх рядків відсортована за `localeCompare` (en, ascending).
|
|
449
|
-
* @param {string[]} paths рядки для перевірки
|
|
450
|
-
* @returns {boolean} `true` якщо послідовність відсортована
|
|
451
|
-
*/
|
|
452
|
-
function stringPathsAreSortedEn(paths) {
|
|
453
|
-
for (let i = 1; i < paths.length; i++) {
|
|
454
|
-
if (paths[i - 1].localeCompare(paths[i], 'en', { sensitivity: 'base' }) > 0) {
|
|
455
|
-
return false
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
return true
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
/**
|
|
462
|
-
* Порушення сорту **`resources`**: лише для **`kustomize.config.k8s.io/…`**, **`kind: Kustomization`**.
|
|
463
|
-
* Порожні рядки в списку ігноруються (як у `pushStringPaths`).
|
|
464
|
-
* @param {unknown} obj корінь першого YAML-документа
|
|
465
|
-
* @returns {string | null} причина або `null`, якщо обмеження не застосовується
|
|
466
|
-
*/
|
|
467
|
-
export function kustomizationResourcesSortedAlphabeticallyViolation(obj) {
|
|
468
|
-
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) return null
|
|
469
|
-
const rec = /** @type {Record<string, unknown>} */ (obj)
|
|
470
|
-
if (rec.kind !== 'Kustomization') return null
|
|
471
|
-
const av = rec.apiVersion
|
|
472
|
-
if (typeof av !== 'string' || !av.startsWith(KUSTOMIZE_CONFIG_API_PREFIX)) return null
|
|
473
|
-
const res = rec.resources
|
|
474
|
-
if (res === undefined) return null
|
|
475
|
-
if (!Array.isArray(res)) {
|
|
476
|
-
return 'Kustomization.resources має бути масивом (k8s.mdc)'
|
|
477
|
-
}
|
|
478
|
-
/**
|
|
479
|
-
@type {string[]}
|
|
480
|
-
*/
|
|
481
|
-
const paths = []
|
|
482
|
-
for (const [i, item] of res.entries()) {
|
|
483
|
-
if (typeof item !== 'string') {
|
|
484
|
-
return `Kustomization.resources[${i}] — очікується рядок-шлях (k8s.mdc)`
|
|
485
|
-
}
|
|
486
|
-
const t = item.trim()
|
|
487
|
-
if (t !== '') paths.push(t)
|
|
488
|
-
}
|
|
489
|
-
if (paths.length < 2) return null
|
|
490
|
-
if (!stringPathsAreSortedEn(paths)) {
|
|
491
|
-
const want = paths.toSorted((a, b) => a.localeCompare(b, 'en', { sensitivity: 'base' }))
|
|
492
|
-
return `Kustomization.resources має бути за алфавітом (en). Зараз: ${paths.join(', ')}; очікувано: ${want.join(', ')} (k8s.mdc)`
|
|
493
|
-
}
|
|
494
|
-
return null
|
|
495
|
-
}
|
|
496
|
-
|
|
497
434
|
// Plan B: per-document `resources[]` sort у Kustomization — у rego-пакеті
|
|
498
435
|
// `k8s.kustomization`, викликається з `runAllK8sRego` на початку `check()`.
|
|
499
436
|
// JS-orchestrator validateKustomizationResourcesSortedAlphabetically видалено.
|
|
@@ -2172,36 +2109,6 @@ export function collectJson6902OperationsFromPatchText(patchText) {
|
|
|
2172
2109
|
return []
|
|
2173
2110
|
}
|
|
2174
2111
|
|
|
2175
|
-
/**
|
|
2176
|
-
* Шляхи JSON Patch, де в одному наборі операцій є і **remove**, і **add** (k8s.mdc: краще **replace**).
|
|
2177
|
-
* @param {Array<{ op: string, path: string }>} ops нормалізовані **op**
|
|
2178
|
-
* @returns {string[]} унікальні **path** з порушенням (відсортовано)
|
|
2179
|
-
*/
|
|
2180
|
-
export function json6902PathsWithRemoveAndAddOnSamePath(ops) {
|
|
2181
|
-
/**
|
|
2182
|
-
@type {Map<string, Set<string>>}
|
|
2183
|
-
*/
|
|
2184
|
-
const byPath = new Map()
|
|
2185
|
-
for (const { op, path } of ops) {
|
|
2186
|
-
if (path) {
|
|
2187
|
-
if (!byPath.has(path)) {
|
|
2188
|
-
byPath.set(path, new Set())
|
|
2189
|
-
}
|
|
2190
|
-
byPath.get(path).add(op)
|
|
2191
|
-
}
|
|
2192
|
-
}
|
|
2193
|
-
/**
|
|
2194
|
-
@type {string[]}
|
|
2195
|
-
*/
|
|
2196
|
-
const out = []
|
|
2197
|
-
for (const [path, set] of byPath) {
|
|
2198
|
-
if (set.has('remove') && set.has('add')) {
|
|
2199
|
-
out.push(path)
|
|
2200
|
-
}
|
|
2201
|
-
}
|
|
2202
|
-
return out.toSorted((a, b) => a.localeCompare(b))
|
|
2203
|
-
}
|
|
2204
|
-
|
|
2205
2112
|
// Plan B: вся audit-ланка JSON6902 (failIfJson6902RemoveAddConflictOnSamePath,
|
|
2206
2113
|
// auditJson6902PatchExternalFile, auditOneKustomizationJson6902Patch,
|
|
2207
2114
|
// auditKustomizationPatchesJson6902) видалена. Per-document inline JSON6902
|
|
@@ -2406,75 +2313,6 @@ function isHasuraGraphqlEngineImageRef(image) {
|
|
|
2406
2313
|
return HASURA_GRAPHQL_ENGINE_RE.test(s)
|
|
2407
2314
|
}
|
|
2408
2315
|
|
|
2409
|
-
/**
|
|
2410
|
-
* Перевірка образу Hasura для одного контейнера у списку **containers** / **initContainers**.
|
|
2411
|
-
* @param {string} list ім’я поля для повідомлення (`containers` / `initContainers`)
|
|
2412
|
-
* @param {unknown} c елемент масиву
|
|
2413
|
-
* @param {number} i індекс
|
|
2414
|
-
* @returns {string | null} текст порушення або null
|
|
2415
|
-
*/
|
|
2416
|
-
function hasuraGraphqlEngineViolationForOneContainer(list, c, i) {
|
|
2417
|
-
const label =
|
|
2418
|
-
typeof c === 'object' && c !== null && !Array.isArray(c) && typeof c.name === 'string' && c.name !== ''
|
|
2419
|
-
? c.name
|
|
2420
|
-
: `#${i + 1}`
|
|
2421
|
-
if (c === null || c === undefined || typeof c !== 'object' || Array.isArray(c)) {
|
|
2422
|
-
return null
|
|
2423
|
-
}
|
|
2424
|
-
const cont = /** @type {Record<string, unknown>} */ (c)
|
|
2425
|
-
const image = cont.image
|
|
2426
|
-
if (typeof image !== 'string' || image.trim() === '' || !isHasuraGraphqlEngineImageRef(image)) {
|
|
2427
|
-
return null
|
|
2428
|
-
}
|
|
2429
|
-
const normalized = stripImageDigest(image)
|
|
2430
|
-
if (!HASURA_GRAPHQL_ENGINE_ALLOWED_IMAGES.has(normalized)) {
|
|
2431
|
-
return `${list} "${label}": образ hasura/graphql-engine має бути ${HASURA_GRAPHQL_ENGINE_IMAGE} (зараз: ${image}) (див. k8s.mdc)`
|
|
2432
|
-
}
|
|
2433
|
-
return null
|
|
2434
|
-
}
|
|
2435
|
-
|
|
2436
|
-
/**
|
|
2437
|
-
* Перевіряє масив **containers** / **initContainers** на зафіксований образ Hasura.
|
|
2438
|
-
* @param {string} list **containers** або **initContainers** (для тексту помилки)
|
|
2439
|
-
* @param {unknown} containers значення поля з маніфесту
|
|
2440
|
-
* @returns {string | null} текст порушення або null
|
|
2441
|
-
*/
|
|
2442
|
-
function hasuraGraphqlEngineViolationInContainerList(list, containers) {
|
|
2443
|
-
if (!Array.isArray(containers)) return null
|
|
2444
|
-
for (const [i, c] of containers.entries()) {
|
|
2445
|
-
const v = hasuraGraphqlEngineViolationForOneContainer(list, c, i)
|
|
2446
|
-
if (v !== null) {
|
|
2447
|
-
return v
|
|
2448
|
-
}
|
|
2449
|
-
}
|
|
2450
|
-
return null
|
|
2451
|
-
}
|
|
2452
|
-
|
|
2453
|
-
/**
|
|
2454
|
-
* Чи порушує **Deployment** вимогу щодо зафіксованого образу **hasura/graphql-engine** (k8s.mdc).
|
|
2455
|
-
* @param {unknown} manifest корінь YAML-документа
|
|
2456
|
-
* @returns {string | null} текст порушення або null, якщо не Deployment / образу немає / ок
|
|
2457
|
-
*/
|
|
2458
|
-
export function deploymentHasuraGraphqlEngineImageViolation(manifest) {
|
|
2459
|
-
if (manifest === null || manifest === undefined || typeof manifest !== 'object' || Array.isArray(manifest))
|
|
2460
|
-
return null
|
|
2461
|
-
const rec = /** @type {Record<string, unknown>} */ (manifest)
|
|
2462
|
-
if (rec.kind !== 'Deployment') return null
|
|
2463
|
-
const spec = rec.spec
|
|
2464
|
-
if (spec === null || spec === undefined || typeof spec !== 'object' || Array.isArray(spec)) return null
|
|
2465
|
-
const template = /** @type {Record<string, unknown>} */ (spec).template
|
|
2466
|
-
if (template === null || template === undefined || typeof template !== 'object' || Array.isArray(template))
|
|
2467
|
-
return null
|
|
2468
|
-
const podSpecRaw = /** @type {Record<string, unknown>} */ (template).spec
|
|
2469
|
-
if (podSpecRaw === null || podSpecRaw === undefined || typeof podSpecRaw !== 'object' || Array.isArray(podSpecRaw))
|
|
2470
|
-
return null
|
|
2471
|
-
const podSpec = /** @type {Record<string, unknown>} */ (podSpecRaw)
|
|
2472
|
-
|
|
2473
|
-
const main = hasuraGraphqlEngineViolationInContainerList('containers', podSpec.containers)
|
|
2474
|
-
if (main !== null) return main
|
|
2475
|
-
return hasuraGraphqlEngineViolationInContainerList('initContainers', podSpec.initContainers)
|
|
2476
|
-
}
|
|
2477
|
-
|
|
2478
2316
|
/**
|
|
2479
2317
|
* Чи у списку контейнерів є хоча б один з образом **hasura/graphql-engine** (будь-який тег).
|
|
2480
2318
|
* @param {unknown} containers значення **containers** / **initContainers** із podSpec
|
|
@@ -2755,34 +2593,9 @@ export function collectDeploymentConfigMapRefs(deployment) {
|
|
|
2755
2593
|
return names
|
|
2756
2594
|
}
|
|
2757
2595
|
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
* @returns {string | null} текст порушення або null, якщо не Service / анотацій немає / ок
|
|
2762
|
-
*/
|
|
2763
|
-
export function serviceForbiddenGcpAnnotationsViolation(manifest) {
|
|
2764
|
-
if (manifest === null || manifest === undefined || typeof manifest !== 'object' || Array.isArray(manifest))
|
|
2765
|
-
return null
|
|
2766
|
-
const rec = /** @type {Record<string, unknown>} */ (manifest)
|
|
2767
|
-
if (rec.kind !== 'Service') return null
|
|
2768
|
-
const meta = rec.metadata
|
|
2769
|
-
if (meta === null || meta === undefined || typeof meta !== 'object' || Array.isArray(meta)) return null
|
|
2770
|
-
const m = /** @type {Record<string, unknown>} */ (meta)
|
|
2771
|
-
const ann = m.annotations
|
|
2772
|
-
if (ann === null || ann === undefined || typeof ann !== 'object' || Array.isArray(ann)) return null
|
|
2773
|
-
const a = /** @type {Record<string, unknown>} */ (ann)
|
|
2774
|
-
/**
|
|
2775
|
-
@type {string[]}
|
|
2776
|
-
*/
|
|
2777
|
-
const found = []
|
|
2778
|
-
for (const key of SERVICE_FORBIDDEN_GCP_ANNOTATION_KEYS) {
|
|
2779
|
-
if (Object.hasOwn(a, key)) {
|
|
2780
|
-
found.push(key)
|
|
2781
|
-
}
|
|
2782
|
-
}
|
|
2783
|
-
if (found.length === 0) return null
|
|
2784
|
-
return `metadata.annotations: прибери заборонені ключі GKE: ${found.join(', ')} (див. k8s.mdc)`
|
|
2785
|
-
}
|
|
2596
|
+
// Plan B: заборонені GKE-анотації на Service — у rego-пакеті k8s.* (per-document).
|
|
2597
|
+
// JS-функцію serviceForbiddenGcpAnnotationsViolation видалено; константа
|
|
2598
|
+
// SERVICE_FORBIDDEN_GCP_ANNOTATION_KEYS лишається експортованою (власний тест).
|
|
2786
2599
|
|
|
2787
2600
|
/** Суфікс **`metadata.name`** headless-сервісу поруч із **`svc.yaml`** (див. k8s.mdc). */
|
|
2788
2601
|
const SVC_HL_NAME_SUFFIX = '-hl'
|
|
@@ -4317,15 +4130,6 @@ export function snippetNameForKind(kind) {
|
|
|
4317
4130
|
return name
|
|
4318
4131
|
}
|
|
4319
4132
|
|
|
4320
|
-
/**
|
|
4321
|
-
* Читає deployment.snippet.yaml і повертає розпарсений spec.
|
|
4322
|
-
* @deprecated Використовуй loadSnippetSpec('deployment')
|
|
4323
|
-
* @returns {{ podSelector: Record<string, unknown>, policyTypes: string[], ingress: unknown[], egress: unknown[] }} розпарсений spec deployment snippet
|
|
4324
|
-
*/
|
|
4325
|
-
export function readNetworkPolicySnippet() {
|
|
4326
|
-
return /** @type {any} */ (loadSnippetSpec('deployment'))
|
|
4327
|
-
}
|
|
4328
|
-
|
|
4329
4133
|
/**
|
|
4330
4134
|
* No-op fail-callback (повертає аргумент). Використовується як дефолт у `regenerateLegacyNetworkPolicyDocsInFile`,
|
|
4331
4135
|
* коли caller не передає власний `fail` — щоб `collectHttpRouteIngressForWorkload` не падав.
|
|
@@ -5129,17 +4933,6 @@ export async function prodOverlayHpaPdbOverrideNeeds(rootNorm, kustAbs) {
|
|
|
5129
4933
|
}
|
|
5130
4934
|
}
|
|
5131
4935
|
|
|
5132
|
-
/**
|
|
5133
|
-
* Чи прод-оверлей потребує **будь-яких** overrides HPA/PDB у **patches[]** (зведений прапорець).
|
|
5134
|
-
* @param {string} rootNorm нормалізований корінь репозиторію
|
|
5135
|
-
* @param {string} kustAbs абсолютний шлях до kustomization.yaml
|
|
5136
|
-
* @returns {Promise<boolean>} true, якщо потрібен хоча б один тип оверрайду
|
|
5137
|
-
*/
|
|
5138
|
-
export async function prodOverlayNeedsHpaPdbOverrides(rootNorm, kustAbs) {
|
|
5139
|
-
const n = await prodOverlayHpaPdbOverrideNeeds(rootNorm, kustAbs)
|
|
5140
|
-
return n.needsHpaReplicaPatches || n.needsPdbMinAvailablePatch
|
|
5141
|
-
}
|
|
5142
|
-
|
|
5143
4936
|
/**
|
|
5144
4937
|
* Для прод kustomization.yaml вимагає **patches[]** за потреби: **`/spec/minReplicas`** і **`/spec/maxReplicas`**
|
|
5145
4938
|
* для **HorizontalPodAutoscaler** (якщо в успадкованому base лишився HPA без delete-patch), **`/spec/minAvailable`**
|
package/rules/k8s/k8s.mdc
CHANGED
|
@@ -152,7 +152,7 @@ patches:
|
|
|
152
152
|
|
|
153
153
|
Поле **`imagePullPolicy`** скрипт **не** перевіряє (залишається політиці Kubernetes за тегом образу).
|
|
154
154
|
|
|
155
|
-
Образ **`hasura/graphql-engine`**: дозволений лише канонічний тег
|
|
155
|
+
Образ **`hasura/graphql-engine`**: дозволений лише канонічний тег зі списку **`allowed_hasura_images`** у rego-пакеті **`k8s.manifest`** (`policy/manifest/manifest.rego` — єдине джерело істини; допускається префікс **`docker.io/`**); решта — помилка **check k8s**.
|
|
156
156
|
|
|
157
157
|
### HTTPRoute для Deployment з `hasura/graphql-engine`
|
|
158
158
|
|
|
@@ -44,13 +44,14 @@ forbidden_service_annotations := {
|
|
|
44
44
|
"cloud.google.com/backend-config",
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
# Дозволені посилання на образ `hasura/graphql-engine
|
|
48
|
-
#
|
|
49
|
-
#
|
|
47
|
+
# Дозволені посилання на образ `hasura/graphql-engine`. Це **єдине** джерело
|
|
48
|
+
# істини для канонічного тега (JS-копія `HASURA_GRAPHQL_ENGINE_IMAGE` видалена —
|
|
49
|
+
# пер-документна перевірка делегована цьому rego-пакету). Зараз — один канонічний
|
|
50
|
+
# тег у двох варіантах префіксу (із `docker.io/` і без). Digest (`@sha256:…`)
|
|
50
51
|
# відрізається перед звіркою.
|
|
51
52
|
allowed_hasura_images := {
|
|
52
|
-
"hasura/graphql-engine:v2.
|
|
53
|
-
"docker.io/hasura/graphql-engine:v2.
|
|
53
|
+
"hasura/graphql-engine:v2.49.0.ubuntu.amd64",
|
|
54
|
+
"docker.io/hasura/graphql-engine:v2.49.0.ubuntu.amd64",
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
# Канонічне значення `topologyKey` для `topologySpreadConstraints` (k8s.mdc).
|
|
@@ -161,7 +162,7 @@ deny contains msg if {
|
|
|
161
162
|
|
|
162
163
|
# ── deny: Deployment — образ hasura/graphql-engine з білого списку ────────
|
|
163
164
|
#
|
|
164
|
-
# Spec вимагає рівно тег
|
|
165
|
+
# Spec вимагає рівно тег зі списку `allowed_hasura_images` (вище)
|
|
165
166
|
# (з опційним префіксом `docker.io/`). Digest `@sha256:…` у поточних правилах
|
|
166
167
|
# відрізається перед порівнянням (k8s.mdc допускає, але не вимагає його).
|
|
167
168
|
|
|
@@ -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
|
-
*
|
|
50
|
-
*
|
|
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
|
-
|
|
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
|
|
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'))
|
package/rules/rust/rust.mdc
CHANGED
|
@@ -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.
|
|
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
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Гарантує, що кореневий `.gitignore` проєкту ігнорує локальні git-worktree
|
|
3
|
+
* (`.worktrees/`). Викликається з дефолтного sync (`npx \@nitra/cursor`) окремим
|
|
4
|
+
* top-level кроком — поза `syncClaudeConfig`, бо `.worktrees/` — артефакт
|
|
5
|
+
* завжди-активного flow/worktree-tooling, а не Claude/Cursor-конфігу.
|
|
6
|
+
*
|
|
7
|
+
* Один запис `.worktrees/` покриває каталог worktree та всі sibling-файли в ньому
|
|
8
|
+
* (`<branch>.flow.json`, `.events.jsonl`, `<name>.md`, `.flow-lock-*`). Запис
|
|
9
|
+
* безумовний (без гейта за `.n-cursor.json`-правилами): продюсер артефактів —
|
|
10
|
+
* завжди-активний flow, тож гейт міг би розсинхронитися з ним.
|
|
11
|
+
*
|
|
12
|
+
* Делегує наявній idempotent+append-only утиліті `ensureGitignoreEntries` (header-
|
|
13
|
+
* секція, не перезаписує/не видаляє наявні рядки; створює `.gitignore`, якщо нема).
|
|
14
|
+
*/
|
|
15
|
+
import { ensureGitignoreEntries } from '../utils/ensure-gitignore-entries.mjs'
|
|
16
|
+
|
|
17
|
+
/** Header-секція для керованого запису у `.gitignore`. */
|
|
18
|
+
const WORKTREE_SECTION_LABEL = '@nitra/cursor — локальні git-worktree, не коміти'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Дописує `.worktrees/` у кореневий `.gitignore`, якщо рядка ще немає.
|
|
22
|
+
* @param {string} projectRoot корінь проєкту-споживача (де `.gitignore`)
|
|
23
|
+
* @returns {Promise<{ written: boolean }>} чи був дописаний рядок
|
|
24
|
+
*/
|
|
25
|
+
export async function syncGitignoreWorktree(projectRoot) {
|
|
26
|
+
const { added } = await ensureGitignoreEntries(projectRoot, ['.worktrees/'], WORKTREE_SECTION_LABEL)
|
|
27
|
+
return { written: added.length > 0 }
|
|
28
|
+
}
|
|
@@ -75,8 +75,7 @@ const CYRILLIC_TRANSLIT = new Map(
|
|
|
75
75
|
* @returns {string} транслітерований текст
|
|
76
76
|
*/
|
|
77
77
|
function transliterate(value) {
|
|
78
|
-
return
|
|
79
|
-
.map((char) => CYRILLIC_TRANSLIT.get(char) ?? char)
|
|
78
|
+
return Array.from(value.toLowerCase(), (char) => CYRILLIC_TRANSLIT.get(char) ?? char)
|
|
80
79
|
.join("");
|
|
81
80
|
}
|
|
82
81
|
|
|
@@ -92,9 +91,9 @@ function deriveSuffix(content) {
|
|
|
92
91
|
.trim()
|
|
93
92
|
.replace(/^n-/u, "")
|
|
94
93
|
.normalize("NFKD")
|
|
95
|
-
.
|
|
96
|
-
.
|
|
97
|
-
.
|
|
94
|
+
.replaceAll(/[\u0300-\u036F]/gu, "")
|
|
95
|
+
.replaceAll(/[^a-z0-9]+/gu, "-")
|
|
96
|
+
.replaceAll(/^-+|-+$/gu, "");
|
|
98
97
|
|
|
99
98
|
return (
|
|
100
99
|
(slug || FALLBACK_SUFFIX).slice(0, 10).replace(/-+$/u, "") ||
|
package/scripts/lib/worktree.mjs
CHANGED
|
@@ -27,7 +27,7 @@ export function sanitizeBranch(branch) {
|
|
|
27
27
|
const sanitized = branch
|
|
28
28
|
.trim()
|
|
29
29
|
.replace(UNSAFE_PATH_CHARS_RE, '-')
|
|
30
|
-
.
|
|
30
|
+
.replaceAll(/^-+|-+$/gu, '')
|
|
31
31
|
if (sanitized === '') {
|
|
32
32
|
throw new Error(`worktree: імʼя гілки "${branch}" не містить допустимих символів`)
|
|
33
33
|
}
|