@nitra/cursor 1.8.106 → 1.8.108

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/mdc/js-pino.mdc CHANGED
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  description: Використання @nitra/pino
3
3
  alwaysApply: true
4
- version: '1.0'
4
+ version: '1.1'
5
5
  ---
6
6
 
7
7
  Проект використовує @nitra/pino для логування.
8
- Якщо в проекті присутній @nitra/bunyan, то він повинен бути замінений на @nitra/pino.
8
+ Якщо в проекті присутній @nitra/bunyan, то він повинен бути замінений на @nitra/pino — як у `package.json`, так і в коді: усі `import` / `require` / динамічні `import()` з `@nitra/bunyan` (і застарілого `bunyan`) треба замінити на `@nitra/pino` і за потреби адаптувати виклики під його API.
9
9
 
10
10
  В **/k8s/base/configmap.yaml повинен бути заданий OTEL_RESOURCE_ATTRIBUTES: 'service.name=<project_name>,service.namespace=<project_namespace>'
11
11
  а в директоріях з kustomize повинні бути перевизначені значення OTEL_RESOURCE_ATTRIBUTES і в них service.namespace повинен відповідати namespace, в якому знаходиться дана директорія.
package/mdc/k8s.mdc CHANGED
@@ -110,7 +110,16 @@ resources: {}
110
110
 
111
111
  ### HTTPRoute для Deployment з `hasura/graphql-engine`
112
112
 
113
- Якщо для такого **Deployment** описано **`HTTPRoute`**, **`spec.rules`** мають відповідати канону нижче: редірект **`/ql`** та **`/ql/`** на **`/ql/console`** (**`302`**), правило з **`PathPrefix` `/ql`**, **`URLRewrite`** префікса на **`/`**, окреме правило для **WebSocket** (**`Upgrade: websocket`**) з **`RequestHeaderModifier`** (прибрати **`Authorization`** авторизація для WebSocket іде всередині messages), далі **`PathPrefix` `/`** на той самий backend.
113
+ **Прив'язка:** **check k8s** вважає **HTTPRoute** Hasura-маршрутом, якщо в **тому самому каталозі** є **`Deployment`** з образом **`hasura/graphql-engine`**, а його **`metadata.name`** збігається з **`metadata.name`** цього **`HTTPRoute`**. Саме за цією конвенцією скрипт шукає пари для звірки канону.
114
+
115
+ **Префікс параметризовано:** **`<prefix>`** — рядок перед **`/ql`** у першому Hasura-правилі (**`Exact <prefix>/ql`**). Може бути порожнім (**`<prefix>`** = **``**, шлях **`/ql`**) або непорожнім (наприклад **`<prefix>`** = **`/notify`**, шлях **`/notify/ql`**). Усі інші Hasura-правила цього **HTTPRoute** мають містити той самий **`<prefix>`**.
116
+
117
+ **Канон — 4 правила у цьому порядку** (додаткові правила поверх канону дозволені — вони просто ігноруються під час зіставлення):
118
+
119
+ 1. **`Exact <prefix>/ql`** → **`RequestRedirect`** **`ReplaceFullPath <prefix>/ql/console`** **`statusCode: 302`**.
120
+ 2. **`Exact <prefix>/ql/`** → те саме (редирект на **`<prefix>/ql/console`** 302).
121
+ 3. **`PathPrefix <prefix>/ql`** → **`URLRewrite`** **`ReplacePrefixMatch /`**, один **`backendRef`** на headless **Service** (**`-hl`**).
122
+ 4. **WebSocket:** **`PathPrefix <prefix>/ql`** + header **`Upgrade: websocket`** → **`URLRewrite`** **`ReplacePrefixMatch /`** + **`RequestHeaderModifier`** **`remove: [Authorization]`** (авторизація для WebSocket іде всередині messages). Той самий **`backendRef`**.
114
123
 
115
124
  **`parentRefs`**, **`hostnames`**, **`metadata.namespace`** / **`name`** підлаштуй під середовище. У **`backendRefs.name`** вказуй **headless** **Service** з суфіксом **`-hl`** (див. розділ **«Service: `svc.yaml` і `svc-hl.yaml`»**); у прикладі **`db-h-hl`** заміни на фактичне ім’я.
116
125
 
@@ -278,7 +287,7 @@ patches:
278
287
 
279
288
  **`npx @nitra/cursor check k8s`** — програмні критерії в **JSDoc на початку** **`npm/scripts/check-k8s.mjs`**. Якщо під **`k8s`** немає **`*.yaml`** — крок пропущено. Канон **`$schema`** для редактора — розділ **«Визначення схеми YAML`** нижче.
280
289
 
281
- **Не входить у check k8s:** наприклад повна структура **`HTTPRoute`** для **Hasura** (канон — у розділі про **`hasura/graphql-engine`**), **kubeconform** / **kubescape** — це **`bun run lint-k8s`**.
290
+ **Не входить у check k8s:** **kubeconform** / **kubescape** — це **`bun run lint-k8s`**.
282
291
 
283
292
  ## Коли застосовувати (агентам)
284
293
 
package/mdc/text.mdc CHANGED
@@ -120,7 +120,7 @@ version: '1.25'
120
120
  "lint-text": "npx cspell . && bunx markdownlint-cli2 --fix \"**/*.md\" \"**/*.mdc\" && bun ./npm/scripts/run-v8r.mjs"
121
121
  },
122
122
  "devDependencies": {
123
- "@nitra/cspell-dict": "^2.0.0"
123
+ "@nitra/cspell-dict": "^2.1.0"
124
124
  }
125
125
  }
126
126
  ```
@@ -235,7 +235,7 @@ jobs:
235
235
  "lint-text": "npx cspell . && bunx markdownlint-cli2 --fix \"**/*.md\" \"**/*.mdc\" && bun ./npm/scripts/run-v8r.mjs"
236
236
  },
237
237
  "devDependencies": {
238
- "@nitra/cspell-dict": "^2.0.0"
238
+ "@nitra/cspell-dict": "^2.1.0"
239
239
  }
240
240
  }
241
241
  ```
@@ -252,7 +252,7 @@ jobs:
252
252
  "lint-text": "npx cspell . && bunx markdownlint-cli2 --fix \"**/*.md\" \"**/*.mdc\" && bun ./npm/scripts/run-v8r.mjs"
253
253
  },
254
254
  "devDependencies": {
255
- "@nitra/cspell-dict": "^2.0.0"
255
+ "@nitra/cspell-dict": "^2.1.0"
256
256
  }
257
257
  }
258
258
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.106",
3
+ "version": "1.8.108",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -1,16 +1,56 @@
1
1
  /**
2
2
  * Для кожного workspace-пакета перевіряє правило js-pino.mdc.
3
3
  *
4
- * Заборона bunyan на користь pino та наявність `OTEL_RESOURCE_ATTRIBUTES` у `k8s/base/configmap.yaml`,
5
- * якщо такий файл існує.
4
+ * Заборона `@nitra/bunyan` / `bunyan` як у залежностях `package.json`, так і в коді
5
+ * (`import` / `require` / динамічний `import()`); наявність `OTEL_RESOURCE_ATTRIBUTES`
6
+ * у `k8s/base/configmap.yaml`, якщо такий файл існує.
7
+ *
8
+ * Імпорти в джерелах сканує AST через `oxc-parser` (див. `utils/bunyan-imports.mjs`),
9
+ * щоб виявити випадки на кшталт `import log from '@nitra/bunyan'`, які лишаються в коді
10
+ * після підміни залежності.
6
11
  */
7
12
  import { existsSync } from 'node:fs'
8
13
  import { readFile } from 'node:fs/promises'
9
- import { join } from 'node:path'
14
+ import { join, relative } from 'node:path'
10
15
 
16
+ import {
17
+ findBunyanImportsInText,
18
+ isBunyanScanSourceFile,
19
+ shouldSkipFileForBunyanScan
20
+ } from './utils/bunyan-imports.mjs'
11
21
  import { createCheckReporter } from './utils/check-reporter.mjs'
22
+ import { walkDir } from './utils/walkDir.mjs'
12
23
  import { getMonorepoPackageRootDirs } from './utils/workspaces.mjs'
13
24
 
25
+ /**
26
+ * Сканує джерела пакета на заборонені імпорти `@nitra/bunyan` / `bunyan`.
27
+ * @param {string} absPackageRoot абсолютний шлях до кореня пакета
28
+ * @param {string} label префікс повідомлення `[<pkg>] `
29
+ * @param {(msg: string) => void} fail callback при помилці
30
+ * @returns {Promise<number>} кількість знайдених порушень
31
+ */
32
+ async function checkBunyanImports(absPackageRoot, label, fail) {
33
+ /** @type {string[]} */
34
+ const sourcePaths = []
35
+ await walkDir(absPackageRoot, absPath => {
36
+ const rel = relative(absPackageRoot, absPath).split('\\').join('/')
37
+ if (!shouldSkipFileForBunyanScan(rel) && isBunyanScanSourceFile(rel)) {
38
+ sourcePaths.push(absPath)
39
+ }
40
+ })
41
+
42
+ let violations = 0
43
+ for (const absPath of sourcePaths) {
44
+ const rel = relative(absPackageRoot, absPath).split('\\').join('/')
45
+ const content = await readFile(absPath, 'utf8')
46
+ for (const v of findBunyanImportsInText(content, rel)) {
47
+ violations++
48
+ fail(`${label}${rel}:${v.line} — заміни '${v.module}' на '@nitra/pino': ${v.snippet}`)
49
+ }
50
+ }
51
+ return violations
52
+ }
53
+
14
54
  /**
15
55
  * Перевіряє відповідність правилам js-pino.mdc для одного workspace-пакета.
16
56
  * @param {string} rootDir відносний шлях workspace (не `'.'`)
@@ -33,6 +73,11 @@ async function checkWorkspacePackage(rootDir, fail, passFn) {
33
73
  }
34
74
  }
35
75
 
76
+ const importViolations = await checkBunyanImports(join(process.cwd(), rootDir), label, fail)
77
+ if (importViolations === 0) {
78
+ passFn(`${label}немає імпортів '@nitra/bunyan' / 'bunyan' у джерелах`)
79
+ }
80
+
36
81
  const configmapPath = join(rootDir, 'k8s/base/configmap.yaml')
37
82
  if (existsSync(configmapPath)) {
38
83
  const content = await readFile(configmapPath, 'utf8')
@@ -55,7 +55,7 @@
55
55
  * компонент). Спочатку шукається збіг за фактичним `type`, потім за **`*`**.
56
56
  * Dockerfile — правило docker.mdc, скрипт check-docker.mjs.
57
57
  *
58
- * Структура **`HTTPRoute`** для **Deployment** з образом **`hasura/graphql-engine`** (редиректи **`/ql`**, **WebSocket**, **`URLRewrite`**) — лише в **k8s.mdc**, автоматично не звіряється.
58
+ * **Структура `HTTPRoute` для Hasura-Deployment:** звіряється канон 4 правил у **`spec.rules`** (редиректи **`<prefix>/ql`** і **`<prefix>/ql/`** на **`<prefix>/ql/console`** 302, **`PathPrefix <prefix>/ql`** + **URLRewrite** на **`/`**, окреме WebSocket-правило з **`RequestHeaderModifier`** remove **`Authorization`**). **Префікс параметризовано** (рядок перед **`/ql`** у першому Hasura-правилі). **Прив'язка** за **`metadata.name`** у тому ж каталозі, що й **Deployment** з образом **`hasura/graphql-engine`** (див. k8s.mdc). **Додаткові правила** поверх канону дозволені.
59
59
  */
60
60
  import { existsSync } from 'node:fs'
61
61
  import { readFile, stat, unlink } from 'node:fs/promises'
@@ -1924,6 +1924,41 @@ export function deploymentHasuraGraphqlEngineImageViolation(manifest) {
1924
1924
  return hasuraGraphqlEngineViolationInContainerList('initContainers', podSpec.initContainers)
1925
1925
  }
1926
1926
 
1927
+ /**
1928
+ * Чи у списку контейнерів є хоча б один з образом **hasura/graphql-engine** (будь-який тег).
1929
+ * @param {unknown} containers значення **containers** / **initContainers** із podSpec
1930
+ * @returns {boolean} true — якщо знайдено хоча б один контейнер з образом Hasura
1931
+ */
1932
+ function containerListHasHasuraImage(containers) {
1933
+ if (!Array.isArray(containers)) return false
1934
+ for (const c of containers) {
1935
+ if (c !== null && typeof c === 'object' && !Array.isArray(c)) {
1936
+ const image = /** @type {Record<string, unknown>} */ (c).image
1937
+ if (typeof image === 'string' && image !== '' && isHasuraGraphqlEngineImageRef(image)) return true
1938
+ }
1939
+ }
1940
+ return false
1941
+ }
1942
+
1943
+ /**
1944
+ * Чи **Deployment** використовує образ **hasura/graphql-engine** у будь-якому контейнері (маркер для прив'язки HTTPRoute-канона).
1945
+ * @param {unknown} manifest корінь YAML-документа
1946
+ * @returns {boolean} true — для Deployment з Hasura-контейнером у containers / initContainers
1947
+ */
1948
+ export function isHasuraDeploymentManifest(manifest) {
1949
+ if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) return false
1950
+ const rec = /** @type {Record<string, unknown>} */ (manifest)
1951
+ if (rec.kind !== 'Deployment') return false
1952
+ const spec = rec.spec
1953
+ if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) return false
1954
+ const template = /** @type {Record<string, unknown>} */ (spec).template
1955
+ if (template === null || typeof template !== 'object' || Array.isArray(template)) return false
1956
+ const podSpec = /** @type {Record<string, unknown>} */ (template).spec
1957
+ if (podSpec === null || typeof podSpec !== 'object' || Array.isArray(podSpec)) return false
1958
+ const p = /** @type {Record<string, unknown>} */ (podSpec)
1959
+ return containerListHasHasuraImage(p.containers) || containerListHasHasuraImage(p.initContainers)
1960
+ }
1961
+
1927
1962
  /**
1928
1963
  * Чи **Service** містить заборонені анотації GKE у **`metadata.annotations`** (k8s.mdc).
1929
1964
  * @param {unknown} manifest корінь YAML-документа
@@ -2154,6 +2189,247 @@ function scanGatewayApiRouteBackendRefsInYamlBody(rel, body, fail) {
2154
2189
  }
2155
2190
  }
2156
2191
 
2192
+ /**
2193
+ * Звузити `unknown` до `Record<string, unknown>` (`null`, масиви, примітиви → null).
2194
+ * @param {unknown} node довільний вузол YAML-документа
2195
+ * @returns {Record<string, unknown> | null} plain-об'єкт або null, якщо це не plain-запис
2196
+ */
2197
+ function asPlainRecord(node) {
2198
+ if (node === null || node === undefined || typeof node !== 'object' || Array.isArray(node)) return null
2199
+ return /** @type {Record<string, unknown>} */ (node)
2200
+ }
2201
+
2202
+ /**
2203
+ * Чи `match` — рівно один шлях заданого типу з потрібним значенням, **без** `headers`.
2204
+ * @param {unknown} rule одне правило `HTTPRoute`
2205
+ * @param {'Exact' | 'PathPrefix'} pathType очікуваний `path.type`
2206
+ * @param {string} pathValue очікуваний `path.value`
2207
+ * @returns {boolean} true — якщо `matches` рівно один і відповідає критерію
2208
+ */
2209
+ function hasuraRuleMatchesSinglePathNoHeaders(rule, pathType, pathValue) {
2210
+ const r = asPlainRecord(rule)
2211
+ if (r === null) return false
2212
+ const matches = r.matches
2213
+ if (!Array.isArray(matches) || matches.length !== 1) return false
2214
+ const m = asPlainRecord(matches[0])
2215
+ if (m === null) return false
2216
+ if (m.headers !== undefined) return false
2217
+ const p = asPlainRecord(m.path)
2218
+ if (p === null) return false
2219
+ return p.type === pathType && p.value === pathValue
2220
+ }
2221
+
2222
+ /**
2223
+ * Чи **filters** — рівно один `RequestRedirect` з `ReplaceFullPath` на `toPath` і `statusCode: 302`.
2224
+ * @param {unknown} rule одне правило `HTTPRoute`
2225
+ * @param {string} toPath очікуваний `requestRedirect.path.replaceFullPath`
2226
+ * @returns {boolean} true — якщо filters відповідають канону редиректу
2227
+ */
2228
+ function hasuraRuleHasExactRedirect(rule, toPath) {
2229
+ const r = asPlainRecord(rule)
2230
+ if (r === null) return false
2231
+ const filters = r.filters
2232
+ if (!Array.isArray(filters) || filters.length !== 1) return false
2233
+ const f = asPlainRecord(filters[0])
2234
+ if (f === null || f.type !== 'RequestRedirect') return false
2235
+ const rr = asPlainRecord(f.requestRedirect)
2236
+ if (rr === null || rr.statusCode !== 302) return false
2237
+ const p = asPlainRecord(rr.path)
2238
+ return p !== null && p.type === 'ReplaceFullPath' && p.replaceFullPath === toPath
2239
+ }
2240
+
2241
+ /**
2242
+ * Чи серед **filters** є `URLRewrite` з `ReplacePrefixMatch: /`.
2243
+ * @param {unknown[]} filters масив filters з одного правила `HTTPRoute`
2244
+ * @returns {boolean} true — якщо фільтр `URLRewrite` має `ReplacePrefixMatch: /`
2245
+ */
2246
+ function hasuraFiltersIncludeUrlRewriteToSlash(filters) {
2247
+ for (const f of filters) {
2248
+ const fr = asPlainRecord(f)
2249
+ if (fr !== null && fr.type === 'URLRewrite') {
2250
+ const rw = asPlainRecord(fr.urlRewrite)
2251
+ if (rw === null) return false
2252
+ const p = asPlainRecord(rw.path)
2253
+ return p !== null && p.type === 'ReplacePrefixMatch' && p.replacePrefixMatch === '/'
2254
+ }
2255
+ }
2256
+ return false
2257
+ }
2258
+
2259
+ /**
2260
+ * Чи серед **filters** є `RequestHeaderModifier` з `remove: [Authorization]`.
2261
+ * @param {unknown[]} filters масив filters з одного правила `HTTPRoute`
2262
+ * @returns {boolean} true — якщо фільтр `RequestHeaderModifier` видаляє саме `Authorization`
2263
+ */
2264
+ function hasuraFiltersRemoveAuthorization(filters) {
2265
+ for (const f of filters) {
2266
+ const fr = asPlainRecord(f)
2267
+ if (fr !== null && fr.type === 'RequestHeaderModifier') {
2268
+ const mod = asPlainRecord(fr.requestHeaderModifier)
2269
+ if (mod === null) return false
2270
+ const remove = mod.remove
2271
+ if (!Array.isArray(remove) || remove.length !== 1) return false
2272
+ return remove[0] === 'Authorization'
2273
+ }
2274
+ }
2275
+ return false
2276
+ }
2277
+
2278
+ /**
2279
+ * Ім'я єдиного `backendRef` у правилі (або null, якщо backend-ів не рівно один).
2280
+ * @param {unknown} rule одне правило `HTTPRoute`
2281
+ * @returns {string | null} `backendRefs[0].name` або null, якщо backend-ів не рівно один
2282
+ */
2283
+ function hasuraRuleSingleBackendName(rule) {
2284
+ const r = asPlainRecord(rule)
2285
+ if (r === null) return null
2286
+ const refs = r.backendRefs
2287
+ if (!Array.isArray(refs) || refs.length !== 1) return null
2288
+ const b = asPlainRecord(refs[0])
2289
+ if (b === null || typeof b.name !== 'string') return null
2290
+ return b.name
2291
+ }
2292
+
2293
+ /**
2294
+ * Правило 3: `PathPrefix <qlPath>` + **filters** = 1 × `URLRewrite(ReplacePrefixMatch: /)`.
2295
+ * @param {unknown} rule одне правило `HTTPRoute`
2296
+ * @param {string} qlPath очікуваний `path.value` (`<prefix>/ql`)
2297
+ * @returns {boolean} true — якщо правило відповідає канону пункту 3
2298
+ */
2299
+ function hasuraRuleIsQlUrlRewrite(rule, qlPath) {
2300
+ if (!hasuraRuleMatchesSinglePathNoHeaders(rule, 'PathPrefix', qlPath)) return false
2301
+ const r = asPlainRecord(rule)
2302
+ if (r === null) return false
2303
+ const filters = r.filters
2304
+ if (!Array.isArray(filters) || filters.length !== 1) return false
2305
+ return hasuraFiltersIncludeUrlRewriteToSlash(filters)
2306
+ }
2307
+
2308
+ /**
2309
+ * Правило 4: WebSocket — `PathPrefix <qlPath>` + `Upgrade: websocket`, **filters** = `URLRewrite` + `RequestHeaderModifier(remove Authorization)`.
2310
+ * @param {unknown} rule одне правило `HTTPRoute`
2311
+ * @param {string} qlPath очікуваний `path.value` (`<prefix>/ql`)
2312
+ * @returns {boolean} true — якщо правило відповідає канону пункту 4 (WebSocket)
2313
+ */
2314
+ function hasuraRuleIsWebsocket(rule, qlPath) {
2315
+ const r = asPlainRecord(rule)
2316
+ if (r === null) return false
2317
+ const matches = r.matches
2318
+ if (!Array.isArray(matches) || matches.length !== 1) return false
2319
+ const m = asPlainRecord(matches[0])
2320
+ if (m === null) return false
2321
+ const p = asPlainRecord(m.path)
2322
+ if (p === null || p.type !== 'PathPrefix' || p.value !== qlPath) return false
2323
+ const headers = m.headers
2324
+ if (!Array.isArray(headers) || headers.length !== 1) return false
2325
+ const h = asPlainRecord(headers[0])
2326
+ if (h === null || h.type !== 'Exact' || h.name !== 'Upgrade' || h.value !== 'websocket') return false
2327
+ const filters = r.filters
2328
+ if (!Array.isArray(filters) || filters.length !== 2) return false
2329
+ return hasuraFiltersIncludeUrlRewriteToSlash(filters) && hasuraFiltersRemoveAuthorization(filters)
2330
+ }
2331
+
2332
+ /**
2333
+ * Знаходить перше правило з **`matches`** = `[{ path: { type: 'Exact', value: '<prefix>/ql' } }]` (без headers),
2334
+ * повертає `<prefix>` (може бути порожнім) і позицію правила 1.
2335
+ * @param {unknown[]} rules вміст `spec.rules` HTTPRoute
2336
+ * @returns {{ prefix: string, startIndex: number } | null} виявлений префікс і позиція правила 1 або null
2337
+ */
2338
+ function findHasuraCanonStart(rules) {
2339
+ for (let i = 0; i < rules.length; i++) {
2340
+ const r = asPlainRecord(rules[i])
2341
+ const matches = r === null ? null : r.matches
2342
+ if (!Array.isArray(matches) || matches.length !== 1) {
2343
+ // наступне правило
2344
+ } else {
2345
+ const m = asPlainRecord(matches[0])
2346
+ const p = m === null || m.headers !== undefined ? null : asPlainRecord(m.path)
2347
+ if (
2348
+ p !== null &&
2349
+ p.type === 'Exact' &&
2350
+ typeof p.value === 'string' &&
2351
+ p.value.endsWith('/ql')
2352
+ ) {
2353
+ return { prefix: p.value.slice(0, -'/ql'.length), startIndex: i }
2354
+ }
2355
+ }
2356
+ }
2357
+ return null
2358
+ }
2359
+
2360
+ /**
2361
+ * Знаходить перше правило за індексом ≥ `from`, що задовольняє `predicate`. Повертає індекс або -1.
2362
+ * @param {unknown[]} rules вміст `spec.rules` HTTPRoute
2363
+ * @param {number} from мінімальний індекс, з якого починати пошук
2364
+ * @param {(rule: unknown) => boolean} predicate предикат на одне правило
2365
+ * @returns {number} індекс знайденого правила або -1
2366
+ */
2367
+ function findHasuraRule(rules, from, predicate) {
2368
+ for (let i = from; i < rules.length; i++) {
2369
+ if (predicate(rules[i])) return i
2370
+ }
2371
+ return -1
2372
+ }
2373
+
2374
+ /**
2375
+ * Чи **`HTTPRoute`** порушує канон 4 правил Hasura (див. k8s.mdc).
2376
+ * Повертає текст порушення або null, якщо канон витримано. Додаткові правила поверх канону допускаються.
2377
+ * @param {unknown} manifest корінь YAML-документа
2378
+ * @returns {string | null} текст порушення або null, якщо канон витримано
2379
+ */
2380
+ export function httpRouteHasuraCanonViolation(manifest) {
2381
+ const rec = asPlainRecord(manifest)
2382
+ if (rec === null) return null
2383
+ const spec = asPlainRecord(rec.spec)
2384
+ if (spec === null) return 'HTTPRoute без spec — канон Hasura вимагає 4 правил (див. k8s.mdc)'
2385
+ const rules = spec.rules
2386
+ if (!Array.isArray(rules) || rules.length === 0) {
2387
+ return 'spec.rules порожній — канон Hasura вимагає 4 правил у порядку (див. k8s.mdc)'
2388
+ }
2389
+ const start = findHasuraCanonStart(rules)
2390
+ if (start === null) {
2391
+ return 'не знайдено правило 1 Hasura-канона: Exact "<prefix>/ql" + RequestRedirect ReplaceFullPath "<prefix>/ql/console" statusCode 302 (див. k8s.mdc)'
2392
+ }
2393
+ const { prefix, startIndex } = start
2394
+ const qlPath = `${prefix}/ql`
2395
+ const qlSlashPath = `${prefix}/ql/`
2396
+ const consolePath = `${prefix}/ql/console`
2397
+
2398
+ if (!hasuraRuleHasExactRedirect(rules[startIndex], consolePath)) {
2399
+ return `правило 1 Hasura-канона (rules[${startIndex}], prefix «${prefix}»): Exact "${qlPath}" має мати RequestRedirect ReplaceFullPath "${consolePath}" statusCode 302 (див. k8s.mdc)`
2400
+ }
2401
+
2402
+ const i2 = findHasuraRule(
2403
+ rules,
2404
+ startIndex + 1,
2405
+ r => hasuraRuleMatchesSinglePathNoHeaders(r, 'Exact', qlSlashPath) && hasuraRuleHasExactRedirect(r, consolePath)
2406
+ )
2407
+ if (i2 === -1) {
2408
+ return `правило 2 Hasura-канона: після правила 1 має бути Exact "${qlSlashPath}" + RequestRedirect ReplaceFullPath "${consolePath}" statusCode 302 (див. k8s.mdc)`
2409
+ }
2410
+
2411
+ const i3 = findHasuraRule(
2412
+ rules,
2413
+ i2 + 1,
2414
+ r => hasuraRuleIsQlUrlRewrite(r, qlPath) && hasuraRuleSingleBackendName(r) !== null
2415
+ )
2416
+ if (i3 === -1) {
2417
+ return `правило 3 Hasura-канона: після правила 2 має бути PathPrefix "${qlPath}" + URLRewrite ReplacePrefixMatch "/" + один backendRef на headless Service (див. k8s.mdc)`
2418
+ }
2419
+ const backendName = /** @type {string} */ (hasuraRuleSingleBackendName(rules[i3]))
2420
+
2421
+ const i4 = findHasuraRule(
2422
+ rules,
2423
+ i3 + 1,
2424
+ r => hasuraRuleIsWebsocket(r, qlPath) && hasuraRuleSingleBackendName(r) === backendName
2425
+ )
2426
+ if (i4 === -1) {
2427
+ return `правило 4 Hasura-канона (WebSocket): після правила 3 має бути PathPrefix "${qlPath}" + header "Upgrade: websocket" + URLRewrite ReplacePrefixMatch "/" + RequestHeaderModifier remove [Authorization] + backendRef «${backendName}» (див. k8s.mdc)`
2428
+ }
2429
+
2430
+ return null
2431
+ }
2432
+
2157
2433
  /**
2158
2434
  * Збирає **`metadata.name`** для **kind: Service** у коренях документів; при помилці викликає **fail** і повертає false.
2159
2435
  * @param {Record<string, unknown>[]} roots корені YAML-документів
@@ -2300,6 +2576,113 @@ async function validateSvcYamlAndSvcHlPairs(root, yamlFiles, fail) {
2300
2576
  }
2301
2577
  }
2302
2578
 
2579
+ /**
2580
+ * Індексує Hasura-Deployment-и за каталогом (ключ — абсолютний шлях каталогу, значення — множина `metadata.name`).
2581
+ * Паралельно збирає всі `kind: HTTPRoute` Gateway API (`gateway.networking.k8s.io/*`) із doc-індексом.
2582
+ * @param {string[]} yamlFiles абсолютні шляхи до `*.yaml` під `k8s`
2583
+ * @returns {Promise<{
2584
+ * hasuraByDir: Map<string, Set<string>>,
2585
+ * httpRoutes: { abs: string, dir: string, docIndex: number, obj: Record<string, unknown> }[]
2586
+ * }>} індекс Hasura-Deployment-ів за каталогом і список HTTPRoute-документів
2587
+ */
2588
+ async function collectHasuraDeploymentsAndHttpRoutes(yamlFiles) {
2589
+ /** @type {Map<string, Set<string>>} */
2590
+ const hasuraByDir = new Map()
2591
+ /** @type {{ abs: string, dir: string, docIndex: number, obj: Record<string, unknown> }[]} */
2592
+ const httpRoutes = []
2593
+
2594
+ for (const abs of yamlFiles) {
2595
+ await indexOneK8sYamlForHasuraCanon(abs, hasuraByDir, httpRoutes)
2596
+ }
2597
+
2598
+ return { hasuraByDir, httpRoutes }
2599
+ }
2600
+
2601
+ /**
2602
+ * Читає один YAML і додає Hasura-Deployment-и / HTTPRoute-документи до відповідних колекцій (нещасливі читання ігнорує).
2603
+ * @param {string} abs абсолютний шлях до файлу
2604
+ * @param {Map<string, Set<string>>} hasuraByDir індекс Hasura Deployment-ів за каталогом
2605
+ * @param {{ abs: string, dir: string, docIndex: number, obj: Record<string, unknown> }[]} httpRoutes колектор HTTPRoute-документів
2606
+ * @returns {Promise<void>}
2607
+ */
2608
+ async function indexOneK8sYamlForHasuraCanon(abs, hasuraByDir, httpRoutes) {
2609
+ let raw
2610
+ try {
2611
+ raw = await readFile(abs, 'utf8')
2612
+ } catch {
2613
+ return
2614
+ }
2615
+ const lines = toLines(raw)
2616
+ const body = lines.length > 0 && MODELINE_RE.test(lines[0]) ? yamlBodyAfterModeline(lines) : lines.join('\n')
2617
+ /** @type {import('yaml').Document[]} */
2618
+ let docs
2619
+ try {
2620
+ docs = parseAllDocuments(body)
2621
+ } catch {
2622
+ return
2623
+ }
2624
+ const dir = dirname(abs)
2625
+
2626
+ for (const [di, doc] of docs.entries()) {
2627
+ if (doc.errors.length === 0) {
2628
+ const rec = asPlainRecord(doc.toJSON())
2629
+ if (rec !== null) {
2630
+ recordHasuraDeploymentName(rec, dir, hasuraByDir)
2631
+ const av = rec.apiVersion
2632
+ if (rec.kind === 'HTTPRoute' && typeof av === 'string' && av.startsWith(GATEWAY_API_GROUP_PREFIX)) {
2633
+ httpRoutes.push({ abs, dir, docIndex: di + 1, obj: rec })
2634
+ }
2635
+ }
2636
+ }
2637
+ }
2638
+ }
2639
+
2640
+ /**
2641
+ * Якщо документ — Hasura-Deployment із непорожнім `metadata.name`, додає ім'я до індексу за каталогом.
2642
+ * @param {Record<string, unknown>} rec корінь YAML-документа
2643
+ * @param {string} dir абсолютний шлях до каталогу файлу
2644
+ * @param {Map<string, Set<string>>} hasuraByDir індекс Hasura Deployment-ів за каталогом (мутується)
2645
+ * @returns {void}
2646
+ */
2647
+ function recordHasuraDeploymentName(rec, dir, hasuraByDir) {
2648
+ if (!isHasuraDeploymentManifest(rec)) return
2649
+ const meta = asPlainRecord(rec.metadata)
2650
+ const name = meta === null ? undefined : meta.name
2651
+ if (typeof name !== 'string' || name === '') return
2652
+ let set = hasuraByDir.get(dir)
2653
+ if (set === undefined) {
2654
+ set = new Set()
2655
+ hasuraByDir.set(dir, set)
2656
+ }
2657
+ set.add(name)
2658
+ }
2659
+
2660
+ /**
2661
+ * Для кожного `kind: HTTPRoute`, що прив'язаний до **Hasura-Deployment** у тому самому каталозі за **`metadata.name`**,
2662
+ * звіряє канон 4 правил (див. `httpRouteHasuraCanonViolation` і k8s.mdc).
2663
+ * @param {string} root корінь репозиторію
2664
+ * @param {string[]} yamlFiles абсолютні шляхи до `*.yaml` під `k8s`
2665
+ * @param {(msg: string) => void} fail callback реєстрації помилки
2666
+ * @returns {Promise<void>}
2667
+ */
2668
+ async function validateHasuraHttpRouteCanon(root, yamlFiles, fail) {
2669
+ const { hasuraByDir, httpRoutes } = await collectHasuraDeploymentsAndHttpRoutes(yamlFiles)
2670
+ if (hasuraByDir.size === 0 || httpRoutes.length === 0) return
2671
+
2672
+ for (const hr of httpRoutes) {
2673
+ const meta = asPlainRecord(hr.obj.metadata)
2674
+ const name = meta === null ? undefined : meta.name
2675
+ const set = typeof name === 'string' && name !== '' ? hasuraByDir.get(hr.dir) : undefined
2676
+ if (set !== undefined && typeof name === 'string' && set.has(name)) {
2677
+ const v = httpRouteHasuraCanonViolation(hr.obj)
2678
+ if (v !== null) {
2679
+ const rel = (relative(root, hr.abs) || hr.abs).replaceAll('\\', '/')
2680
+ fail(`${rel}: HTTPRoute «${name}» (документ ${hr.docIndex}; прив'язано до Hasura-Deployment у тому ж каталозі): ${v}`)
2681
+ }
2682
+ }
2683
+ }
2684
+ }
2685
+
2303
2686
  /**
2304
2687
  * Для маніфестів, **підключених** до Kustomize (шлях у `resources` / `patches` / …), **metadata.namespace** не додають.
2305
2688
  * @param {unknown} manifest корінь YAML-документа
@@ -2811,6 +3194,8 @@ export async function check() {
2811
3194
 
2812
3195
  await validateSvcYamlAndSvcHlPairs(root, yamlFiles, fail)
2813
3196
 
3197
+ await validateHasuraHttpRouteCanon(root, yamlFiles, fail)
3198
+
2814
3199
  await validateKustomizationIncludesSvcHlWithSvc(root, yamlFiles, fail)
2815
3200
 
2816
3201
  await validateKustomizationJson6902NoRemoveAddSamePath(root, yamlFiles, fail)
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Знаходить імпорти з `@nitra/bunyan` (і застарілого `bunyan`) у джерелах — їх треба замінити
3
+ * на `@nitra/pino` згідно з js-pino.mdc.
4
+ *
5
+ * Семантика береться з **oxc-parser** (`module.staticImports`) — без regex по тілу файлу.
6
+ * Додатково по AST програми ловимо `require('@nitra/bunyan')` і динамічний `import('@nitra/bunyan')`,
7
+ * щоб правило працювало й у CommonJS/інлайн-завантаженні.
8
+ *
9
+ * Сканер не вимагає, щоб файл компілювався: при синтаксичних помилках повертається порожній
10
+ * результат — спочатку треба полагодити синтаксис, потім перезапустити перевірку.
11
+ */
12
+ import { parseSync } from 'oxc-parser'
13
+
14
+ const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/
15
+ const FORBIDDEN_MODULES = new Set(['@nitra/bunyan', 'bunyan'])
16
+
17
+ /**
18
+ * Мова для Oxc за шляхом файлу (розширення).
19
+ * @param {string} filePath віртуальний або реальний шлях до файлу
20
+ * @returns {'js' | 'jsx' | 'ts' | 'tsx'} значення опції `lang` для `parseSync`
21
+ */
22
+ function langFromPath(filePath) {
23
+ const lower = filePath.toLowerCase()
24
+ if (lower.endsWith('.tsx')) {
25
+ return 'tsx'
26
+ }
27
+ if (lower.endsWith('.ts') || lower.endsWith('.mts') || lower.endsWith('.cts')) {
28
+ return 'ts'
29
+ }
30
+ if (lower.endsWith('.jsx')) {
31
+ return 'jsx'
32
+ }
33
+ return 'js'
34
+ }
35
+
36
+ /**
37
+ * Номер рядка (1-based) за зміщенням у буфері.
38
+ * @param {string} content повний текст файлу
39
+ * @param {number} offset байтове зміщення початку фрагмента
40
+ * @returns {number} номер рядка від 1
41
+ */
42
+ function offsetToLine(content, offset) {
43
+ let line = 1
44
+ const n = Math.min(offset, content.length)
45
+ for (let i = 0; i < n; i++) {
46
+ if (content.codePointAt(i) === 10) {
47
+ line++
48
+ }
49
+ }
50
+ return line
51
+ }
52
+
53
+ /**
54
+ * Стискає пробіли для повідомлення про порушення.
55
+ * @param {string} s фрагмент коду
56
+ * @returns {string} скорочений однорядковий рядок
57
+ */
58
+ function normalizeSnippet(s) {
59
+ return s.replaceAll(/\s+/g, ' ').trim().slice(0, 160)
60
+ }
61
+
62
+ /**
63
+ * Перевіряє, чи це виклик `require('<module>')` з рядковим аргументом.
64
+ * @param {any} node вузол AST
65
+ * @returns {string | null} ім'я модуля з аргументу, інакше `null`
66
+ */
67
+ function requireCallModule(node) {
68
+ if (!node || node.type !== 'CallExpression') return null
69
+ const callee = node.callee
70
+ if (!callee || callee.type !== 'Identifier' || callee.name !== 'require') return null
71
+ const arg = node.arguments?.[0]
72
+ if (!arg || arg.type !== 'Literal' || typeof arg.value !== 'string') return null
73
+ return arg.value
74
+ }
75
+
76
+ /**
77
+ * Перевіряє, чи це динамічний `import('<module>')` з рядковим аргументом.
78
+ * @param {any} node вузол AST
79
+ * @returns {string | null} ім'я модуля, інакше `null`
80
+ */
81
+ function dynamicImportModule(node) {
82
+ if (!node || node.type !== 'ImportExpression') return null
83
+ const src = node.source
84
+ if (!src || src.type !== 'Literal' || typeof src.value !== 'string') return null
85
+ return src.value
86
+ }
87
+
88
+ /**
89
+ * Простий рекурсивний обхід AST: заходимо в усі об'єкти/масиви, щоб знайти require/import-вузли.
90
+ * @param {any} node корінь або під-вузол AST
91
+ * @param {(n: any) => void} visit виклик для кожного об'єкта-вузла
92
+ * @returns {void}
93
+ */
94
+ function walkAst(node, visit) {
95
+ if (!node || typeof node !== 'object') return
96
+ if (Array.isArray(node)) {
97
+ for (const item of node) walkAst(item, visit)
98
+ return
99
+ }
100
+ if (typeof node.type === 'string') {
101
+ visit(node)
102
+ }
103
+ for (const key of Object.keys(node)) {
104
+ if (key === 'parent') continue
105
+ const v = node[key]
106
+ if (v && typeof v === 'object') walkAst(v, visit)
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Знаходить заборонені імпорти/require з `@nitra/bunyan` у тексті.
112
+ * @param {string} content вихідний код
113
+ * @param {string} [virtualPath] шлях для вибору `lang` (наприклад `pkg/src/foo.ts`)
114
+ * @returns {{ line: number, snippet: string, module: string }[]} список порушень
115
+ */
116
+ export function findBunyanImportsInText(content, virtualPath = 'scan.ts') {
117
+ const pathForLang = virtualPath || 'scan.ts'
118
+ const lang = langFromPath(pathForLang)
119
+ let result
120
+ try {
121
+ result = parseSync(pathForLang, content, { lang, sourceType: 'module' })
122
+ } catch {
123
+ return []
124
+ }
125
+ if (result.errors?.length) {
126
+ return []
127
+ }
128
+
129
+ /** @type {{ line: number, snippet: string, module: string }[]} */
130
+ const out = []
131
+
132
+ for (const imp of result.module?.staticImports ?? []) {
133
+ const mod = imp.moduleRequest?.value
134
+ if (mod && FORBIDDEN_MODULES.has(mod)) {
135
+ out.push({
136
+ line: offsetToLine(content, imp.start),
137
+ snippet: normalizeSnippet(content.slice(imp.start, imp.end)),
138
+ module: mod
139
+ })
140
+ }
141
+ }
142
+
143
+ walkAst(result.program, node => {
144
+ const reqMod = requireCallModule(node)
145
+ if (reqMod && FORBIDDEN_MODULES.has(reqMod)) {
146
+ out.push({
147
+ line: offsetToLine(content, node.start),
148
+ snippet: normalizeSnippet(content.slice(node.start, node.end)),
149
+ module: reqMod
150
+ })
151
+ return
152
+ }
153
+ const dynMod = dynamicImportModule(node)
154
+ if (dynMod && FORBIDDEN_MODULES.has(dynMod)) {
155
+ out.push({
156
+ line: offsetToLine(content, node.start),
157
+ snippet: normalizeSnippet(content.slice(node.start, node.end)),
158
+ module: dynMod
159
+ })
160
+ }
161
+ })
162
+
163
+ return out
164
+ }
165
+
166
+ /**
167
+ * Чи сканувати цей файл за розширенням (JS/TS-сім'я).
168
+ * @param {string} relativePath відносний шлях до файлу
169
+ * @returns {boolean} `true`, якщо розширення підходить для пошуку імпорту
170
+ */
171
+ export function isBunyanScanSourceFile(relativePath) {
172
+ return SOURCE_FILE_RE.test(relativePath)
173
+ }
174
+
175
+ /**
176
+ * Чи слід пропустити файл під час обходу пакета (декларації типів).
177
+ * @param {string} relativePosix шлях з posix-слешами
178
+ * @returns {boolean} `true`, якщо файл не сканувати
179
+ */
180
+ export function shouldSkipFileForBunyanScan(relativePosix) {
181
+ return relativePosix.endsWith('.d.ts')
182
+ }