@nitra/cursor 1.25.2 → 1.26.1
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/.pi-template/extensions/n-cursor-adr/index.ts +2 -8
- package/CHANGELOG.md +43 -0
- package/package.json +5 -5
- package/rules/js-lint/coverage/coverage.mjs +14 -9
- package/rules/k8s/js/manifests.mjs +212 -33
- package/rules/k8s/k8s.mdc +57 -1
- package/rules/k8s/policy/network_policy/network_policy.rego +21 -23
- package/rules/test/coverage/coverage.mjs +4 -14
- package/rules/test/js/cargo_mutants_config.mjs +1 -3
- package/rules/test/js/data/stryker_config/stryker.config.baseline.mjs +1 -1
- package/rules/test/js/stryker_config.mjs +1 -3
- package/scripts/coverage-fix.mjs +24 -19
- package/scripts/lib/timing-summary.mjs +1 -4
- package/scripts/post-tool-use-fix.mjs +1 -1
- package/scripts/sync-claude-config.mjs +0 -1
- package/skills/fix-tests/SKILL.md +3 -0
|
@@ -36,10 +36,7 @@ interface PiExec {
|
|
|
36
36
|
timeout?: number
|
|
37
37
|
}
|
|
38
38
|
) => Promise<{ code: number; stdout: string; stderr: string }>
|
|
39
|
-
on: (
|
|
40
|
-
event: string,
|
|
41
|
-
handler: (event: unknown, ctx: PiContext) => Promise<void> | void
|
|
42
|
-
) => void
|
|
39
|
+
on: (event: string, handler: (event: unknown, ctx: PiContext) => Promise<void> | void) => void
|
|
43
40
|
}
|
|
44
41
|
|
|
45
42
|
const CAPTURE_HOOK = '.claude/hooks/capture-decisions.sh'
|
|
@@ -68,10 +65,7 @@ export default function (pi: PiExec): void {
|
|
|
68
65
|
jsonlPath = join(tmpdir(), `n-cursor-pi-transcript-${Date.now()}-${randomUUID()}.jsonl`)
|
|
69
66
|
writeFileSync(jsonlPath, lines + '\n', 'utf8')
|
|
70
67
|
} catch (error) {
|
|
71
|
-
ctx.ui?.notify?.(
|
|
72
|
-
`@nitra/cursor: transcript serialization failed — ${(error as Error).message}`,
|
|
73
|
-
'error'
|
|
74
|
-
)
|
|
68
|
+
ctx.ui?.notify?.(`@nitra/cursor: transcript serialization failed — ${(error as Error).message}`, 'error')
|
|
75
69
|
return
|
|
76
70
|
}
|
|
77
71
|
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,49 @@
|
|
|
4
4
|
|
|
5
5
|
Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
|
|
6
6
|
|
|
7
|
+
## [1.26.1] - 2026-05-26
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **`k8s/js/manifests.mjs`**: `Set.prototype.toSorted` не існує — повернено `Array.from(new Set(...)).toSorted(...)` (oxlint `--fix` помилково замінив `[...new Set(...)].sort(...)` на невалідний `new Set(...).toSorted(...)`).
|
|
12
|
+
- **`k8s/js/manifests.mjs`**: hardcoded CIDR-и (`35.191.0.0/16`, `130.211.0.0/22`, `10.0.0.0/8`) у `NETWORK_POLICY_GCLB_INGRESS_FROM` тепер з `// eslint-disable-next-line sonarjs/no-hardcoded-ip` (це канон GCLB, який не змінюється).
|
|
13
|
+
- **`k8s/js/manifests.mjs`**: `() => {}` default callback → іменована константа `noopFail` (eslint `no-empty-function`).
|
|
14
|
+
- **`k8s/js/tests/manifests/tests/check-schema.test.mjs`**: винесено `HR_YAML_RE`, `HTTPROUTE_NP_MAPPING_RE`, `K8S_MDC_RE`, `EXPECTED_GCLB_INGRESS_FROM`, `GCLB_HC_GLOBAL_CIDR` як module-scope константи (e18e `prefer-static-regex` + sonarjs `no-hardcoded-ip`); `mock(() => {})` → `mock(msg => msg)`.
|
|
15
|
+
- **`.cspell.json`**: додано `GCLB`, `gclb`, `байтово` до words.
|
|
16
|
+
|
|
17
|
+
## [1.26.0] - 2026-05-26
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **`k8s/js/manifests.mjs`**: нова `collectHttpRouteIngressForWorkload(dir, appLabel, fail)` — резолвить HTTPRoute → `-hl` Service → `selector.app` mapping і повертає унікальні TCP-порти з `backendRefs[].port` для workload з міткою `appLabel`. Викликається з `appendNetworkPolicyDocuments` і `regenerateLegacyNetworkPolicyDocsInFile` під час `check k8s`.
|
|
22
|
+
- **`k8s/js/manifests.mjs:buildNetworkPolicyYaml`**: опційний 4-й параметр `gclbPorts: number[]` — якщо непорожній, додає ingress-правило з `ipBlock` 35.191.0.0/16, 130.211.0.0/22, 10.0.0.0/8 і TCP-портами (відсортовано). Без параметра output байтово ідентичний baseline canon.
|
|
23
|
+
- **`k8s.mdc` v1.42**: новий розділ «HTTPRoute → NetworkPolicy ingress (GCLB + Envoy)» з описом mapping і прикладом NetworkPolicy для HTTPRoute-paired workload.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- **Service blocking via NetworkPolicy** для workload-ів, прив'язаних до `HTTPRoute` через GKE Gateway: попередній canon допускав тільки `podSelector: {}` ingress, що блокувало трафік від Envoy data-plane (`10.10.0.0/23` для `us-central1-proxy-only`) і Google health checks (`35.191.0.0/16`, `130.211.0.0/22`). Тепер правило автоматично додається.
|
|
28
|
+
|
|
29
|
+
## [1.25.3] - 2026-05-26
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- **JSDoc**: дописано опис `@returns`/`@param`-описи й типи в `rules/js-lint/coverage/coverage.mjs`, `rules/k8s/js/manifests.mjs`, `rules/adr/js/tests/*.test.mjs`, `rules/test/js/tests/*.test.mjs`, `scripts/coverage-fix.mjs`, `scripts/post-tool-use-fix.mjs`, `scripts/utils/tests/resolve-*.test.mjs` (oxlint/eslint jsdoc-правила).
|
|
34
|
+
- **`k8s/js/manifests.mjs`**: `JSON.parse(JSON.stringify(...))` → `structuredClone(...)` (unicorn `prefer-structured-clone`); інверсія негованої умови в `validateNetworkPolicyForWorkload` (eslint `no-negated-condition`).
|
|
35
|
+
- **`k8s/policy/network_policy/network_policy.rego`**: `list_contains` → `contains_item` (regal `avoid-get-and-list-prefix`); `items[i] == item` → `some candidate in items` (`prefer-some-in-iteration`); `workload_kind` без зайвого `if {}` (`unconditional-assignment`); helper-правила переміщено після всіх `deny`, щоб задовольнити `messy-rule`. `network_policy_test.rego` переформатовано через `opa fmt`.
|
|
36
|
+
- **`scripts/tests/post-tool-use-fix.test.mjs`**: fake-child перероблено з `EventEmitter` на duck-typed `addListener`/`removeListener` (unicorn `prefer-event-target`).
|
|
37
|
+
- **`scripts/tests/cli-entry.test.mjs`**: symlink-тест /tmp ↔ /private/tmp використовує `mkdtempSync` з префіксом, зібраним з частин (sonarjs `publicly-writable-directories`).
|
|
38
|
+
- **`rules/test/coverage/coverage.mjs`**: множинні `push()` об’єднано в один виклик (unicorn `prefer-single-call`).
|
|
39
|
+
- Винесено повторно-компільовані regex у module scope (`e18e/prefer-static-regex`) у `coverage.mjs`, `test/coverage/tests/coverage.test.mjs`, `k8s/tests/manifests/tests/check-schema.test.mjs`.
|
|
40
|
+
- Видалено невикористаний `npm/lib/x.js` (unicorn `no-empty-file`).
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
|
|
44
|
+
- **`.claude/hooks/{capture,normalize}-decisions.sh`** синхронізовано з `npm/.claude-template/hooks/` (включно з новим `lib/tooling-only.sh`).
|
|
45
|
+
- **`knip.json`**: додано entry-патерни для динамічно імпортованих/зовнішніх скриптів (stryker configs, pi extensions, fixtures, `coverage-fix.mjs`); `@anthropic-ai/claude-code`, `@anthropic-ai/sdk`, `@stryker-mutator/core` додано в `ignoreDependencies` (тип-only або dynamic import).
|
|
46
|
+
- **`.jscpd.json`**: ігнор-патерни розширено для template/canonical пар тієї ж природи, що вже були в винятках (`npm/.pi-template/**`, `knip-canonical.json`, `*.snippet.yaml`).
|
|
47
|
+
- **`.cspell.json`**: до `ignorePaths` додано `**/reports/stryker/**` (генеровані Stryker-репорти, вже в .gitignore).
|
|
48
|
+
- **Кореневий `package.json#scripts.lint`** — чейн `bun run lint-ga && lint-js && lint-rego && lint-security && lint-style && lint-text && oxfmt .` замість делегування до CLI (bun.mdc + security.mdc).
|
|
49
|
+
|
|
7
50
|
## [1.25.2] - 2026-05-26
|
|
8
51
|
|
|
9
52
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nitra/cursor",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.26.1",
|
|
4
4
|
"description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -10,10 +10,6 @@
|
|
|
10
10
|
"n",
|
|
11
11
|
"pi-package"
|
|
12
12
|
],
|
|
13
|
-
"pi": {
|
|
14
|
-
"skills": "skills",
|
|
15
|
-
"extensions": ".pi-template/extensions"
|
|
16
|
-
},
|
|
17
13
|
"homepage": "https://github.com/n/cursor#readme",
|
|
18
14
|
"bugs": {
|
|
19
15
|
"url": "https://github.com/n/cursor/issues"
|
|
@@ -64,5 +60,9 @@
|
|
|
64
60
|
"engines": {
|
|
65
61
|
"bun": ">=1.3",
|
|
66
62
|
"node": ">=25"
|
|
63
|
+
},
|
|
64
|
+
"pi": {
|
|
65
|
+
"skills": "skills",
|
|
66
|
+
"extensions": ".pi-template/extensions"
|
|
67
67
|
}
|
|
68
68
|
}
|
|
@@ -13,6 +13,9 @@ import { join } from 'node:path'
|
|
|
13
13
|
|
|
14
14
|
import { resolveJsRoot } from '../../../scripts/utils/resolve-js-root.mjs'
|
|
15
15
|
|
|
16
|
+
const TEST_BLOCK_START = /^\s*(it|test)\(/
|
|
17
|
+
const FILE_EXTENSION = /\.[^.]+$/
|
|
18
|
+
|
|
16
19
|
/**
|
|
17
20
|
* Чи `scripts` містить coverage-сумісну команду.
|
|
18
21
|
* @param {Record<string, string> | undefined} scripts секція scripts з package.json
|
|
@@ -89,13 +92,15 @@ export function extractFirstTestBlock(content) {
|
|
|
89
92
|
let depth = 0
|
|
90
93
|
let inBlock = false
|
|
91
94
|
const result = []
|
|
92
|
-
for (
|
|
93
|
-
if (startLine === -1 &&
|
|
95
|
+
for (const [i, line] of lines.entries()) {
|
|
96
|
+
if (startLine === -1 && TEST_BLOCK_START.test(line)) startLine = i
|
|
94
97
|
if (startLine === -1) continue
|
|
95
|
-
result.push(
|
|
96
|
-
for (const ch of
|
|
97
|
-
if (ch === '{') {
|
|
98
|
-
|
|
98
|
+
result.push(line)
|
|
99
|
+
for (const ch of line) {
|
|
100
|
+
if (ch === '{') {
|
|
101
|
+
depth++
|
|
102
|
+
inBlock = true
|
|
103
|
+
} else if (ch === '}') depth--
|
|
99
104
|
}
|
|
100
105
|
if (inBlock && depth === 0) break
|
|
101
106
|
}
|
|
@@ -110,7 +115,7 @@ export function extractFirstTestBlock(content) {
|
|
|
110
115
|
* @returns {{testFile:string, code:string|null} | null} null — якщо тест-файл не знайдено
|
|
111
116
|
*/
|
|
112
117
|
export function findExampleTest(jsRoot, filename) {
|
|
113
|
-
const base = filename.replace(
|
|
118
|
+
const base = filename.replace(FILE_EXTENSION, '')
|
|
114
119
|
const candidates = [`${base}.test.js`, `${base}.test.mjs`, `${base}.test.ts`]
|
|
115
120
|
const lastSlash = base.lastIndexOf('/')
|
|
116
121
|
if (lastSlash !== -1) {
|
|
@@ -131,9 +136,9 @@ export function findExampleTest(jsRoot, filename) {
|
|
|
131
136
|
* Парс Stryker mutation.json: Killed+Timeout → caught; Survived+NoCoverage → до total.
|
|
132
137
|
* Compile/Runtime errors виключаються з total.
|
|
133
138
|
* 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
|
|
139
|
+
* @param {{files:Record<string,{mutants:Array<{status:string,mutatorName?:string,replacement?:string,location?:{start:{line:number,column:number},end:{line:number,column:number}}}>}>}} report Stryker mutation.json
|
|
135
140
|
* @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}>}}
|
|
141
|
+
* @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}>}} результат парсу: caught/total та згруповані survived мутанти
|
|
137
142
|
*/
|
|
138
143
|
export function parseStrykerReport(report, jsRoot) {
|
|
139
144
|
let caught = 0
|
|
@@ -4245,7 +4245,7 @@ export function pdbManifestViolations(manifest, expectedAppLabel, isDevLike) {
|
|
|
4245
4245
|
|
|
4246
4246
|
const NETWORK_POLICY_SNIPPET_URLS = {
|
|
4247
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)
|
|
4248
|
+
statefulset: new URL('../policy/network_policy/template/statefulset.snippet.yaml', import.meta.url)
|
|
4249
4249
|
}
|
|
4250
4250
|
|
|
4251
4251
|
/** @type {Record<string, Record<string, unknown>>} */
|
|
@@ -4256,7 +4256,7 @@ const _snippetCache = {}
|
|
|
4256
4256
|
* Кожен snippet — повний самодостатній канон NetworkPolicy для своєї групи workload-типів
|
|
4257
4257
|
* (без merge між snippets у runtime).
|
|
4258
4258
|
* @param {'deployment' | 'statefulset'} snippetName ім'я сніпету
|
|
4259
|
-
* @returns {{ podSelector?: Record<string, unknown>, policyTypes?: string[], ingress?: unknown[], egress?: unknown[] }}
|
|
4259
|
+
* @returns {{ podSelector?: Record<string, unknown>, policyTypes?: string[], ingress?: unknown[], egress?: unknown[] }} розпарсений spec
|
|
4260
4260
|
*/
|
|
4261
4261
|
export function loadSnippetSpec(snippetName) {
|
|
4262
4262
|
if (_snippetCache[snippetName]) return _snippetCache[snippetName]
|
|
@@ -4277,13 +4277,13 @@ export const KIND_TO_SNIPPET = {
|
|
|
4277
4277
|
Job: 'deployment',
|
|
4278
4278
|
CronJob: 'deployment',
|
|
4279
4279
|
DaemonSet: 'deployment',
|
|
4280
|
-
StatefulSet: 'statefulset'
|
|
4280
|
+
StatefulSet: 'statefulset'
|
|
4281
4281
|
}
|
|
4282
4282
|
|
|
4283
4283
|
/**
|
|
4284
4284
|
* Обирає snippet name для конкретного workload-kind; throws на невідомий.
|
|
4285
4285
|
* @param {string} kind workload-kind
|
|
4286
|
-
* @returns {'deployment' | 'statefulset'}
|
|
4286
|
+
* @returns {'deployment' | 'statefulset'} snippet name
|
|
4287
4287
|
*/
|
|
4288
4288
|
export function snippetNameForKind(kind) {
|
|
4289
4289
|
const name = KIND_TO_SNIPPET[kind]
|
|
@@ -4294,27 +4294,64 @@ export function snippetNameForKind(kind) {
|
|
|
4294
4294
|
/**
|
|
4295
4295
|
* Читає deployment.snippet.yaml і повертає розпарсений spec.
|
|
4296
4296
|
* @deprecated Використовуй loadSnippetSpec('deployment')
|
|
4297
|
-
* @returns {{ podSelector: Record<string, unknown>, policyTypes: string[], ingress: unknown[], egress: unknown[] }}
|
|
4297
|
+
* @returns {{ podSelector: Record<string, unknown>, policyTypes: string[], ingress: unknown[], egress: unknown[] }} розпарсений spec deployment snippet
|
|
4298
4298
|
*/
|
|
4299
4299
|
export function readNetworkPolicySnippet() {
|
|
4300
4300
|
return /** @type {any} */ (loadSnippetSpec('deployment'))
|
|
4301
4301
|
}
|
|
4302
4302
|
|
|
4303
|
+
/**
|
|
4304
|
+
* No-op fail-callback (повертає аргумент). Використовується як дефолт у `regenerateLegacyNetworkPolicyDocsInFile`,
|
|
4305
|
+
* коли caller не передає власний `fail` — щоб `collectHttpRouteIngressForWorkload` не падав.
|
|
4306
|
+
* @param {string} msg повідомлення помилки
|
|
4307
|
+
* @returns {string} той самий аргумент
|
|
4308
|
+
*/
|
|
4309
|
+
const noopFail = msg => msg
|
|
4310
|
+
|
|
4311
|
+
/**
|
|
4312
|
+
* `from`-peers для HTTPRoute-aware ingress-правила (GCP HC global + Envoy data-plane / proxy-only subnet).
|
|
4313
|
+
* Порядок зафіксовано детерміністичним (HC-global → 10.0.0.0/8). Див. розділ «HTTPRoute → NetworkPolicy ingress» у k8s.mdc.
|
|
4314
|
+
* @type {ReadonlyArray<{ ipBlock: { cidr: string } }>}
|
|
4315
|
+
*/
|
|
4316
|
+
const NETWORK_POLICY_GCLB_INGRESS_FROM = Object.freeze([
|
|
4317
|
+
// eslint-disable-next-line sonarjs/no-hardcoded-ip
|
|
4318
|
+
{ ipBlock: { cidr: '35.191.0.0/16' } },
|
|
4319
|
+
// eslint-disable-next-line sonarjs/no-hardcoded-ip
|
|
4320
|
+
{ ipBlock: { cidr: '130.211.0.0/22' } },
|
|
4321
|
+
// eslint-disable-next-line sonarjs/no-hardcoded-ip
|
|
4322
|
+
{ ipBlock: { cidr: '10.0.0.0/8' } }
|
|
4323
|
+
])
|
|
4324
|
+
|
|
4303
4325
|
/**
|
|
4304
4326
|
* Канонічний YAML **NetworkPolicy** для workload з іменем `deployName`, міткою `app` і типом `kind`.
|
|
4305
4327
|
* Snippet обирається за `kind` через `KIND_TO_SNIPPET` (без merge — кожен snippet самодостатній).
|
|
4306
4328
|
* Анотація `nitra.dev/workload-kind` додається, щоб rego диспатчив на правильний канон.
|
|
4329
|
+
*
|
|
4330
|
+
* Якщо `gclbPorts` непорожній — після canon ingress-правил додається одне ingress-правило
|
|
4331
|
+
* з фіксованими CIDR-ами (GCP HC global + Envoy data-plane) і відсортованими унікальними TCP-портами
|
|
4332
|
+
* (для HTTPRoute-paired workload-ів; див. `collectHttpRouteIngressForWorkload` і k8s.mdc).
|
|
4307
4333
|
* @param {string} deployName `metadata.name` workload
|
|
4308
4334
|
* @param {string} appLabel `spec.selector.matchLabels.app`
|
|
4309
4335
|
* @param {string} kind `kind` workload (обовʼязковий: Deployment | StatefulSet | Job | CronJob | DaemonSet)
|
|
4336
|
+
* @param {readonly number[]} [gclbPorts] TCP-порти з backendRefs HTTPRoute (опційно)
|
|
4310
4337
|
* @returns {string} вміст `networkpolicy.yaml`
|
|
4311
4338
|
*/
|
|
4312
|
-
export function buildNetworkPolicyYaml(deployName, appLabel, kind) {
|
|
4339
|
+
export function buildNetworkPolicyYaml(deployName, appLabel, kind, gclbPorts) {
|
|
4313
4340
|
const schemaUrl = `${YANNH_BASE}networkpolicy-networking-v1.json`
|
|
4314
4341
|
const snippetName = snippetNameForKind(kind)
|
|
4315
|
-
const spec =
|
|
4342
|
+
const spec = structuredClone(loadSnippetSpec(snippetName))
|
|
4316
4343
|
spec.podSelector.matchLabels = { app: appLabel }
|
|
4317
|
-
|
|
4344
|
+
if (Array.isArray(gclbPorts) && gclbPorts.length > 0) {
|
|
4345
|
+
const uniqueSorted = [...new Set(gclbPorts)].toSorted((a, b) => a - b)
|
|
4346
|
+
const gclbRule = {
|
|
4347
|
+
from: structuredClone(NETWORK_POLICY_GCLB_INGRESS_FROM),
|
|
4348
|
+
ports: uniqueSorted.map(port => ({ protocol: 'TCP', port }))
|
|
4349
|
+
}
|
|
4350
|
+
spec.ingress = [...(spec.ingress ?? []), gclbRule]
|
|
4351
|
+
}
|
|
4352
|
+
const specYaml = stringify(spec, { indent: 2 })
|
|
4353
|
+
.replaceAll(/^(?!$)/gm, ' ')
|
|
4354
|
+
.trimEnd()
|
|
4318
4355
|
return `# yaml-language-server: $schema=${schemaUrl}
|
|
4319
4356
|
apiVersion: networking.k8s.io/v1
|
|
4320
4357
|
kind: NetworkPolicy
|
|
@@ -4326,6 +4363,126 @@ spec:
|
|
|
4326
4363
|
${specYaml}`
|
|
4327
4364
|
}
|
|
4328
4365
|
|
|
4366
|
+
/**
|
|
4367
|
+
* Збирає унікальні TCP-порти з `HTTPRoute.backendRefs`, які адресують workload з міткою `appLabel`.
|
|
4368
|
+
*
|
|
4369
|
+
* Mapping: `backendRef.name` → `Service.metadata.name` у тому ж каталозі → `service.spec.selector.matchLabels.app === appLabel`.
|
|
4370
|
+
* Використовується для побудови HTTPRoute-aware ingress-правила в NetworkPolicy (GCLB + Envoy data-plane CIDR-и; див. k8s.mdc).
|
|
4371
|
+
* @param {string} dir абсолютний каталог
|
|
4372
|
+
* @param {string} appLabel `spec.selector.matchLabels.app` цільового workload
|
|
4373
|
+
* @param {(msg: string) => void} fail callback при read/parse-помилці YAML у каталозі
|
|
4374
|
+
* @returns {Promise<{ ports: number[] } | null>} відсортовані унікальні TCP-порти або null, якщо HTTPRoute не вказує на цей workload
|
|
4375
|
+
*/
|
|
4376
|
+
export async function collectHttpRouteIngressForWorkload(dir, appLabel, fail) {
|
|
4377
|
+
let entries
|
|
4378
|
+
try {
|
|
4379
|
+
entries = await readdir(dir, { withFileTypes: true })
|
|
4380
|
+
} catch {
|
|
4381
|
+
return null
|
|
4382
|
+
}
|
|
4383
|
+
const yamlFiles = entries
|
|
4384
|
+
.filter(e => e.isFile() && (e.name.endsWith('.yaml') || e.name.endsWith('.yml')))
|
|
4385
|
+
.map(e => join(dir, e.name))
|
|
4386
|
+
if (yamlFiles.length === 0) return null
|
|
4387
|
+
|
|
4388
|
+
/** @type {Array<{ name: string, port: number }>} */
|
|
4389
|
+
const allBackendRefs = []
|
|
4390
|
+
/** @type {Map<string, string>} */
|
|
4391
|
+
const servicesByName = new Map()
|
|
4392
|
+
|
|
4393
|
+
for (const abs of yamlFiles) {
|
|
4394
|
+
let raw
|
|
4395
|
+
try {
|
|
4396
|
+
raw = await readFile(abs, 'utf8')
|
|
4397
|
+
} catch (error) {
|
|
4398
|
+
const msg = error instanceof Error ? error.message : String(error)
|
|
4399
|
+
fail(`${abs}: не вдалося прочитати для GCLB ingress (HTTPRoute → NetworkPolicy mapping; k8s.mdc): ${msg}`)
|
|
4400
|
+
continue
|
|
4401
|
+
}
|
|
4402
|
+
const lines = toLines(raw)
|
|
4403
|
+
const body = lines.length > 0 && MODELINE_RE.test(lines[0]) ? yamlBodyAfterModeline(lines) : lines.join('\n')
|
|
4404
|
+
/** @type {import('yaml').Document[]} */
|
|
4405
|
+
let docs
|
|
4406
|
+
try {
|
|
4407
|
+
docs = parseAllDocuments(body)
|
|
4408
|
+
} catch (error) {
|
|
4409
|
+
const msg = error instanceof Error ? error.message : String(error)
|
|
4410
|
+
fail(`${abs}: не вдалося розпарсити YAML для GCLB ingress (HTTPRoute → NetworkPolicy mapping; k8s.mdc): ${msg}`)
|
|
4411
|
+
continue
|
|
4412
|
+
}
|
|
4413
|
+
for (const doc of docs) {
|
|
4414
|
+
if (doc.errors.length > 0) {
|
|
4415
|
+
fail(
|
|
4416
|
+
`${abs}: YAML містить помилки для GCLB ingress (HTTPRoute → NetworkPolicy mapping; k8s.mdc): ${doc.errors[0].message}`
|
|
4417
|
+
)
|
|
4418
|
+
continue
|
|
4419
|
+
}
|
|
4420
|
+
const rec = asPlainRecord(doc.toJSON())
|
|
4421
|
+
if (rec === null) continue
|
|
4422
|
+
const av = rec.apiVersion
|
|
4423
|
+
if (rec.kind === 'HTTPRoute' && typeof av === 'string' && av.startsWith(GATEWAY_API_GROUP_PREFIX)) {
|
|
4424
|
+
collectHttpRouteBackendRefsInto(rec.spec, allBackendRefs)
|
|
4425
|
+
} else if (rec.kind === 'Service') {
|
|
4426
|
+
const name = manifestMetadataName(rec)
|
|
4427
|
+
if (name !== null) {
|
|
4428
|
+
const app = serviceSelectorAppLabel(rec.spec)
|
|
4429
|
+
if (app !== null) servicesByName.set(name, app)
|
|
4430
|
+
}
|
|
4431
|
+
}
|
|
4432
|
+
}
|
|
4433
|
+
}
|
|
4434
|
+
|
|
4435
|
+
/** @type {Set<number>} */
|
|
4436
|
+
const ports = new Set()
|
|
4437
|
+
for (const { name, port } of allBackendRefs) {
|
|
4438
|
+
const targetApp = servicesByName.get(name)
|
|
4439
|
+
if (targetApp === appLabel) ports.add(port)
|
|
4440
|
+
}
|
|
4441
|
+
if (ports.size === 0) return null
|
|
4442
|
+
return { ports: [...ports].toSorted((a, b) => a - b) }
|
|
4443
|
+
}
|
|
4444
|
+
|
|
4445
|
+
/**
|
|
4446
|
+
* Витягує мітку `app` з `Service.spec.selector.app` (плоский селектор, без `matchLabels`).
|
|
4447
|
+
* Окремий helper від `appLabelFromSpecSelector` (той — для Deployment/StatefulSet, де `selector.matchLabels.app`).
|
|
4448
|
+
* @param {unknown} spec значення `spec` Service
|
|
4449
|
+
* @returns {string | null} мітка `app` або null
|
|
4450
|
+
*/
|
|
4451
|
+
function serviceSelectorAppLabel(spec) {
|
|
4452
|
+
if (spec === null || spec === undefined || typeof spec !== 'object' || Array.isArray(spec)) return null
|
|
4453
|
+
const selector = /** @type {Record<string, unknown>} */ (spec).selector
|
|
4454
|
+
if (selector === null || selector === undefined || typeof selector !== 'object' || Array.isArray(selector)) return null
|
|
4455
|
+
const app = /** @type {Record<string, unknown>} */ (selector).app
|
|
4456
|
+
return typeof app === 'string' && app.trim() !== '' ? app : null
|
|
4457
|
+
}
|
|
4458
|
+
|
|
4459
|
+
/**
|
|
4460
|
+
* Обходить `spec` HTTPRoute (`spec.rules[*].backendRefs[*]`) і додає `(name, port)` у акумулятор.
|
|
4461
|
+
* Дублює walk-логіку `collectGatewayApiRouteBackendServiceNames`, але зберігає `port` поруч з `name`.
|
|
4462
|
+
* @param {unknown} spec значення `spec` маршруту
|
|
4463
|
+
* @param {Array<{ name: string, port: number }>} out акумулятор
|
|
4464
|
+
* @returns {void} результат
|
|
4465
|
+
*/
|
|
4466
|
+
function collectHttpRouteBackendRefsInto(spec, out) {
|
|
4467
|
+
/**
|
|
4468
|
+
* @param {unknown} node вузол для обходу
|
|
4469
|
+
* @returns {void} результат
|
|
4470
|
+
*/
|
|
4471
|
+
function walk(node) {
|
|
4472
|
+
if (node === null || node === undefined) return
|
|
4473
|
+
if (Array.isArray(node)) {
|
|
4474
|
+
for (const x of node) walk(x)
|
|
4475
|
+
return
|
|
4476
|
+
}
|
|
4477
|
+
if (typeof node !== 'object') return
|
|
4478
|
+
if (isGatewayApiBackendRefToService(node)) {
|
|
4479
|
+
const o = /** @type {Record<string, unknown>} */ (node)
|
|
4480
|
+
out.push({ name: String(o.name), port: /** @type {number} */ (o.port) })
|
|
4481
|
+
}
|
|
4482
|
+
for (const v of Object.values(node)) walk(v)
|
|
4483
|
+
}
|
|
4484
|
+
walk(spec)
|
|
4485
|
+
}
|
|
4329
4486
|
|
|
4330
4487
|
/**
|
|
4331
4488
|
* Додає `resourceName` у `resources:` kustomization/Component YAML, якщо ще немає; сортує за алфавітом (en).
|
|
@@ -5094,12 +5251,12 @@ function validateNetworkPolicyForWorkload(npDocs, workloadName, appLabel, worklo
|
|
|
5094
5251
|
}
|
|
5095
5252
|
const spec = /** @type {Record<string, unknown>} */ (matchedNp).spec
|
|
5096
5253
|
const foundLabel = networkPolicyPodSelectorAppLabel(spec)
|
|
5097
|
-
if (foundLabel
|
|
5254
|
+
if (foundLabel === appLabel) {
|
|
5255
|
+
passFn(`${npRel}: NetworkPolicy для ${workloadKind} '${workloadName}' валідний (k8s.mdc)`)
|
|
5256
|
+
} else {
|
|
5098
5257
|
fail(
|
|
5099
5258
|
`${npRel}: NetworkPolicy '${workloadName}' spec.podSelector.matchLabels.app='${foundLabel}' не відповідає мітці workload '${appLabel}' (k8s.mdc)`
|
|
5100
5259
|
)
|
|
5101
|
-
} else {
|
|
5102
|
-
passFn(`${npRel}: NetworkPolicy для ${workloadKind} '${workloadName}' валідний (k8s.mdc)`)
|
|
5103
5260
|
}
|
|
5104
5261
|
}
|
|
5105
5262
|
|
|
@@ -6288,23 +6445,30 @@ async function existingNetworkPolicyNames(npAbs) {
|
|
|
6288
6445
|
|
|
6289
6446
|
/**
|
|
6290
6447
|
* Дописує відсутні NetworkPolicy-документи у `networkpolicy.yaml` (multi-doc через `---`).
|
|
6448
|
+
* Перед побудовою YAML для кожного workload викликає `collectHttpRouteIngressForWorkload`,
|
|
6449
|
+
* щоб додати GCLB-aware ingress-правило, якщо в каталозі є paired HTTPRoute (k8s.mdc).
|
|
6291
6450
|
* @param {string} npAbs абсолютний шлях до файлу
|
|
6292
6451
|
* @param {Array<{ name: string, appLabel: string, kind: string }>} toAdd workload-и без NP
|
|
6293
6452
|
* @param {string} npRel відносний шлях для повідомлень
|
|
6453
|
+
* @param {(msg: string) => void} fail callback при read/parse-помилках HTTPRoute/Service
|
|
6294
6454
|
* @param {(msg: string) => void} passFn callback при успіху
|
|
6295
6455
|
* @returns {Promise<void>} результат
|
|
6296
6456
|
*/
|
|
6297
|
-
async function appendNetworkPolicyDocuments(npAbs, toAdd, npRel, passFn) {
|
|
6457
|
+
async function appendNetworkPolicyDocuments(npAbs, toAdd, npRel, fail, passFn) {
|
|
6298
6458
|
if (toAdd.length === 0) return
|
|
6299
6459
|
let content = ''
|
|
6300
6460
|
if (existsSync(npAbs)) {
|
|
6301
6461
|
const raw = await readFile(npAbs, 'utf8')
|
|
6302
6462
|
content = raw.trimEnd()
|
|
6303
6463
|
}
|
|
6304
|
-
const
|
|
6305
|
-
|
|
6306
|
-
|
|
6307
|
-
|
|
6464
|
+
const dir = dirname(npAbs)
|
|
6465
|
+
const blocks = []
|
|
6466
|
+
for (const [i, { name, appLabel, kind }] of toAdd.entries()) {
|
|
6467
|
+
const gclb = await collectHttpRouteIngressForWorkload(dir, appLabel, fail)
|
|
6468
|
+
const gclbPorts = gclb === null ? undefined : gclb.ports
|
|
6469
|
+
const block = buildNetworkPolicyYaml(name, appLabel, kind, gclbPorts)
|
|
6470
|
+
blocks.push(i === 0 && content === '' ? block.trimEnd() : stripYamlLanguageServerModeline(block).trimEnd())
|
|
6471
|
+
}
|
|
6308
6472
|
const joined = blocks.join('\n---\n')
|
|
6309
6473
|
content = content === '' ? `${joined}\n` : `${content}\n---\n${joined}\n`
|
|
6310
6474
|
await writeFile(npAbs, content, 'utf8')
|
|
@@ -6359,11 +6523,14 @@ function networkPolicyPodSelectorAppLabel(spec) {
|
|
|
6359
6523
|
|
|
6360
6524
|
/**
|
|
6361
6525
|
* Migrate legacy `networkpolicy.yaml`: якщо хоч один документ має catch-all in-cluster egress —
|
|
6362
|
-
* перезаписати **всі** документи у файлі через `buildNetworkPolicyYaml(name, appLabel)`.
|
|
6526
|
+
* перезаписати **всі** документи у файлі через `buildNetworkPolicyYaml(name, appLabel, kind, gclbPorts)`.
|
|
6527
|
+
* `gclbPorts` витягуються з HTTPRoute paired у тому ж каталозі (див. `collectHttpRouteIngressForWorkload`).
|
|
6528
|
+
* Деталі — k8s.mdc.
|
|
6363
6529
|
* @param {string} npAbs абсолютний шлях до networkpolicy.yaml
|
|
6530
|
+
* @param {(msg: string) => void} [fail] callback при read/parse-помилці HTTPRoute/Service (опційно — для backward compat)
|
|
6364
6531
|
* @returns {Promise<boolean>} true якщо файл переписаний
|
|
6365
6532
|
*/
|
|
6366
|
-
export async function regenerateLegacyNetworkPolicyDocsInFile(npAbs) {
|
|
6533
|
+
export async function regenerateLegacyNetworkPolicyDocsInFile(npAbs, fail) {
|
|
6367
6534
|
if (!existsSync(npAbs)) return false
|
|
6368
6535
|
const docs = await readAllDocsByKindFromFile(npAbs, 'NetworkPolicy')
|
|
6369
6536
|
if (docs.length === 0) return false
|
|
@@ -6379,20 +6546,27 @@ export async function regenerateLegacyNetworkPolicyDocsInFile(npAbs) {
|
|
|
6379
6546
|
const spec = docRec.spec
|
|
6380
6547
|
const appLabel = networkPolicyPodSelectorAppLabel(spec)
|
|
6381
6548
|
const meta = docRec.metadata
|
|
6382
|
-
const annotations =
|
|
6383
|
-
|
|
6384
|
-
|
|
6385
|
-
|
|
6386
|
-
|
|
6387
|
-
|
|
6549
|
+
const annotations =
|
|
6550
|
+
meta !== null && typeof meta === 'object' && !Array.isArray(meta)
|
|
6551
|
+
? /** @type {Record<string, unknown>} */ (meta).annotations
|
|
6552
|
+
: null
|
|
6553
|
+
const rawKind =
|
|
6554
|
+
annotations !== null && typeof annotations === 'object' && !Array.isArray(annotations)
|
|
6555
|
+
? /** @type {Record<string, unknown>} */ (annotations)['nitra.dev/workload-kind']
|
|
6556
|
+
: null
|
|
6388
6557
|
const kind = typeof rawKind === 'string' && rawKind !== '' ? rawKind : 'Deployment'
|
|
6389
6558
|
if (typeof name === 'string' && name !== '' && appLabel !== '') specs.push({ name, appLabel, kind })
|
|
6390
6559
|
}
|
|
6391
6560
|
if (specs.length === 0) return false
|
|
6392
|
-
const
|
|
6393
|
-
|
|
6394
|
-
|
|
6395
|
-
})
|
|
6561
|
+
const dir = dirname(npAbs)
|
|
6562
|
+
const failCb = typeof fail === 'function' ? fail : noopFail
|
|
6563
|
+
const blocks = []
|
|
6564
|
+
for (const [i, { name, appLabel, kind }] of specs.entries()) {
|
|
6565
|
+
const gclb = await collectHttpRouteIngressForWorkload(dir, appLabel, failCb)
|
|
6566
|
+
const gclbPorts = gclb === null ? undefined : gclb.ports
|
|
6567
|
+
const block = buildNetworkPolicyYaml(name, appLabel, kind, gclbPorts)
|
|
6568
|
+
blocks.push(i === 0 ? block.trimEnd() : stripYamlLanguageServerModeline(block).trimEnd())
|
|
6569
|
+
}
|
|
6396
6570
|
await writeFile(npAbs, `${blocks.join('\n---\n')}\n`, 'utf8')
|
|
6397
6571
|
return true
|
|
6398
6572
|
}
|
|
@@ -6412,7 +6586,7 @@ async function ensureNetworkPoliciesForWorkloadsInDir(dir, workloads, rootNorm,
|
|
|
6412
6586
|
const npAbs = join(dir, NETWORK_POLICY_FILENAME)
|
|
6413
6587
|
const npRel = (relative(rootNorm, npAbs) || npAbs).replaceAll('\\', '/')
|
|
6414
6588
|
if (existsSync(npAbs)) {
|
|
6415
|
-
const migrated = await regenerateLegacyNetworkPolicyDocsInFile(npAbs)
|
|
6589
|
+
const migrated = await regenerateLegacyNetworkPolicyDocsInFile(npAbs, fail)
|
|
6416
6590
|
if (migrated) {
|
|
6417
6591
|
passFn(`${npRel}: міграція legacy catch-all egress → канон з явними in-cluster портами (k8s.mdc)`)
|
|
6418
6592
|
}
|
|
@@ -6431,7 +6605,7 @@ async function ensureNetworkPoliciesForWorkloadsInDir(dir, workloads, rootNorm,
|
|
|
6431
6605
|
}
|
|
6432
6606
|
if (toAdd.length === 0) return
|
|
6433
6607
|
try {
|
|
6434
|
-
await appendNetworkPolicyDocuments(npAbs, toAdd, npRel, passFn)
|
|
6608
|
+
await appendNetworkPolicyDocuments(npAbs, toAdd, npRel, fail, passFn)
|
|
6435
6609
|
const kustAbs = join(dir, 'kustomization.yaml')
|
|
6436
6610
|
if (existsSync(kustAbs)) {
|
|
6437
6611
|
const raw = await readFile(kustAbs, 'utf8')
|
|
@@ -6572,8 +6746,8 @@ function runAllK8sRego(root, yamlFiles, fail) {
|
|
|
6572
6746
|
files: allYaml,
|
|
6573
6747
|
templateData: {
|
|
6574
6748
|
deployment_snippet: loadSnippetSpec('deployment'),
|
|
6575
|
-
statefulset_snippet: loadSnippetSpec('statefulset')
|
|
6576
|
-
}
|
|
6749
|
+
statefulset_snippet: loadSnippetSpec('statefulset')
|
|
6750
|
+
}
|
|
6577
6751
|
},
|
|
6578
6752
|
{ ns: 'k8s.kustomization', dir: 'k8s/kustomization', files: kustYaml },
|
|
6579
6753
|
{ ns: 'k8s.svc_yaml', dir: 'k8s/svc_yaml', files: svcYaml },
|
|
@@ -6584,7 +6758,12 @@ function runAllK8sRego(root, yamlFiles, fail) {
|
|
|
6584
6758
|
|
|
6585
6759
|
for (const t of targets) {
|
|
6586
6760
|
if (t.files.length === 0) continue
|
|
6587
|
-
const violations = runConftestBatch({
|
|
6761
|
+
const violations = runConftestBatch({
|
|
6762
|
+
policyDirRel: t.dir,
|
|
6763
|
+
namespace: t.ns,
|
|
6764
|
+
files: t.files,
|
|
6765
|
+
templateData: t.templateData
|
|
6766
|
+
})
|
|
6588
6767
|
for (const v of violations) {
|
|
6589
6768
|
fail(`${relOf(v.filename)}: ${v.message}`)
|
|
6590
6769
|
}
|
package/rules/k8s/k8s.mdc
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: K8s YAML — $schema (yaml-language-server); lint-k8s (kubeconform, kubescape); check-k8s
|
|
3
|
-
version: '1.
|
|
3
|
+
version: '1.42'
|
|
4
4
|
globs: "**/k8s/**/*.yaml"
|
|
5
5
|
alwaysApply: false
|
|
6
6
|
---
|
|
@@ -414,6 +414,34 @@ images:
|
|
|
414
414
|
|
|
415
415
|
**Порядок `resources`:** у маніфесті з **`apiVersion: kustomize.config.k8s.io/…`**, **`kind: Kustomization`**, елементи **`resources:`** (лише непорожні рядки) мають бути **відсортовані за алфавітом (англ. локаль, як `localeCompare('en')` у `rules/k8s/fix.mjs`)**. Поля **`bases`**, **`components`**, **`crds`** цією перевіркою **не** впорядковуються.
|
|
416
416
|
|
|
417
|
+
### HTTPRoute → NetworkPolicy ingress (GCLB + Envoy)
|
|
418
|
+
|
|
419
|
+
Якщо в каталозі workload є **`HTTPRoute`** (Gateway API; `apiVersion: gateway.networking.k8s.io/*`) з **`backendRef`** на **`<workload>-hl`** Service (mapping через `service.spec.selector.app`), **`check k8s`** автоматично додає в NetworkPolicy цього workload **ingress-правило** з фіксованим набором CIDR-ів і **TCP-портами з `backendRefs[].port`** (дедуп, відсортовано за зростанням).
|
|
420
|
+
|
|
421
|
+
Без цього правила трафік від **GKE Gateway** (Envoy data-plane з proxy-only subnet регіону, наприклад `us-central1-proxy-only` = `10.10.0.0/23`) і **Google health checks** (`35.191.0.0/16`, `130.211.0.0/22`) блокується базовим NetworkPolicy (бо canon ingress допускає тільки `podSelector: {}` — intra-namespace pod ↔ pod).
|
|
422
|
+
|
|
423
|
+
CIDR-набір зафіксовано (без конфігурації):
|
|
424
|
+
|
|
425
|
+
- `35.191.0.0/16` — GCP HC global
|
|
426
|
+
- `130.211.0.0/22` — GCP HC global (legacy)
|
|
427
|
+
- `10.0.0.0/8` — широкий range, покриває proxy-only subnets усіх регіонів GKE
|
|
428
|
+
|
|
429
|
+
Приклад згенерованого ingress-правила (поверх baseline canon):
|
|
430
|
+
|
|
431
|
+
```yaml
|
|
432
|
+
ingress:
|
|
433
|
+
- from:
|
|
434
|
+
- podSelector: {}
|
|
435
|
+
- from:
|
|
436
|
+
- ipBlock: { cidr: 35.191.0.0/16 }
|
|
437
|
+
- ipBlock: { cidr: 130.211.0.0/22 }
|
|
438
|
+
- ipBlock: { cidr: 10.0.0.0/8 }
|
|
439
|
+
ports:
|
|
440
|
+
- { protocol: TCP, port: 8080 }
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
Якщо workload не прив'язаний до жодного HTTPRoute — правило **не** додається; NP лишається baseline (intra-namespace + canon egress). Не-HTTP routes (`GRPCRoute`, `TCPRoute`, `TLSRoute`, `UDPRoute`) поки не покриті — додається лише за HTTPRoute. Алгоритм: функція `collectHttpRouteIngressForWorkload` у **`rules/k8s/js/manifests.mjs`** індексує `HTTPRoute.backendRefs` і `Service` у каталозі, резолвить через `selector.app`, дедуп TCP-портів. Виклики — з `appendNetworkPolicyDocuments` і `regenerateLegacyNetworkPolicyDocsInFile`.
|
|
444
|
+
|
|
417
445
|
### Env-залежні межі (за сегментом після `/k8s/`)
|
|
418
446
|
|
|
419
447
|
**Dev-like середовища** — сегмент `base`, `dev`, або з суфіксом `-qa` (напр. `tr-qa`):
|
|
@@ -554,6 +582,34 @@ spec:
|
|
|
554
582
|
port: 4318
|
|
555
583
|
```
|
|
556
584
|
|
|
585
|
+
```yaml title="k8s/base/networkpolicy.yaml — workload з HTTPRoute (з GCLB ingress)"
|
|
586
|
+
# yaml-language-server: $schema=https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/v1.33.9-standalone-strict/networkpolicy-networking-v1.json
|
|
587
|
+
apiVersion: networking.k8s.io/v1
|
|
588
|
+
kind: NetworkPolicy
|
|
589
|
+
metadata:
|
|
590
|
+
name: backend-api
|
|
591
|
+
annotations:
|
|
592
|
+
nitra.dev/workload-kind: Deployment
|
|
593
|
+
spec:
|
|
594
|
+
podSelector:
|
|
595
|
+
matchLabels:
|
|
596
|
+
app: backend-api
|
|
597
|
+
policyTypes:
|
|
598
|
+
- Ingress
|
|
599
|
+
- Egress
|
|
600
|
+
ingress:
|
|
601
|
+
- from:
|
|
602
|
+
- podSelector: {}
|
|
603
|
+
- from: # auto-added by check k8s for HTTPRoute-paired workloads
|
|
604
|
+
- ipBlock: { cidr: 35.191.0.0/16 }
|
|
605
|
+
- ipBlock: { cidr: 130.211.0.0/22 }
|
|
606
|
+
- ipBlock: { cidr: 10.0.0.0/8 }
|
|
607
|
+
ports:
|
|
608
|
+
- { protocol: TCP, port: 8080 }
|
|
609
|
+
egress:
|
|
610
|
+
# ... (ідентично до базового прикладу вище)
|
|
611
|
+
```
|
|
612
|
+
|
|
557
613
|
```yaml title="k8s/components/hpa.yaml"
|
|
558
614
|
# yaml-language-server: $schema=https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/v1.33.9-standalone-strict/horizontalpodautoscaler-autoscaling-v2.json
|
|
559
615
|
apiVersion: autoscaling/v2
|
|
@@ -72,31 +72,13 @@ deny contains "spec.ingress має містити from.podSelector (NetworkPolic
|
|
|
72
72
|
not ingress_has_pod_selector_rule(spec)
|
|
73
73
|
}
|
|
74
74
|
|
|
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"
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
snippet_name_for_kind("StatefulSet") := "statefulset"
|
|
84
|
-
|
|
85
|
-
snippet_name_for_kind(kind) := "deployment" if {
|
|
86
|
-
kind != "StatefulSet"
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
workload_kind := kind if {
|
|
90
|
-
kind := object.get(object.get(input.metadata, "annotations", {}), "nitra.dev/workload-kind", "")
|
|
91
|
-
}
|
|
92
|
-
|
|
93
75
|
# Superset-check egress: кожне канонічне правило має бути в input.spec.egress.
|
|
94
76
|
deny contains msg if {
|
|
95
77
|
is_np_doc
|
|
96
78
|
is_object(object.get(input, "spec", null))
|
|
97
79
|
canon := canon_for_kind(workload_kind)
|
|
98
80
|
some canon_rule in canon.egress
|
|
99
|
-
not
|
|
81
|
+
not contains_item(object.get(input.spec, "egress", []), canon_rule)
|
|
100
82
|
msg := sprintf(
|
|
101
83
|
"NetworkPolicy %v: відсутнє обовʼязкове egress-правило (%v.snippet.yaml; k8s.mdc): %v",
|
|
102
84
|
[input.metadata.name, snippet_name_for_kind(workload_kind), json.marshal(canon_rule)],
|
|
@@ -109,7 +91,7 @@ deny contains msg if {
|
|
|
109
91
|
is_object(object.get(input, "spec", null))
|
|
110
92
|
canon := canon_for_kind(workload_kind)
|
|
111
93
|
some canon_rule in canon.ingress
|
|
112
|
-
not
|
|
94
|
+
not contains_item(object.get(input.spec, "ingress", []), canon_rule)
|
|
113
95
|
msg := sprintf(
|
|
114
96
|
"NetworkPolicy %v: відсутнє обовʼязкове ingress-правило (%v.snippet.yaml; k8s.mdc): %v",
|
|
115
97
|
[input.metadata.name, snippet_name_for_kind(workload_kind), json.marshal(canon_rule)],
|
|
@@ -124,6 +106,22 @@ deny contains "spec.egress: заборонено allow-all {} — додавай
|
|
|
124
106
|
count(object.keys(rule)) == 0
|
|
125
107
|
}
|
|
126
108
|
|
|
109
|
+
# Dispatch на повний canon-snippet за анотацією nitra.dev/workload-kind.
|
|
110
|
+
# StatefulSet → statefulset_snippet (з intra-replica), решта → deployment_snippet.
|
|
111
|
+
canon_for_kind("StatefulSet") := data.template.statefulset_snippet
|
|
112
|
+
|
|
113
|
+
canon_for_kind(kind) := data.template.deployment_snippet if {
|
|
114
|
+
kind != "StatefulSet"
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
snippet_name_for_kind("StatefulSet") := "statefulset"
|
|
118
|
+
|
|
119
|
+
snippet_name_for_kind(kind) := "deployment" if {
|
|
120
|
+
kind != "StatefulSet"
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
workload_kind := object.get(object.get(input.metadata, "annotations", {}), "nitra.dev/workload-kind", "")
|
|
124
|
+
|
|
127
125
|
is_np_doc if input.kind == "NetworkPolicy"
|
|
128
126
|
|
|
129
127
|
is_np_doc if startswith(object.get(input, "apiVersion", ""), "networking.k8s.io/")
|
|
@@ -146,8 +144,8 @@ ingress_has_pod_selector_rule(spec) if {
|
|
|
146
144
|
object.get(peer, "podSelector", null) != null
|
|
147
145
|
}
|
|
148
146
|
|
|
149
|
-
|
|
147
|
+
contains_item(items, item) if {
|
|
150
148
|
is_array(items)
|
|
151
|
-
some
|
|
152
|
-
|
|
149
|
+
some candidate in items
|
|
150
|
+
candidate == item
|
|
153
151
|
}
|
|
@@ -92,23 +92,15 @@ export function renderMarkdown(rows) {
|
|
|
92
92
|
|
|
93
93
|
const allSurvived = rows.flatMap(r => r.survived ?? [])
|
|
94
94
|
if (allSurvived.length > 0) {
|
|
95
|
-
lines.push('', '## Вижилі мутанти')
|
|
96
|
-
// JSON-блок для /n-fix-tests skill — парситься скілом для написання тестів
|
|
97
|
-
lines.push('', '```json')
|
|
98
|
-
lines.push(JSON.stringify(allSurvived, null, 2))
|
|
99
|
-
lines.push('```')
|
|
95
|
+
lines.push('', '## Вижилі мутанти', '', '```json', JSON.stringify(allSurvived, null, 2), '```')
|
|
100
96
|
// Людиночитабельна таблиця
|
|
101
97
|
for (const group of allSurvived) {
|
|
102
|
-
lines.push('', `### ${group.file}`, '')
|
|
103
|
-
lines.push('| Рядок | Оригінал | Заміна | Тип |')
|
|
104
|
-
lines.push('| --- | --- | --- | --- |')
|
|
98
|
+
lines.push('', `### ${group.file}`, '', '| Рядок | Оригінал | Заміна | Тип |', '| --- | --- | --- | --- |')
|
|
105
99
|
for (const m of group.mutants) {
|
|
106
100
|
lines.push(`| ${m.line} | \`${m.original}\` | \`${m.replacement}\` | ${m.mutantType} |`)
|
|
107
101
|
}
|
|
108
102
|
if (group.exampleTest) {
|
|
109
|
-
lines.push('', `**Приклад тесту** (\`${group.exampleTest.testFile}\`):`, '', '```js')
|
|
110
|
-
lines.push(group.exampleTest.code ?? '')
|
|
111
|
-
lines.push('```')
|
|
103
|
+
lines.push('', `**Приклад тесту** (\`${group.exampleTest.testFile}\`):`, '', '```js', group.exampleTest.code ?? '', '```')
|
|
112
104
|
}
|
|
113
105
|
if (group.recommendationText) {
|
|
114
106
|
lines.push('', '**Що треба протестувати:**', '', group.recommendationText)
|
|
@@ -189,9 +181,7 @@ export async function runCoverageSteps(opts = {}) {
|
|
|
189
181
|
if (opts.fix) {
|
|
190
182
|
const allSurvived = rows.flatMap(r => r.survived ?? [])
|
|
191
183
|
// eslint-disable-next-line no-unsanitized/method -- шлях відносний до пакету, не user-input
|
|
192
|
-
const { fixSurvivedMutants } = await import(
|
|
193
|
-
new URL('../../scripts/coverage-fix.mjs', import.meta.url).href
|
|
194
|
-
)
|
|
184
|
+
const { fixSurvivedMutants } = await import(new URL('../../scripts/coverage-fix.mjs', import.meta.url).href)
|
|
195
185
|
await fixSurvivedMutants(allSurvived, cwd)
|
|
196
186
|
}
|
|
197
187
|
|
|
@@ -42,9 +42,7 @@ export async function check() {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
if (!existsSync(BASELINE_PATH)) {
|
|
45
|
-
reporter.fail(
|
|
46
|
-
`.cargo/mutants.toml canonical baseline не знайдено (${BASELINE_PATH}) — перевстанови @nitra/cursor`
|
|
47
|
-
)
|
|
45
|
+
reporter.fail(`.cargo/mutants.toml canonical baseline не знайдено (${BASELINE_PATH}) — перевстанови @nitra/cursor`)
|
|
48
46
|
return reporter.getExitCode()
|
|
49
47
|
}
|
|
50
48
|
|
|
@@ -49,9 +49,7 @@ export async function check() {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
if (!existsSync(BASELINE_PATH)) {
|
|
52
|
-
reporter.fail(
|
|
53
|
-
`stryker.config.mjs canonical baseline не знайдено (${BASELINE_PATH}) — перевстанови @nitra/cursor`
|
|
54
|
-
)
|
|
52
|
+
reporter.fail(`stryker.config.mjs canonical baseline не знайдено (${BASELINE_PATH}) — перевстанови @nitra/cursor`)
|
|
55
53
|
return reporter.getExitCode()
|
|
56
54
|
}
|
|
57
55
|
|
package/scripts/coverage-fix.mjs
CHANGED
|
@@ -39,7 +39,7 @@ export async function fixSurvivedMutants(survived, projectRoot) {
|
|
|
39
39
|
options: {
|
|
40
40
|
cwd: projectRoot,
|
|
41
41
|
maxTurns: 20,
|
|
42
|
-
allowedTools: ['Read', 'Edit', 'Bash']
|
|
42
|
+
allowedTools: ['Read', 'Edit', 'Bash']
|
|
43
43
|
}
|
|
44
44
|
})) {
|
|
45
45
|
if (msg.type === 'text') process.stdout.write(msg.text)
|
|
@@ -50,9 +50,9 @@ export async function fixSurvivedMutants(survived, projectRoot) {
|
|
|
50
50
|
/**
|
|
51
51
|
* Формує rich-промпт для агента: список вижилих мутантів згрупований по файлах,
|
|
52
52
|
* з контекстом ±3 рядки навколо кожного мутанта з source-файлу.
|
|
53
|
-
* @param {SurvivedFileGroup[]} survived
|
|
54
|
-
* @param {string} projectRoot
|
|
55
|
-
* @returns {Promise<string>}
|
|
53
|
+
* @param {SurvivedFileGroup[]} survived групи вижилих мутантів по файлах
|
|
54
|
+
* @param {string} projectRoot корінь проєкту
|
|
55
|
+
* @returns {Promise<string>} текст rich-промпту
|
|
56
56
|
*/
|
|
57
57
|
async function buildFixPrompt(survived, projectRoot) {
|
|
58
58
|
const sections = []
|
|
@@ -60,25 +60,30 @@ async function buildFixPrompt(survived, projectRoot) {
|
|
|
60
60
|
for (const { file, mutants, exampleTest } of survived) {
|
|
61
61
|
let srcLines = []
|
|
62
62
|
try {
|
|
63
|
-
|
|
63
|
+
const src = await readFile(join(projectRoot, file), 'utf8')
|
|
64
|
+
srcLines = src.split('\n')
|
|
64
65
|
} catch {
|
|
65
66
|
// файл може бути недоступним — пропускаємо контекст, але продовжуємо
|
|
66
67
|
}
|
|
67
68
|
|
|
68
|
-
const mutantDescs = mutants
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
69
|
+
const mutantDescs = mutants
|
|
70
|
+
.map(m => {
|
|
71
|
+
const ctxStart = Math.max(0, m.line - 4)
|
|
72
|
+
const ctxEnd = Math.min(srcLines.length, m.line + 3)
|
|
73
|
+
const context = srcLines
|
|
74
|
+
.slice(ctxStart, ctxEnd)
|
|
75
|
+
.map((l, i) => `${ctxStart + i + 1}: ${l}`)
|
|
76
|
+
.join('\n')
|
|
77
|
+
return [
|
|
78
|
+
` - Рядок ${m.line}, колонка ${m.col}, тип мутації \`${m.mutantType}\``,
|
|
79
|
+
` Оригінал: \`${m.original}\``,
|
|
80
|
+
` Вижив варіант: \`${m.replacement}\``,
|
|
81
|
+
context ? ` Контекст:\n\`\`\`\n${context}\n\`\`\`` : ''
|
|
82
|
+
]
|
|
83
|
+
.filter(Boolean)
|
|
84
|
+
.join('\n')
|
|
85
|
+
})
|
|
86
|
+
.join('\n')
|
|
82
87
|
|
|
83
88
|
const exampleSection = exampleTest?.code
|
|
84
89
|
? `\n\nПриклад тесту з \`${exampleTest.testFile}\`:\n\`\`\`js\n${exampleTest.code}\n\`\`\``
|
|
@@ -58,9 +58,6 @@ export function formatTimingSummary(title, timings) {
|
|
|
58
58
|
const failMark = ok ? '' : ' ❌'
|
|
59
59
|
lines.push(` ${id.padEnd(idWidth)} ${formatDurationMs(ms)}${failMark}`)
|
|
60
60
|
}
|
|
61
|
-
lines.push(
|
|
62
|
-
` ${RULER.repeat(idWidth + 2 + 6)}`,
|
|
63
|
-
` ${'total'.padEnd(idWidth)} ${formatDurationMs(totalMs)}`
|
|
64
|
-
)
|
|
61
|
+
lines.push(` ${RULER.repeat(idWidth + 2 + 6)}`, ` ${'total'.padEnd(idWidth)} ${formatDurationMs(totalMs)}`)
|
|
65
62
|
return `${lines.join('\n')}\n`
|
|
66
63
|
}
|
|
@@ -104,7 +104,7 @@ function extractFilePath(stdinJson) {
|
|
|
104
104
|
* Точка входу. Викликається з `bin/n-cursor.js` коли argv[0] === `post-tool-use-fix`.
|
|
105
105
|
* Параметри ін'єктовні для тестів: `stdinJson` обходить read від `process.stdin`,
|
|
106
106
|
* `spawnFn` — заміна `node:child_process.spawn` (повертає EventEmitter-сумісний об'єкт).
|
|
107
|
-
* @param {{ stdinJson?: string, spawnFn?: typeof spawn }} [options]
|
|
107
|
+
* @param {{ stdinJson?: string, spawnFn?: typeof spawn }} [options] параметри для тестів (ін'єкція stdin/spawn)
|
|
108
108
|
* @returns {Promise<number>} exit code (0 — пропущено / fix ОК; інше — exit-код `fix`)
|
|
109
109
|
*/
|
|
110
110
|
export async function runPostToolUseFixCli(options = {}) {
|
|
@@ -502,7 +502,6 @@ export async function syncPiExtensions(projectRoot, bundledPackageRoot) {
|
|
|
502
502
|
* Видаляє `.pi/extensions/n-cursor-adr/` директорію з проєкту-споживача.
|
|
503
503
|
* Викликається коли правило `adr` вимкнено у `.n-cursor.json` (симетрично до
|
|
504
504
|
* cleanup-у `.claude/hooks/{capture,normalize}-decisions.sh`).
|
|
505
|
-
*
|
|
506
505
|
* @param {string} projectRoot корінь проєкту-споживача
|
|
507
506
|
* @returns {Promise<{ removed: boolean, path: string }>} чи було щось видалено та відносний шлях
|
|
508
507
|
*/
|
|
@@ -32,10 +32,12 @@ description: >-
|
|
|
32
32
|
Прочитай `package.json` у кореневій директорії.
|
|
33
33
|
|
|
34
34
|
**test-команда** (перша що існує):
|
|
35
|
+
|
|
35
36
|
1. `scripts["test"]` з `package.json`
|
|
36
37
|
2. fallback: `bun test`
|
|
37
38
|
|
|
38
39
|
**coverage-команда** (перша що існує):
|
|
40
|
+
|
|
39
41
|
1. `scripts["coverage"]` з `package.json` → виклик: `bun run coverage`
|
|
40
42
|
2. fallback: `n-cursor coverage`
|
|
41
43
|
|
|
@@ -107,6 +109,7 @@ bun run coverage # або coverage-команда з кроку 2
|
|
|
107
109
|
`newCount = новий масив.length`
|
|
108
110
|
|
|
109
111
|
**Рішення:**
|
|
112
|
+
|
|
110
113
|
- Якщо `newCount < prevCount` → повтор з Кроку 1 з оновленим масивом
|
|
111
114
|
- Якщо `newCount >= prevCount` → зупинись:
|
|
112
115
|
`✓ Конвергенція: mutation score більше не покращується. Вижило: <newCount> мутантів.`
|