@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 +13 -0
- package/bin/n-cursor.js +1 -1
- package/package.json +3 -1
- 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/skills/fix-tests/SKILL.md +109 -0
- package/skills/fix-tests/auto.md +1 -0
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.
|
|
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
|
-
*
|
|
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
|
}
|
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
|
|
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
|
|
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
|
-
#
|
|
6
|
-
#
|
|
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
|
-
#
|
|
10
|
-
#
|
|
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(
|
|
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(
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
93
|
+
# Superset-check egress: кожне канонічне правило має бути в input.spec.egress.
|
|
94
|
+
deny contains msg if {
|
|
82
95
|
is_np_doc
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
106
|
+
# Superset-check ingress.
|
|
107
|
+
deny contains msg if {
|
|
89
108
|
is_np_doc
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
118
|
-
is_array(
|
|
119
|
-
|
|
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
|
}
|
|
@@ -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
|
|
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
|
-
*
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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]
|