@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.
@@ -10,14 +10,14 @@
10
10
  ]
11
11
  },
12
12
  "hooks": {
13
- "Stop": [
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 stop-hook",
20
- "timeout": 60
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 stop-hook` — точка входу Stop hook Claude Code (читає stdin, виходить 0 при `stop_hook_active`,
13
- * інакше викликає `fix`); прописується автоматично в `.claude/settings.json`
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 { runStopHookCli } from '../scripts/claude-stop-hook.mjs'
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 'stop-hook': {
1431
- // Викликається з .claude/settings.json як Stop hook Claude Code.
1432
- // Прокидає `check` і поважає stop_hook_active, щоб не зациклюватись.
1433
- const code = await runStopHookCli()
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, stop-hook, lint-ga, lint-rego, lint-k8s, lint-docker, lint-text, coverage, skill`
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.19.2",
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 (поряд із lint stop-hook):
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
- Усі три групи ідентифікуються пакетом за маркером у `command` (`@nitra/cursor stop-hook`, `.claude/hooks/capture-decisions.sh`, `.claude/hooks/normalize-decisions.sh`) — користувацькі hook-групи поряд не чіпаються. Якщо `adr` прибрати з `rules`, обидві ADR-групи автоматично видаляються на наступному `npx @nitra/cursor`.
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
- * @param {{files:Record<string,{mutants:Array<{status:string}>}>}} report розпарсений mutation.json
62
- * @returns {{caught:number,total:number}} агрегований mutation score
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
- for (const file of Object.values(report.files)) {
68
- for (const mutant of file.mutants) {
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
- return { caught, total }
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 mutation = parseStrykerReport(mutationReport)
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
- * Канонічний список in-cluster TCP-портів у `to: [{namespaceSelector: {}}]` rule (k8s.mdc).
4247
- * Зовнішній доступ (80/443 → 0.0.0.0/0) і kube-dns (53 UDP/TCP) — окремі rule вище.
4248
- * Catch-all (`namespaceSelector: {}` без `ports:`) заборонено.
4249
- */
4250
- const NETWORK_POLICY_IN_CLUSTER_DEFAULT_PORTS = [80, 443, 5432, 3306, 1433, 6379, 8080, 4317, 4318]
4251
-
4252
- /**
4253
- * Канонічний блок `spec.egress` NetworkPolicy (k8s.mdc): kube-dns; TCP 80/443 на 0.0.0.0/0;
4254
- * in-cluster `namespaceSelector: {}` зі списком `NETWORK_POLICY_IN_CLUSTER_DEFAULT_PORTS`.
4255
- */
4256
- const NETWORK_POLICY_EGRESS_YAML = ` egress:
4257
- - to:
4258
- - namespaceSelector:
4259
- matchLabels:
4260
- kubernetes.io/metadata.name: kube-system
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
- podSelector:
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 npErrs = networkPolicyManifestViolations(matchedNp, workloadName, appLabel)
5130
- if (npErrs.length === 0) {
5131
- passFn(`${npRel}: NetworkPolicy для ${workloadKind} '${workloadName}' валідний (k8s.mdc)`)
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
- for (const e of npErrs) fail(`${npRel}: ${e} (k8s.mdc)`)
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 spec = /** @type {Record<string, unknown>} */ (doc).spec
6378
+ const docRec = /** @type {Record<string, unknown>} */ (doc)
6379
+ const spec = docRec.spec
6410
6380
  const appLabel = networkPolicyPodSelectorAppLabel(spec)
6411
- if (typeof name === 'string' && name !== '' && appLabel !== '') specs.push({ name, appLabel })
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
- { ns: 'k8s.network_policy', dir: 'k8s/network_policy', files: allYaml },
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
  }