@7n/rules 1.36.1 → 1.37.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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.37.0] - 2026-07-21
4
+
5
+ ### Added
6
+
7
+ - skills/storybook: скіл `n-storybook` — тонка обгортка запуску канону Storybook (`@7n/rules-lang-js`): звичайний прогін `lint storybook` і `--adopt`-режим для rollout-у на пакетах з уже наявним ручним Storybook (ADR канон-storybook-для-vue-компонентних-бібліотек, Кластер 8)
8
+
9
+ ### Fixed
10
+
11
+ - k8s: hasura_configmap/hasura_httproute тепер gated власним main.mjs (без false positive на ConfigMap/HTTPRoute без сусіднього Hasura Deployment); kubescape більше не падає fatal на Job/CronJob через auto-generated per-resource виняток C-0056/C-0018
12
+
3
13
  ## [1.36.1] - 2026-07-20
4
14
 
5
15
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/rules",
3
- "version": "1.36.1",
3
+ "version": "1.37.0",
4
4
  "description": "CLI еталонних правил і skills (префікс n-): синк у репозиторій, дельта-lint, конформність",
5
5
  "keywords": [
6
6
  "cli",
@@ -0,0 +1,9 @@
1
+ ---
2
+ type: Directory Index
3
+ title: npm/rules/k8s/hasura_configmap
4
+ resource: npm/rules/k8s/hasura_configmap/
5
+ ---
6
+
7
+ | Файл | Тип |
8
+ | ------------------- | --------- |
9
+ | [main.mjs](main.md) | JS Module |
@@ -0,0 +1,31 @@
1
+ ---
2
+ type: JS Module
3
+ title: main.mjs
4
+ resource: npm/rules/k8s/hasura_configmap/main.mjs
5
+ docgen:
6
+ crc: ec4396c4
7
+ model: openai-codex/gpt-5.4-mini
8
+ score: 90
9
+ issues: internal-name:validateHasuraConfigMapRemoteSchemaPermissions,judge:inaccurate:0.99
10
+ judgeModel: openai-codex/gpt-5.4-mini
11
+ ---
12
+
13
+ ## Огляд
14
+
15
+ Gated detector для поверхні `k8s/hasura_configmap`, який захищає `hasura_configmap.rego` від промоції в ungated standalone detector через generic lint-surface з `hasHandWrittenMain` у `scripts/lib/lint-surface/detect.mjs`. Саме `k8s/manifests/main.mjs` через `findDeploymentDocInDir` і `isHasuraDeploymentManifest` робить cross-file JS-гейт і лише тоді викликає `validateHasuraConfigMapRemoteSchemaPermissions`, тож перевірка охоплює тільки ті `ConfigMap`, для яких поруч є Hasura Deployment. Це потрібно, щоб не проганяти Hasura-специфічний `rego` на звичайні `ConfigMap` у `k8s` і не ловити false positive на CronJob/Job `ConfigMap` поза Hasura-манифестами, зокрема в контексті issue `efes-cloud/backend`.
16
+
17
+ ## Поведінка
18
+
19
+ 1. `lint` запускає перевірку поверхні `k8s/hasura_configmap` у режимі read-only та збирає підсумок через reporter.
20
+ 2. Вона бере корінь workspace, враховує `cursor`-ігнори й шукає YAML-файли лише в межах `k8s`.
21
+ 3. Якщо в `k8s` немає YAML-файлів, перевірку пропускає і повертає успішний результат без порушень.
22
+ 4. Якщо YAML-файли знайдені, перевіряє лише ті ConfigMap, для яких поруч є Hasura Deployment; звичайні ConfigMap без такого сусідства не ескалюються в цю перевірку.
23
+ 5. У разі виявлення невідповідностей фіксує порушення, а за відсутності проблем — підтверджує успішний стан.
24
+
25
+ ## Публічний API
26
+
27
+ - lint — Detector k8s/hasura_configmap (read-only).
28
+
29
+ ## Гарантії поведінки
30
+
31
+ - Read-only: не виконує операцій запису (ФС/БД).
@@ -0,0 +1,35 @@
1
+ /**
2
+ * lint-поверхня k8s/hasura_configmap: gated detector. Без власного `main.mjs` generic
3
+ * lint-surface (`hasHandWrittenMain` у `scripts/lib/lint-surface/detect.mjs`) промотує
4
+ * будь-який concern із резолвним `policy.files.walkGlob` у **ungated** standalone detector —
5
+ * rego `hasura_configmap.rego` прогнав би тоді напряму на всі `configmap.yaml` під k8s,
6
+ * без перевірки, чи є поруч Hasura Deployment (false positive на звичайних CronJob/Job
7
+ * ConfigMap, issue: efes-cloud/backend). Cross-file JS-гейт (`findDeploymentDocInDir` +
8
+ * `isHasuraDeploymentManifest`) і сам виклик rego живуть у `k8s/manifests/main.mjs`
9
+ * (`validateHasuraConfigMapRemoteSchemaPermissions`, export) — тут лише тонка обгортка.
10
+ */
11
+ import { createViolationReporter } from '../../../scripts/lib/lint-surface/violation-reporter.mjs'
12
+ import { loadCursorIgnorePaths } from '../../../scripts/lib/load-cursor-config.mjs'
13
+ import { findK8sYamlFiles, validateHasuraConfigMapRemoteSchemaPermissions } from '../manifests/main.mjs'
14
+
15
+ /**
16
+ * Detector k8s/hasura_configmap (read-only).
17
+ * @param {import('../../../scripts/lib/lint-surface/types.mjs').LintContext} ctx контекст лінту.
18
+ * @returns {Promise<import('../../../scripts/lib/lint-surface/types.mjs').LintResult>} результат із порушеннями
19
+ */
20
+ export async function lint(ctx) {
21
+ const reporter = createViolationReporter(ctx)
22
+ const { pass, fail } = reporter
23
+ const root = ctx.cwd
24
+
25
+ const ignorePaths = await loadCursorIgnorePaths(root)
26
+ const yamlFiles = await findK8sYamlFiles(root, ignorePaths)
27
+ if (yamlFiles.length === 0) {
28
+ pass('Немає *.yaml під k8s — перевірку hasura_configmap пропущено')
29
+ return reporter.result()
30
+ }
31
+
32
+ await validateHasuraConfigMapRemoteSchemaPermissions(root, yamlFiles, fail, pass)
33
+
34
+ return reporter.result()
35
+ }
@@ -0,0 +1,9 @@
1
+ ---
2
+ type: Directory Index
3
+ title: npm/rules/k8s/hasura_httproute
4
+ resource: npm/rules/k8s/hasura_httproute/
5
+ ---
6
+
7
+ | Файл | Тип |
8
+ | ------------------- | --------- |
9
+ | [main.mjs](main.md) | JS Module |
@@ -0,0 +1,32 @@
1
+ ---
2
+ type: JS Module
3
+ title: main.mjs
4
+ resource: npm/rules/k8s/hasura_httproute/main.mjs
5
+ docgen:
6
+ crc: 2f28a731
7
+ model: openai-codex/gpt-5.4-mini
8
+ score: 90
9
+ issues: internal-name:validateHasuraHttpRouteCanon,judge:inaccurate:0.98
10
+ judgeModel: openai-codex/gpt-5.4-mini
11
+ ---
12
+
13
+ ## Огляд
14
+
15
+ Read-only `lint`-обгортка для Kubernetes YAML у `k8s`, яка не має власного `main.mjs` і тому через `hasHandWrittenMain` у `scripts/lib/lint-surface/detect.mjs` не повинна промотуватися в ungated standalone detector. Її роль — делегувати перевірку в `k8s/manifests/main.mjs` через `validateHasuraHttpRouteCanon`, де `collectHasuraDeploymentsAndHttpRoutes` зв’язує `hasura_httproute.rego` лише з HTTPRoute, що має поруч Hasura Deployment з тим самим `metadata.name`. Це запобігає запуску `hasura_httproute.rego` напряму на всі `hr.yaml` під `k8s` і прибирає false positive на HTTPRoute без Hasura.
16
+
17
+ ## Поведінка
18
+
19
+ 1. `lint` збирає стан перевірки для поточного робочого каталогу й працює лише на читання.
20
+ 2. `lint` знаходить Kubernetes YAML у межах `k8s` з урахуванням ignore-шляхів з `.cursor`.
21
+ 3. Якщо під `k8s` немає YAML-файлів, `lint` фіксує пропуск і завершує роботу без помилки.
22
+ 4. Якщо YAML-файли є, `lint` запускає перевірку `hasura_httproute` тільки на цьому наборі файлів.
23
+ 5. Перевірка навмисно gated: вона не повинна перетворюватися на ungated standalone detector для всіх `hr.yaml` під `k8s`, бо тоді з’являлися б false positive для HTTPRoute без відповідного Hasura Deployment з тим самим `metadata.name`.
24
+ 6. `lint` повертає підсумок із зафіксованими порушеннями або повідомленням про пропуск, не змінюючи файлову систему чи бази даних.
25
+
26
+ ## Публічний API
27
+
28
+ - lint — Detector k8s/hasura_httproute (read-only).
29
+
30
+ ## Гарантії поведінки
31
+
32
+ - Read-only: не виконує операцій запису (ФС/БД).
@@ -0,0 +1,35 @@
1
+ /**
2
+ * lint-поверхня k8s/hasura_httproute: gated detector. Без власного `main.mjs` generic
3
+ * lint-surface (`hasHandWrittenMain` у `scripts/lib/lint-surface/detect.mjs`) промотує
4
+ * будь-який concern із резолвним `policy.files.walkGlob` у **ungated** standalone detector —
5
+ * rego `hasura_httproute.rego` прогнав би тоді напряму на всі `hr.yaml` під k8s, без перевірки,
6
+ * чи є поруч Hasura Deployment з тим самим `metadata.name` (false positive на HTTPRoute без
7
+ * Hasura, issue: efes-cloud/backend). Cross-file JS-гейт (`collectHasuraDeploymentsAndHttpRoutes`)
8
+ * і сам виклик rego живуть у `k8s/manifests/main.mjs` (`validateHasuraHttpRouteCanon`, export) —
9
+ * тут лише тонка обгортка.
10
+ */
11
+ import { createViolationReporter } from '../../../scripts/lib/lint-surface/violation-reporter.mjs'
12
+ import { loadCursorIgnorePaths } from '../../../scripts/lib/load-cursor-config.mjs'
13
+ import { findK8sYamlFiles, validateHasuraHttpRouteCanon } from '../manifests/main.mjs'
14
+
15
+ /**
16
+ * Detector k8s/hasura_httproute (read-only).
17
+ * @param {import('../../../scripts/lib/lint-surface/types.mjs').LintContext} ctx контекст лінту.
18
+ * @returns {Promise<import('../../../scripts/lib/lint-surface/types.mjs').LintResult>} результат із порушеннями
19
+ */
20
+ export async function lint(ctx) {
21
+ const reporter = createViolationReporter(ctx)
22
+ const { pass, fail } = reporter
23
+ const root = ctx.cwd
24
+
25
+ const ignorePaths = await loadCursorIgnorePaths(root)
26
+ const yamlFiles = await findK8sYamlFiles(root, ignorePaths)
27
+ if (yamlFiles.length === 0) {
28
+ pass('Немає *.yaml під k8s — перевірку hasura_httproute пропущено')
29
+ return reporter.result()
30
+ }
31
+
32
+ await validateHasuraHttpRouteCanon(root, yamlFiles, fail)
33
+
34
+ return reporter.result()
35
+ }
@@ -36,3 +36,11 @@
36
36
  **Увага:** готовий snippet-файл цього прикладу (`js/templates/kubescape_exceptions/.kubescape-exceptions.json.snippet.json` до `da05f89d`) під час "combine concern" **не був перенесений** у жоден поточний concern-каталог і в репозиторії відсутній — знайти оригінал не вдалося; за потреби відтвори JSON вручну за прикладом вище.
37
37
 
38
38
  Виключай контрольно, а не глобально (не додавай винятки без `attributes.name`/`labels`).
39
+
40
+ ## Auto-generated виняток C-0056/C-0018 для Job/CronJob
41
+
42
+ Controls **C-0056**/**C-0018** (liveness/readiness probe) структурно незастосовні до **`kind: Job`**/**`kind: CronJob`** — под виконується один раз і завершується, on-going readiness/liveness нема чим міряти. Без винятку kubescape (поріг `--severity-threshold high`) падає fatal на першому ж такому ресурсі й зупиняє скан усього дерева `k8s` (issue: efes-cloud/backend, 261 ресурс, жоден job не стартує Hasura server-side).
43
+
44
+ Лінт **сам** генерує по одному exception-запису на **кожен реальний** Job/CronJob-ресурс (з `attributes.kind`+`attributes.name`, за наявності — `attributes.namespace`) для scan-таргету, що його якраз сканує kubescape (`autoJobCronJobProbeExceptions` у `npm/rules/k8s/manifests/main.mjs`, виклик з `runKubescapeManifest`/`scanRawK8sDir`). Це **не** kind-only виняток — кожен запис прив'язаний до конкретного ресурсу, тому відповідає конвенції "виключай контрольно, а не глобально" вище, і не вимагає ручної підтримки: нові CronJob покриваються автоматично при наступному скані.
45
+
46
+ Auto-generated записи **мержаться** з `.kubescape-exceptions.json` користувача (якщо файл є) в один tmp-файл, переданий через `--exceptions`; committed-файл користувача лишається джерелом істини для власних, ручних винятків (напр. C-0012 вище) — цей механізм його не замінює і не редагує. Deployment/StatefulSet/DaemonSet-ресурси винятку **не** отримують — C-0056/C-0018 там і надалі fail-closed.
@@ -1589,7 +1589,7 @@ export function baseKustomizationNamespaceViolation(obj) {
1589
1589
  * @param {string[]} [ignorePaths] шляхи каталогів, повністю виключених з обходу
1590
1590
  * @returns {Promise<string[]>} відсортовані абсолютні шляхи до файлів
1591
1591
  */
1592
- async function findK8sYamlFiles(root, ignorePaths = []) {
1592
+ export async function findK8sYamlFiles(root, ignorePaths = []) {
1593
1593
  /**
1594
1594
  @type {string[]}
1595
1595
  */
@@ -3184,7 +3184,7 @@ function recordHasuraDeploymentName(rec, dir, hasuraByDir) {
3184
3184
  * @param {(msg: string, opts?: string | { reason?: string, file?: string, data?: object }) => void} fail callback реєстрації помилки
3185
3185
  * @returns {Promise<void>} результат
3186
3186
  */
3187
- async function validateHasuraHttpRouteCanon(root, yamlFiles, fail) {
3187
+ export async function validateHasuraHttpRouteCanon(root, yamlFiles, fail) {
3188
3188
  const { hasuraByDir, httpRoutes } = await collectHasuraDeploymentsAndHttpRoutes(yamlFiles)
3189
3189
  if (hasuraByDir.size === 0 || httpRoutes.length === 0) return
3190
3190
 
@@ -3610,7 +3610,7 @@ async function validateConfigMapNameMatchesDeployment(root, yamlFilesAbs, fail,
3610
3610
  * @param {(msg: string) => void} fail callback при помилці
3611
3611
  * @param {(msg: string) => void} passFn callback при успіху
3612
3612
  */
3613
- async function validateHasuraConfigMapRemoteSchemaPermissions(root, yamlFilesAbs, fail, passFn) {
3613
+ export async function validateHasuraConfigMapRemoteSchemaPermissions(root, yamlFilesAbs, fail, passFn) {
3614
3614
  const cmFiles = yamlFilesAbs.filter(abs => {
3615
3615
  const rel = relative(root, abs).replaceAll('\\', '/')
3616
3616
  return CONFIGMAP_BASE_PATH_RE.test(`/${rel}`) || rel === 'k8s/base/configmap.yaml'
@@ -6553,9 +6553,15 @@ export async function regenerateLegacyNetworkPolicyDocsInFile(npAbs, fail) {
6553
6553
  /**
6554
6554
  * Plan B (rego-authoritative): на початку `check()` батч-викликаємо path-фільтровані
6555
6555
  * rego-пакети з `npm/policy/k8s/` через `runConftestBatch`. Пакети hasura_configmap і
6556
- * hasura_httproute мають cross-file gating (паруються з Hasura-Deployment) — вони запускаються
6557
- * з відповідних orchestrator-функцій (`validateHasuraConfigMapRemoteSchemaPermissions`,
6558
- * `validateHasuraHttpRouteCanon`). Структурна частина HPA/PDB (`k8s.hpa_pdb`) тут на всіх yaml,
6556
+ * hasura_httproute мають cross-file gating (паруються з Hasura-Deployment) — цей JS-гейт
6557
+ * (`validateHasuraConfigMapRemoteSchemaPermissions`, `validateHasuraHttpRouteCanon`, обидві
6558
+ * export) викликається з **власного** `main.mjs` кожного з цих двох концернів
6559
+ * (`k8s/hasura_configmap/main.mjs`, `k8s/hasura_httproute/main.mjs`) — не звідси. Це навмисно:
6560
+ * без власного `main.mjs` generic lint-surface (`hasHandWrittenMain` у
6561
+ * `scripts/lib/lint-surface/detect.mjs`) промотує concern із самостійним `policy.files.walkGlob`
6562
+ * у **ungated** detector, що прогонить rego напряму на всі файли-збіги glob — саме так виникали
6563
+ * false positive на ConfigMap/HTTPRoute без сусіднього Hasura Deployment (issue: efes-cloud/backend).
6564
+ * Структурна частина HPA/PDB (`k8s.hpa_pdb`) тут на всіх yaml,
6559
6565
  * env-залежні межі min/maxReplicas і expected-name — JS-cross-file у `validateDeploymentHpaPdbAndTopology`.
6560
6566
  * @param {string} root корінь репозиторію (cwd)
6561
6567
  * @param {string[]} yamlFiles абсолютні шляхи знайдених *.yaml під `…/k8s/`
@@ -6716,7 +6722,8 @@ export async function lint(ctx) {
6716
6722
 
6717
6723
  await validateSvcYamlAndSvcHlPairs(root, yamlFiles, fail)
6718
6724
 
6719
- await validateHasuraHttpRouteCanon(root, yamlFiles, fail)
6725
+ // validateHasuraHttpRouteCanon переїхав у власний main.mjs концерну k8s/hasura_httproute
6726
+ // (gated detector, див. коментар над runAllK8sRego вище).
6720
6727
 
6721
6728
  await validateKustomizationIncludesSvcHlWithSvc(root, yamlFiles, fail)
6722
6729
 
@@ -6728,7 +6735,8 @@ export async function lint(ctx) {
6728
6735
 
6729
6736
  await validateConfigMapNameMatchesDeployment(root, yamlFiles, fail, pass)
6730
6737
 
6731
- await validateHasuraConfigMapRemoteSchemaPermissions(root, yamlFiles, fail, pass)
6738
+ // validateHasuraConfigMapRemoteSchemaPermissions переїхав у власний main.mjs концерну
6739
+ // k8s/hasura_configmap (gated detector, див. коментар над runAllK8sRego вище).
6732
6740
 
6733
6741
  await validateDeploymentHpaPdbAndTopology(root, yamlFiles, fail, pass)
6734
6742
 
@@ -6826,13 +6834,118 @@ export async function runKubeconform(dirs, verbose = false) {
6826
6834
  }
6827
6835
 
6828
6836
  /**
6829
- * Будує CLI-аргументи `--exceptions`, якщо в корені є `.kubescape-exceptions.json`.
6837
+ * `kind`, для яких kubescape-контроли C-0056/C-0018 (liveness/readiness probe) структурно
6838
+ * незастосовні — под Job/CronJob виконується один раз і завершується, on-going readiness/liveness
6839
+ * нема чим міряти (k8s.mdc).
6840
+ * @type {Set<string>}
6841
+ */
6842
+ const KUBESCAPE_PROBE_EXEMPT_KINDS = new Set(['Job', 'CronJob'])
6843
+
6844
+ /**
6845
+ * Auto-generated kubescape `postureExceptionPolicy`-записи для C-0056/C-0018 — по одному на
6846
+ * кожен реальний Job/CronJob-ресурс з непорожнім `metadata.name`, знайдений у `yamlText`.
6847
+ * Навмисно **не** kind-only (без `name`) — house-конвенція "виключай контрольно, а не глобально"
6848
+ * (`k8s/kubeconform/kubeconform.mdc`): kind-only виняток замовчав би C-0056/C-0018 і на
6849
+ * Deployment-подібних ресурсах, для яких ці controls реально застосовні.
6850
+ * @param {string} yamlText вміст одного або декількох YAML-документів (kubescape scan target)
6851
+ * @returns {object[]} масив exception-записів (може бути порожній)
6852
+ */
6853
+ export function autoJobCronJobProbeExceptions(yamlText) {
6854
+ const docs = tryParseAllYamlDocs(yamlText)
6855
+ if (docs === undefined) return []
6856
+ const out = []
6857
+ for (const doc of docs) {
6858
+ if (doc.errors.length !== 0) continue
6859
+ const rec = asPlainRecord(doc.toJSON())
6860
+ if (rec === null || typeof rec.kind !== 'string' || !KUBESCAPE_PROBE_EXEMPT_KINDS.has(rec.kind)) continue
6861
+ const meta = asPlainRecord(rec.metadata)
6862
+ const name = meta === null ? undefined : meta.name
6863
+ if (typeof name !== 'string' || name === '') continue
6864
+ const namespace = meta === null ? undefined : meta.namespace
6865
+ const hasNamespace = typeof namespace === 'string' && namespace !== ''
6866
+ const attributes = hasNamespace ? { kind: rec.kind, name, namespace } : { kind: rec.kind, name }
6867
+ out.push({
6868
+ name: `auto-${rec.kind.toLowerCase()}-${hasNamespace ? namespace : 'default'}-${name}-probes`,
6869
+ policyType: 'postureExceptionPolicy',
6870
+ actions: ['alertOnly'],
6871
+ resources: [{ designatorType: 'Attributes', attributes }],
6872
+ posturePolicies: [{ controlID: 'C-0056' }, { controlID: 'C-0018' }]
6873
+ })
6874
+ }
6875
+ return out
6876
+ }
6877
+
6878
+ /**
6879
+ * Читає та конкатенує вміст усіх `*.yaml`/`*.yml` під каталогом — для похідних auto-exceptions
6880
+ * (треба геть увесь вміст `dir`, що його рекурсивно сканує `kubescape scan <dir>`, а не лише
6881
+ * basename-фільтровані файли k8s.mdc-обходу).
6882
+ * @param {string} dir абсолютний шлях каталогу
6883
+ * @returns {Promise<string>} конкатенований YAML-текст (документи розділені `---`)
6884
+ */
6885
+ async function readAllYamlTextUnderDir(dir) {
6886
+ const files = []
6887
+ await walkDir(dir, p => {
6888
+ if (YAML_EXTENSION_RE.test(p)) files.push(p)
6889
+ })
6890
+ const parts = []
6891
+ for (const f of files) {
6892
+ const raw = await tryReadFileUtf8(f)
6893
+ if (raw !== undefined) parts.push(raw)
6894
+ }
6895
+ return parts.join('\n---\n')
6896
+ }
6897
+
6898
+ /**
6899
+ * Безпечно парсить user-файл `.kubescape-exceptions.json` як масив exception-записів.
6900
+ * @param {string} exceptionsPath абсолютний шлях до файлу
6901
+ * @returns {object[]} масив (порожній при помилці читання/парсингу або якщо це не масив)
6902
+ */
6903
+ function readUserKubescapeExceptions(exceptionsPath) {
6904
+ try {
6905
+ const parsed = JSON.parse(readFileSync(exceptionsPath, 'utf8'))
6906
+ return Array.isArray(parsed) ? parsed : []
6907
+ } catch {
6908
+ console.error(
6909
+ `${exceptionsPath}: не вдалося розпарсити як JSON-масив — auto-generated винятки Job/CronJob застосовано без нього`
6910
+ )
6911
+ return []
6912
+ }
6913
+ }
6914
+
6915
+ /**
6916
+ * Будує CLI-аргументи `--exceptions`. Без `autoExceptions` — сумісна поведінка: пряме
6917
+ * посилання на `.kubescape-exceptions.json` у корені (якщо є), інакше `[]`. З непорожнім
6918
+ * `autoExceptions` — мержить його з user-файлом (якщо є) у tmp-файл; виклик відповідає за
6919
+ * cleanup через `cleanupKubescapeExceptionsArgs`.
6830
6920
  * @param {string} root корінь репозиторію.
6831
- * @returns {string[]} масив аргументів (порожній, якщо файла винятків немає).
6921
+ * @param {object[]} [autoExceptions] auto-generated exception-записи для мержу з user-файлом.
6922
+ * @returns {string[]} масив аргументів (порожній, якщо винятків немає взагалі).
6832
6923
  */
6833
- export function buildKubescapeExceptionsArgs(root) {
6924
+ export function buildKubescapeExceptionsArgs(root, autoExceptions = []) {
6834
6925
  const exceptionsPath = join(root, KUBESCAPE_EXCEPTIONS_FILE)
6835
- return existsSync(exceptionsPath) ? ['--exceptions', exceptionsPath] : []
6926
+ const userFileExists = existsSync(exceptionsPath)
6927
+ if (autoExceptions.length === 0) {
6928
+ return userFileExists ? ['--exceptions', exceptionsPath] : []
6929
+ }
6930
+ const userExceptions = userFileExists ? readUserKubescapeExceptions(exceptionsPath) : []
6931
+ const merged = [...userExceptions, ...autoExceptions]
6932
+ const dir = mkdtempSync(join(tmpdir(), 'nitra-cursor-k8s-exceptions-'))
6933
+ const tmpFile = join(dir, 'kubescape-exceptions.json')
6934
+ writeFileSync(tmpFile, JSON.stringify(merged))
6935
+ return ['--exceptions', tmpFile]
6936
+ }
6937
+
6938
+ /**
6939
+ * Прибирає tmp-файл винятків, згенерований `buildKubescapeExceptionsArgs` (якщо це не пряме
6940
+ * посилання на committed `.kubescape-exceptions.json` користувача — той не чіпаємо).
6941
+ * @param {string[]} exceptionsArgs результат `buildKubescapeExceptionsArgs`.
6942
+ * @param {string} root корінь репозиторію.
6943
+ * @returns {void} результат
6944
+ */
6945
+ function cleanupKubescapeExceptionsArgs(exceptionsArgs, root) {
6946
+ const p = exceptionsArgs[1]
6947
+ if (p === undefined || p === join(root, KUBESCAPE_EXCEPTIONS_FILE)) return
6948
+ rmSync(dirname(p), { recursive: true, force: true })
6836
6949
  }
6837
6950
 
6838
6951
  /**
@@ -6884,16 +6997,20 @@ async function runKustomizeBuild(kubectlPath, dir, verbose = false) {
6884
6997
  }
6885
6998
 
6886
6999
  /**
6887
- * Сканує один зібраний маніфест через `kubescape scan` (пише у tmp-файл, чистить його).
7000
+ * Сканує один зібраний маніфест через `kubescape scan` (пише у tmp-файл, чистить його). Auto-
7001
+ * generated виняток C-0056/C-0018 для Job/CronJob-ресурсів у `manifest` (див.
7002
+ * `autoJobCronJobProbeExceptions`) мержиться з user-файлом `.kubescape-exceptions.json` (якщо є).
6888
7003
  * @param {string} kubescapePath шлях до бінарника kubescape.
6889
7004
  * @param {string|Buffer} manifest вміст маніфеста для сканування.
6890
- * @param {string[]} exceptionsArgs додаткові CLI-аргументи (`--exceptions ...`).
7005
+ * @param {string} root корінь репозиторію (для user-файла винятків).
6891
7006
  * @param {boolean} [verbose] показати повний нативний вивід kubescape (`stdio: 'inherit'`).
6892
7007
  * @returns {Promise<{ status: number, enoent: boolean }>} exit-код і прапорець відсутнього тула.
6893
7008
  */
6894
- async function runKubescapeManifest(kubescapePath, manifest, exceptionsArgs, verbose = false) {
7009
+ async function runKubescapeManifest(kubescapePath, manifest, root, verbose = false) {
6895
7010
  const dir = mkdtempSync(join(tmpdir(), 'nitra-cursor-k8s-'))
6896
7011
  const file = join(dir, 'manifest.yaml')
7012
+ const exceptionsArgs = buildKubescapeExceptionsArgs(root, autoJobCronJobProbeExceptions(String(manifest)))
7013
+ if (verbose && exceptionsArgs.length > 0) console.log(`run-k8s: kubescape exceptions — ${exceptionsArgs[1]}`)
6897
7014
  try {
6898
7015
  writeFileSync(file, manifest)
6899
7016
  try {
@@ -6906,20 +7023,26 @@ async function runKubescapeManifest(kubescapePath, manifest, exceptionsArgs, ver
6906
7023
  return { status: 1, enoent }
6907
7024
  }
6908
7025
  } finally {
7026
+ cleanupKubescapeExceptionsArgs(exceptionsArgs, root)
6909
7027
  rmSync(dir, { recursive: true, force: true })
6910
7028
  }
6911
7029
  }
6912
7030
 
6913
7031
  /**
6914
- * Сканує сирий k8s-каталог (без kustomization) через `kubescape scan <dir>`.
7032
+ * Сканує сирий k8s-каталог (без kustomization) через `kubescape scan <dir>`. Auto-generated
7033
+ * виняток C-0056/C-0018 для Job/CronJob-ресурсів під `dir` мержиться з user-файлом
7034
+ * `.kubescape-exceptions.json` (якщо є).
6915
7035
  * @param {string} kubescapePath шлях до бінарника kubescape.
6916
7036
  * @param {string} dir каталог для сканування.
6917
- * @param {string[]} exceptionsArgs додаткові CLI-аргументи (`--exceptions ...`).
7037
+ * @param {string} root корінь репозиторію (для user-файла винятків).
6918
7038
  * @param {boolean} [verbose] показати повний нативний вивід kubescape (`stdio: 'inherit'`) і хід сканування.
6919
7039
  * @returns {Promise<number>} exit-код (127 якщо тул відсутній).
6920
7040
  */
6921
- async function scanRawK8sDir(kubescapePath, dir, exceptionsArgs, verbose = false) {
7041
+ async function scanRawK8sDir(kubescapePath, dir, root, verbose = false) {
6922
7042
  if (verbose) console.log(`run-k8s: kubescape scan ${dir} (без kustomization — сирий dir-скан)`)
7043
+ const yamlText = await readAllYamlTextUnderDir(dir)
7044
+ const exceptionsArgs = buildKubescapeExceptionsArgs(root, autoJobCronJobProbeExceptions(yamlText))
7045
+ if (verbose && exceptionsArgs.length > 0) console.log(`run-k8s: kubescape exceptions — ${exceptionsArgs[1]}`)
6923
7046
  try {
6924
7047
  const r = await spawnAsync(kubescapePath, ['scan', dir, '--severity-threshold', 'high', ...exceptionsArgs], {
6925
7048
  stdio: verbose ? 'inherit' : 'pipe'
@@ -6931,6 +7054,8 @@ async function scanRawK8sDir(kubescapePath, dir, exceptionsArgs, verbose = false
6931
7054
  return 127
6932
7055
  }
6933
7056
  return 1
7057
+ } finally {
7058
+ cleanupKubescapeExceptionsArgs(exceptionsArgs, root)
6934
7059
  }
6935
7060
  }
6936
7061
 
@@ -6939,16 +7064,16 @@ async function scanRawK8sDir(kubescapePath, dir, exceptionsArgs, verbose = false
6939
7064
  * @param {string} kubectlPath шлях до бінарника kubectl.
6940
7065
  * @param {string} kubescapePath шлях до бінарника kubescape.
6941
7066
  * @param {string[]} kdirs каталоги з kustomization.
6942
- * @param {string[]} exceptionsArgs додаткові CLI-аргументи (`--exceptions ...`).
7067
+ * @param {string} root корінь репозиторію (для user-файла винятків).
6943
7068
  * @param {boolean} [verbose] показати повний нативний вивід kubectl/kubescape і хід сканування.
6944
7069
  * @returns {Promise<number>} 0 якщо всі чисті, інакше перший ненульовий exit-код (127 — тул відсутній).
6945
7070
  */
6946
- async function scanKustomizeK8sDirs(kubectlPath, kubescapePath, kdirs, exceptionsArgs, verbose = false) {
7071
+ async function scanKustomizeK8sDirs(kubectlPath, kubescapePath, kdirs, root, verbose = false) {
6947
7072
  for (const kdir of kdirs) {
6948
7073
  if (verbose) console.log(`run-k8s: kubectl kustomize ${kdir} | kubescape scan <tmp>`)
6949
7074
  const build = await runKustomizeBuild(kubectlPath, kdir, verbose)
6950
7075
  if (build.status !== 0) return build.status
6951
- const ks = await runKubescapeManifest(kubescapePath, build.stdout, exceptionsArgs, verbose)
7076
+ const ks = await runKubescapeManifest(kubescapePath, build.stdout, root, verbose)
6952
7077
  if (ks.enoent) {
6953
7078
  console.error(KUBESCAPE_MISSING_HINT)
6954
7079
  return 127
@@ -6960,20 +7085,20 @@ async function scanKustomizeK8sDirs(kubectlPath, kubescapePath, kdirs, exception
6960
7085
 
6961
7086
  /**
6962
7087
  * Оркеструє kubescape-скан по k8s-коренях: kustomize-каталоги збираються, решта — сирий скан.
7088
+ * Виняток C-0056/C-0018 для Job/CronJob — auto-generated з реального контенту кожного скан-таргету
7089
+ * (не один спільний файл на весь репо), див. `runKubescapeManifest`/`scanRawK8sDir`.
6963
7090
  * @param {string[]} dirs абсолютні шляхи k8s-коренів.
6964
- * @param {string} root корінь репозиторію (для файла винятків).
7091
+ * @param {string} root корінь репозиторію (для user-файла винятків).
6965
7092
  * @param {boolean} [verbose] показати повний нативний вивід kubectl/kubescape (`stdio: 'inherit'`) і хід сканування.
6966
7093
  * @returns {Promise<number>} 0 якщо все чисто, інакше перший ненульовий exit-код (127 — тул відсутній).
6967
7094
  */
6968
7095
  async function runKubescape(dirs, root, verbose = false) {
6969
- const exceptionsArgs = buildKubescapeExceptionsArgs(root)
6970
- if (verbose && exceptionsArgs.length > 0) console.log(`run-k8s: kubescape exceptions — ${KUBESCAPE_EXCEPTIONS_FILE}`)
6971
7096
  const kubescapePath = ensureTool('kubescape')
6972
7097
  let kubectlPath = null
6973
7098
  for (const d of dirs) {
6974
7099
  const kdirs = await findKustomizationDirs(d)
6975
7100
  if (kdirs.length === 0) {
6976
- const rawStatus = await scanRawK8sDir(kubescapePath, d, exceptionsArgs, verbose)
7101
+ const rawStatus = await scanRawK8sDir(kubescapePath, d, root, verbose)
6977
7102
  if (rawStatus !== 0) return rawStatus
6978
7103
  continue
6979
7104
  }
@@ -6984,7 +7109,7 @@ async function runKubescape(dirs, root, verbose = false) {
6984
7109
  return 127
6985
7110
  }
6986
7111
  }
6987
- const buildStatus = await scanKustomizeK8sDirs(kubectlPath, kubescapePath, kdirs, exceptionsArgs, verbose)
7112
+ const buildStatus = await scanKustomizeK8sDirs(kubectlPath, kubescapePath, kdirs, root, verbose)
6988
7113
  if (buildStatus !== 0) return buildStatus
6989
7114
  }
6990
7115
  return 0
@@ -0,0 +1,96 @@
1
+ ---
2
+ name: n-storybook
3
+ description: >-
4
+ Канон Storybook для Vue-компонентних бібліотек — прогін lint-поверхонь правила
5
+ storybook (scope/scaffold/vitest-config/hygiene) по поточному репо, і окремий
6
+ --adopt режим для пакетів із уже наявним ручним Storybook (діагностика diff
7
+ проти канону без сліпого перезапису)
8
+ version: '1.0'
9
+ ---
10
+
11
+ # n-storybook — канон Storybook для Vue-компонентних бібліотек
12
+
13
+ Джерело рішення: `docs/adr/канон-storybook-для-vue-компонентних-бібліотек.md`. Уся логіка канону
14
+ (скоуп, скафолд, vitest-конфіг, гігієна залежностей, adopt-діагностика) живе в правилі `storybook`
15
+ пакета `@7n/rules-lang-js` (`node_modules/@7n/rules-lang-js/rules/storybook/`) — цей скіл лише
16
+ тонка обгортка запуску, за зразком `doc-files`.
17
+
18
+ ## Передумови
19
+
20
+ - Правило `storybook` увімкнене у `.n-rules.json` (`"storybook"` у `rules`) — інакше lint-поверхні
21
+ concern-ів не виконуються (правило `alwaysApply: false`, хвильовий rollout, ADR Decision Outcome).
22
+ - Плагін `@7n/rules-lang-js` встановлений (JS/Vue-екосистема) — сигнал автодетекту: кореневий
23
+ `package.json`.
24
+
25
+ ## Звичайний запуск (rule увімкнене, без адопції)
26
+
27
+ ```bash
28
+ npx @7n/rules lint storybook
29
+ ```
30
+
31
+ Прогонить усі lint-поверхні правила (`scope`, `scaffold`, `vitest-config`, `hygiene`) по всьому
32
+ репо. T0-autofix (`fixability: config` — детермінований, без LLM-ladder) відтворює канонічний
33
+ скафолд ЛИШЕ для секцій, яких немає ВЗАЄМНО (`.storybook/main.js`, `preview.js`,
34
+ `package.json#scripts.storybook`, `vitest.config`-projects, `vitest.stryker.config`) — з
35
+ `template/` правила. Наявний-але-розбіжний файл (маркер канону відсутній) — `fixability: config`
36
+ зупиняється після T0 без LLM-ladder (canon — одна правильна форма, вгадувати нема чого): звіт
37
+ покаже violation, ручне чи agent-виправлення за посиланням на `storybook.mdc`/`vitest-config.mdc`.
38
+
39
+ Звіт користувачу — стандартний вивід `n-rules lint`: список violations (якщо є) + підсумок
40
+ виправлених T0-патернів.
41
+
42
+ ## `--adopt` — пакети з уже наявним ручним Storybook (ADR Кластер 8)
43
+
44
+ Для пакетів, де `.storybook/` вже існує (ручне впровадження ДО канону чи паралельно з ним) —
45
+ окремий діагностичний режим: diff по секціях проти канонічних `template/`
46
+ (`main.js`/`preview.js`/`mocks/gql-sse.js`/`package.json#scripts.storybook`/vitest
47
+ `test.projects`/`vitest.stryker.config`), **без сліпого перезапису** розбіжних файлів.
48
+ Автофікс (`--fix-missing`) генерує ЛИШЕ секції, яких немає ВЗАГАЛІ — розбіжні секції завжди
49
+ йдуть як інструкція для агента/людини, ніколи не переписуються автоматично.
50
+
51
+ ```bash
52
+ # Діагностика всіх пакетів у скоупі (без запису)
53
+ bun node_modules/@7n/rules-lang-js/rules/storybook/adopt/main.mjs
54
+
55
+ # + генерація повністю відсутніх секцій (main.js/preview.js/mocks/scripts/vitest-конфіги
56
+ # лишаються недоторканими, якщо вже існують хоч у якомусь вигляді)
57
+ bun node_modules/@7n/rules-lang-js/rules/storybook/adopt/main.mjs --fix-missing
58
+
59
+ # Звузити діагностику до конкретних пакетів (root dir, той самий формат що storybook.optOut)
60
+ bun node_modules/@7n/rules-lang-js/rules/storybook/adopt/main.mjs --fix-missing packages/ui packages/legacy-ui
61
+ ```
62
+
63
+ Прогін через прямий виклик скрипта плагіна (`node_modules/@7n/rules-lang-js/...`) — CLI-плюмбінг
64
+ окремого прапорця `--adopt` у ядро `n-rules.js` для однієї команди одного правила визнано
65
+ надлишковим (нема інших concern-ів з подібним diagnostic-only режимом; якщо з'явиться другий
66
+ кандидат — тоді вартий узагальнення на рівні ядра).
67
+
68
+ ### Формат звіту
69
+
70
+ Для кожного пакета — статус (`canonical` / `missing-files` / `differs` / `broken`) і розклад
71
+ по секціях (`match` / `differ` / `missing`, з поясненням для `differ`). При `--fix-missing` —
72
+ перелік згенерованих файлів. Приклад одного пакета:
73
+
74
+ ```text
75
+ ⚠️ [packages/legacy-ui] differs
76
+ ✗ main.js (packages/legacy-ui/.storybook/main.js): differ — бракує: viteFinal-override vite.config пакета
77
+ + preview.js (packages/legacy-ui/.storybook/preview.js): missing
78
+ ✗ package.json#scripts.storybook (packages/legacy-ui/package.json): differ — зараз 'storybook dev', канон '...'
79
+ ```
80
+
81
+ Прочитай звіт користувачу: скільки пакетів канонічні, скільки мають лише прогалини (безпечно
82
+ `--fix-missing`), скільки мають розбіжності (потребують ручного рішення — мігрувати секцію на
83
+ канон чи свідомо лишити виняток), скільки зламані (circuit breaker нижче).
84
+
85
+ ## Circuit breaker (ADR)
86
+
87
+ Якщо діагностика чи фікс одного пакета кидає виняток (пошкоджений файл, directory замість файлу
88
+ тощо) — цей пакет позначається `status: 'broken'` з текстом помилки, а решта пакетів прогону
89
+ обробляються далі. Один зламаний пакет ніколи не валить увесь `--adopt`-прогін.
90
+
91
+ ## Rollout-порядок (ADR Кластер 8)
92
+
93
+ Пілот на одному репо перед глобальним увімкненням правила; серед пакетів одного репо — малі
94
+ спершу (менше `.vue`-файлів → менший blast radius одного скафолд-фейлу). Це організаційна
95
+ рекомендація для агента/людини, що вмикає правило — скіл сам не має списку "усіх репо
96
+ організації", тому не автоматизує послідовність.
@@ -0,0 +1 @@
1
+ { "auto": ["storybook"], "worktree": true }