@nitra/cursor 1.19.2 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,19 @@
4
4
 
5
5
  Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
6
6
 
7
+ ## [1.20.0] - 2026-05-25
8
+
9
+ ### Added
10
+
11
+ - **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.
12
+ - **`rules/js-lint/coverage`**: `parseStrykerReport` тепер зчитує оригінальний код вижилих мутантів (`extractOriginal`), групує по файлах і повертає `survived: [{file, mutants: [{line,col,mutantType,original,replacement}], exampleTest, recommendationText}]`; `findExampleTest` + `extractFirstTestBlock` знаходять і витягують перший тест-блок із тест-файлу поруч — для стилю.
13
+ - **LLM-рекомендації у COVERAGE.md**: коли встановлено `ANTHROPIC_API_KEY`, `n-cursor coverage` робить один Anthropic API-виклик на кожен файл з вижилими мутантами та записує рекомендацію «Що треба протестувати» у секцію `## Recommendations`. Модель: `claude-haiku-4-5-20251001` з prompt caching (`ephemeral`). Без ключа — секція генерується без LLM-тексту.
14
+ - **`rules/js-lint/coverage/lib/generate-recommendation.mjs`**: `generateMutantRecommendation(client, sourceContent, mutants)` — ізольований модуль LLM-виклику.
15
+ - **`@anthropic-ai/sdk`** у dependencies — потрібен для LLM-рекомендацій (опціонально: якщо `ANTHROPIC_API_KEY` не задано, sdk не викликається).
16
+ - **`rules/test/coverage`**: `renderMarkdown` генерує секцію `## Recommendations` з per-file підрозділами (`### <file>`) — таблиця мутантів + приклад тесту + LLM-текст (якщо є).
17
+ - **Stryker incremental mode** у `stryker.config.baseline.mjs`: `incremental: true` + `incrementalFile: 'reports/stryker/stryker-incremental.json'` — Stryker зберігає прогрес між прогонами, відновлює стан після переривання (SIGURG, OOM тощо).
18
+ - **`skills/coverage-fix`**: новий скіл `/n-coverage-fix` — читає `## Recommendations` з COVERAGE.md і ітеративно дописує тести до конвергенції mutation score, включаючи LLM-рекомендації та приклади тестів у промпт агента.
19
+
7
20
  ## [1.19.2] - 2026-05-25
8
21
 
9
22
  ### Fixed
package/bin/n-cursor.js CHANGED
@@ -1470,7 +1470,7 @@ try {
1470
1470
  // n-cursor coverage — оркестратор покриття + мутаційного тестування з discovery
1471
1471
  // провайдерів через .n-cursor.json#rules (test.mdc).
1472
1472
  const { runCoverageCli } = await import('../rules/test/coverage/coverage.mjs')
1473
- process.exitCode = await runCoverageCli()
1473
+ process.exitCode = await runCoverageCli({ fix: args.includes('--fix') })
1474
1474
 
1475
1475
  break
1476
1476
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.19.2",
3
+ "version": "1.20.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",
@@ -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
  }
package/rules/k8s/k8s.mdc CHANGED
@@ -393,7 +393,7 @@ images:
393
393
  - **`hpa.yaml`** — `autoscaling/v2`, `HorizontalPodAutoscaler`, **без** `metadata.namespace` (namespace задає kustomization-споживач), `spec.scaleTargetRef.name` **= `metadata.name`** Deployment з base, dev-like значення `minReplicas: 1`, `maxReplicas: 1`.
394
394
  - **`pdb.yaml`** — `policy/v1`, `PodDisruptionBudget`, **без** `metadata.namespace`, `spec.selector.matchLabels.app` **= `spec.selector.matchLabels.app`** Deployment, dev-like `minAvailable: 0`.
395
395
 
396
- **Канонічний `base/networkpolicy.yaml`** — `networking.k8s.io/v1`, `NetworkPolicy`, **без** `metadata.namespace` (namespace додає `base/kustomization.yaml`); один або кілька документів (`---`) — по одному на кожен workload (**Deployment** / **StatefulSet** / **DaemonSet** / **Job** / **CronJob**) у тому ж `base/`; `metadata.name` **= `metadata.name`** workload, `spec.podSelector.matchLabels.app` **= мітка `app`** workload. **Ingress:** `from.podSelector: {}` (інші Pod у namespace). **Egress (усі workload-и):** kube-dns (UDP/TCP 53); **TCP 80 і 443** на `0.0.0.0/0` (HTTP/HTTPS назовні, включно з metadata `169.254.169.254:80`); **in-cluster** — `to.namespaceSelector: {}` з **явним списком TCP-портів** (`80, 443, 5432, 3306, 1433, 6379, 8080, 4317, 4318`; трафік на `*.svc` / Pod-и в кластері). Заборонено: `egress: [{}]`; `to.namespaceSelector: {}` без `ports:` (catch-all). Додаткові in-cluster порти можна додати вручну у `ports:` цього rule. Канон: [networkpolicy.snippet.yaml](./policy/network_policy/template/networkpolicy.snippet.yaml). Якщо документа для workload немає — **`check k8s`** дописує його автоматично.
396
+ **Канонічний `base/networkpolicy.yaml`** — `networking.k8s.io/v1`, `NetworkPolicy`, **без** `metadata.namespace` (namespace додає `base/kustomization.yaml`); один або кілька документів (`---`) — по одному на кожен workload (**Deployment** / **StatefulSet** / **DaemonSet** / **Job** / **CronJob**) у тому ж `base/`; `metadata.name` **= `metadata.name`** workload, `spec.podSelector.matchLabels.app` **= мітка `app`** workload. **Ingress:** `from.podSelector: {}` (інші Pod у namespace). **Egress (усі workload-и):** kube-dns через `kube-system` namespaceSelector (UDP/TCP 53); link-local DNS `169.254.0.0/16` (UDP/TCP 53, GKE NodeLocal DNSCache); **TCP 80 і 443** на `0.0.0.0/0` (HTTP/HTTPS назовні); **in-cluster** — `to.namespaceSelector: {}` з **явним списком TCP-портів** (`80, 443, 5432, 3306, 1433, 6379, 8080, 4317, 4318`; трафік на `*.svc` / Pod-и в кластері). **StatefulSet** додатково має egress/ingress `to/from.podSelector: {}` для intra-replica реплікації. Заборонено: `egress: [{}]`; `to.namespaceSelector: {}` без `ports:` (catch-all). Додаткові правила можна дописати поряд — superset-підхід. Канон — два **повних** snippet-файли (без merge у runtime; JS-генератор/rego обирають один за `kind` workload-у через анотацію `metadata.annotations['nitra.dev/workload-kind']`): [deployment.snippet.yaml](./policy/network_policy/template/deployment.snippet.yaml) (для `Deployment`/`Job`/`CronJob`/`DaemonSet`) та [statefulset.snippet.yaml](./policy/network_policy/template/statefulset.snippet.yaml) (для `StatefulSet`; містить deployment-канон + intra-replica правила). Якщо документа для workload немає — **`check k8s`** дописує його автоматично.
397
397
 
398
398
  Інші назви каталогу (`scale/`, `hpa-component/`, `pdb-component/`) — fail.
399
399
 
@@ -490,6 +490,8 @@ apiVersion: networking.k8s.io/v1
490
490
  kind: NetworkPolicy
491
491
  metadata:
492
492
  name: backend-api
493
+ annotations:
494
+ nitra.dev/workload-kind: Deployment
493
495
  spec:
494
496
  podSelector:
495
497
  matchLabels:
@@ -513,6 +515,14 @@ spec:
513
515
  port: 53
514
516
  - protocol: TCP
515
517
  port: 53
518
+ - to:
519
+ - ipBlock:
520
+ cidr: 169.254.0.0/16
521
+ ports:
522
+ - protocol: UDP
523
+ port: 53
524
+ - protocol: TCP
525
+ port: 53
516
526
  - to:
517
527
  - ipBlock:
518
528
  cidr: 0.0.0.0/0
@@ -1,31 +1,35 @@
1
- # Пер-документна структурна перевірка NetworkPolicy (k8s.mdc).
2
- # Cross-file (metadata.name = workload, podSelector.app = мітка app) — JS
3
- # (`networkPolicyManifestViolations`, `validateNetworkPolicyForWorkload`).
1
+ # Пер-документна структурна перевірка NetworkPolicy.
2
+ # Cross-file (metadata.name = workload, podSelector.app = мітка app) — JS (validateNetworkPolicyForWorkload).
4
3
  #
5
- # Канон egress: kube-dns; TCP 80/443 на 0.0.0.0/0; інші порти — namespaceSelector: {}
6
- # (in-cluster, зокрема *.svc). Заборонено egress: [{}] (allow-all).
4
+ # Superset-перевірка egress/ingress: кожне правило з обраного canon-snippet'у
5
+ # має бути присутнє в input (extra-правила дозволені). Канон обирається за
6
+ # анотацією `nitra.dev/workload-kind`:
7
+ # StatefulSet → data.template.statefulset_snippet (повний канон з intra-replica)
8
+ # решта → data.template.deployment_snippet (повний канон, default fallback)
7
9
  #
8
- # Запуск:
9
- # conftest test path/to/networkpolicy.yaml -p npm/policy/k8s/network_policy \
10
- # --namespace k8s.network_policy
10
+ # Обидва snippets — самодостатні (без merge на runtime).
11
+ #
12
+ # Snippets передаються через templateData при виклику runConftestBatch для k8s.network_policy.
13
+ #
14
+ # Запуск (dev):
15
+ # conftest test path/to/networkpolicy.yaml -p npm/rules/k8s/policy/network_policy \
16
+ # --namespace k8s.network_policy \
17
+ # --data npm/rules/k8s/policy/network_policy/template/deployment.snippet.yaml \
18
+ # --data npm/rules/k8s/policy/network_policy/template/statefulset.snippet.yaml
11
19
  package k8s.network_policy
12
20
 
13
21
  import rego.v1
14
22
 
15
- np_kind_template := "kind має бути NetworkPolicy (зараз: %v) (k8s.mdc)"
16
-
17
- np_api_template := "apiVersion має бути networking.k8s.io/v1 (зараз: %v) (k8s.mdc)"
18
-
19
23
  deny contains msg if {
20
24
  is_np_doc
21
25
  input.kind != "NetworkPolicy"
22
- msg := sprintf(np_kind_template, [input.kind])
26
+ msg := sprintf("kind має бути NetworkPolicy (зараз: %v) (k8s.mdc)", [input.kind])
23
27
  }
24
28
 
25
29
  deny contains msg if {
26
30
  is_np_doc
27
31
  input.apiVersion != "networking.k8s.io/v1"
28
- msg := sprintf(np_api_template, [input.apiVersion])
32
+ msg := sprintf("apiVersion має бути networking.k8s.io/v1 (зараз: %v) (k8s.mdc)", [input.apiVersion])
29
33
  }
30
34
 
31
35
  deny contains "spec відсутній або некоректний (NetworkPolicy; k8s.mdc)" if {
@@ -42,6 +46,17 @@ deny contains "spec.podSelector.matchLabels відсутній (NetworkPolicy; k
42
46
  not is_object(object.get(selector, "matchLabels", null))
43
47
  }
44
48
 
49
+ deny contains "spec.podSelector.matchLabels.app відсутній або порожній (NetworkPolicy; k8s.mdc)" if {
50
+ is_np_doc
51
+ spec := object.get(input, "spec", null)
52
+ is_object(spec)
53
+ selector := object.get(spec, "podSelector", null)
54
+ is_object(selector)
55
+ ml := object.get(selector, "matchLabels", null)
56
+ is_object(ml)
57
+ object.get(ml, "app", null) == null
58
+ }
59
+
45
60
  deny contains "spec.policyTypes має містити Ingress і Egress (NetworkPolicy; k8s.mdc)" if {
46
61
  is_np_doc
47
62
  spec := object.get(input, "spec", null)
@@ -57,39 +72,56 @@ deny contains "spec.ingress має містити from.podSelector (NetworkPolic
57
72
  not ingress_has_pod_selector_rule(spec)
58
73
  }
59
74
 
60
- deny contains "spec.egress має бути непорожнім масивом (NetworkPolicy; k8s.mdc)" if {
61
- is_np_doc
62
- spec := object.get(input, "spec", null)
63
- is_object(spec)
64
- not is_non_empty_array(object.get(spec, "egress", null))
75
+ # Dispatch на повний canon-snippet за анотацією nitra.dev/workload-kind.
76
+ # StatefulSet → statefulset_snippet (з intra-replica), решта → deployment_snippet.
77
+ canon_for_kind("StatefulSet") := data.template.statefulset_snippet
78
+
79
+ canon_for_kind(kind) := data.template.deployment_snippet if {
80
+ kind != "StatefulSet"
65
81
  }
66
82
 
67
- deny contains "spec.egress: заборонено allow-all {} — канон k8s.mdc (80/443 назовні, інше — in-cluster)" if {
68
- is_np_doc
69
- spec := object.get(input, "spec", null)
70
- is_object(spec)
71
- egress_allows_all(object.get(spec, "egress", null))
83
+ snippet_name_for_kind("StatefulSet") := "statefulset"
84
+
85
+ snippet_name_for_kind(kind) := "deployment" if {
86
+ kind != "StatefulSet"
72
87
  }
73
88
 
74
- deny contains "spec.egress: потрібен ipBlock 0.0.0.0/0 з ports 80 і 443 (HTTP/HTTPS назовні; k8s.mdc)" if {
75
- is_np_doc
76
- spec := object.get(input, "spec", null)
77
- is_object(spec)
78
- not egress_has_internet_http_https(spec)
89
+ workload_kind := kind if {
90
+ kind := object.get(object.get(input.metadata, "annotations", {}), "nitra.dev/workload-kind", "")
79
91
  }
80
92
 
81
- deny contains "spec.egress: потрібен to.namespaceSelector: {} (інші порти лише in-cluster / *.svc; k8s.mdc)" if {
93
+ # Superset-check egress: кожне канонічне правило має бути в input.spec.egress.
94
+ deny contains msg if {
82
95
  is_np_doc
83
- spec := object.get(input, "spec", null)
84
- is_object(spec)
85
- not egress_has_cluster_namespace_selector(spec)
96
+ is_object(object.get(input, "spec", null))
97
+ canon := canon_for_kind(workload_kind)
98
+ some canon_rule in canon.egress
99
+ not list_contains(object.get(input.spec, "egress", []), canon_rule)
100
+ msg := sprintf(
101
+ "NetworkPolicy %v: відсутнє обовʼязкове egress-правило (%v.snippet.yaml; k8s.mdc): %v",
102
+ [input.metadata.name, snippet_name_for_kind(workload_kind), json.marshal(canon_rule)],
103
+ )
86
104
  }
87
105
 
88
- deny contains "spec.egress: to.namespaceSelector: {} мусить мати непорожні ports — catch-all заборонено (k8s.mdc)" if {
106
+ # Superset-check ingress.
107
+ deny contains msg if {
89
108
  is_np_doc
90
- spec := object.get(input, "spec", null)
91
- is_object(spec)
92
- cluster_egress_rule_without_ports(spec)
109
+ is_object(object.get(input, "spec", null))
110
+ canon := canon_for_kind(workload_kind)
111
+ some canon_rule in canon.ingress
112
+ not list_contains(object.get(input.spec, "ingress", []), canon_rule)
113
+ msg := sprintf(
114
+ "NetworkPolicy %v: відсутнє обовʼязкове ingress-правило (%v.snippet.yaml; k8s.mdc): %v",
115
+ [input.metadata.name, snippet_name_for_kind(workload_kind), json.marshal(canon_rule)],
116
+ )
117
+ }
118
+
119
+ # Safety-net: allow-all `egress: [{}]` — заборонено навіть як extra-правило.
120
+ deny contains "spec.egress: заборонено allow-all {} — додавай явні правила (k8s.mdc)" if {
121
+ is_np_doc
122
+ some rule in object.get(input.spec, "egress", [])
123
+ is_object(rule)
124
+ count(object.keys(rule)) == 0
93
125
  }
94
126
 
95
127
  is_np_doc if input.kind == "NetworkPolicy"
@@ -114,68 +146,8 @@ ingress_has_pod_selector_rule(spec) if {
114
146
  object.get(peer, "podSelector", null) != null
115
147
  }
116
148
 
117
- is_non_empty_array(x) if {
118
- is_array(x)
119
- count(x) > 0
120
- }
121
-
122
- egress_allows_all(egress) if {
123
- is_array(egress)
124
- some rule in egress
125
- is_object(rule)
126
- count(object.keys(rule)) == 0
127
- }
128
-
129
- egress_has_internet_http_https(spec) if {
130
- egress := object.get(spec, "egress", null)
131
- is_array(egress)
132
- some rule in egress
133
- is_object(rule)
134
- to_list := object.get(rule, "to", null)
135
- is_array(to_list)
136
- some peer in to_list
137
- is_object(peer)
138
- ipb := object.get(peer, "ipBlock", null)
139
- is_object(ipb)
140
- ipb.cidr == "0.0.0.0/0"
141
- ports := object.get(rule, "ports", null)
142
- is_array(ports)
143
- egress_ports_include(ports, 80)
144
- egress_ports_include(ports, 443)
145
- }
146
-
147
- egress_ports_include(ports, want) if {
148
- some p in ports
149
- is_object(p)
150
- p.port == want
151
- }
152
-
153
- egress_has_cluster_namespace_selector(spec) if {
154
- egress := object.get(spec, "egress", null)
155
- is_array(egress)
156
- some rule in egress
157
- is_object(rule)
158
- to_list := object.get(rule, "to", null)
159
- is_array(to_list)
160
- some peer in to_list
161
- is_object(peer)
162
- ns := object.get(peer, "namespaceSelector", null)
163
- is_object(ns)
164
- count(ns) == 0
165
- }
166
-
167
- cluster_egress_rule_without_ports(spec) if {
168
- egress := object.get(spec, "egress", null)
169
- is_array(egress)
170
- some rule in egress
171
- is_object(rule)
172
- to_list := object.get(rule, "to", null)
173
- is_array(to_list)
174
- some peer in to_list
175
- is_object(peer)
176
- ns := object.get(peer, "namespaceSelector", null)
177
- is_object(ns)
178
- count(ns) == 0
179
- ports := object.get(rule, "ports", [])
180
- count(ports) == 0
149
+ list_contains(items, item) if {
150
+ is_array(items)
151
+ some i
152
+ items[i] == item
181
153
  }
@@ -20,6 +20,14 @@ spec:
20
20
  port: 53
21
21
  - protocol: TCP
22
22
  port: 53
23
+ - to:
24
+ - ipBlock:
25
+ cidr: 169.254.0.0/16
26
+ ports:
27
+ - protocol: UDP
28
+ port: 53
29
+ - protocol: TCP
30
+ port: 53
23
31
  - to:
24
32
  - ipBlock:
25
33
  cidr: 0.0.0.0/0
@@ -0,0 +1,67 @@
1
+ spec:
2
+ podSelector:
3
+ matchLabels: {}
4
+ policyTypes:
5
+ - Ingress
6
+ - Egress
7
+ ingress:
8
+ - from:
9
+ - podSelector: {}
10
+ # intra-replica (StatefulSet pod ↔ pod у тому ж namespace — matchLabels:{} лишається без JS-substitution)
11
+ - from:
12
+ - podSelector:
13
+ matchLabels: {}
14
+ egress:
15
+ - to:
16
+ - namespaceSelector:
17
+ matchLabels:
18
+ kubernetes.io/metadata.name: kube-system
19
+ podSelector:
20
+ matchLabels:
21
+ k8s-app: kube-dns
22
+ ports:
23
+ - protocol: UDP
24
+ port: 53
25
+ - protocol: TCP
26
+ port: 53
27
+ - to:
28
+ - ipBlock:
29
+ cidr: 169.254.0.0/16
30
+ ports:
31
+ - protocol: UDP
32
+ port: 53
33
+ - protocol: TCP
34
+ port: 53
35
+ - to:
36
+ - ipBlock:
37
+ cidr: 0.0.0.0/0
38
+ ports:
39
+ - protocol: TCP
40
+ port: 80
41
+ - protocol: TCP
42
+ port: 443
43
+ - to:
44
+ - namespaceSelector: {}
45
+ ports:
46
+ - protocol: TCP
47
+ port: 80
48
+ - protocol: TCP
49
+ port: 443
50
+ - protocol: TCP
51
+ port: 5432
52
+ - protocol: TCP
53
+ port: 3306
54
+ - protocol: TCP
55
+ port: 1433
56
+ - protocol: TCP
57
+ port: 6379
58
+ - protocol: TCP
59
+ port: 8080
60
+ - protocol: TCP
61
+ port: 4317
62
+ - protocol: TCP
63
+ port: 4318
64
+ # intra-replica (StatefulSet pod ↔ pod у тому ж namespace — matchLabels:{} лишається без JS-substitution)
65
+ - to:
66
+ - podSelector:
67
+ matchLabels: {}
@@ -70,9 +70,11 @@ export function formatScore({ caught, total }) {
70
70
 
71
71
  /**
72
72
  * Рендерить таблицю покриття + мутаційного тестування як Markdown.
73
+ * Якщо будь-який рядок містить непустий `survived`, додає секцію
74
+ * `## Вижилі мутанти` з JSON-блоком для `/n-fix-tests`.
73
75
  * Без timestamp, щоб git diff рухався лише при зміні метрик.
74
- * @param {Array<{area:string, coverage:{lines:{covered:number,total:number},functions:{covered:number,total:number}}, mutation:{caught:number,total:number}}>} rows рядки провайдерів
75
- * @returns {string} Markdown-таблиця з заголовком `# Coverage`
76
+ * @param {Array<{area:string, coverage:{lines:{covered:number,total:number},functions:{covered:number,total:number}}, mutation:{caught:number,total:number}, survived?: Array<{file:string,line:number,col:number,mutantType:string,original:string,replacement:string}>}>} rows рядки провайдерів
77
+ * @returns {string} Markdown з заголовком `# Coverage`
76
78
  */
77
79
  export function renderMarkdown(rows) {
78
80
  const lines = [
@@ -87,6 +89,28 @@ export function renderMarkdown(rows) {
87
89
  `${row.mutation.caught}/${row.mutation.total} | ${formatScore(row.mutation)} |`
88
90
  )
89
91
  }
92
+
93
+ const allSurvived = rows.flatMap(r => r.survived ?? [])
94
+ if (allSurvived.length > 0) {
95
+ lines.push('', '## Recommendations')
96
+ for (const group of allSurvived) {
97
+ lines.push('', `### ${group.file}`, '')
98
+ lines.push('| Рядок | Оригінал | Заміна | Тип |')
99
+ lines.push('| --- | --- | --- | --- |')
100
+ for (const m of group.mutants) {
101
+ lines.push(`| ${m.line} | \`${m.original}\` | \`${m.replacement}\` | ${m.mutantType} |`)
102
+ }
103
+ if (group.exampleTest) {
104
+ lines.push('', `**Приклад тесту** (\`${group.exampleTest.testFile}\`):`, '', '```js')
105
+ lines.push(group.exampleTest.code ?? '')
106
+ lines.push('```')
107
+ }
108
+ if (group.recommendationText) {
109
+ lines.push('', '**Що треба протестувати:**', '', group.recommendationText)
110
+ }
111
+ }
112
+ }
113
+
90
114
  return `${lines.join('\n')}\n`
91
115
  }
92
116
 
@@ -127,7 +151,9 @@ function buildTotalsRow(rows) {
127
151
  /**
128
152
  * Виконує coverage-pipeline: discovery провайдерів за `.n-cursor.json#rules`,
129
153
  * detect+collect для кожного, агрегація, запис COVERAGE.md.
130
- * @param {{cwd?:string, rulesDir?:string}} [opts] ін'єкція cwd/rulesDir для тестів
154
+ * При `opts.fix === true` після запису COVERAGE.md запускає агента (coverage-fix.mjs)
155
+ * для написання тестів по вижилих мутантах.
156
+ * @param {{cwd?:string, rulesDir?:string, fix?:boolean}} [opts] ін'єкція cwd/rulesDir для тестів; fix — --fix режим
131
157
  * @returns {Promise<number>} exit code (0 OK, 1 коли жоден провайдер не дав даних)
132
158
  */
133
159
  export async function runCoverageSteps(opts = {}) {
@@ -154,12 +180,32 @@ export async function runCoverageSteps(opts = {}) {
154
180
  const md = renderMarkdown(rows)
155
181
  await writeFile(join(cwd, 'COVERAGE.md'), md, 'utf8')
156
182
  console.log('✓ COVERAGE.md')
183
+
184
+ if (opts.fix) {
185
+ const allSurvived = rows.flatMap(r => r.survived ?? [])
186
+ // eslint-disable-next-line no-unsanitized/method -- шлях відносний до пакету, не user-input
187
+ const { fixSurvivedMutants } = await import(
188
+ new URL('../../scripts/coverage-fix.mjs', import.meta.url).href
189
+ )
190
+ await fixSurvivedMutants(allSurvived, cwd)
191
+ }
192
+
157
193
  return 0
158
194
  }
159
195
 
160
- // Один оркестратор, один callsite — `withLock` викликається напряму, без спільної
161
- // точки входу. Канонічне обмеження «не імпортуй withLock у lint.mjs/fix.mjs напряму»
162
- // (scripts.mdc § withLock) націлене на дедуплікацію preamble серед багатьох файлів
163
- // для одного coverage-консумера не релевантне (див. C4 у
164
- // specs/2026-05-24-coverage-rule-design.md).
165
- export const runCoverageCli = () => withLock('coverage', runCoverageSteps)
196
+ /**
197
+ * CLI entrypoint для `n-cursor coverage [--fix]`.
198
+ * Із `--fix`: збирає метрики запускає агента повторно збирає метрики.
199
+ * Без `--fix`: лише збирає метрики.
200
+ * Лок охоплює кожен coverage-прогін окремо.
201
+ * @param {{fix?:boolean}} [opts] прапор --fix
202
+ * @returns {Promise<number>} exit code
203
+ */
204
+ export async function runCoverageCli(opts = {}) {
205
+ const code = await withLock('coverage', () => runCoverageSteps(opts))
206
+ if (code === 0 && opts.fix) {
207
+ console.log('\n♻️ Повторний coverage після агента…\n')
208
+ return withLock('coverage', () => runCoverageSteps({ fix: false }))
209
+ }
210
+ return code
211
+ }
@@ -8,5 +8,8 @@ export default {
8
8
  tempDirName: 'reports/stryker/.tmp',
9
9
  reporters: ['json', 'clear-text'],
10
10
  jsonReporter: { fileName: 'reports/stryker/mutation.json' },
11
- coverageAnalysis: 'off'
11
+ coverageAnalysis: 'off',
12
+ // incremental: зберігає прогрес між прогонами — відновлення після переривання без старту з нуля.
13
+ incremental: true,
14
+ incrementalFile: 'reports/stryker/stryker-incremental.json',
12
15
  }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * `n-cursor coverage --fix`: запускає Claude Code агента для написання тестів
3
+ * по вижилих мутантах Stryker. Агент отримує список мутантів з контекстом
4
+ * (file, line, оригінальний код, вижилий варіант, тип мутації) і самостійно
5
+ * знаходить або створює відповідні test-файли.
6
+ *
7
+ * Залежить від `@anthropic-ai/claude-code` (dependencies у npm/package.json).
8
+ */
9
+ import { readFile } from 'node:fs/promises'
10
+ import { join } from 'node:path'
11
+
12
+ /**
13
+ * @typedef {{line:number, col:number, mutantType:string, original:string, replacement:string}} MutantDetail
14
+ * @typedef {{file:string, mutants:MutantDetail[], exampleTest:{testFile:string,code:string|null}|null, recommendationText:string|null}} SurvivedFileGroup
15
+ */
16
+
17
+ /**
18
+ * Запускає Claude Code агента для написання тестів по вижилих мутантах.
19
+ * @param {SurvivedFileGroup[]} survived вижилі мутанти, згруповані по файлах
20
+ * @param {string} projectRoot абсолютний шлях до кореня проєкту
21
+ * @returns {Promise<void>}
22
+ */
23
+ export async function fixSurvivedMutants(survived, projectRoot) {
24
+ const totalMutants = survived.reduce((s, g) => s + g.mutants.length, 0)
25
+ if (totalMutants === 0) {
26
+ console.log('✓ Всі мутанти вбиті — доповнення тестів не потрібне')
27
+ return
28
+ }
29
+
30
+ const prompt = await buildFixPrompt(survived, projectRoot)
31
+ console.log(`\n🤖 coverage --fix: запускаю агента для ${totalMutants} вижилих мутантів...\n`)
32
+
33
+ // Dynamic import: @anthropic-ai/claude-code завантажується лише при --fix,
34
+ // щоб не гальмувати звичайний coverage-прогін за відсутності пакету.
35
+ const { query } = await import('@anthropic-ai/claude-code')
36
+
37
+ for await (const msg of query({
38
+ prompt,
39
+ options: {
40
+ cwd: projectRoot,
41
+ maxTurns: 20,
42
+ allowedTools: ['Read', 'Edit', 'Bash'],
43
+ }
44
+ })) {
45
+ if (msg.type === 'text') process.stdout.write(msg.text)
46
+ }
47
+ process.stdout.write('\n')
48
+ }
49
+
50
+ /**
51
+ * Формує rich-промпт для агента: список вижилих мутантів згрупований по файлах,
52
+ * з контекстом ±3 рядки навколо кожного мутанта з source-файлу.
53
+ * @param {SurvivedFileGroup[]} survived
54
+ * @param {string} projectRoot
55
+ * @returns {Promise<string>}
56
+ */
57
+ async function buildFixPrompt(survived, projectRoot) {
58
+ const sections = []
59
+
60
+ for (const { file, mutants, exampleTest } of survived) {
61
+ let srcLines = []
62
+ try {
63
+ srcLines = (await readFile(join(projectRoot, file), 'utf8')).split('\n')
64
+ } catch {
65
+ // файл може бути недоступним — пропускаємо контекст, але продовжуємо
66
+ }
67
+
68
+ const mutantDescs = mutants.map(m => {
69
+ const ctxStart = Math.max(0, m.line - 4)
70
+ const ctxEnd = Math.min(srcLines.length, m.line + 3)
71
+ const context = srcLines
72
+ .slice(ctxStart, ctxEnd)
73
+ .map((l, i) => `${ctxStart + i + 1}: ${l}`)
74
+ .join('\n')
75
+ return [
76
+ ` - Рядок ${m.line}, колонка ${m.col}, тип мутації \`${m.mutantType}\``,
77
+ ` Оригінал: \`${m.original}\``,
78
+ ` Вижив варіант: \`${m.replacement}\``,
79
+ context ? ` Контекст:\n\`\`\`\n${context}\n\`\`\`` : ''
80
+ ].filter(Boolean).join('\n')
81
+ }).join('\n')
82
+
83
+ const exampleSection = exampleTest?.code
84
+ ? `\n\nПриклад тесту з \`${exampleTest.testFile}\`:\n\`\`\`js\n${exampleTest.code}\n\`\`\``
85
+ : ''
86
+
87
+ sections.push(`### \`${file}\`${exampleSection}\n${mutantDescs}`)
88
+ }
89
+
90
+ return [
91
+ 'Твоє завдання — написати unit-тести, що вбивають наступні вижилі мутанти Stryker.',
92
+ 'Для кожного мутанта: знайди або створи відповідний test-файл, додай тест-кейс,',
93
+ 'що явно перевіряє цю гілку/умову і провалиться якщо код замінити на "вижилий варіант".',
94
+ '',
95
+ '## Вижилі мутанти',
96
+ '',
97
+ ...sections,
98
+ '',
99
+ '## Правила',
100
+ '- Не змінюй source-файли — лише test-файли.',
101
+ '- Використовуй той самий test-фреймворк, що вже в проєкті.',
102
+ '- Запусти `bun test` (або відповідну команду) після кожного файлу — переконайся, що 0 fail.',
103
+ '- Якщо мутант охоплений іншим новим тестом — не дублюй.'
104
+ ].join('\n')
105
+ }
@@ -0,0 +1,109 @@
1
+ ---
2
+ name: n-fix-tests
3
+ description: >-
4
+ Ітеративно дописати тести щоб підвищити mutation score — читає вижилі мутанти з COVERAGE.md і запускає агент до конвергенції
5
+ ---
6
+
7
+ # n-fix-tests — підвищення mutation score
8
+
9
+ ## Мета
10
+
11
+ Читає структурований JSON-блок вижилих мутантів з `COVERAGE.md` і ітеративно дописує тести що їх вловлюють. Зупиняється коли score перестає покращуватись (конвергенція).
12
+
13
+ ## Передумови
14
+
15
+ - У `COVERAGE.md` є секція `## Вижилі мутанти` з JSON-блоком
16
+ - Залежності встановлені (`bun i`)
17
+ - `bun run coverage` (або `n-cursor coverage`) доступний
18
+
19
+ ## Workflow
20
+
21
+ ### Крок 1: Зчитай вижилих мутантів
22
+
23
+ Прочитай `COVERAGE.md`. Знайди секцію `## Вижилі мутанти`. Знайди фенсований блок ` ```json ` у цій секції і розпарси JSON-масив.
24
+
25
+ Якщо секція відсутня або масив порожній — зупинись з повідомленням:
26
+ `✓ Жодних вижилих мутантів — mutation score повний`
27
+
28
+ Запамʼятай поточну кількість вижилих: `prevCount = масив.length`
29
+
30
+ ### Крок 2: Знайди test-команду і coverage-команду
31
+
32
+ Прочитай `package.json` у кореневій директорії.
33
+
34
+ **test-команда** (перша що існує):
35
+ 1. `scripts["test"]` з `package.json`
36
+ 2. fallback: `bun test`
37
+
38
+ **coverage-команда** (перша що існує):
39
+ 1. `scripts["coverage"]` з `package.json` → виклик: `bun run coverage`
40
+ 2. fallback: `n-cursor coverage`
41
+
42
+ ### Крок 3: Для кожного файлу — спауни Agent
43
+
44
+ Згрупуй мутанти по полю `file`. Для кожної групи виконай:
45
+
46
+ **3a. Знайди файли:**
47
+ - Source: `<cwd>/<file>` (прочитай вміст)
48
+ - Test файл (перший що існує):
49
+ - `<dir>/<basename>.test.<ext>` — поруч із source
50
+ - `<dir>/tests/<basename>.test.<ext>`
51
+ - `tests/<basename>.test.<ext>` від кореня
52
+ - Якщо жоден не знайдено — буде створено поруч із source
53
+
54
+ **3b. Сформуй промпт для Agent:**
55
+
56
+ ```
57
+ Тобі дані вижилі мутанти зі Stryker для файлу `<file>`.
58
+ Ці мутанти вижили тому що наявні тести НЕ вловили конкретні зміни коду.
59
+
60
+ **Вихідний код** (`<file>`):
61
+ \`\`\`
62
+ <зміст source-файлу>
63
+ \`\`\`
64
+
65
+ **Наявні тести** (`<test-file>`):
66
+ \`\`\`
67
+ <зміст test-файлу або "файл ще не існує">
68
+ \`\`\`
69
+
70
+ **Вижилі мутанти** (кожен — зміна коду що НЕ вловлена):
71
+ <для кожного мутанта:>
72
+ - Рядок <line>, колонка <col>: `<original>` → `<replacement>` (тип мутації: <mutantType>)
73
+
74
+ **Завдання:**
75
+ Допиши мінімальні test-cases у файл `<test-file>` які б вловили кожен із перелічених мутантів.
76
+ Правила:
77
+ - НЕ видаляй і НЕ змінюй наявні тести
78
+ - Стиль тестів — відповідно до наявного файлу (той самий фреймворк, той самий стиль describe/test)
79
+ - Якщо файл ще не існує — створи його з правильними імпортами відповідно до файлів у тому самому каталозі
80
+ - Після написання запусти: `bun test <test-file>` і переконайся що всі тести проходять (виправ якщо падають)
81
+ ```
82
+
83
+ **3c. Запусти Agent** з цим промптом і дочекайся завершення.
84
+
85
+ ### Крок 4: Перевір що всі тести проходять
86
+
87
+ ```bash
88
+ bun test # або test-команда з кроку 2
89
+ ```
90
+
91
+ Якщо тести падають — поверни конкретний Agent (для того файлу) з помилкою і попроси виправити.
92
+
93
+ ### Крок 5: Запусти coverage і порівняй
94
+
95
+ ```bash
96
+ bun run coverage # або coverage-команда з кроку 2
97
+ ```
98
+
99
+ Прочитай новий `COVERAGE.md`, знайди і розпарси JSON-масив вижилих.
100
+ `newCount = новий масив.length`
101
+
102
+ **Рішення:**
103
+ - Якщо `newCount < prevCount` → повтор з Кроку 1 з оновленим масивом
104
+ - Якщо `newCount >= prevCount` → зупинись:
105
+ `✓ Конвергенція: mutation score більше не покращується. Вижило: <newCount> мутантів.`
106
+
107
+ ## Зупинка після конвергенції
108
+
109
+ Конвергенція — нормальний результат. Деякі мутанти не можна вбити (захищений зовнішнім станом, недетермінована логіка тощо). Не намагайся виправити те що не змінилось після ітерації.
@@ -0,0 +1 @@
1
+ [js-lint]