@nitra/cursor 1.19.2 → 1.21.0

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