@nitra/cursor 1.19.2 → 1.21.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/.claude-template/settings.template.json +4 -4
- package/CHANGELOG.md +29 -0
- package/bin/n-cursor.js +11 -9
- package/package.json +3 -1
- package/rules/adr/adr.mdc +2 -12
- package/rules/js-lint/coverage/coverage.mjs +115 -9
- package/rules/k8s/js/manifests.mjs +109 -123
- package/rules/k8s/k8s.mdc +11 -1
- package/rules/k8s/policy/network_policy/network_policy.rego +73 -101
- package/rules/k8s/policy/network_policy/template/{networkpolicy.snippet.yaml → deployment.snippet.yaml} +8 -0
- package/rules/k8s/policy/network_policy/template/statefulset.snippet.yaml +67 -0
- package/rules/test/coverage/coverage.mjs +55 -9
- package/rules/test/js/data/stryker_config/stryker.config.baseline.mjs +4 -1
- package/scripts/coverage-fix.mjs +105 -0
- package/scripts/post-tool-use-fix.mjs +129 -0
- package/scripts/sync-claude-config.mjs +23 -14
- package/skills/fix-tests/SKILL.md +109 -0
- package/skills/fix-tests/auto.md +1 -0
- package/scripts/claude-stop-hook.mjs +0 -74
|
@@ -10,14 +10,14 @@
|
|
|
10
10
|
]
|
|
11
11
|
},
|
|
12
12
|
"hooks": {
|
|
13
|
-
"
|
|
13
|
+
"PostToolUse": [
|
|
14
14
|
{
|
|
15
|
-
"matcher": "",
|
|
15
|
+
"matcher": "Edit|Write|MultiEdit",
|
|
16
16
|
"hooks": [
|
|
17
17
|
{
|
|
18
18
|
"type": "command",
|
|
19
|
-
"command": "npx --no @nitra/cursor
|
|
20
|
-
"timeout":
|
|
19
|
+
"command": "npx --no @nitra/cursor post-tool-use-fix",
|
|
20
|
+
"timeout": 300
|
|
21
21
|
}
|
|
22
22
|
]
|
|
23
23
|
}
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,35 @@
|
|
|
4
4
|
|
|
5
5
|
Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
|
|
6
6
|
|
|
7
|
+
## [1.21.0] - 2026-05-25
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **Stop-hook → PostToolUse з маршрутизацією за типом файла** (BREAKING для консьюмерів із кастомним `stop-hook` записом). `.claude-template/settings.template.json` тепер реєструє `PostToolUse` (matcher `Edit|Write|MultiEdit`, timeout 300) із командою `npx --no @nitra/cursor post-tool-use-fix` замість попереднього синхронного `Stop`-хука, що ганяв повний `fix` усіх правил на кожному turn-і. Новий хук читає `tool_input.file_path` зі stdin і запускає `fix` **лише** з релевантними правилами: `*.{mjs,js,cjs,ts,tsx,jsx}` → `js-lint`; `*.vue` → `js-lint style-lint vue`; `*.{css,scss,sass}` → `style-lint`; `**/k8s/**/*.{yaml,yml}` → `k8s`; `*.rego` → `rego`; `Dockerfile`/`*.Dockerfile` → `docker`; `.github/workflows/*.{yml,yaml}` → `ga`; `package.json` → `npm-module bun`; `*.sh` → `security`; `*.md` → `text` (поза `docs/adr/**` — там покриває async `normalize-decisions.sh`).
|
|
12
|
+
- **CLI**: підкоманду `npx @nitra/cursor stop-hook` видалено; замість неї — `npx @nitra/cursor post-tool-use-fix`. `MANAGED_HOOK_COMMAND_MARKER` у `sync-claude-config.mjs` змінено на `@nitra/cursor post-tool-use-fix`; legacy-маркер `@nitra/cursor stop-hook` лишається у `MANAGED_HOOK_COMMAND_MARKERS` для автоматичного cleanup-у старих entries при наступному `npx @nitra/cursor`. `mergeHooks` тепер обходить union usually template+existing events, тому застарілі managed-групи у вже-непотрібних подіях (`Stop` у даному випадку) теж зачищаються.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- `npm/scripts/post-tool-use-fix.mjs` — реалізація `routeFilePathToRules(filePath)` (чиста функція, picomatch) і `runPostToolUseFixCli({ stdinJson, spawnFn })` (DI-friendly для тестів). 21 тест у `npm/scripts/tests/post-tool-use-fix.test.mjs`.
|
|
17
|
+
- `LEGACY_STOP_HOOK_COMMAND_MARKER` — публічний export для тестів і потенційних консьюмерів, які перевіряють відсутність застарілого хука.
|
|
18
|
+
|
|
19
|
+
### Removed
|
|
20
|
+
|
|
21
|
+
- `npm/scripts/claude-stop-hook.mjs` — більше не потрібен.
|
|
22
|
+
|
|
23
|
+
## [1.20.0] - 2026-05-25
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- **NetworkPolicy: два повних канон-snippets**: `deployment.snippet.yaml` (для `Deployment`/`Job`/`CronJob`/`DaemonSet`) і `statefulset.snippet.yaml` (повний канон для `StatefulSet` з intra-replica правилами). Жодного runtime-merge — JS-генератор/rego обирають один за `kind` workload-у через анотацію `metadata.annotations['nitra.dev/workload-kind']`. Нові publiс exports: `loadSnippetSpec('deployment'|'statefulset')`, `KIND_TO_SNIPPET`, `snippetNameForKind(kind)`. `buildNetworkPolicyYaml(deployName, appLabel, kind)` — `kind` тепер обовʼязковий (throws на невідомий). Rego (`network_policy.rego`) робить superset-перевірку проти обраного канону; safety-net deny на allow-all `egress: [{}]`. GKE NodeLocal DNSCache: link-local `169.254.0.0/16` UDP/TCP 53 — у обох канонах. **Breaking** з v1.19.x: видалено `networkPolicyManifestViolations` (структуру тримає rego); `buildNetworkPolicyYaml` без `kind` тепер throws. Перейменування `common.snippet.yaml` → `deployment.snippet.yaml`; `data.template.snippet` → `data.template.deployment_snippet` у rego.
|
|
28
|
+
- **`rules/js-lint/coverage`**: `parseStrykerReport` тепер зчитує оригінальний код вижилих мутантів (`extractOriginal`), групує по файлах і повертає `survived: [{file, mutants: [{line,col,mutantType,original,replacement}], exampleTest, recommendationText}]`; `findExampleTest` + `extractFirstTestBlock` знаходять і витягують перший тест-блок із тест-файлу поруч — для стилю.
|
|
29
|
+
- **LLM-рекомендації у COVERAGE.md**: коли встановлено `ANTHROPIC_API_KEY`, `n-cursor coverage` робить один Anthropic API-виклик на кожен файл з вижилими мутантами та записує рекомендацію «Що треба протестувати» у секцію `## Recommendations`. Модель: `claude-haiku-4-5-20251001` з prompt caching (`ephemeral`). Без ключа — секція генерується без LLM-тексту.
|
|
30
|
+
- **`rules/js-lint/coverage/lib/generate-recommendation.mjs`**: `generateMutantRecommendation(client, sourceContent, mutants)` — ізольований модуль LLM-виклику.
|
|
31
|
+
- **`@anthropic-ai/sdk`** у dependencies — потрібен для LLM-рекомендацій (опціонально: якщо `ANTHROPIC_API_KEY` не задано, sdk не викликається).
|
|
32
|
+
- **`rules/test/coverage`**: `renderMarkdown` генерує секцію `## Recommendations` з per-file підрозділами (`### <file>`) — таблиця мутантів + приклад тесту + LLM-текст (якщо є).
|
|
33
|
+
- **Stryker incremental mode** у `stryker.config.baseline.mjs`: `incremental: true` + `incrementalFile: 'reports/stryker/stryker-incremental.json'` — Stryker зберігає прогрес між прогонами, відновлює стан після переривання (SIGURG, OOM тощо).
|
|
34
|
+
- **`skills/coverage-fix`**: новий скіл `/n-coverage-fix` — читає `## Recommendations` з COVERAGE.md і ітеративно дописує тести до конвергенції mutation score, включаючи LLM-рекомендації та приклади тестів у промпт агента.
|
|
35
|
+
|
|
7
36
|
## [1.19.2] - 2026-05-25
|
|
8
37
|
|
|
9
38
|
### Fixed
|
package/bin/n-cursor.js
CHANGED
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
* якщо в корені вже є `.n-cursor.json`, спочатку зчитується конфіг і за потреби дописується `$schema`
|
|
10
10
|
* `npx \@nitra/cursor fix bun` — перевірити лише вказані правила (ігнорує `.cursor/rules/`)
|
|
11
11
|
* `npx \@nitra/cursor rename-yaml-extensions` — k8s `*.yml` → `*.yaml`, `.github` `*.yaml` → `*.yml` (опції: `--dry-run`, `--root=…`; див. bin/rename-yaml-extensions.mjs)
|
|
12
|
-
* `npx \@nitra/cursor
|
|
13
|
-
*
|
|
12
|
+
* `npx \@nitra/cursor post-tool-use-fix` — точка входу PostToolUse hook Claude Code: читає stdin JSON,
|
|
13
|
+
* дістає `tool_input.file_path`, маршрутизує його у відповідні правила
|
|
14
|
+
* (`*.mjs` → `js-lint`, `*.vue` → `js-lint style-lint vue` тощо) і викликає
|
|
15
|
+
* `fix` лише з ними. Прописується автоматично в `.claude/settings.json`.
|
|
14
16
|
* `npx \@nitra/cursor lint-ga` — канонічний lint-ga (ga.mdc): preflight на `shellcheck` →
|
|
15
17
|
* `bunx github-actionlint` → `uvx zizmor --offline --collect=workflows .`
|
|
16
18
|
* `npx \@nitra/cursor lint-rego` — канонічний lint-rego (conftest.mdc + rego.mdc):
|
|
@@ -83,7 +85,7 @@ import {
|
|
|
83
85
|
RULE_MIGRATIONS
|
|
84
86
|
} from '../scripts/auto-rules.mjs'
|
|
85
87
|
import { detectAutoSkills } from '../scripts/auto-skills.mjs'
|
|
86
|
-
import {
|
|
88
|
+
import { runPostToolUseFixCli } from '../scripts/post-tool-use-fix.mjs'
|
|
87
89
|
import { discoverCheckRulesFromCursorRules } from '../scripts/lib/discover-check-rules-from-cursor.mjs'
|
|
88
90
|
import { listRuleIds } from '../scripts/lib/list-rule-ids.mjs'
|
|
89
91
|
import { ensureNitraCursorInRootDevDependencies } from '../scripts/ensure-nitra-cursor-dev-dependencies.mjs'
|
|
@@ -1427,10 +1429,10 @@ try {
|
|
|
1427
1429
|
|
|
1428
1430
|
break
|
|
1429
1431
|
}
|
|
1430
|
-
case '
|
|
1431
|
-
// Викликається з .claude/settings.json як
|
|
1432
|
-
//
|
|
1433
|
-
const code = await
|
|
1432
|
+
case 'post-tool-use-fix': {
|
|
1433
|
+
// Викликається з .claude/settings.json як PostToolUse hook Claude Code.
|
|
1434
|
+
// Маршрутизує змінений файл у релевантні правила і прокидає `fix` лише з ними.
|
|
1435
|
+
const code = await runPostToolUseFixCli()
|
|
1434
1436
|
process.exitCode = code
|
|
1435
1437
|
|
|
1436
1438
|
break
|
|
@@ -1470,7 +1472,7 @@ try {
|
|
|
1470
1472
|
// n-cursor coverage — оркестратор покриття + мутаційного тестування з discovery
|
|
1471
1473
|
// провайдерів через .n-cursor.json#rules (test.mdc).
|
|
1472
1474
|
const { runCoverageCli } = await import('../rules/test/coverage/coverage.mjs')
|
|
1473
|
-
process.exitCode = await runCoverageCli()
|
|
1475
|
+
process.exitCode = await runCoverageCli({ fix: args.includes('--fix') })
|
|
1474
1476
|
|
|
1475
1477
|
break
|
|
1476
1478
|
}
|
|
@@ -1488,7 +1490,7 @@ try {
|
|
|
1488
1490
|
default: {
|
|
1489
1491
|
console.error(`❌ Невідома команда: ${command}`)
|
|
1490
1492
|
console.error(
|
|
1491
|
-
` Очікується: (без аргументів) синхронізація правил, check, rename-yaml-extensions,
|
|
1493
|
+
` Очікується: (без аргументів) синхронізація правил, check, rename-yaml-extensions, post-tool-use-fix, lint-ga, lint-rego, lint-k8s, lint-docker, lint-text, coverage, skill`
|
|
1492
1494
|
)
|
|
1493
1495
|
process.exitCode = 1
|
|
1494
1496
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nitra/cursor",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.21.0",
|
|
4
4
|
"description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -48,6 +48,8 @@
|
|
|
48
48
|
"rename-yaml-extensions": "bun ./bin/n-cursor.js rename-yaml-extensions"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
+
"@anthropic-ai/claude-code": "^1.0.0",
|
|
52
|
+
"@anthropic-ai/sdk": "^0.54.0",
|
|
51
53
|
"oxc-parser": "^0.128.0",
|
|
52
54
|
"picomatch": "^4.0.4",
|
|
53
55
|
"smol-toml": "^1.6.1",
|
package/rules/adr/adr.mdc
CHANGED
|
@@ -103,22 +103,12 @@ docs/adr/
|
|
|
103
103
|
|
|
104
104
|
## Stop-hook у `.claude/settings.json`
|
|
105
105
|
|
|
106
|
-
Канонічний запис, який вставляє sync (поряд із
|
|
106
|
+
Канонічний запис, який вставляє sync (поряд із PostToolUse fix-хуком — той живе в іншій події, тут не показаний):
|
|
107
107
|
|
|
108
108
|
```json title=".claude/settings.json"
|
|
109
109
|
{
|
|
110
110
|
"hooks": {
|
|
111
111
|
"Stop": [
|
|
112
|
-
{
|
|
113
|
-
"matcher": "",
|
|
114
|
-
"hooks": [
|
|
115
|
-
{
|
|
116
|
-
"type": "command",
|
|
117
|
-
"command": "npx --no @nitra/cursor stop-hook",
|
|
118
|
-
"timeout": 60
|
|
119
|
-
}
|
|
120
|
-
]
|
|
121
|
-
},
|
|
122
112
|
{
|
|
123
113
|
"matcher": "",
|
|
124
114
|
"hooks": [
|
|
@@ -146,7 +136,7 @@ docs/adr/
|
|
|
146
136
|
}
|
|
147
137
|
```
|
|
148
138
|
|
|
149
|
-
|
|
139
|
+
Обидві ADR-групи ідентифікуються пакетом за маркером у `command` (`.claude/hooks/capture-decisions.sh`, `.claude/hooks/normalize-decisions.sh`) — користувацькі hook-групи поряд не чіпаються. Якщо `adr` прибрати з `rules`, обидві ADR-групи автоматично видаляються на наступному `npx @nitra/cursor`.
|
|
150
140
|
|
|
151
141
|
## Stop-hook у `.cursor/hooks.json`
|
|
152
142
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Контракт провайдера — у docs/superpowers/specs/2026-05-24-coverage-rule-design.md.
|
|
7
7
|
*/
|
|
8
8
|
import { spawnSync } from 'node:child_process'
|
|
9
|
-
import { existsSync } from 'node:fs'
|
|
9
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
10
10
|
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
|
11
11
|
import { tmpdir } from 'node:os'
|
|
12
12
|
import { join } from 'node:path'
|
|
@@ -55,26 +55,132 @@ function parseLcov(text) {
|
|
|
55
55
|
return acc
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Витягує оригінальний фрагмент коду з рядків файлу за позицією мутанта.
|
|
60
|
+
* @param {string[]} fileLines рядки файлу (0-indexed)
|
|
61
|
+
* @param {{start:{line:number,column:number},end:{line:number,column:number}}} loc позиція (рядки 1-indexed)
|
|
62
|
+
* @returns {string} оригінальний текст мутанта
|
|
63
|
+
*/
|
|
64
|
+
function extractOriginal(fileLines, loc) {
|
|
65
|
+
const startLine = loc.start.line - 1
|
|
66
|
+
const endLine = loc.end.line - 1
|
|
67
|
+
if (startLine === endLine) {
|
|
68
|
+
return fileLines[startLine]?.slice(loc.start.column, loc.end.column) ?? ''
|
|
69
|
+
}
|
|
70
|
+
const parts = []
|
|
71
|
+
for (let i = startLine; i <= endLine; i++) {
|
|
72
|
+
const line = fileLines[i] ?? ''
|
|
73
|
+
if (i === startLine) parts.push(line.slice(loc.start.column))
|
|
74
|
+
else if (i === endLine) parts.push(line.slice(0, loc.end.column))
|
|
75
|
+
else parts.push(line)
|
|
76
|
+
}
|
|
77
|
+
return parts.join('\n')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Витягує перший `it(` або `test(` блок з вмісту тест-файлу.
|
|
82
|
+
* Відстежує глибину `{}` для коректного завершення.
|
|
83
|
+
* @param {string} content вміст тест-файлу
|
|
84
|
+
* @returns {string | null} перший тест-блок або null
|
|
85
|
+
*/
|
|
86
|
+
export function extractFirstTestBlock(content) {
|
|
87
|
+
const lines = content.split('\n')
|
|
88
|
+
let startLine = -1
|
|
89
|
+
let depth = 0
|
|
90
|
+
let inBlock = false
|
|
91
|
+
const result = []
|
|
92
|
+
for (let i = 0; i < lines.length; i++) {
|
|
93
|
+
if (startLine === -1 && /^\s*(it|test)\(/.test(lines[i])) startLine = i
|
|
94
|
+
if (startLine === -1) continue
|
|
95
|
+
result.push(lines[i])
|
|
96
|
+
for (const ch of lines[i]) {
|
|
97
|
+
if (ch === '{') { depth++; inBlock = true }
|
|
98
|
+
else if (ch === '}') depth--
|
|
99
|
+
}
|
|
100
|
+
if (inBlock && depth === 0) break
|
|
101
|
+
}
|
|
102
|
+
return result.length > 0 ? result.join('\n') : null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Шукає тест-файл для заданого source-файлу і повертає перший тест-блок як приклад стилю.
|
|
107
|
+
* Кандидати: `<base>.test.js`, `<base>.test.mjs`, `<dir>/tests/<name>.test.js`.
|
|
108
|
+
* @param {string} jsRoot абсолютний шлях до JS-кореня
|
|
109
|
+
* @param {string} filename відносний шлях source-файлу (від jsRoot)
|
|
110
|
+
* @returns {{testFile:string, code:string|null} | null} null — якщо тест-файл не знайдено
|
|
111
|
+
*/
|
|
112
|
+
export function findExampleTest(jsRoot, filename) {
|
|
113
|
+
const base = filename.replace(/\.[^.]+$/, '')
|
|
114
|
+
const candidates = [`${base}.test.js`, `${base}.test.mjs`, `${base}.test.ts`]
|
|
115
|
+
const lastSlash = base.lastIndexOf('/')
|
|
116
|
+
if (lastSlash !== -1) {
|
|
117
|
+
const dir = base.slice(0, lastSlash)
|
|
118
|
+
const name = base.slice(lastSlash + 1)
|
|
119
|
+
candidates.push(`${dir}/tests/${name}.test.js`, `${dir}/tests/${name}.test.mjs`)
|
|
120
|
+
}
|
|
121
|
+
for (const rel of candidates) {
|
|
122
|
+
const full = join(jsRoot, rel)
|
|
123
|
+
if (!existsSync(full)) continue
|
|
124
|
+
const content = readFileSync(full, 'utf8')
|
|
125
|
+
return { testFile: rel, code: extractFirstTestBlock(content) }
|
|
126
|
+
}
|
|
127
|
+
return null
|
|
128
|
+
}
|
|
129
|
+
|
|
58
130
|
/**
|
|
59
131
|
* Парс Stryker mutation.json: Killed+Timeout → caught; Survived+NoCoverage → до total.
|
|
60
132
|
* Compile/Runtime errors виключаються з total.
|
|
61
|
-
*
|
|
62
|
-
* @
|
|
133
|
+
* Survived мутанти групуються по файлах з exampleTest.
|
|
134
|
+
* @param {{files:Record<string,{mutants:Array<{status:string,mutatorName?:string,replacement?:string,location?:{start:{line:number,column:number},end:{line:number,column:number}}}>}>}} report
|
|
135
|
+
* @param {string|null} [jsRoot] корінь для читання source-рядків і пошуку тест-файлів
|
|
136
|
+
* @returns {{caught:number,total:number,survived:Array<{file:string,mutants:Array<{line:number,col:number,mutantType:string,original:string,replacement:string}>,exampleTest:{testFile:string,code:string|null}|null,recommendationText:string|null}>}}
|
|
63
137
|
*/
|
|
64
|
-
function parseStrykerReport(report) {
|
|
138
|
+
export function parseStrykerReport(report, jsRoot) {
|
|
65
139
|
let caught = 0
|
|
66
140
|
let total = 0
|
|
67
|
-
|
|
68
|
-
|
|
141
|
+
/** @type {Map<string, Array<{line:number,col:number,mutantType:string,original:string,replacement:string}>>} */
|
|
142
|
+
const byFile = new Map()
|
|
143
|
+
|
|
144
|
+
for (const [filePath, fileData] of Object.entries(report.files)) {
|
|
145
|
+
let fileLines = null
|
|
146
|
+
for (const mutant of fileData.mutants) {
|
|
69
147
|
if (mutant.status === 'Killed' || mutant.status === 'Timeout') {
|
|
70
148
|
caught += 1
|
|
71
149
|
total += 1
|
|
72
150
|
} else if (mutant.status === 'Survived' || mutant.status === 'NoCoverage') {
|
|
73
151
|
total += 1
|
|
152
|
+
if (mutant.status === 'Survived' && jsRoot && mutant.location) {
|
|
153
|
+
if (!fileLines) {
|
|
154
|
+
try {
|
|
155
|
+
fileLines = readFileSync(join(jsRoot, filePath), 'utf8').split('\n')
|
|
156
|
+
} catch {
|
|
157
|
+
fileLines = []
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (!byFile.has(filePath)) byFile.set(filePath, [])
|
|
161
|
+
byFile.get(filePath).push({
|
|
162
|
+
line: mutant.location.start.line,
|
|
163
|
+
col: mutant.location.start.column,
|
|
164
|
+
mutantType: mutant.mutatorName ?? 'Unknown',
|
|
165
|
+
original: extractOriginal(fileLines, mutant.location),
|
|
166
|
+
replacement: mutant.replacement ?? ''
|
|
167
|
+
})
|
|
168
|
+
}
|
|
74
169
|
}
|
|
75
170
|
}
|
|
76
171
|
}
|
|
77
|
-
|
|
172
|
+
|
|
173
|
+
const survived = []
|
|
174
|
+
for (const [file, mutants] of byFile) {
|
|
175
|
+
survived.push({
|
|
176
|
+
file,
|
|
177
|
+
mutants,
|
|
178
|
+
exampleTest: jsRoot ? findExampleTest(jsRoot, file) : null,
|
|
179
|
+
recommendationText: null
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return { caught, total, survived }
|
|
78
184
|
}
|
|
79
185
|
|
|
80
186
|
/**
|
|
@@ -130,7 +236,7 @@ export async function collect(cwd, opts = {}) {
|
|
|
130
236
|
'або налаштуй його вручну'
|
|
131
237
|
)
|
|
132
238
|
}
|
|
133
|
-
const
|
|
239
|
+
const { caught, total, survived } = parseStrykerReport(mutationReport, jsRoot)
|
|
134
240
|
|
|
135
|
-
return [{ area: 'JS', coverage, mutation }]
|
|
241
|
+
return [{ area: 'JS', coverage, mutation: { caught, total }, survived }]
|
|
136
242
|
}
|
|
@@ -134,11 +134,12 @@
|
|
|
134
134
|
* У `kustomization.yaml` overlay, який підключає каталог `…/k8s/…/base`, не додавай окремі YAML-файли з HPA / PDB,
|
|
135
135
|
* поки в наслідуваному `base` у дереві не з'явиться такий Deployment (k8s.mdc).
|
|
136
136
|
*/
|
|
137
|
-
import { existsSync } from 'node:fs'
|
|
137
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
138
138
|
import { readFile, readdir, stat, unlink, writeFile } from 'node:fs/promises'
|
|
139
139
|
import { basename, dirname, join, relative, resolve } from 'node:path'
|
|
140
|
+
import { fileURLToPath } from 'node:url'
|
|
140
141
|
|
|
141
|
-
import { isSeq, parseAllDocuments, parseDocument } from 'yaml'
|
|
142
|
+
import { isSeq, parseAllDocuments, parseDocument, stringify } from 'yaml'
|
|
142
143
|
|
|
143
144
|
import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
|
|
144
145
|
import { loadCursorIgnorePaths } from '../../../scripts/lib/load-cursor-config.mjs'
|
|
@@ -4242,125 +4243,90 @@ export function pdbManifestViolations(manifest, expectedAppLabel, isDevLike) {
|
|
|
4242
4243
|
return errs
|
|
4243
4244
|
}
|
|
4244
4245
|
|
|
4246
|
+
const NETWORK_POLICY_SNIPPET_URLS = {
|
|
4247
|
+
deployment: new URL('../policy/network_policy/template/deployment.snippet.yaml', import.meta.url),
|
|
4248
|
+
statefulset: new URL('../policy/network_policy/template/statefulset.snippet.yaml', import.meta.url),
|
|
4249
|
+
}
|
|
4250
|
+
|
|
4251
|
+
/** @type {Record<string, Record<string, unknown>>} */
|
|
4252
|
+
const _snippetCache = {}
|
|
4253
|
+
|
|
4254
|
+
/**
|
|
4255
|
+
* Читає snippet-файл і повертає розпарсений spec. Результат кешується в пам'яті процесу.
|
|
4256
|
+
* Кожен snippet — повний самодостатній канон NetworkPolicy для своєї групи workload-типів
|
|
4257
|
+
* (без merge між snippets у runtime).
|
|
4258
|
+
* @param {'deployment' | 'statefulset'} snippetName ім'я сніпету
|
|
4259
|
+
* @returns {{ podSelector?: Record<string, unknown>, policyTypes?: string[], ingress?: unknown[], egress?: unknown[] }}
|
|
4260
|
+
*/
|
|
4261
|
+
export function loadSnippetSpec(snippetName) {
|
|
4262
|
+
if (_snippetCache[snippetName]) return _snippetCache[snippetName]
|
|
4263
|
+
const url = NETWORK_POLICY_SNIPPET_URLS[snippetName]
|
|
4264
|
+
if (!url) throw new Error(`Unknown NetworkPolicy snippet: ${snippetName}`)
|
|
4265
|
+
const raw = readFileSync(fileURLToPath(url), 'utf8')
|
|
4266
|
+
_snippetCache[snippetName] = /** @type {any} */ (parseDocument(raw).toJS()).spec
|
|
4267
|
+
return _snippetCache[snippetName]
|
|
4268
|
+
}
|
|
4269
|
+
|
|
4270
|
+
/**
|
|
4271
|
+
* Mapping workload-kind → snippet name. Єдине джерело dispatch'а в JS;
|
|
4272
|
+
* rego використовує симетричний mapping через анотацію `nitra.dev/workload-kind`.
|
|
4273
|
+
* @type {Record<string, 'deployment' | 'statefulset'>}
|
|
4274
|
+
*/
|
|
4275
|
+
export const KIND_TO_SNIPPET = {
|
|
4276
|
+
Deployment: 'deployment',
|
|
4277
|
+
Job: 'deployment',
|
|
4278
|
+
CronJob: 'deployment',
|
|
4279
|
+
DaemonSet: 'deployment',
|
|
4280
|
+
StatefulSet: 'statefulset',
|
|
4281
|
+
}
|
|
4282
|
+
|
|
4283
|
+
/**
|
|
4284
|
+
* Обирає snippet name для конкретного workload-kind; throws на невідомий.
|
|
4285
|
+
* @param {string} kind workload-kind
|
|
4286
|
+
* @returns {'deployment' | 'statefulset'}
|
|
4287
|
+
*/
|
|
4288
|
+
export function snippetNameForKind(kind) {
|
|
4289
|
+
const name = KIND_TO_SNIPPET[kind]
|
|
4290
|
+
if (!name) throw new Error(`Unknown workload kind for NetworkPolicy canon: ${kind}`)
|
|
4291
|
+
return name
|
|
4292
|
+
}
|
|
4293
|
+
|
|
4245
4294
|
/**
|
|
4246
|
-
*
|
|
4247
|
-
*
|
|
4248
|
-
*
|
|
4249
|
-
*/
|
|
4250
|
-
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
podSelector:
|
|
4262
|
-
matchLabels:
|
|
4263
|
-
k8s-app: kube-dns
|
|
4264
|
-
ports:
|
|
4265
|
-
- protocol: UDP
|
|
4266
|
-
port: 53
|
|
4267
|
-
- protocol: TCP
|
|
4268
|
-
port: 53
|
|
4269
|
-
- to:
|
|
4270
|
-
- ipBlock:
|
|
4271
|
-
cidr: 0.0.0.0/0
|
|
4272
|
-
ports:
|
|
4273
|
-
- protocol: TCP
|
|
4274
|
-
port: 80
|
|
4275
|
-
- protocol: TCP
|
|
4276
|
-
port: 443
|
|
4277
|
-
- to:
|
|
4278
|
-
- namespaceSelector: {}
|
|
4279
|
-
ports:
|
|
4280
|
-
${NETWORK_POLICY_IN_CLUSTER_DEFAULT_PORTS.map(p => ` - protocol: TCP\n port: ${p}`).join('\n')}
|
|
4281
|
-
`
|
|
4282
|
-
|
|
4283
|
-
/**
|
|
4284
|
-
* Канонічний YAML **NetworkPolicy** для workload з іменем `workloadName` і міткою `app`.
|
|
4285
|
-
* @param {string} deployName `metadata.name` workload (Deployment, StatefulSet, …)
|
|
4286
|
-
* @param {string} appLabel `spec.selector.matchLabels.app` (або selector у `jobTemplate` для CronJob)
|
|
4295
|
+
* Читає deployment.snippet.yaml і повертає розпарсений spec.
|
|
4296
|
+
* @deprecated Використовуй loadSnippetSpec('deployment')
|
|
4297
|
+
* @returns {{ podSelector: Record<string, unknown>, policyTypes: string[], ingress: unknown[], egress: unknown[] }}
|
|
4298
|
+
*/
|
|
4299
|
+
export function readNetworkPolicySnippet() {
|
|
4300
|
+
return /** @type {any} */ (loadSnippetSpec('deployment'))
|
|
4301
|
+
}
|
|
4302
|
+
|
|
4303
|
+
/**
|
|
4304
|
+
* Канонічний YAML **NetworkPolicy** для workload з іменем `deployName`, міткою `app` і типом `kind`.
|
|
4305
|
+
* Snippet обирається за `kind` через `KIND_TO_SNIPPET` (без merge — кожен snippet самодостатній).
|
|
4306
|
+
* Анотація `nitra.dev/workload-kind` додається, щоб rego диспатчив на правильний канон.
|
|
4307
|
+
* @param {string} deployName `metadata.name` workload
|
|
4308
|
+
* @param {string} appLabel `spec.selector.matchLabels.app`
|
|
4309
|
+
* @param {string} kind `kind` workload (обовʼязковий: Deployment | StatefulSet | Job | CronJob | DaemonSet)
|
|
4287
4310
|
* @returns {string} вміст `networkpolicy.yaml`
|
|
4288
4311
|
*/
|
|
4289
|
-
export function buildNetworkPolicyYaml(deployName, appLabel) {
|
|
4312
|
+
export function buildNetworkPolicyYaml(deployName, appLabel, kind) {
|
|
4290
4313
|
const schemaUrl = `${YANNH_BASE}networkpolicy-networking-v1.json`
|
|
4314
|
+
const snippetName = snippetNameForKind(kind)
|
|
4315
|
+
const spec = JSON.parse(JSON.stringify(loadSnippetSpec(snippetName)))
|
|
4316
|
+
spec.podSelector.matchLabels = { app: appLabel }
|
|
4317
|
+
const specYaml = stringify(spec, { indent: 2 }).replaceAll(/^(?!$)/gm, ' ').trimEnd()
|
|
4291
4318
|
return `# yaml-language-server: $schema=${schemaUrl}
|
|
4292
4319
|
apiVersion: networking.k8s.io/v1
|
|
4293
4320
|
kind: NetworkPolicy
|
|
4294
4321
|
metadata:
|
|
4295
4322
|
name: ${deployName}
|
|
4323
|
+
annotations:
|
|
4324
|
+
nitra.dev/workload-kind: ${kind}
|
|
4296
4325
|
spec:
|
|
4297
|
-
|
|
4298
|
-
matchLabels:
|
|
4299
|
-
app: ${appLabel}
|
|
4300
|
-
policyTypes:
|
|
4301
|
-
- Ingress
|
|
4302
|
-
- Egress
|
|
4303
|
-
ingress:
|
|
4304
|
-
- from:
|
|
4305
|
-
- podSelector: {}
|
|
4306
|
-
${NETWORK_POLICY_EGRESS_YAML}`
|
|
4307
|
-
}
|
|
4308
|
-
|
|
4309
|
-
/**
|
|
4310
|
-
* Перевіряє **NetworkPolicy** (`networking.k8s.io/v1`): структура й прив'язка до workload.
|
|
4311
|
-
* @param {unknown} manifest корінь YAML-документа NetworkPolicy
|
|
4312
|
-
* @param {string} expectedDeployName очікуване `metadata.name` workload
|
|
4313
|
-
* @param {string} expectedAppLabel очікувана мітка `app` у `podSelector.matchLabels`
|
|
4314
|
-
* @returns {string[]} список порушень (порожній — ок)
|
|
4315
|
-
*/
|
|
4316
|
-
export function networkPolicyManifestViolations(manifest, expectedDeployName, expectedAppLabel) {
|
|
4317
|
-
/**
|
|
4318
|
-
@type {string[]}
|
|
4319
|
-
*/
|
|
4320
|
-
const errs = []
|
|
4321
|
-
if (manifest === null || manifest === undefined || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
|
4322
|
-
errs.push('NetworkPolicy має бути обʼєктом YAML')
|
|
4323
|
-
return errs
|
|
4324
|
-
}
|
|
4325
|
-
const rec = /** @type {Record<string, unknown>} */ (manifest)
|
|
4326
|
-
if (rec.kind !== 'NetworkPolicy') errs.push(`kind має бути NetworkPolicy (зараз: ${JSON.stringify(rec.kind)})`)
|
|
4327
|
-
if (rec.apiVersion !== 'networking.k8s.io/v1')
|
|
4328
|
-
errs.push(`apiVersion має бути networking.k8s.io/v1 (зараз: ${JSON.stringify(rec.apiVersion)})`)
|
|
4329
|
-
const name = manifestMetadataName(rec)
|
|
4330
|
-
if (name !== expectedDeployName)
|
|
4331
|
-
errs.push(`metadata.name має бути '${expectedDeployName}' (зараз: ${JSON.stringify(name)})`)
|
|
4332
|
-
const spec = rec.spec
|
|
4333
|
-
if (spec === null || spec === undefined || typeof spec !== 'object' || Array.isArray(spec)) {
|
|
4334
|
-
errs.push('spec відсутній або некоректний')
|
|
4335
|
-
return errs
|
|
4336
|
-
}
|
|
4337
|
-
const s = /** @type {Record<string, unknown>} */ (spec)
|
|
4338
|
-
const podSelector = s.podSelector
|
|
4339
|
-
if (
|
|
4340
|
-
podSelector === null ||
|
|
4341
|
-
podSelector === undefined ||
|
|
4342
|
-
typeof podSelector !== 'object' ||
|
|
4343
|
-
Array.isArray(podSelector)
|
|
4344
|
-
) {
|
|
4345
|
-
errs.push('spec.podSelector відсутній')
|
|
4346
|
-
return errs
|
|
4347
|
-
}
|
|
4348
|
-
const matchLabels = /** @type {Record<string, unknown>} */ (podSelector).matchLabels
|
|
4349
|
-
if (
|
|
4350
|
-
matchLabels === null ||
|
|
4351
|
-
matchLabels === undefined ||
|
|
4352
|
-
typeof matchLabels !== 'object' ||
|
|
4353
|
-
Array.isArray(matchLabels)
|
|
4354
|
-
) {
|
|
4355
|
-
errs.push('spec.podSelector.matchLabels відсутній')
|
|
4356
|
-
return errs
|
|
4357
|
-
}
|
|
4358
|
-
const app = /** @type {Record<string, unknown>} */ (matchLabels).app
|
|
4359
|
-
if (app !== expectedAppLabel)
|
|
4360
|
-
errs.push(`spec.podSelector.matchLabels.app має бути '${expectedAppLabel}' (зараз: ${JSON.stringify(app)})`)
|
|
4361
|
-
return errs
|
|
4326
|
+
${specYaml}`
|
|
4362
4327
|
}
|
|
4363
4328
|
|
|
4329
|
+
|
|
4364
4330
|
/**
|
|
4365
4331
|
* Додає `resourceName` у `resources:` kustomization/Component YAML, якщо ще немає; сортує за алфавітом (en).
|
|
4366
4332
|
* @param {string} raw вміст `kustomization.yaml`
|
|
@@ -5126,11 +5092,14 @@ function validateNetworkPolicyForWorkload(npDocs, workloadName, appLabel, worklo
|
|
|
5126
5092
|
)
|
|
5127
5093
|
return
|
|
5128
5094
|
}
|
|
5129
|
-
const
|
|
5130
|
-
|
|
5131
|
-
|
|
5095
|
+
const spec = /** @type {Record<string, unknown>} */ (matchedNp).spec
|
|
5096
|
+
const foundLabel = networkPolicyPodSelectorAppLabel(spec)
|
|
5097
|
+
if (foundLabel !== appLabel) {
|
|
5098
|
+
fail(
|
|
5099
|
+
`${npRel}: NetworkPolicy '${workloadName}' spec.podSelector.matchLabels.app='${foundLabel}' не відповідає мітці workload '${appLabel}' (k8s.mdc)`
|
|
5100
|
+
)
|
|
5132
5101
|
} else {
|
|
5133
|
-
|
|
5102
|
+
passFn(`${npRel}: NetworkPolicy для ${workloadKind} '${workloadName}' валідний (k8s.mdc)`)
|
|
5134
5103
|
}
|
|
5135
5104
|
}
|
|
5136
5105
|
|
|
@@ -6332,8 +6301,8 @@ async function appendNetworkPolicyDocuments(npAbs, toAdd, npRel, passFn) {
|
|
|
6332
6301
|
const raw = await readFile(npAbs, 'utf8')
|
|
6333
6302
|
content = raw.trimEnd()
|
|
6334
6303
|
}
|
|
6335
|
-
const blocks = toAdd.map(({ name, appLabel }, i) => {
|
|
6336
|
-
const block = buildNetworkPolicyYaml(name, appLabel)
|
|
6304
|
+
const blocks = toAdd.map(({ name, appLabel, kind }, i) => {
|
|
6305
|
+
const block = buildNetworkPolicyYaml(name, appLabel, kind)
|
|
6337
6306
|
return i === 0 && content === '' ? block.trimEnd() : stripYamlLanguageServerModeline(block).trimEnd()
|
|
6338
6307
|
})
|
|
6339
6308
|
const joined = blocks.join('\n---\n')
|
|
@@ -6401,18 +6370,27 @@ export async function regenerateLegacyNetworkPolicyDocsInFile(npAbs) {
|
|
|
6401
6370
|
const needsMigration = docs.some(d => networkPolicyHasLegacyCatchAllEgress(d))
|
|
6402
6371
|
if (!needsMigration) return false
|
|
6403
6372
|
/**
|
|
6404
|
-
@type {Array<{ name: string, appLabel: string }>}
|
|
6373
|
+
@type {Array<{ name: string, appLabel: string, kind: string }>}
|
|
6405
6374
|
*/
|
|
6406
6375
|
const specs = []
|
|
6407
6376
|
for (const doc of docs) {
|
|
6408
6377
|
const name = manifestMetadataName(doc)
|
|
6409
|
-
const
|
|
6378
|
+
const docRec = /** @type {Record<string, unknown>} */ (doc)
|
|
6379
|
+
const spec = docRec.spec
|
|
6410
6380
|
const appLabel = networkPolicyPodSelectorAppLabel(spec)
|
|
6411
|
-
|
|
6381
|
+
const meta = docRec.metadata
|
|
6382
|
+
const annotations = (meta !== null && typeof meta === 'object' && !Array.isArray(meta))
|
|
6383
|
+
? /** @type {Record<string, unknown>} */ (meta).annotations
|
|
6384
|
+
: null
|
|
6385
|
+
const rawKind = (annotations !== null && typeof annotations === 'object' && !Array.isArray(annotations))
|
|
6386
|
+
? /** @type {Record<string, unknown>} */ (annotations)['nitra.dev/workload-kind']
|
|
6387
|
+
: null
|
|
6388
|
+
const kind = typeof rawKind === 'string' && rawKind !== '' ? rawKind : 'Deployment'
|
|
6389
|
+
if (typeof name === 'string' && name !== '' && appLabel !== '') specs.push({ name, appLabel, kind })
|
|
6412
6390
|
}
|
|
6413
6391
|
if (specs.length === 0) return false
|
|
6414
|
-
const blocks = specs.map(({ name, appLabel }, i) => {
|
|
6415
|
-
const block = buildNetworkPolicyYaml(name, appLabel)
|
|
6392
|
+
const blocks = specs.map(({ name, appLabel, kind }, i) => {
|
|
6393
|
+
const block = buildNetworkPolicyYaml(name, appLabel, kind)
|
|
6416
6394
|
return i === 0 ? block.trimEnd() : stripYamlLanguageServerModeline(block).trimEnd()
|
|
6417
6395
|
})
|
|
6418
6396
|
await writeFile(npAbs, `${blocks.join('\n---\n')}\n`, 'utf8')
|
|
@@ -6582,13 +6560,21 @@ function runAllK8sRego(root, yamlFiles, fail) {
|
|
|
6582
6560
|
})
|
|
6583
6561
|
|
|
6584
6562
|
/**
|
|
6585
|
-
@type {Array<{ ns: string, dir: string, files: string[] }>}
|
|
6563
|
+
@type {Array<{ ns: string, dir: string, files: string[], templateData?: Record<string, unknown> }>}
|
|
6586
6564
|
*/
|
|
6587
6565
|
const targets = [
|
|
6588
6566
|
{ ns: 'k8s.manifest', dir: 'k8s/manifest', files: allYaml },
|
|
6589
6567
|
{ ns: 'k8s.gateway', dir: 'k8s/gateway', files: allYaml },
|
|
6590
6568
|
{ ns: 'k8s.hpa_pdb', dir: 'k8s/hpa_pdb', files: allYaml },
|
|
6591
|
-
{
|
|
6569
|
+
{
|
|
6570
|
+
ns: 'k8s.network_policy',
|
|
6571
|
+
dir: 'k8s/network_policy',
|
|
6572
|
+
files: allYaml,
|
|
6573
|
+
templateData: {
|
|
6574
|
+
deployment_snippet: loadSnippetSpec('deployment'),
|
|
6575
|
+
statefulset_snippet: loadSnippetSpec('statefulset'),
|
|
6576
|
+
},
|
|
6577
|
+
},
|
|
6592
6578
|
{ ns: 'k8s.kustomization', dir: 'k8s/kustomization', files: kustYaml },
|
|
6593
6579
|
{ ns: 'k8s.svc_yaml', dir: 'k8s/svc_yaml', files: svcYaml },
|
|
6594
6580
|
{ ns: 'k8s.svc_hl_yaml', dir: 'k8s/svc_hl_yaml', files: svcHlYaml },
|
|
@@ -6598,7 +6584,7 @@ function runAllK8sRego(root, yamlFiles, fail) {
|
|
|
6598
6584
|
|
|
6599
6585
|
for (const t of targets) {
|
|
6600
6586
|
if (t.files.length === 0) continue
|
|
6601
|
-
const violations = runConftestBatch({ policyDirRel: t.dir, namespace: t.ns, files: t.files })
|
|
6587
|
+
const violations = runConftestBatch({ policyDirRel: t.dir, namespace: t.ns, files: t.files, templateData: t.templateData })
|
|
6602
6588
|
for (const v of violations) {
|
|
6603
6589
|
fail(`${relOf(v.filename)}: ${v.message}`)
|
|
6604
6590
|
}
|