@nitra/cursor 1.9.23 → 1.11.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.
Files changed (84) hide show
  1. package/.claude-template/hooks/capture-decisions.sh +3 -3
  2. package/.claude-template/hooks/normalize-decisions.sh +370 -0
  3. package/CHANGELOG.md +52 -0
  4. package/bin/n-cursor.js +30 -29
  5. package/package.json +2 -1
  6. package/rules/abie/js/applies/check.mjs +24 -0
  7. package/rules/abie/js/env_dns/check.mjs +53 -0
  8. package/rules/abie/js/firebase_hosting/check.mjs +49 -0
  9. package/rules/abie/js/hc_pairing/check.mjs +58 -0
  10. package/rules/abie/js/ua_http_route/check.mjs +86 -0
  11. package/rules/abie/js/ua_node_selector/check.mjs +65 -0
  12. package/rules/abie/policy/base_deployment_preem/target.json +10 -0
  13. package/rules/abie/policy/clean_merged_ignore_branches/target.json +4 -0
  14. package/rules/abie/policy/health_check_policy/target.json +4 -0
  15. package/rules/abie/policy/http_route_base/target.json +4 -0
  16. package/rules/abie/utils/enabled.mjs +35 -0
  17. package/rules/abie/utils/env-dns.mjs +81 -0
  18. package/rules/abie/utils/hc-yaml.mjs +27 -0
  19. package/rules/abie/utils/http-route.mjs +93 -0
  20. package/rules/abie/utils/k8s-tree.mjs +102 -0
  21. package/rules/abie/utils/kustomization-patches.mjs +224 -0
  22. package/rules/abie/utils/overlay-paths.mjs +97 -0
  23. package/rules/abie/utils/yaml.mjs +72 -0
  24. package/rules/adr/adr.mdc +82 -18
  25. package/rules/adr/js/check.mjs +84 -40
  26. package/rules/adr/policy/settings_json/settings_json.rego +17 -11
  27. package/rules/adr/policy/settings_json/target.json +4 -0
  28. package/rules/adr/policy/settings_local_json/settings_local_json.rego +24 -12
  29. package/rules/adr/policy/settings_local_json/target.json +4 -0
  30. package/rules/bun/policy/bunfig/target.json +4 -0
  31. package/rules/bun/policy/package_json/target.json +4 -0
  32. package/rules/capacitor/policy/package_json/target.json +4 -0
  33. package/rules/docker/policy/lint_docker_yml/target.json +4 -0
  34. package/rules/docker/policy/package_json/target.json +4 -0
  35. package/rules/hasura/policy/svc_hl/target.json +4 -0
  36. package/rules/image-avif/policy/package_json/target.json +4 -0
  37. package/rules/image-compress/policy/package_json/target.json +4 -0
  38. package/rules/js-bun-db/policy/package_json/target.json +4 -0
  39. package/rules/js-bun-redis/policy/package_json/target.json +4 -0
  40. package/rules/js-lint/policy/lint_js_yml/target.json +4 -0
  41. package/rules/js-lint/policy/package_json/target.json +4 -0
  42. package/rules/js-mssql/policy/package_json/target.json +4 -0
  43. package/rules/js-run/policy/configmap/target.json +4 -0
  44. package/rules/js-run/policy/package_json/target.json +4 -0
  45. package/rules/k8s/policy/base_kustomization/target.json +4 -0
  46. package/rules/k8s/policy/base_manifest/target.json +10 -0
  47. package/rules/k8s/policy/gateway/target.json +4 -0
  48. package/rules/k8s/policy/hpa_pdb/target.json +4 -0
  49. package/rules/k8s/policy/kustomization/target.json +4 -0
  50. package/rules/k8s/policy/manifest/target.json +4 -0
  51. package/rules/k8s/policy/svc_hl_yaml/target.json +4 -0
  52. package/rules/k8s/policy/svc_yaml/target.json +4 -0
  53. package/rules/npm-module/policy/emit_types_config/target.json +4 -0
  54. package/rules/npm-module/policy/npm_package_json/target.json +4 -0
  55. package/rules/npm-module/policy/npm_publish_yml/target.json +4 -0
  56. package/rules/npm-module/policy/root_package_json/target.json +4 -0
  57. package/rules/php/policy/lint_php_yml/target.json +4 -0
  58. package/rules/php/policy/package_json/target.json +4 -0
  59. package/rules/rego/js/applies/check.mjs +54 -0
  60. package/rules/rego/policy/package_json/target.json +5 -0
  61. package/rules/rego/policy/vscode_extensions/target.json +5 -0
  62. package/rules/rego/policy/vscode_settings/target.json +5 -0
  63. package/rules/style-lint/policy/lint_style_yml/target.json +4 -0
  64. package/rules/style-lint/policy/package_json/target.json +4 -0
  65. package/rules/style-lint/policy/vscode_extensions/target.json +4 -0
  66. package/rules/style-lint/policy/vscode_settings/target.json +4 -0
  67. package/rules/text/policy/cspell/target.json +4 -0
  68. package/rules/text/policy/markdownlint/target.json +4 -0
  69. package/rules/text/policy/oxfmtrc/target.json +4 -0
  70. package/rules/text/policy/package_json/target.json +4 -0
  71. package/rules/text/policy/vscode_extensions/target.json +4 -0
  72. package/rules/text/policy/vscode_settings/target.json +4 -0
  73. package/rules/vue/policy/package_json/target.json +4 -0
  74. package/schemas/target.json +58 -0
  75. package/scripts/auto-skills.mjs +2 -0
  76. package/scripts/lint-conftest.mjs +65 -414
  77. package/scripts/sync-claude-config.mjs +70 -14
  78. package/scripts/utils/discover-checkable-rules.mjs +123 -0
  79. package/scripts/utils/resolve-target-files.mjs +109 -0
  80. package/scripts/utils/run-rule.mjs +131 -0
  81. package/skills/adr-normalize/SKILL.md +71 -0
  82. package/skills/adr-normalize/auto.md +1 -0
  83. package/rules/abie/js/check.mjs +0 -1152
  84. package/rules/rego/js/check.mjs +0 -106
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Обхід k8s-дерева abie з кешуванням на час одного прогону:
3
+ * - `findK8sYamlFiles(root, ignorePaths)` — yaml/yml файли під сегментом `k8s/`.
4
+ * - `collectDeploymentDirs(root, yamlAbs)` — каталоги, де знайдено `kind: Deployment`.
5
+ *
6
+ * Кеш — module-level singleton, ключований за `(root, ignorePaths)`. Перший виклик
7
+ * платить за обхід; наступні концерни в межах того ж прогону отримують готове.
8
+ * Для тестів — `resetAbieK8sTreeCache()` (інакше withTmpCwd-фікстури злипатимуться).
9
+ */
10
+ import { dirname, relative } from 'node:path'
11
+
12
+ import { pathHasK8sSegment } from '../../k8s/js/check.mjs'
13
+ import { walkDir } from '../../../scripts/utils/walkDir.mjs'
14
+ import { isDeploymentDoc, readAndParseYamlDocs } from './yaml.mjs'
15
+
16
+ const YAML_EXTENSION_RE = /\.ya?ml$/iu
17
+
18
+ /** @type {Map<string, Promise<string[]>>} */
19
+ const yamlCache = new Map()
20
+ /** @type {Map<string, Promise<Set<string>>>} */
21
+ const deploymentCache = new Map()
22
+
23
+ /**
24
+ * Скидає кеш — тести мусять викликати між фікстурами.
25
+ * @returns {void}
26
+ */
27
+ export function resetAbieK8sTreeCache() {
28
+ yamlCache.clear()
29
+ deploymentCache.clear()
30
+ }
31
+
32
+ /**
33
+ * Стабільний ключ кешу за (root, ignorePaths).
34
+ * @param {string} root
35
+ * @param {string[]} ignorePaths
36
+ * @returns {string}
37
+ */
38
+ function cacheKey(root, ignorePaths) {
39
+ return `${root}|${[...ignorePaths].toSorted((a, b) => a.localeCompare(b)).join(':')}`
40
+ }
41
+
42
+ /**
43
+ * Збирає абсолютні шляхи до `.yaml`/`.yml` під деревом, де є сегмент `k8s/`.
44
+ * Каталог `.github/` свідомо пропускається (належить `ga.mdc`).
45
+ * @param {string} root корінь репозиторію
46
+ * @param {string[]} [ignorePaths] абсолютні шляхи каталогів-виключень
47
+ * @returns {Promise<string[]>}
48
+ */
49
+ export function findK8sYamlFiles(root, ignorePaths = []) {
50
+ const key = cacheKey(root, ignorePaths)
51
+ const cached = yamlCache.get(key)
52
+ if (cached) return cached
53
+ const promise = (async () => {
54
+ /** @type {string[]} */
55
+ const out = []
56
+ await walkDir(
57
+ root,
58
+ p => {
59
+ const rel = relative(root, p).replaceAll('\\', '/')
60
+ if (rel.startsWith('.github/')) return
61
+ if (!pathHasK8sSegment(p, root)) return
62
+ if (!YAML_EXTENSION_RE.test(p)) return
63
+ out.push(p)
64
+ },
65
+ ignorePaths
66
+ )
67
+ return [...out].toSorted((a, b) => a.localeCompare(b))
68
+ })()
69
+ yamlCache.set(key, promise)
70
+ return promise
71
+ }
72
+
73
+ /**
74
+ * Каталоги, де є хоча б один `kind: Deployment` у YAML під `k8s/`.
75
+ * @param {string} root корінь репозиторію
76
+ * @param {string[]} yamlAbs абсолютні шляхи `.yaml`/`.yml` під `k8s/` (як з `findK8sYamlFiles`)
77
+ * @param {(msg: string) => void} [fail] репортер помилок парсингу (опц.)
78
+ * @returns {Promise<Set<string>>} абсолютні шляхи директорій
79
+ */
80
+ export function collectDeploymentDirs(root, yamlAbs, fail = () => {}) {
81
+ const key = `${root}|${[...yamlAbs].toSorted((a, b) => a.localeCompare(b)).join(':')}`
82
+ const cached = deploymentCache.get(key)
83
+ if (cached) return cached
84
+ const promise = (async () => {
85
+ /** @type {Set<string>} */
86
+ const dirs = new Set()
87
+ for (const abs of yamlAbs) {
88
+ const rel = relative(root, abs).replaceAll('\\', '/') || abs
89
+ const docs = await readAndParseYamlDocs(abs, rel, fail)
90
+ if (docs) {
91
+ for (const doc of docs) {
92
+ if (doc.errors.length === 0 && isDeploymentDoc(doc.toJSON())) {
93
+ dirs.add(dirname(abs))
94
+ }
95
+ }
96
+ }
97
+ }
98
+ return dirs
99
+ })()
100
+ deploymentCache.set(key, promise)
101
+ return promise
102
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Парсинг inline JSON6902-патчів у abie ua-kustomization:
3
+ * - **nodeSelector** patch на `Deployment` (preem: false);
4
+ * - **HTTPRoute** patch (hostnames, parentRefs namespace, backendRefs namespace).
5
+ *
6
+ * Regex використовуються, бо `patch:` — це YAML-string з вкладеним JSON6902, який ми не парсимо
7
+ * вдруге; підрядки на кшталт `path: /spec/hostnames` і `value: ua` достатньо інформативні.
8
+ */
9
+ import { parseAllDocuments } from 'yaml'
10
+
11
+ import { LINE_SPLIT_RE, MODELINE_RE, stripBom } from './yaml.mjs'
12
+
13
+ const PATCH_NODE_SELECTOR_PATH_RE = /path:\s*\/spec\/template\/spec\/nodeSelector\b/u
14
+ const PATCH_PREEM_FALSE_RE = /\bpreem:\s*['"]?false['"]?\b/u
15
+ const PATCH_HOSTNAMES_PATH_RE = /path:\s*\/spec\/hostnames\b/mu
16
+ // Overlay namespaces: дозволено `ua` і `ua-*` (наприклад `ua-b2b`).
17
+ const PATCH_PARENT_REF_NS_UA_RE =
18
+ /path:\s*\/spec\/parentRefs\/0\/namespace\b[\s\S]{0,200}?value:\s*['"]?ua(?:-[a-z0-9][a-z0-9-]*)?['"]?(?:\s|$)/imu
19
+
20
+ /** Домени `hostnames` для overlay `ua` (підрядки у JSON6902-тексті patch). */
21
+ const ABIE_UA_HTTPROUTE_HOST_MARKERS = ['abie.app', 'vybeerai.com.ua', '*.abie.app', '*.vybeerai.com.ua']
22
+
23
+ // ── nodeSelector (ua) ─────────────────────────────────────────────────────
24
+
25
+ /**
26
+ * Чи patch-рядок містить очікуваний ua nodeSelector (preem: false).
27
+ * @param {string} patchText
28
+ * @returns {boolean}
29
+ */
30
+ function jsonPatchTextHasUaDeploymentNodeSelector(patchText) {
31
+ if (typeof patchText !== 'string' || patchText.trim() === '') return false
32
+ if (!PATCH_NODE_SELECTOR_PATH_RE.test(patchText)) return false
33
+ if (!PATCH_PREEM_FALSE_RE.test(patchText)) return false
34
+ return true
35
+ }
36
+
37
+ /**
38
+ * Чи один елемент `patches` відповідає abie nodeSelector для `mode`.
39
+ * @param {unknown} p
40
+ * @param {'ua'} mode
41
+ * @returns {boolean}
42
+ */
43
+ function inlineKustomizationPatchMatchesAbieMode(p, mode) {
44
+ if (p === null || typeof p !== 'object' || Array.isArray(p)) return false
45
+ const pr = /** @type {Record<string, unknown>} */ (p)
46
+ const target = pr.target
47
+ if (target === null || typeof target !== 'object' || Array.isArray(target)) return false
48
+ const tg = /** @type {Record<string, unknown>} */ (target)
49
+ if (tg.kind !== 'Deployment') return false
50
+ const patchStr = pr.patch
51
+ if (typeof patchStr !== 'string') return false
52
+ if (mode === 'ua' && jsonPatchTextHasUaDeploymentNodeSelector(patchStr)) return true
53
+ return false
54
+ }
55
+
56
+ /**
57
+ * Чи документ Kustomization містить відповідний inline patch на Deployment.
58
+ * @param {import('yaml').Document} doc
59
+ * @param {'ua'} mode
60
+ * @returns {boolean}
61
+ */
62
+ function kustomizationDocumentHasAbieDeploymentNodeSelectorPatch(doc, mode) {
63
+ if (doc.errors.length > 0) return false
64
+ const root = doc.toJSON()
65
+ if (root === null || typeof root !== 'object' || Array.isArray(root)) return false
66
+ const rec = /** @type {Record<string, unknown>} */ (root)
67
+ if (rec.kind !== 'Kustomization') return false
68
+ const patches = rec.patches
69
+ if (!Array.isArray(patches)) return false
70
+ for (const p of patches) {
71
+ if (inlineKustomizationPatchMatchesAbieMode(p, mode)) return true
72
+ }
73
+ return false
74
+ }
75
+
76
+ /**
77
+ * Чи `kustomization.yaml` містить валідні inline patch для Deployment nodeSelector (ua).
78
+ * @param {string} raw повний текст файла
79
+ * @param {'ua'} mode
80
+ * @returns {boolean}
81
+ */
82
+ export function kustomizationHasAbieDeploymentNodeSelectorPatch(raw, mode) {
83
+ const body = stripBom(raw)
84
+ const lines = body.split(LINE_SPLIT_RE)
85
+ const first = lines[0] ?? ''
86
+ const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
87
+ /** @type {import('yaml').Document[]} */
88
+ let docs
89
+ try {
90
+ docs = parseAllDocuments(rest)
91
+ } catch {
92
+ return false
93
+ }
94
+ for (const doc of docs) {
95
+ if (kustomizationDocumentHasAbieDeploymentNodeSelectorPatch(doc, mode)) return true
96
+ }
97
+ return false
98
+ }
99
+
100
+ // ── HTTPRoute (ua) ────────────────────────────────────────────────────────
101
+
102
+ /**
103
+ * @param {unknown} p
104
+ * @returns {string | null}
105
+ */
106
+ function extractHttpRoutePatchString(p) {
107
+ if (p === null || typeof p !== 'object' || Array.isArray(p)) return null
108
+ const pr = /** @type {Record<string, unknown>} */ (p)
109
+ const target = pr.target
110
+ if (target === null || typeof target !== 'object' || Array.isArray(target)) return null
111
+ const tg = /** @type {Record<string, unknown>} */ (target)
112
+ if (tg.kind !== 'HTTPRoute' || typeof tg.name !== 'string' || tg.name.trim() === '') return null
113
+ const patchStr = pr.patch
114
+ return typeof patchStr === 'string' && patchStr.trim() !== '' ? patchStr : null
115
+ }
116
+
117
+ /**
118
+ * Збирає inline `patch`-рядки для HTTPRoute (непорожній `target.name`) з одного Kustomization-документа.
119
+ * @param {import('yaml').Document} doc
120
+ * @returns {string[]}
121
+ */
122
+ function collectAbieHttpRoutePatchStringsFromKustomizationDoc(doc) {
123
+ if (doc.errors.length > 0) return []
124
+ const root = doc.toJSON()
125
+ if (root === null || typeof root !== 'object' || Array.isArray(root)) return []
126
+ const rec = /** @type {Record<string, unknown>} */ (root)
127
+ if (rec.kind !== 'Kustomization' || !Array.isArray(rec.patches)) return []
128
+ /** @type {string[]} */
129
+ const out = []
130
+ for (const p of rec.patches) {
131
+ const s = extractHttpRoutePatchString(p)
132
+ if (s !== null) out.push(s)
133
+ }
134
+ return out
135
+ }
136
+
137
+ /**
138
+ * Збирає всі inline JSON6902-фрагменти HTTPRoute (непорожній `target.name`) у kustomization.yaml.
139
+ * @param {string} raw повний текст файла
140
+ * @returns {string}
141
+ */
142
+ export function getCombinedNginxRunPatchTextFromKustomization(raw) {
143
+ const body = stripBom(raw)
144
+ const lines = body.split(LINE_SPLIT_RE)
145
+ const first = lines[0] ?? ''
146
+ const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
147
+ /** @type {import('yaml').Document[]} */
148
+ let docs
149
+ try {
150
+ docs = parseAllDocuments(rest)
151
+ } catch {
152
+ return ''
153
+ }
154
+ /** @type {string[]} */
155
+ const chunks = []
156
+ for (const doc of docs) {
157
+ chunks.push(...collectAbieHttpRoutePatchStringsFromKustomizationDoc(doc))
158
+ }
159
+ return chunks.join('\n')
160
+ }
161
+
162
+ /**
163
+ * Рахує операції JSON6902 з `path: /spec/rules/.../backendRefs/.../namespace` і `value: ua[-…]`.
164
+ * @param {string} combined сукупний текст patch
165
+ * @param {'ua'} mode
166
+ * @returns {number}
167
+ */
168
+ function countAbieHttpRouteBackendRefNamespacePatchesInCombined(combined, mode) {
169
+ if (mode !== 'ua') return 0
170
+ const re =
171
+ /path:\s*\/spec\/rules\/\d+\/backendRefs\/\d+\/namespace\b[\s\S]{0,200}?value:\s*['"]?ua(?:-[a-z0-9][a-z0-9-]*)?['"]?(?:\s|$)/gimu
172
+ return [...combined.matchAll(re)].length
173
+ }
174
+
175
+ /**
176
+ * Перевіряє сукупний текст patch(ів) HTTPRoute на відповідність abie.mdc.
177
+ * @param {string} combined сукупний текст patch
178
+ * @param {'ua'} mode
179
+ * @param {string} [_fullKustomizationRaw] зберігається для API-сумісності, не використовується
180
+ * @param {number} [sharedCrossNsBackendRefCount] кількість `auth-run-hl`/`file-link-hl` у base HTTPRoute
181
+ * @returns {string | null} повідомлення про помилку або null
182
+ */
183
+ export function validateAbieNginxRunHttpRoutePatches(
184
+ combined,
185
+ mode,
186
+ _fullKustomizationRaw,
187
+ sharedCrossNsBackendRefCount = 0
188
+ ) {
189
+ if (typeof combined !== 'string' || combined.trim() === '') {
190
+ return `очікується patch target kind HTTPRoute з непорожнім target.name (hostnames, parentRefs namespace ${mode}) — abie.mdc`
191
+ }
192
+ if (!PATCH_HOSTNAMES_PATH_RE.test(combined)) {
193
+ return 'HTTPRoute: потрібен path /spec/hostnames у patch (abie.mdc)'
194
+ }
195
+ const markers = ABIE_UA_HTTPROUTE_HOST_MARKERS
196
+ if (!markers.some(m => combined.includes(m))) {
197
+ return `HTTPRoute: у value для /spec/hostnames має бути один із доменів abie (${markers.join(', ')}) — abie.mdc`
198
+ }
199
+ if (!PATCH_PARENT_REF_NS_UA_RE.test(combined)) {
200
+ return `HTTPRoute: потрібен path /spec/parentRefs/0/namespace з value ${mode} (abie.mdc)`
201
+ }
202
+ const sharedCount =
203
+ typeof sharedCrossNsBackendRefCount === 'number' && Number.isFinite(sharedCrossNsBackendRefCount)
204
+ ? Math.max(0, Math.floor(sharedCrossNsBackendRefCount))
205
+ : 0
206
+ if (sharedCount > 0) {
207
+ const patchHits = countAbieHttpRouteBackendRefNamespacePatchesInCombined(combined, mode)
208
+ if (patchHits < sharedCount) {
209
+ return `HTTPRoute: для backendRefs до спільних сервісів auth-run-hl, file-link-hl очікується ${sharedCount} JSON6902 patch(ів) з path /spec/rules/…/backendRefs/…/namespace та value ${mode} (зараз ${patchHits}) — abie.mdc`
210
+ }
211
+ }
212
+ return null
213
+ }
214
+
215
+ /**
216
+ * Чи kustomization містить валідні patch для HTTPRoute (ua).
217
+ * @param {string} raw повний текст kustomization.yaml
218
+ * @param {'ua'} mode
219
+ * @returns {boolean}
220
+ */
221
+ export function kustomizationHasAbieNginxRunHttpRoutePatch(raw, mode) {
222
+ const combined = getCombinedNginxRunPatchTextFromKustomization(raw)
223
+ return validateAbieNginxRunHttpRoutePatches(combined, mode, raw) === null
224
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Path-хелпери для overlay-перевірок abie:
3
+ * - класифікація шляхів (`isUaKustomizationPath`, `isAbieK8sBaseYamlPath`),
4
+ * - вилучення каталогу пакета з overlay-шляху (`abiePackageDirFromK8sOverlay`),
5
+ * - умовний gate для HTTPRoute через наявність `vite.config.*` (`abieOverlayRequiresHttpRouteByVite`),
6
+ * - перевірка наявності `Deployment` у дереві пакета (`abieOverlayK8sTreeHasDeployment`),
7
+ * - чи yaml належить base-шару пакета (`isK8sYamlInAbiePackageExcludingUaOverlay`).
8
+ */
9
+ import { existsSync } from 'node:fs'
10
+ import { join, relative } from 'node:path'
11
+
12
+ const UA_KUSTOMIZATION_PATH_RE = /(^|\/)ua\/kustomization\.yaml$/u
13
+ const OVERLAY_PACKAGE_DIR_RE = /^(.+)\/k8s\/ua\/kustomization\.yaml$/u
14
+ const BASE_SEGMENT_RE = /(^|\/)base\//u
15
+ const TRAILING_SLASH_RE = /\/$/u
16
+
17
+ /**
18
+ * Чи `rel` — це `…/ua/kustomization.yaml` (abie overlay).
19
+ * @param {string} rel посі від кореня репозиторію
20
+ * @returns {boolean}
21
+ */
22
+ export function isUaKustomizationPath(rel) {
23
+ const norm = rel.replaceAll('\\', '/')
24
+ return UA_KUSTOMIZATION_PATH_RE.test(norm)
25
+ }
26
+
27
+ /**
28
+ * Каталог пакета (батько `k8s/`) для overlay `…/k8s/ua/kustomization.yaml`.
29
+ * @param {string} root корінь репозиторію
30
+ * @param {string} kustomizationAbs абсолютний шлях до ua kustomization
31
+ * @returns {string | null}
32
+ */
33
+ export function abiePackageDirFromK8sOverlay(root, kustomizationAbs) {
34
+ const rel = relative(root, kustomizationAbs).replaceAll('\\', '/') || kustomizationAbs
35
+ const m = rel.match(OVERLAY_PACKAGE_DIR_RE)
36
+ return m ? join(root, m[1]) : null
37
+ }
38
+
39
+ /**
40
+ * Чи у каталозі пакета (батько `k8s/`) є `vite.config.{js,mjs,ts}` — HTTPRoute-вимога abie
41
+ * застосовується лише до Vite-пакетів.
42
+ * @param {string} root корінь репозиторію
43
+ * @param {string} kustomizationAbs абсолютний шлях до ua kustomization
44
+ * @returns {boolean}
45
+ */
46
+ export function abieOverlayRequiresHttpRouteByVite(root, kustomizationAbs) {
47
+ const pkg = abiePackageDirFromK8sOverlay(root, kustomizationAbs)
48
+ if (!pkg) return false
49
+ return (
50
+ existsSync(join(pkg, 'vite.config.js')) ||
51
+ existsSync(join(pkg, 'vite.config.mjs')) ||
52
+ existsSync(join(pkg, 'vite.config.ts'))
53
+ )
54
+ }
55
+
56
+ /**
57
+ * Чи у дереві `k8s/` пакета є `Deployment` (за каталогами з `collectDeploymentDirs`).
58
+ * @param {Set<string>} deploymentDirs абсолютні каталоги з Deployment
59
+ * @param {string} root корінь репозиторію
60
+ * @param {string} kustomizationAbs абсолютний шлях до ua kustomization
61
+ * @returns {boolean}
62
+ */
63
+ export function abieOverlayK8sTreeHasDeployment(deploymentDirs, root, kustomizationAbs) {
64
+ const pkg = abiePackageDirFromK8sOverlay(root, kustomizationAbs)
65
+ if (!pkg) return false
66
+ const k8sRoot = join(pkg, 'k8s').replaceAll('\\', '/')
67
+ for (const dir of deploymentDirs) {
68
+ const norm = dir.replaceAll('\\', '/')
69
+ if (norm === k8sRoot || norm.startsWith(`${k8sRoot}/`)) return true
70
+ }
71
+ return false
72
+ }
73
+
74
+ /**
75
+ * Чи rel-шлях `…/k8s/base/…` (base-шар abie, не overlay).
76
+ * @param {string} rel
77
+ * @returns {boolean}
78
+ */
79
+ export function isAbieK8sBaseYamlPath(rel) {
80
+ const norm = rel.replaceAll('\\', '/')
81
+ return BASE_SEGMENT_RE.test(norm)
82
+ }
83
+
84
+ /**
85
+ * Чи yaml належить до `<pkgRel>/k8s/**` поза `ua/` піддеревом (base-шар abie).
86
+ * @param {string} relFromRoot шлях від кореня
87
+ * @param {string} pkgRelFromRoot каталог пакета від кореня
88
+ * @returns {boolean}
89
+ */
90
+ export function isK8sYamlInAbiePackageExcludingUaOverlay(relFromRoot, pkgRelFromRoot) {
91
+ const normRel = relFromRoot.replaceAll('\\', '/')
92
+ const pkg = pkgRelFromRoot.replaceAll('\\', '/').replace(TRAILING_SLASH_RE, '')
93
+ const prefix = `${pkg}/k8s/`
94
+ if (!normRel.startsWith(prefix)) return false
95
+ const after = normRel.slice(prefix.length)
96
+ return !after.startsWith('ua/')
97
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Спільні YAML-хелпери для abie-перевірок: парсинг документів з опційним modeline,
3
+ * BOM-strip, regex для `# yaml-language-server: $schema=` і поділу на рядки.
4
+ */
5
+ import { readFile } from 'node:fs/promises'
6
+
7
+ import { parseAllDocuments } from 'yaml'
8
+
9
+ export const MODELINE_RE = /^#\s*yaml-language-server:\s*\$schema=(\S+)\s*$/
10
+ export const LINE_SPLIT_RE = /\r?\n/u
11
+
12
+ /**
13
+ * Прибирає BOM на початку файлу.
14
+ * @param {string} s вміст
15
+ * @returns {string}
16
+ */
17
+ export function stripBom(s) {
18
+ return s.startsWith('') ? s.slice(1) : s
19
+ }
20
+
21
+ /**
22
+ * Чи YAML-документ — це `kind: Deployment`.
23
+ * @param {unknown} obj корінь YAML-документа
24
+ * @returns {boolean}
25
+ */
26
+ export function isDeploymentDoc(obj) {
27
+ return (
28
+ obj !== null &&
29
+ typeof obj === 'object' &&
30
+ !Array.isArray(obj) &&
31
+ /** @type {Record<string, unknown>} */ (obj).kind === 'Deployment'
32
+ )
33
+ }
34
+
35
+ /**
36
+ * No-op fail-handler для функцій, що мовчки повертають null/[] при помилці парсингу.
37
+ * @param {string} _msg ігнорується
38
+ */
39
+ export const silentFail = _msg => {
40
+ /* silent — пошкоджені файли ловить check-k8s */
41
+ }
42
+
43
+ /**
44
+ * Зчитує і парсить YAML-документи з файлу. BOM і modeline (перший рядок `$schema`)
45
+ * автоматично прибираються перед `parseAllDocuments`. При помилці читання/парсингу
46
+ * викликає `failFn` і повертає `null`.
47
+ * @param {string} abs абсолютний шлях
48
+ * @param {string} rel відносний (для повідомлень)
49
+ * @param {(msg: string) => void} failFn
50
+ * @returns {Promise<import('yaml').Document[] | null>}
51
+ */
52
+ export async function readAndParseYamlDocs(abs, rel, failFn) {
53
+ let raw
54
+ try {
55
+ raw = await readFile(abs, 'utf8')
56
+ } catch (error) {
57
+ const msg = error instanceof Error ? error.message : String(error)
58
+ failFn(`${rel}: не вдалося прочитати (${msg})`)
59
+ return null
60
+ }
61
+ const body = stripBom(raw)
62
+ const lines = body.split(LINE_SPLIT_RE)
63
+ const first = lines[0] ?? ''
64
+ const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
65
+ try {
66
+ return parseAllDocuments(rest)
67
+ } catch (error) {
68
+ const msg = error instanceof Error ? error.message : String(error)
69
+ failFn(`${rel}: YAML (${msg})`)
70
+ return null
71
+ }
72
+ }
package/rules/adr/adr.mdc CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- description: Автоматичний збір ADR/Runbook/Knowledge-чернеток із Stop-хука Claude Code (capture-decisions)
2
+ description: Автоматичний збір ADR/Runbook/Knowledge-чернеток і батч-нормалізація у `docs/adr/` через Stop-хук Claude Code
3
3
  alwaysApply: true
4
- version: '1.0'
4
+ version: '2.0'
5
5
  ---
6
6
 
7
7
  Правило вмикається **вручну** — додай `"adr"` у масив `rules` файлу `.n-cursor.json`. У `auto-rules.md` його немає, бо корисність залежить від робочого процесу команди (не кожен проєкт хоче літати ADR-чернеткам у `docs/`).
@@ -9,37 +9,90 @@ version: '1.0'
9
9
  Коли правило увімкнене, **`npx @nitra/cursor`** автоматично:
10
10
 
11
11
  - копіює канонічний bash-скрипт у **`.claude/hooks/capture-decisions.sh`** (executable, повністю керується пакетом — на кожен sync перезаписується);
12
- - додає managed-групу у **`hooks.Stop`** в **`.claude/settings.json`**, яка викликає цей скрипт асинхронно (`async: true`, `timeout: 180`);
13
- - ці зміни додаткові до lint Stop-hook (`@nitra/cursor stop-hook`); обидві групи живуть поряд у `Stop`.
12
+ - копіює канонічний bash-скрипт у **`.claude/hooks/normalize-decisions.sh`** (батч-нормалізація чернеток через LLM);
13
+ - додає у **`hooks.Stop`** в **`.claude/settings.json`** дві managed-групи: capture (`async: true`, `timeout: 180`) і normalize (`async: true`, `timeout: 600`);
14
+ - ці зміни — додаткові до lint Stop-hook (`@nitra/cursor stop-hook`); усі три групи живуть поряд у `Stop`.
14
15
 
15
- ## Що робить механізм
16
+ ## Дві фази, один каталог
16
17
 
17
- Stop-hook Claude Code зчитує JSONL-транскрипт сесії (через `jq`), витягає текст, `thinking`-блоки та назви `tool_use`-викликів, передає компактний дайджест у LLM CLI з промптом українською і записує результат у **`docs/adr/_inbox/<timestamp>-<session>.md`**, якщо модель повернула блок з шапкою `## ADR|Runbook|Knowledge …`. Якщо модель повернула `NONE` (тривіальна сесія) — нічого не пишеться. Рекурсію з внутрішнього виклику моделі блокує env-var `CAPTURE_DECISIONS_RUNNING=1`.
18
+ ADR живуть у єдиному каталозі **`docs/adr/`**. Є два стани файлу, які відрізняються YAML frontmatter:
19
+
20
+ - **Draft** — файл з frontmatter `session: …`, `captured: …`, `transcript: …` та timestamp-іменем `YYYYMMDD-HHMMSS-<sid>.md`. Пише `capture-decisions.sh` після кожної сесії.
21
+ - **Clean** — файл без frontmatter, з kebab-case-іменем `<slug>.md` (наприклад `ланцюжок-запуску-abie.md`). Створює `normalize-decisions.sh` або людина руками.
22
+
23
+ `normalize-decisions.sh` ніколи не чіпає clean-файли — крім випадку `merge-into`, коли дописує `## Update YYYY-MM-DD` в кінець наявного clean-файлу.
24
+
25
+ ### Фаза 1 — Capture
26
+
27
+ Stop-hook `capture-decisions.sh` зчитує JSONL-транскрипт сесії (через `jq`), витягає текст, `thinking`-блоки та назви `tool_use`-викликів, передає компактний дайджест у LLM CLI з промптом українською і записує результат у **`docs/adr/<timestamp>-<session>.md`**, якщо модель повернула блок з шапкою `## ADR|Runbook|Knowledge …`. Якщо модель повернула `NONE` (тривіальна сесія) — нічого не пишеться. Рекурсію з внутрішнього виклику моделі блокує env-var `CAPTURE_DECISIONS_RUNNING=1`.
28
+
29
+ ### Фаза 2 — Normalize
30
+
31
+ Stop-hook `normalize-decisions.sh` спрацьовує на тому самому `Stop`-евенті, але:
32
+
33
+ - Виходить миттєво, якщо чернеток (`session:` у frontmatter) менше ніж **`ADR_NORMALIZE_THRESHOLD`** (default 30).
34
+ - Виходить миттєво, якщо від попередньої спроби пройшло менше **`ADR_NORMALIZE_MIN_INTERVAL_HOURS`** годин (default 6) — щоб не крутитися щоразу, коли поріг постійний.
35
+ - Бере не більше **`ADR_NORMALIZE_BATCH`** чернеток (default 30, найстарші за іменем-timestamp), формує один промпт LLM і чекає JSON-відповідь зі списком операцій.
36
+ - Виходить миттєво, якщо репозиторій у стані `MERGE_HEAD` / `rebase-*` — небезпечно правити файли посеред конфлікту.
37
+ - Виходить миттєво, якщо інший normalize-запуск тримає `flock` на `.claude/hooks/.normalize.lock` (тільки де `flock` доступний).
38
+
39
+ LLM повертає масив операцій:
40
+
41
+ | `op` | Семантика | Поля |
42
+ |---|---|---|
43
+ | `delete` | Чернетка тривіальна / повністю покрита іншим clean-ADR-ом. | `file`, `reason` |
44
+ | `rewrite` | Чернетка стає окремим clean-файлом: frontmatter знімається, ім'я → `<slug>.md`, додаються `**Status: Accepted**` і `**Date:**` з `captured`. | `file`, `slug`, `content` |
45
+ | `merge-into` | Чернетка повторює тему вже існуючого clean-файлу; дописуємо `## Update YYYY-MM-DD` у кінець `target`. | `file`, `target`, `additions` |
46
+
47
+ `slug` — kebab-case українською (`ланцюжок-запуску-abie`, `npm-publish-flow`); англійські технічні терміни лишаються англійською без транслітерації. Колізія slug-ів обробляється детермінованим суфіксом `-2`, `-3`.
48
+
49
+ ### Жодних git-операцій
50
+
51
+ `normalize-decisions.sh` **не комітить, не `git add`, нічого з git**. Усі зміни — у робочому дереві. Розробник у зручний момент дивиться `git status` / `git diff` і вирішує: `git add` + commit, `git checkout -- <file>` для відкату, або правки руками. Це і є review-вікно.
52
+
53
+ ### Recursion guard і ENV-керування
54
+
55
+ Інший LLM CLI, який запустить normalize, успадковує `ADR_NORMALIZE_RUNNING=1` — внутрішній Stop-hook вийде відразу. Доступні ENV:
56
+
57
+ | Змінна | Default | Призначення |
58
+ |---|---|---|
59
+ | `ADR_NORMALIZE_THRESHOLD` | `30` | Поріг чернеток для запуску фази. |
60
+ | `ADR_NORMALIZE_BATCH` | `30` | Максимум чернеток у одному виклику LLM. |
61
+ | `ADR_NORMALIZE_MIN_INTERVAL_HOURS` | `6` | Мінімум між спробами (навіть якщо поріг). |
62
+ | `ADR_NORMALIZE_DRY` | `0` | `1` — лише лог запланованих операцій, без змін на диску. |
63
+ | `ADR_NORMALIZE_MODEL` | `sonnet` | Модель для `claude -p`. |
64
+ | `ADR_NORMALIZE_CURSOR_MODEL` | `claude-4.6-sonnet-medium` | Модель для `cursor-agent -p`. |
65
+
66
+ Для ручного запуску (поза порогом і поза Stop-хуком) є **`/n-adr-normalize`** — slash-команда тимчасово виставляє `ADR_NORMALIZE_THRESHOLD=0` і `ADR_NORMALIZE_MIN_INTERVAL_HOURS=0` та викликає скрипт напряму.
18
67
 
19
68
  ## LLM CLI: claude → cursor-agent fallback
20
69
 
21
- Скрипт сам обирає доступний CLI (порядок фіксований):
70
+ Обидва скрипти обирають доступний CLI (порядок фіксований):
22
71
 
23
- 1. **`claude`** (Anthropic Claude Code CLI) — `claude -p --model "$CAPTURE_DECISIONS_CLAUDE_MODEL"` (default `sonnet`).
24
- 2. **`cursor-agent`** (Cursor IDE CLI) — `cursor-agent -p --mode ask --output-format text --model "$CAPTURE_DECISIONS_CURSOR_MODEL"` (default `claude-4.6-sonnet-medium`).
72
+ 1. **`claude`** (Anthropic Claude Code CLI) — `claude -p --model "<model>"`.
73
+ 2. **`cursor-agent`** (Cursor IDE CLI) — `cursor-agent -p --mode ask --output-format text --model "<model>"`.
25
74
  3. Жодного CLI у `PATH` — скрипт виходить з кодом `0` і нічого не пише.
26
75
 
27
- `--mode ask` для cursor-agent навмисно: режим Q&A read-only, без shell/edit-інструментів — для класифікації сесії інструменти не потрібні. Моделі можна перевизначити через ENV: `CAPTURE_DECISIONS_CLAUDE_MODEL`, `CAPTURE_DECISIONS_CURSOR_MODEL`.
76
+ `--mode ask` для cursor-agent навмисно: режим Q&A read-only, без shell/edit-інструментів — для класифікації / нормалізації інструменти не потрібні.
28
77
 
29
78
  ## Структура каталогу
30
79
 
31
80
  ```text
32
81
  docs/adr/
33
- └── _inbox/ # чернетки, що пишуться Stop-хуком
34
- └── YYYYMMDD-HHMMSS-<sid>.md # session_id (перші 8 символів)
82
+ ├── YYYYMMDD-HHMMSS-<sid>.md # drafts (frontmatter session:/captured:/transcript:)
83
+ └── <slug>.md # clean ADR-и (без frontmatter)
35
84
  .claude/hooks/
36
- ├── capture-decisions.sh # auto-synced з пакета
37
- └── capture-decisions.log # лог запусків (НЕ коміти)
85
+ ├── capture-decisions.sh # auto-synced з пакета
86
+ ├── normalize-decisions.sh # auto-synced з пакета
87
+ ├── capture-decisions.log # лог запусків capture (НЕ коміти)
88
+ ├── normalize-decisions.log # лог запусків normalize (НЕ коміти)
89
+ ├── .normalize-state # timestamp останнього normalize-запуску (НЕ коміти)
90
+ └── .normalize.lock # lock-файл (НЕ коміти)
38
91
  ```
39
92
 
40
- `.gitignore` повинен містити рядок **`.claude/hooks/capture-decisions.log`**, щоб лог не потрапляв у git.
93
+ `.gitignore` повинен містити рядки, що покривають **`.claude/hooks/*.log`** і службові файли normalize (`.claude/hooks/.normalize-*`).
41
94
 
42
- `docs/adr/_inbox/` це робоча скринька; чернетки звідти переносяться у структуровані ADR-файли вручну (під час оглядів) або тримаються як архів сесій. Каталог сам створюється скриптом, тому пустим у git тримати не потрібно.
95
+ > Якщо в репозиторії лишився старий каталог **`docs/adr/_inbox/`** з попередньої версії правила `normalize-decisions.sh` бачить його рекурсивно й поступово розчистить. Можна також одразу `git mv docs/adr/_inbox/*.md docs/adr/` і прибрати порожній каталог.
43
96
 
44
97
  ## Stop-hook у `.claude/settings.json`
45
98
 
@@ -69,17 +122,28 @@ docs/adr/
69
122
  "timeout": 180
70
123
  }
71
124
  ]
125
+ },
126
+ {
127
+ "matcher": "",
128
+ "hooks": [
129
+ {
130
+ "type": "command",
131
+ "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/normalize-decisions.sh\"",
132
+ "async": true,
133
+ "timeout": 600
134
+ }
135
+ ]
72
136
  }
73
137
  ]
74
138
  }
75
139
  }
76
140
  ```
77
141
 
78
- Обидві групи ідентифікуються пакетом за маркером у `command` (`@nitra/cursor stop-hook` і `.claude/hooks/capture-decisions.sh`) — користувацькі hook-групи поряд не чіпаються. Якщо `adr` прибрати з `rules`, ADR-група автоматично видаляється на наступному `npx @nitra/cursor`.
142
+ Усі три групи ідентифікуються пакетом за маркером у `command` (`@nitra/cursor stop-hook`, `.claude/hooks/capture-decisions.sh`, `.claude/hooks/normalize-decisions.sh`) — користувацькі hook-групи поряд не чіпаються. Якщо `adr` прибрати з `rules`, обидві ADR-групи автоматично видаляються на наступному `npx @nitra/cursor`.
79
143
 
80
144
  ## Локальні vs project-shared налаштування
81
145
 
82
- Stop-hook ADR живе у **project-shared** `.claude/settings.json` (закомічений), щоб механізм працював у всіх членів команди. Якщо хук колись був у `.claude/settings.local.json` — прибери дубль вручну: project-shared і local-копія створили б два запуски на одну подію.
146
+ Обидва Stop-hook ADR живуть у **project-shared** `.claude/settings.json` (закомічений), щоб механізм працював у всіх членів команди. Якщо хук колись був у `.claude/settings.local.json` — прибери дубль вручну: project-shared і local-копія створили б два запуски на одну подію.
83
147
 
84
148
  ## Перевірка
85
149