@nitra/cursor 3.9.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 +17 -0
- package/bin/n-cursor.js +3 -2
- package/package.json +1 -1
- package/rules/abie/lib/kustomization-patches.mjs +0 -11
- package/rules/flow/flow.mdc +5 -1
- package/rules/js-lint/coverage/coverage.mjs +89 -22
- package/rules/k8s/js/manifests.mjs +3 -128
- package/rules/rust/coverage/coverage.mjs +87 -12
- package/rules/rust/rust.mdc +20 -1
- package/rules/test/coverage/coverage.mjs +50 -6
- package/rules/test/test.mdc +2 -0
- 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 +8 -2
- package/scripts/lib/changed-files.mjs +28 -0
- package/scripts/lib/gha-workflow.mjs +0 -182
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
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
|
+
|
|
9
|
+
## [3.10.0] - 2026-06-01
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- rust coverage: incremental mutation через CARGO_MUTANTS_BASE_REF → cargo-mutants --in-diff (мутуємо лише змінене у <ref>...HEAD)
|
|
14
|
+
- rust coverage: CARGO_MUTANTS_BASELINE=skip → cargo-mutants --baseline skip (пропуск немутованого baseline коли тести вже зелені); rust.mdc — гайд по CI-кешу target/ для coverage-job
|
|
15
|
+
|
|
16
|
+
### Removed
|
|
17
|
+
|
|
18
|
+
- internal: видалено мертвий JS-код — 19 експортованих функцій/констант, що викликались лише з тестів (k8s manifests: 5 предикатів, мігрованих у rego; gha-workflow: 9 предикатів, мігрованих у rego ga.workflow_common + 1 каскадний; abie/text/dispatcher: kustomizationHasAbieNginxRunHttpRoutePatch, getV8rCatalogPath, SUBCOMMANDS, resolveFlow) разом з їхніми тестами й осиротілими хелперами
|
|
19
|
+
|
|
3
20
|
## [3.9.0] - 2026-06-01
|
|
4
21
|
|
|
5
22
|
### Changed
|
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
|
@@ -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
|
@@ -79,7 +79,11 @@ Composer, Claude Code) працює **Пасивний Турнікет**: ти,
|
|
|
79
79
|
```
|
|
80
80
|
|
|
81
81
|
Проганяє Quality Gates (lint + coverage). Повертає `0` (pass) або `1` із
|
|
82
|
-
виводом проваленого gate.
|
|
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`.
|
|
83
87
|
|
|
84
88
|
6. **Review (adversarial)** — рекомендовано перед release:
|
|
85
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
|
-
|
|
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
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
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
|
-
* @
|
|
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
|
-
//
|
|
277
|
-
//
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
//
|
|
282
|
-
|
|
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
|
-
|
|
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/` або поряд із джерелом) — ' +
|
|
@@ -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
|
-
|
|
2678
|
-
|
|
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,14 +8,16 @@
|
|
|
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'
|
|
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 !== '') {
|
|
@@ -43,14 +45,48 @@ export function resolveJobs(envValue) {
|
|
|
43
45
|
return Math.min(4, Math.max(1, Math.floor(cpus().length / 2)))
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Резолвить базовий git-ref для incremental mutation через cargo-mutants `--in-diff`.
|
|
50
|
+
* Порожнє/відсутнє значення → `null` = повний прогін усіх мутантів (дефолт для `main`).
|
|
51
|
+
* Непорожнє (напр. `origin/main`) → мутуємо лише змінене у `<ref>...HEAD` (для feature-гілки).
|
|
52
|
+
* cargo-mutants не має persistent-кешу вердиктів (як Stryker `incremental.json`) — scoping
|
|
53
|
+
* за git-diff це його штатний аналог «не передивляйся незмінений код».
|
|
54
|
+
* @param {string | undefined} envValue значення `process.env.CARGO_MUTANTS_BASE_REF`
|
|
55
|
+
* @returns {string | null} trimmed ref або null
|
|
56
|
+
*/
|
|
57
|
+
export function resolveBaseRef(envValue) {
|
|
58
|
+
if (envValue === undefined) return null
|
|
59
|
+
const trimmed = envValue.trim()
|
|
60
|
+
return trimmed === '' ? null : trimmed
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Резолвить режим baseline для cargo-mutants. `CARGO_MUTANTS_BASELINE=skip`
|
|
65
|
+
* (case-insensitive) → `'skip'` = пропустити немутований baseline build+test:
|
|
66
|
+
* фіксована економія в один повний `cargo test`, безпечна ЛИШЕ коли тести вже
|
|
67
|
+
* зелені у попередньому CI-степі (інакше всі вердикти сміттєві). Будь-що інше →
|
|
68
|
+
* `null` = дефолтний baseline-прогін. Цінність найбільша разом з `--in-diff`,
|
|
69
|
+
* де baseline — більша частка дрібного прогону.
|
|
70
|
+
* @param {string | undefined} envValue значення `process.env.CARGO_MUTANTS_BASELINE`
|
|
71
|
+
* @returns {'skip' | null} режим або null для дефолту
|
|
72
|
+
*/
|
|
73
|
+
export function resolveBaseline(envValue) {
|
|
74
|
+
return envValue !== undefined && envValue.trim().toLowerCase() === 'skip' ? 'skip' : null
|
|
75
|
+
}
|
|
76
|
+
|
|
46
77
|
/**
|
|
47
78
|
* Будує argv для `cargo mutants`. `--in-place` навмисно відсутній: cargo-mutants
|
|
48
79
|
* створює власну sandbox-копію в `target/mutants.<i>/`, що обов'язкове для `--jobs > 1`.
|
|
49
|
-
*
|
|
50
|
-
*
|
|
80
|
+
* `diffPath` (опційно) вмикає `--in-diff` — мутуються лише рядки з цього unified-diff.
|
|
81
|
+
* `baseline === 'skip'` (опційно) додає `--baseline skip` — без немутованого baseline-прогону.
|
|
82
|
+
* @param {{ manifestPath: string, outDir: string, jobs: number, diffPath?: string, baseline?: 'skip' | null }} opts параметри запуску
|
|
83
|
+
* @returns {string[]} argv для cargo
|
|
51
84
|
*/
|
|
52
|
-
export function buildCargoMutantsArgs({ manifestPath, outDir, jobs }) {
|
|
53
|
-
|
|
85
|
+
export function buildCargoMutantsArgs({ manifestPath, outDir, jobs, diffPath, baseline }) {
|
|
86
|
+
const args = ['mutants', '--jobs', String(jobs), '-o', outDir, '--manifest-path', manifestPath]
|
|
87
|
+
if (diffPath) args.push('--in-diff', diffPath)
|
|
88
|
+
if (baseline === 'skip') args.push('--baseline', 'skip')
|
|
89
|
+
return args
|
|
54
90
|
}
|
|
55
91
|
|
|
56
92
|
const defaultRunner = {
|
|
@@ -61,24 +97,45 @@ const defaultRunner = {
|
|
|
61
97
|
})
|
|
62
98
|
return { exitCode: r.status ?? 1, stdout: r.stdout?.toString('utf8') ?? '' }
|
|
63
99
|
},
|
|
64
|
-
runCargoMutants({ manifestPath, outDir }) {
|
|
100
|
+
runCargoMutants({ manifestPath, outDir, diffPath }) {
|
|
65
101
|
const jobs = resolveJobs(process.env.CARGO_MUTANTS_JOBS)
|
|
66
|
-
const
|
|
102
|
+
const baseline = resolveBaseline(process.env.CARGO_MUTANTS_BASELINE)
|
|
103
|
+
const r = spawnSync('cargo', buildCargoMutantsArgs({ manifestPath, outDir, jobs, diffPath, baseline }), {
|
|
67
104
|
stdio: 'inherit',
|
|
68
105
|
env: process.env
|
|
69
106
|
})
|
|
70
107
|
return r.status ?? 1
|
|
108
|
+
},
|
|
109
|
+
runGitDiff({ manifestPath, baseRef }) {
|
|
110
|
+
// `--relative` + cwd = каталог crate → шляхи в diff збігаються з тим, що
|
|
111
|
+
// cargo-mutants мутує (relative до package), навіть у monorepo з src-tauri/.
|
|
112
|
+
// Three-dot `<ref>...HEAD` = зміни гілки від merge-base, а не «з того часу в ref».
|
|
113
|
+
const r = spawnSync('git', ['diff', '--relative', `${baseRef}...HEAD`], {
|
|
114
|
+
cwd: dirname(manifestPath),
|
|
115
|
+
stdio: ['inherit', 'pipe', 'inherit'],
|
|
116
|
+
env: process.env
|
|
117
|
+
})
|
|
118
|
+
return { exitCode: r.status ?? 1, stdout: r.stdout?.toString('utf8') ?? '' }
|
|
71
119
|
}
|
|
72
120
|
}
|
|
73
121
|
|
|
74
122
|
/**
|
|
75
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 — окремий крок).
|
|
76
129
|
* @param {string} cwd корінь проєкту
|
|
77
|
-
* @param {{runner?: typeof defaultRunner}} [opts] ін'єкція runner-а
|
|
130
|
+
* @param {{runner?: typeof defaultRunner, changedFiles?: string[]}} [opts] ін'єкція runner-а + changed-scope
|
|
78
131
|
* @returns {Promise<Array<{area:string, coverage:object, mutation:{caught:number,total:number}}>>} рядки для COVERAGE.md
|
|
79
132
|
*/
|
|
80
133
|
export async function collect(cwd, opts = {}) {
|
|
81
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
|
+
}
|
|
82
139
|
const manifestPath = await resolveCargoManifest(cwd)
|
|
83
140
|
if (manifestPath === null) {
|
|
84
141
|
throw new Error('rust coverage: Cargo.toml не знайдено (cwd + workspaces)')
|
|
@@ -95,13 +152,31 @@ export async function collect(cwd, opts = {}) {
|
|
|
95
152
|
functions: { covered: totals.functions.covered, total: totals.functions.count }
|
|
96
153
|
}
|
|
97
154
|
|
|
98
|
-
// 2. Mutation через cargo mutants
|
|
155
|
+
// 2. Mutation через cargo mutants.
|
|
156
|
+
// CARGO_MUTANTS_BASE_REF (напр. `origin/main`) вмикає incremental-режим: мутуємо
|
|
157
|
+
// лише рядки, змінені у `<baseRef>...HEAD` (`git diff --relative` → cargo-mutants
|
|
158
|
+
// `--in-diff`). Env не задано — повний прогін усіх мутантів (дефолт для `main`).
|
|
159
|
+
const baseRef = resolveBaseRef(process.env.CARGO_MUTANTS_BASE_REF)
|
|
99
160
|
const outDir = await mkdtemp(join(tmpdir(), 'rust-mutants-'))
|
|
100
161
|
let mutation
|
|
101
162
|
try {
|
|
163
|
+
let diffPath
|
|
164
|
+
if (baseRef !== null) {
|
|
165
|
+
const { exitCode: diffCode, stdout: diff } = await runner.runGitDiff({ manifestPath, baseRef })
|
|
166
|
+
if (diffCode !== 0) {
|
|
167
|
+
// Невідомий ref / не git-репо — не валимо прогін, відкочуємось до повного.
|
|
168
|
+
process.stderr.write(`rust coverage: git diff проти '${baseRef}' упав — повний mutation-прогін\n`)
|
|
169
|
+
} else if (diff.trim() === '') {
|
|
170
|
+
// У `<baseRef>...HEAD` немає змін під цим crate — мутувати нічого.
|
|
171
|
+
return [{ area: 'Rust', coverage, mutation: { caught: 0, total: 0 } }]
|
|
172
|
+
} else {
|
|
173
|
+
diffPath = join(outDir, 'in-diff.patch')
|
|
174
|
+
await writeFile(diffPath, diff)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
102
177
|
// cargo-mutants exit ≠ 0 коли є missed — це нормально, не помилка.
|
|
103
178
|
// Реальний крах — відсутній outcomes.json.
|
|
104
|
-
await runner.runCargoMutants({ manifestPath, outDir })
|
|
179
|
+
await runner.runCargoMutants({ manifestPath, outDir, diffPath })
|
|
105
180
|
let outcomes
|
|
106
181
|
try {
|
|
107
182
|
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>/` самі не кешуються, але деривуються з кешованих залежностей.
|
|
@@ -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
|
-
*
|
|
203
|
-
*
|
|
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]
|
|
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
|
}
|
package/rules/test/test.mdc
CHANGED
|
@@ -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`:
|
|
@@ -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,10 +10,16 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { worktreeFingerprint } from '../../utils/worktree-fingerprint.mjs'
|
|
12
12
|
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
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`).
|
|
19
|
+
*/
|
|
14
20
|
export const DEFAULT_GATES = [
|
|
15
21
|
{ name: 'lint', cmd: ['npx', '@nitra/cursor', 'lint'] },
|
|
16
|
-
{ name: 'coverage', cmd: ['npx', '@nitra/cursor', 'coverage'] }
|
|
22
|
+
{ name: 'coverage', cmd: ['npx', '@nitra/cursor', 'coverage', '--changed'] }
|
|
17
23
|
]
|
|
18
24
|
|
|
19
25
|
/**
|
|
@@ -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
|
+
}
|
|
@@ -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
|