@7n/rules 1.36.1 → 1.38.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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.38.0] - 2026-07-21
4
+
5
+ ### Fixed
6
+
7
+ - tauri/gitignore_target, tauri/core_test_isolation: інваріант workspace-кореня замість хибної typo-версії
8
+
9
+ ## [1.37.0] - 2026-07-21
10
+
11
+ ### Added
12
+
13
+ - skills/storybook: скіл `n-storybook` — тонка обгортка запуску канону Storybook (`@7n/rules-lang-js`): звичайний прогін `lint storybook` і `--adopt`-режим для rollout-у на пакетах з уже наявним ручним Storybook (ADR канон-storybook-для-vue-компонентних-бібліотек, Кластер 8)
14
+
15
+ ### Fixed
16
+
17
+ - 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
18
+
3
19
  ## [1.36.1] - 2026-07-20
4
20
 
5
21
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/rules",
3
- "version": "1.36.1",
3
+ "version": "1.38.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
@@ -15,23 +15,37 @@ rebuild (лінковка, bundling, іноді webview) замість секу
15
15
 
16
16
  ```
17
17
  my-tauri-app/
18
- ├── src-tauri/ app-shell: Tauri runtime, window/AppHandle, НЕ містить LLM-залежностей
19
- │ └── Cargo.toml [workspace] members = [".", "../agent-core"]
20
- └── agent-core/ agent/LLM-логіка: prompt building, tool dispatch, provider abstraction
21
- ├── Cargo.toml без залежності на tauri
18
+ ├── Cargo.toml workspace root (virtual, без [package]): [workspace] members = ["src-tauri", "agent-core"]
19
+ ├── src-tauri/ app-shell: Tauri runtime, window/AppHandle, НЕ містить LLM-залежностей
20
+ └── Cargo.toml [package], без [workspace] сам по собі НЕ workspace root
21
+ └── agent-core/ agent/LLM-логіка: prompt building, tool dispatch, provider abstraction
22
+ ├── Cargo.toml ← без залежності на tauri
22
23
  └── tests/
23
24
  └── fake_provider.rs ← FakeLlmProvider / MockLlmClient — без мережі
24
25
  ```
25
26
 
27
+ `[workspace] members = [".", "../agent-core"]` **всередині** `src-tauri/Cargo.toml` — невалідний
28
+ Cargo layout: `../agent-core` лежить поза деревом workspace root (`src-tauri/`), і Cargo відмовляє
29
+ з `workspace member '.../agent-core/Cargo.toml' is not hierarchically below the workspace root`.
30
+ Тому `[workspace]` живе у product-root `Cargo.toml`, що лежить НАД обома крейтами (канон
31
+ `rust/workspace_root.mdc` — один кореневий Cargo workspace на репозиторій).
32
+
33
+ **Наслідок:** `target/` компілюється у корінь цього workspace (`my-tauri-app/target/`), а не в
34
+ `src-tauri/target/` — це впливає на очікуваний запис у `.gitignore` (`tauri/gitignore_target`) і на
35
+ шлях у `workspaces:`-полі `Swatinem/rust-cache` GitHub Action (action бере шлях буквально як
36
+ `<path>/target`, тож там має стояти корінь цього workspace, не `src-tauri`).
37
+
26
38
  Концерн `tauri/core_test_isolation.mjs` (read-only detector, `structural`, авто-фікс ризикований)
27
39
  для кожного `src-tauri/Cargo.toml` перевіряє:
28
40
 
29
41
  1. **LLM-залежність не в app-shell.** Якщо сам `src-tauri/Cargo.toml` має залежність на відомий
30
42
  LLM SDK (напр. `async-openai`, `anthropic`, `ollama-rs`, `rig-core`, ...) — фейл: винеси
31
43
  agent-логіку в окремий crate.
32
- 2. **Окремий crate не тягне tauri.** Якщо у `[workspace] members` є crate з LLM-залежністю, і
33
- цей crate сам залежить від `tauri`/`tauri-*` фейл: `cargo test -p` цього крейту все одно
34
- перезбирає Tauri runtime.
44
+ 2. **Окремий crate не тягне tauri.** `[workspace] members` резолвиться або з самого
45
+ `src-tauri/Cargo.toml` (standalone-патерн), абоколи там `[workspace]` немає з
46
+ найближчого предка-workspace root (product-root `Cargo.toml` над `src-tauri/`). Якщо серед
47
+ members є crate з LLM-залежністю, і цей crate сам залежить від `tauri`/`tauri-*` — фейл:
48
+ `cargo test -p` цього крейту все одно перезбирає Tauri runtime.
35
49
  3. **Є fake-провайдер.** Якщо такий crate є і не залежить від tauri, але ніде в його дереві
36
50
  (`**/*.rs`) немає маркера `Fake*Llm`/`Mock*Provider`/`Stub*Client` — фейл: інтеграційні тести
37
51
  на prompt/tool ітерації або мовчки пропускають LLM-логіку, або б'ють по реальному провайдеру.
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: main.mjs
4
4
  resource: npm/rules/tauri/core_test_isolation/main.mjs
5
5
  docgen:
6
- crc: cfb870f5
6
+ crc: 2fe501fb
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  score: 100
9
9
  issues: best-of-2:retry-won,judge:inaccurate:0.98
@@ -14,6 +14,7 @@ import { parse as parseToml } from 'smol-toml'
14
14
 
15
15
  import { scanGlob } from '../../../scripts/utils/glob-compat.mjs'
16
16
  import { createViolationReporter } from '../../../scripts/lib/lint-surface/violation-reporter.mjs'
17
+ import { findAncestorWorkspaceRoot, resolveWorkspaceMemberDirs } from '../../../scripts/utils/cargo-workspace.mjs'
17
18
  import { getMonorepoPackageRootDirs } from '../../../scripts/lib/workspaces.mjs'
18
19
 
19
20
  /** Стабільні reasons для трьох типів порушення. */
@@ -55,29 +56,6 @@ async function findSrcTauriDirs(cwd) {
55
56
  return result
56
57
  }
57
58
 
58
- /**
59
- * Резолвить `[workspace] members` (шляхи й прості glob-патерни) відносно `src-tauri/`
60
- * у список абсолютних каталогів крейтів, що мають власний Cargo.toml.
61
- * @param {string} srcTauriDir абсолютний шлях до `src-tauri/`
62
- * @param {string[]} members патерни з `workspace.members`
63
- * @returns {Promise<string[]>} абсолютні шляхи членів воркспейсу (без дублікатів)
64
- */
65
- async function resolveWorkspaceMemberDirs(srcTauriDir, members) {
66
- const found = new Set()
67
- for (const pattern of members) {
68
- if (pattern.includes('*')) {
69
- for await (const rel of scanGlob(pattern, srcTauriDir)) {
70
- const abs = resolve(srcTauriDir, rel)
71
- if (existsSync(join(abs, 'Cargo.toml'))) found.add(abs)
72
- }
73
- continue
74
- }
75
- const abs = resolve(srcTauriDir, pattern)
76
- if (existsSync(join(abs, 'Cargo.toml'))) found.add(abs)
77
- }
78
- return [...found]
79
- }
80
-
81
59
  /**
82
60
  * Рекурсивно шукає в дереві крейту рядок, що відповідає `FAKE_PROVIDER_RE`
83
61
  * (fake/mock/stub-реалізацію LLM-провайдера, зазвичай у `tests/` чи `src/`).
@@ -117,12 +95,19 @@ async function checkOneSrcTauri(srcTauriDir, cwd, reporter) {
117
95
  return
118
96
  }
119
97
 
120
- const members = parsed?.workspace?.members
121
- if (!Array.isArray(members) || members.length === 0) {
122
- return
98
+ // `[workspace]` живе або у самому src-tauri/Cargo.toml (старий/standalone патерн), або —
99
+ // канонічно (rust/workspace_root.mdc) у предку-workspace root над src-tauri/.
100
+ let workspaceRootDir = srcTauriDir
101
+ let members = Array.isArray(parsed?.workspace?.members) ? parsed.workspace.members : []
102
+ if (members.length === 0) {
103
+ const ancestor = await findAncestorWorkspaceRoot(srcTauriDir, cwd)
104
+ const ancestorMembers = ancestor?.parsed?.workspace?.members
105
+ if (!ancestor || !Array.isArray(ancestorMembers) || ancestorMembers.length === 0) return
106
+ workspaceRootDir = ancestor.rootDir
107
+ members = ancestorMembers
123
108
  }
124
109
 
125
- const memberDirs = await resolveWorkspaceMemberDirs(srcTauriDir, members)
110
+ const memberDirs = await resolveWorkspaceMemberDirs(workspaceRootDir, members)
126
111
  const otherMemberDirs = memberDirs.filter(d => resolve(d) !== resolve(srcTauriDir))
127
112
 
128
113
  for (const memberDir of otherMemberDirs) {
@@ -3,9 +3,8 @@ type: JS Module
3
3
  title: main.mjs
4
4
  resource: npm/rules/tauri/gitignore_target/main.mjs
5
5
  docgen:
6
- crc: 83aa81ee
6
+ crc: c9fcfc58
7
7
  model: openai-codex/gpt-5.5
8
- tier: cloud-avg
9
8
  score: 85
10
9
  issues: internal-name:findSrcTauriDirs,anchor-miss:(tauri.mdc),judge:inaccurate:0.98
11
10
  judgeModel: openai-codex/gpt-5.4-mini
@@ -1,23 +1,42 @@
1
- ## `.gitignore`: ignore build-артефактів `<ws>/src-tauri/target/`
1
+ ## `.gitignore`: ignore build-артефактів фактичного Cargo workspace root
2
2
 
3
- Кожен `<ws>/src-tauri/` (Rust-крейт Tauri) компілюється в `<ws>/src-tauri/target/` build-артефакти Cargo (сотні MB, десятки тисяч файлів). Корінний `.gitignore` монорепо повинен містити точний ignore-запис для кожного такого воркспейсу:
3
+ Кожен `<ws>/src-tauri/` (Rust-крейт Tauri) компілюється в `target/` свого фактичного
4
+ Cargo workspace root — а не завжди в `<ws>/src-tauri/target/`. Якщо над `src-tauri/` є
5
+ предок-workspace (канон `rust/workspace_root.mdc` — один кореневий Cargo workspace на
6
+ репозиторій, наприклад `<ws>/Cargo.toml` чи кореневий `Cargo.toml` монорепо), Cargo кладе
7
+ `target/` саме туди. Корінний `.gitignore` монорепо повинен містити точний ignore-запис
8
+ для кожного такого крейту, обчислений від цього фактичного workspace root:
4
9
 
5
10
  ```gitignore
6
11
  # Tauri — Rust build artifacts (tauri.mdc)
7
- <ws>/src-tauri/target/
12
+ <workspace-root>/target/
8
13
  ```
9
14
 
15
+ де `<workspace-root>` — найближчий предок `src-tauri/` з `[workspace]`, чиї `members`
16
+ покривають цей крейт (якщо це корінь монорепо — запис голий `target/`); якщо предка-workspace
17
+ немає — крейт сам собі workspace root, і запис лишається `<ws>/src-tauri/target/`.
18
+
10
19
  ### Чому точний шлях, а не голий `target/`
11
20
 
12
- Реальний інцидент: у `nitra/task` корінний `.gitignore` мав `owner/target/` замість `owner/src-tauri/target/` воркспейс скопійований з іншого проєкту без заміни шляху (typo). `target/` там ігнорувався не там, де компілятор його реально створює, тож ~600MB / ~28k файлів build-артефактів не потрапляли під жоден ignore-патерн і мало не потрапили в коміт через `git add -A`.
21
+ Реальний прецедент: у `nitra/task` коміт `3cb0df3` помилково "виправив" коректний запис
22
+ `owner/target/` назад на `owner/src-tauri/target/` — автор вважав це друкарською помилкою,
23
+ не врахувавши, що `owner/Cargo.toml` є Cargo workspace root для `owner/src-tauri` і тому
24
+ `target/` справді компілюється в `owner/target/`, а не в `owner/src-tauri/target/`. Коміт
25
+ `ac3451e` це скасував. Тобто `<ws>/src-tauri/target/` — не універсальний дефолт, а лише
26
+ окремий випадок (crate без workspace root над собою); перевірка мусить рахувати очікуваний
27
+ шлях від реального workspace root, а не від фіксованого суфікса.
13
28
 
14
- Тому перевірка звіряє **точний рядок** `<ws>/src-tauri/target/` для кожного знайденого `src-tauri/`, а не факт наявності підрядка `target/` десь у файлі — голий `target/` (чи вже наявний, але помилковий `owner/target/`) не закриває violation.
29
+ Тому перевірка звіряє **точний рядок** обчисленого запису для кожного знайденого `src-tauri/`,
30
+ а не факт наявності підрядка `target/` десь у файлі — голий `target/` чи запис не того рівня
31
+ не закривають violation. Сторонній/мертвий каталог `target/` на диску (за старим, вже
32
+ невірним шляхом) також не задовольняє перевірку — вона звіряє лише вміст `.gitignore`.
15
33
 
16
34
  ### Область дії
17
35
 
18
36
  - Обхід усіх `<ws>/src-tauri/` — спільний з `tauri/cargo_mutants_config` (`findSrcTauriDirs`, без дублювання обходу монорепо).
37
+ - Очікуваний шлях — `findAncestorWorkspaceRoot` (`scripts/utils/cargo-workspace.mjs`, спільно з `rust/workspace_root`).
19
38
  - Ціль фіксу — **один** корінний `.gitignore` монорепо (не по workspace).
20
- - Детектор лише **додає** відсутнє: сторонні/застарілі ignore-записи (на кшталт помилкового `owner/target/` з інциденту вище) не видаляються — вони можуть бути навмисними чи потребувати ручного review.
39
+ - Детектор лише **додає** відсутнє: сторонні/застарілі ignore-записи не видаляються — вони можуть бути навмисними чи потребувати ручного review.
21
40
 
22
41
  ### Автофікс (T0)
23
42
 
@@ -2,15 +2,19 @@
2
2
  * @see ./docs/main.md
3
3
  *
4
4
  * Read-only detector: для кожного знайденого `<ws>/src-tauri/` у монорепо
5
- * корінний `.gitignore` повинен містити точний ignore-запис
6
- * `<ws>/src-tauri/target/` (tauri.mdc).
5
+ * корінний `.gitignore` повинен містити точний ignore-запис для build-артефактів
6
+ * але шлях залежить від фактичного Cargo workspace root крейту, не від
7
+ * розташування самого `src-tauri/` (tauri.mdc).
7
8
  *
8
- * Реальний інцидент: у `nitra/task` `.gitignore` мав `owner/target/` замість
9
- * `owner/src-tauri/target/` (typo воркспейс скопійований без заміни шляху),
10
- * тому build-артефакти (~600MB, ~28k файлів) не ігнорувались і мало не
11
- * потрапили в коміт через `git add -A`. Тому перевірка точний match рядка
12
- * повного шляху, не пошук голого `target/` десь у файлі: голий `target/`
13
- * (чи `owner/target/`) дав би false negative саме на цьому кейсі.
9
+ * Крейт `src-tauri/` компілюється в `<workspace-root>/target/`, а не обов'язково
10
+ * в `<ws>/src-tauri/target/`: коли над `src-tauri/` є предок-workspace (канон
11
+ * `rust/workspace_root` один кореневий Cargo workspace на репозиторій), Cargo
12
+ * кладе `target/` саме туди. Реальний прецедент: у `nitra/task` коміт `3cb0df3`
13
+ * помилково "виправив" коректний запис `owner/target/` назад на `owner/src-tauri/target/`
14
+ * (бо `owner/Cargo.toml` workspace root для `owner/src-tauri`), що скасував наступний
15
+ * коміт `ac3451e`. Тому перевірка обчислює очікуваний шлях від реального workspace root,
16
+ * а не від фіксованого суфікса `src-tauri/target/`, і звіряє точний рядок — голий
17
+ * `target/` чи запис не-того рівня не рахуються присутністю потрібного запису.
14
18
  *
15
19
  * Обхід `<ws>/src-tauri/` — спільний з `tauri/cargo_mutants_config`
16
20
  * (`findSrcTauriDirs`), без дублювання.
@@ -20,6 +24,7 @@ import { readFile } from 'node:fs/promises'
20
24
  import { join, relative } from 'node:path'
21
25
 
22
26
  import { createViolationReporter } from '../../../scripts/lib/lint-surface/violation-reporter.mjs'
27
+ import { findAncestorWorkspaceRoot } from '../../../scripts/utils/cargo-workspace.mjs'
23
28
  import { findSrcTauriDirs } from '../cargo_mutants_config/main.mjs'
24
29
 
25
30
  /** Стабільний reason: у корінному `.gitignore` бракує ignore-запису(ів) для `src-tauri/target/`. */
@@ -29,13 +34,22 @@ export const MISSING_GITIGNORE_TARGET_ENTRIES = 'missing-gitignore-target-entrie
29
34
  export const ROOT_GITIGNORE = '.gitignore'
30
35
 
31
36
  /**
32
- * Очікуваний ignore-запис для build-артефактів одного `<ws>/src-tauri/`.
37
+ * Очікуваний ignore-запис для build-артефактів одного `<ws>/src-tauri/`: шлях до
38
+ * `target/` фактичного Cargo workspace root цього крейту (найближчий предок з
39
+ * `[workspace]`, чиї `members` покривають `src-tauri/`), а не суфікс `src-tauri/target/`
40
+ * за замовчуванням. Якщо предка-workspace немає — крейт сам собі workspace root
41
+ * (standalone `src-tauri/Cargo.toml`), і `target/` лишається під `src-tauri/`.
33
42
  * @param {string} cwd корінь монорепо
34
43
  * @param {string} srcTauriDir абсолютний шлях до `src-tauri/`
35
- * @returns {string} очікуваний рядок у корінному `.gitignore` (`<ws>/src-tauri/target/`)
44
+ * @returns {Promise<string>} очікуваний рядок у корінному `.gitignore`
36
45
  */
37
- export function expectedTargetEntry(cwd, srcTauriDir) {
38
- return `${relative(cwd, srcTauriDir)}/target/`
46
+ export async function expectedTargetEntry(cwd, srcTauriDir) {
47
+ const ancestor = await findAncestorWorkspaceRoot(srcTauriDir, cwd)
48
+ if (!ancestor) {
49
+ return `${relative(cwd, srcTauriDir)}/target/`
50
+ }
51
+ const relRoot = relative(cwd, ancestor.rootDir)
52
+ return relRoot === '' ? 'target/' : `${relRoot}/target/`
39
53
  }
40
54
 
41
55
  /**
@@ -62,7 +76,8 @@ export async function lint(ctx) {
62
76
  const srcTauriDirs = await findSrcTauriDirs(cwd)
63
77
  if (srcTauriDirs.length === 0) return reporter.result()
64
78
 
65
- const expectedEntries = srcTauriDirs.map(dir => expectedTargetEntry(cwd, dir))
79
+ const expectedEntries = []
80
+ for (const dir of srcTauriDirs) expectedEntries.push(await expectedTargetEntry(cwd, dir))
66
81
  const abs = join(cwd, ROOT_GITIGNORE)
67
82
  const content = existsSync(abs) ? await readFile(abs, 'utf8') : ''
68
83
 
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Спільні T0 (без spawn `cargo`) утиліти для роботи з Cargo workspace-структурою:
3
+ * читання Cargo.toml, резолв `[workspace].members`-glob-патернів у каталоги,
4
+ * пошук найближчого предка-workspace root для крейту. Спільно використовується
5
+ * `plugins/lang-rust/rules/rust/workspace_root` і `npm/rules/tauri/gitignore_target`.
6
+ */
7
+ import { existsSync } from 'node:fs'
8
+ import { readFile } from 'node:fs/promises'
9
+ import { dirname, join, resolve } from 'node:path'
10
+
11
+ import { parse as parseToml } from 'smol-toml'
12
+
13
+ import { scanGlob } from './glob-compat.mjs'
14
+
15
+ const TRAILING_SLASH_RE = /\/$/
16
+
17
+ /**
18
+ * Розпарсений Cargo.toml або null (файл відсутній чи невалідний TOML).
19
+ * @param {string} absPath абсолютний шлях до Cargo.toml
20
+ * @returns {Promise<Record<string, unknown>|null>} розпарсений маніфест або null
21
+ */
22
+ export async function readCargoManifest(absPath) {
23
+ if (!existsSync(absPath)) return null
24
+ try {
25
+ return parseToml(await readFile(absPath, 'utf8'))
26
+ } catch {
27
+ return null
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Резолвить `[workspace].members`/`.exclude`-патерни (літеральні шляхи й прості
33
+ * glob з `*`) відносно `rootDir` у список абсолютних каталогів, що мають власний
34
+ * Cargo.toml. Без повної Cargo glob-семантики — лише `*`-сегменти й літерали.
35
+ * @param {string} rootDir корінь workspace (каталог з Cargo.toml, де живе `[workspace]`)
36
+ * @param {string[]} patterns патерни з `members`/`exclude`
37
+ * @returns {Promise<string[]>} абсолютні шляхи (без дублікатів)
38
+ */
39
+ export async function resolveWorkspaceMemberDirs(rootDir, patterns) {
40
+ const found = new Set()
41
+ for (const pattern of patterns ?? []) {
42
+ const norm = pattern.replace(TRAILING_SLASH_RE, '')
43
+ if (norm.includes('*')) {
44
+ // Шаблон для `Cargo.toml` напряму (не для каталогів) — деякі раннери (Bun.Glob)
45
+ // за замовчуванням матчать лише файли, тож патерн на голий каталог даватиме 0 збігів.
46
+ for await (const relManifest of scanGlob(`${norm}/Cargo.toml`, rootDir)) {
47
+ found.add(resolve(rootDir, dirname(relManifest)))
48
+ }
49
+ continue
50
+ }
51
+ const abs = resolve(rootDir, norm)
52
+ if (existsSync(join(abs, 'Cargo.toml'))) found.add(abs)
53
+ }
54
+ return [...found]
55
+ }
56
+
57
+ /**
58
+ * Чи покриває `[workspace].members` (мінус `.exclude`) конкретний каталог-крейт.
59
+ * @param {string} rootDir корінь workspace
60
+ * @param {string} crateDirAbs абсолютний шлях крейту
61
+ * @param {string[]} members `workspace.members`
62
+ * @param {string[]} excludes `workspace.exclude`
63
+ * @returns {Promise<boolean>} true — крейт є членом workspace
64
+ */
65
+ export async function isWorkspaceMemberDir(rootDir, crateDirAbs, members, excludes) {
66
+ const target = resolve(crateDirAbs)
67
+ const memberDirs = await resolveWorkspaceMemberDirs(rootDir, members)
68
+ if (memberDirs.every(d => resolve(d) !== target)) return false
69
+ if (!excludes || excludes.length === 0) return true
70
+ const excludedDirs = await resolveWorkspaceMemberDirs(rootDir, excludes)
71
+ return excludedDirs.every(d => resolve(d) !== target)
72
+ }
73
+
74
+ /**
75
+ * Йде від `dirname(crateDirAbs)` вгору по предках до `repoRootAbs` (включно),
76
+ * шукаючи найближчий Cargo.toml з `[workspace]`, чиї `members` (мінус `exclude`)
77
+ * покривають `crateDirAbs`. Не перевіряє сам `crateDirAbs` (виклик для нього — окремо).
78
+ * @param {string} crateDirAbs абсолютний шлях крейту (напр. `<repo>/owner/src-tauri`)
79
+ * @param {string} repoRootAbs абсолютний корінь репозиторію (межа обходу вгору)
80
+ * @returns {Promise<{rootDir: string, parsed: Record<string, unknown>}|null>} найближчий ancestor workspace root або null
81
+ */
82
+ export async function findAncestorWorkspaceRoot(crateDirAbs, repoRootAbs) {
83
+ const stopAt = resolve(repoRootAbs)
84
+ let dir = dirname(resolve(crateDirAbs))
85
+ for (;;) {
86
+ const parsed = await readCargoManifest(join(dir, 'Cargo.toml'))
87
+ if (parsed?.workspace) {
88
+ const members = Array.isArray(parsed.workspace.members) ? parsed.workspace.members : []
89
+ const excludes = Array.isArray(parsed.workspace.exclude) ? parsed.workspace.exclude : []
90
+ if (await isWorkspaceMemberDir(dir, crateDirAbs, members, excludes)) {
91
+ return { rootDir: dir, parsed }
92
+ }
93
+ }
94
+ if (dir === stopAt) return null
95
+ const parent = dirname(dir)
96
+ if (parent === dir) return null
97
+ dir = parent
98
+ }
99
+ }
@@ -0,0 +1,40 @@
1
+ ---
2
+ type: JS Module
3
+ title: cargo-workspace.mjs
4
+ resource: npm/scripts/utils/cargo-workspace.mjs
5
+ docgen:
6
+ crc: c852e1fe
7
+ model: openai-codex/gpt-5.4-mini
8
+ tier: cloud-min
9
+ score: 100
10
+ issues: judge:inaccurate:0.98
11
+ judgeModel: openai-codex/gpt-5.4-mini
12
+ ---
13
+
14
+ ## Огляд
15
+
16
+ Спільні утиліти для читання `Cargo.toml`, розгортання `[workspace].members`-glob-патернів у каталоги та пошуку найближчого предка-workspace root для крейта. Публічні `readCargoManifest`, `resolveWorkspaceMemberDirs`, `isWorkspaceMemberDir`, `findAncestorWorkspaceRoot` працюють без запуску `cargo`, перехоплюють помилки й у частині випадків повертають порожнє значення замість винятку, щоб інші правила могли безпечно перевіряти належність каталогу до workspace або знаходити workspace root.
17
+
18
+ ## Поведінка
19
+
20
+ - **readCargoManifest** — читає `Cargo.toml` і повертає розпарсений manifest, або `null`, якщо файл відсутній чи TOML невалідний.
21
+ - **resolveWorkspaceMemberDirs** — перетворює workspace-патерни на список абсолютних каталогів із власним `Cargo.toml`, усуваючи дублікати.
22
+ - **isWorkspaceMemberDir** — визначає, чи належить конкретний каталог до workspace з урахуванням `members` і `exclude`.
23
+ - **findAncestorWorkspaceRoot** — шукає найближчий предок `Cargo.toml` з `[workspace]`, чий workspace покриває крейт; сам каталог крейту не перевіряє.
24
+
25
+ ## Публічний API
26
+
27
+ - readCargoManifest — Розпарсений Cargo.toml або null (файл відсутній чи невалідний TOML).
28
+ - resolveWorkspaceMemberDirs — Резолвить `[workspace].members`/`.exclude`-патерни (літеральні шляхи й прості
29
+ glob з `*`) відносно `rootDir` у список абсолютних каталогів, що мають власний
30
+ Cargo.toml. Без повної Cargo glob-семантики — лише `*`-сегменти й літерали.
31
+ - isWorkspaceMemberDir — Чи покриває `[workspace].members` (мінус `.exclude`) конкретний каталог-крейт.
32
+ - findAncestorWorkspaceRoot — Йде від `dirname(crateDirAbs)` вгору по предках до `repoRootAbs` (включно),
33
+ шукаючи найближчий Cargo.toml з `[workspace]`, чиї `members` (мінус `exclude`)
34
+ покривають `crateDirAbs`. Не перевіряє сам `crateDirAbs` (виклик для нього — окремо).
35
+
36
+ ## Гарантії поведінки
37
+
38
+ - Read-only: не виконує операцій запису (ФС/БД).
39
+ - Перехоплює помилки і не пропускає винятків назовні (fail-safe).
40
+ - За певних помилок повертає порожнє значення (напр. `null`) замість винятку.
@@ -9,6 +9,7 @@ resource: npm/scripts/utils/
9
9
  | [apply-to-files.mjs](apply-to-files.md) | JS Module |
10
10
  | [ast-extract.mjs](ast-extract.md) | JS Module |
11
11
  | [ast-scan-utils.mjs](ast-scan-utils.md) | JS Module |
12
+ | [cargo-workspace.mjs](cargo-workspace.md) | JS Module |
12
13
  | [ensure-gitignore-entries.mjs](ensure-gitignore-entries.md) | JS Module |
13
14
  | [find-package-json-paths.mjs](find-package-json-paths.md) | JS Module |
14
15
  | [glob-compat.mjs](glob-compat.md) | JS Module |
@@ -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 }