@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/.claude-template/settings.template.json +4 -4
- package/CHANGELOG.md +29 -0
- package/bin/n-cursor.js +11 -9
- package/package.json +3 -1
- package/rules/adr/adr.mdc +2 -12
- 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/scripts/post-tool-use-fix.mjs +129 -0
- package/scripts/sync-claude-config.mjs +23 -14
- package/skills/fix-tests/SKILL.md +109 -0
- package/skills/fix-tests/auto.md +1 -0
- package/scripts/claude-stop-hook.mjs +0 -74
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
|
+
}
|