@7n/rules-ci-github 2.3.2 → 2.4.1

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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.4.1] - 2026-08-26
4
+
5
+ ### Fixed
6
+
7
+ - docker.lint_docker_yml: додано фолбек object.get(input, "on", …) для YAML 1.2 (regorus/saphyr) на випадок міграції з conftest — без нього push_paths_set стає порожнім і deny спрацьовує на кожному валідному workflow
8
+
9
+ ## [2.4.0] - 2026-08-25
10
+
11
+ ### Removed
12
+
13
+ - rust/toolchain_cache і ga/workflows: видалено JS lint-детектори (main.mjs) — канон тепер wasm-гість crates/plugin-ci-github (detect_toolchain_cache, detect_workflows). Фіксери (fix-toolchain_cache.mjs, fix-workflows.mjs) лишаються JS; сканувальний рушій toolchain_cache перенесено у фіксер. .mdc обох концернів оновлено: посилання на main.mjs замінено на назви детекторів гостя, ga/workflows додатково фіксує regorus in-process замість зовнішнього conftest
14
+
3
15
  ## [2.3.2] - 2026-08-25
4
16
 
5
17
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/rules-ci-github",
3
- "version": "2.3.2",
3
+ "version": "2.4.1",
4
4
  "description": "Плагін @7n/rules: канон GitHub Actions (.github/workflows) — обов'язкові workflow, zizmor, VS Code",
5
5
  "keywords": [
6
6
  "github-actions",
@@ -40,10 +40,13 @@ all_run_text := concat("\n", [run_text |
40
40
  run_text := step_run_to_text(step)
41
41
  ])
42
42
 
43
- # conftest парсить YAML 1.1, тож канонічний `on:` без лапок стає булевим ключем
44
- # `true` (як у `ga.lint_ga`). Тому читаємо через `input["true"]`.
43
+ # conftest (YAML 1.1) серіалізує канонічний `on:` без лапок у булевий ключ
44
+ # `true`; regorus/saphyr (YAML 1.2, in-process шлях crates/plugin-ci-github)
45
+ # лишає рядковий ключ `"on"`. Фолбек — як у `ga.lint_ga` та сусідніх пакетів.
46
+ gha_on := object.get(input, "on", object.get(input, "true", {}))
47
+
45
48
  push_paths_set := {p |
46
- some p in object.get(object.get(object.get(input, "true", {}), "push", {}), "paths", [])
49
+ some p in object.get(object.get(gha_on, "push", {}), "paths", [])
47
50
  }
48
51
 
49
52
  # ── deny: on.push.paths subset-of ──────────────────────────────────────
@@ -7,4 +7,3 @@ resource: plugins/ci-github/rules/ga/workflows/
7
7
  | Файл | Тип |
8
8
  | ------------------------------------- | --------- |
9
9
  | [fix-workflows.mjs](fix-workflows.md) | JS Module |
10
- | [main.mjs](main.md) | JS Module |
@@ -1,3 +1,39 @@
1
+ Concern: `ga/workflows` (`detect_workflows`, wasm-гість `crates/plugin-ci-github` — JS-детектора (`main.mjs`) більше немає, подвійну реалізацію знято).
2
+
3
+ ## Rego лишається буквальним джерелом правди
4
+
5
+ ЧОТИРИ per-workflow rego-політики — `clean_ga_workflows`, `clean_merged_branch`,
6
+ `lint_ga`, `git_ai` — плюс спільна `workflow_common` (кожна лежить поряд, у
7
+ `plugins/ci-github/rules/ga/<name>/<name>.rego`, той самий файл, що читає й
8
+ документує своя концерн-тека) виконуються **IN-PROCESS через
9
+ [regorus](https://github.com/microsoft/regorus)** усередині гостя —
10
+ `include_str!`-вшиті напряму з файлової системи (`crates/plugin-ci-github/src/lib.rs`),
11
+ не переказані на Rust. Решта `.rego`-концернів під `plugins/ci-github/rules/ga/`
12
+ (`service_deploy_workflow`, `vscode_extensions`, `vscode_settings`, `zizmor_yml`)
13
+ у гість НЕ вшиті — поза обсягом цього порту.
14
+
15
+ Раніше вшиту п'ятірку виконував зовнішній `conftest`, викликаний JS-детектором
16
+ (`main.mjs`, якого більше немає); тепер субпроцес `conftest` не спавниться
17
+ взагалі — `regorus::Engine` компілює й обчислює `data.<namespace>.deny` прямо в
18
+ пам'яті гостя. Анти-дрейф-тест
19
+ (`embedded_rego_policies_namespace_matches_rust_side_constant`,
20
+ `crates/plugin-ci-github/src/lib.rs`) звіряє, що Rust-side namespace-константи
21
+ й досі збігаються з `package`-декларацією вшитого `.rego` — розходження ловить
22
+ compile/eval-помилку, не мовчазний no-op.
23
+
24
+ ## Канал «shellcheck відсутній» змінив ФОРМУ, не наслідок
25
+
26
+ JS-канон (`main.mjs`, якого більше немає) мав ДВІ незалежні перевірки
27
+ `shellcheck`: ранній `ensureTool('shellcheck')`-preflight (Plugin API v2, міг
28
+ кинути виняток і завалити весь `lint()`, якщо auto-install вимкнено) і пізніший
29
+ `checkShellcheckInstalled` (звичайне порушення з підказкою про встановлення).
30
+ Гість несе лише ДРУГИЙ канал: `check_shellcheck_installed`
31
+ (`crates/plugin-ci-github/src/lib.rs`) за відсутнього тула пише `Diagnostic`
32
+ (той самий WIT-запис, що й будь-яке порушення), ніколи не падає — контракт `exec-tool` структурно повертає `ToolResult`, не виняток.
33
+ Той самий патерн, що описала хвиля #481 для `mago` в
34
+ `plugins/lang-php/rules/php/tooling/tooling.mdc`: канал не втрачено, він
35
+ змінив форму (throw → діагностика).
36
+
1
37
  ## Структура workflow-файлів та обов'язкові workflows
2
38
 
3
39
  У `.github/workflows/` лише **`.yml`** (не `.yaml`). Обов'язкові файли:
@@ -7,4 +7,3 @@ resource: plugins/ci-github/rules/rust/toolchain_cache/
7
7
  | Файл | Тип |
8
8
  | ------------------------------------------------- | --------- |
9
9
  | [fix-toolchain_cache.mjs](fix-toolchain_cache.md) | JS Module |
10
- | [main.mjs](main.md) | JS Module |
@@ -7,10 +7,132 @@
7
7
  * де Cargo.toml не в корені репо. Текстові splice-и (як `ga/workflows/fix-workflows.mjs`) —
8
8
  * зберігають коментарі/формат, мінімальний diff. Ідемпотентно: `scanToolchainSteps`
9
9
  * заново перевіряє стан файла на кожному прогоні.
10
+ *
11
+ * `scanToolchainSteps` (і решта сканувального рушія нижче — `TOOLCHAIN_RE`/
12
+ * `CACHE_RE`/`TAURI_ACTION_RE`/`WORKSPACES_KEY_RE`/`indentOf`/`dashColFor`/
13
+ * `scanJobForCache`/`cacheStepHasWorkspaces`) переїхали СЮДИ з видаленого
14
+ * `main.mjs`: канон детекту тепер `detect_toolchain_cache` wasm-гостя
15
+ * `crates/plugin-ci-github`, і фіксер лишився ЄДИНИМ JS-споживачем цього
16
+ * коду. `tauriWorkspaceDir` і сам `lint()` — НЕ перенесені, вони вмирали разом
17
+ * із детектором (фіксер їх не використовував; перевірено grep-ом по репо).
18
+ * Раніше експортовані символи стали локальними — ніхто ззовні на них не
19
+ * посилався (перевірено тим самим grep-ом).
20
+ *
21
+ * Значення `MISSING_RUST_CACHE`/`MISSING_RUST_CACHE_WORKSPACES` мусять
22
+ * збігатися з reason-рядками гостя БАЙТ-У-БАЙТ — інакше `patterns[].test`
23
+ * нижче не побачить порушень, які гість реально видає, і T0-фікс тихо
24
+ * перестане спрацьовувати. Звірку тримає `tests/fix-toolchain_cache.test.mjs`
25
+ * (T0 round-trip-сценарій через `runWasmConcern`) і parity-гейт
26
+ * (`npm/scripts/lib/lint-surface/tests/wasm-plugin-parity-ci-github.test.mjs`).
10
27
  */
11
28
  import { applyToFiles } from '@7n/rules/scripts/utils/apply-to-files.mjs'
12
29
 
13
- import { MISSING_RUST_CACHE, MISSING_RUST_CACHE_WORKSPACES, scanToolchainSteps } from './main.mjs'
30
+ /** Reason-код: job ставить Rust toolchain, але не має кроку `Swatinem/rust-cache@v2`. */
31
+ const MISSING_RUST_CACHE = 'missing-rust-cache'
32
+ /** Reason-код: кеш-крок Tauri-job-а без `with.workspaces` на каталог `src-tauri`. */
33
+ const MISSING_RUST_CACHE_WORKSPACES = 'missing-rust-cache-workspaces'
34
+
35
+ /** Рядок кроку встановлення Rust toolchain (`dtolnay/rust-toolchain@…`). */
36
+ const TOOLCHAIN_RE = /uses:\s*dtolnay\/rust-toolchain@/u
37
+ /** Рядок кроку кешування Cargo-артефактів (`Swatinem/rust-cache@…`). */
38
+ const CACHE_RE = /uses:\s*Swatinem\/rust-cache@/u
39
+ const TAURI_ACTION_RE = /uses:\s*tauri-apps\/tauri-action@/u
40
+ const WORKSPACES_KEY_RE = /^\s*workspaces\s*:/u
41
+
42
+ /**
43
+ * Відступ рядка (кількість пробілів перед першим непробільним символом).
44
+ * @param {string} line рядок файла
45
+ * @returns {number} кількість пробілів відступу
46
+ */
47
+ function indentOf(line) {
48
+ return line.length - line.trimStart().length
49
+ }
50
+
51
+ /**
52
+ * Дашова колонка кроку (`- uses: …`) з колонки `uses:`. Захист від негативного
53
+ * значення для нетипового форматування (dash не на тому ж рядку).
54
+ * @param {number} usesCol колонка підрядка `uses:`
55
+ * @returns {number} колонка dash-а (не менше 0)
56
+ */
57
+ function dashColFor(usesCol) {
58
+ return Math.max(usesCol - 2, 0)
59
+ }
60
+
61
+ /**
62
+ * Один запис аналізу `dtolnay/rust-toolchain` кроку в межах його job-а (обмеженого
63
+ * indentation-dedent-ом, без явного YAML-парсу job-структури).
64
+ * @typedef {object} ToolchainStepScan
65
+ * @property {number} line індекс рядка кроку `dtolnay/rust-toolchain@…`
66
+ * @property {number} dashCol колонка dash-а кроку (рівень step-list-а job-а)
67
+ * @property {boolean} hasCache чи є `Swatinem/rust-cache@…` пізніше в тому самому job-і
68
+ * @property {number} cacheLine індекс рядка кеш-кроку (−1, якщо відсутній)
69
+ * @property {boolean} cacheHasWorkspaces чи кеш-крок вже має ключ `workspaces`
70
+ * @property {boolean} jobHasTauriAction чи job також викликає `tauri-apps/tauri-action`
71
+ */
72
+
73
+ /**
74
+ * Сканує job від рядка ОДРАЗУ ПІСЛЯ toolchain-кроку до dedent-у: шукає перший
75
+ * `Swatinem/rust-cache@…` крок і чи job також викликає `tauri-apps/tauri-action`.
76
+ * @param {string[]} lines усі рядки файла
77
+ * @param {number} fromLine рядок, з якого починати сканування (i + 1)
78
+ * @param {number} dashCol колонка dash-а step-list-а job-а (межа dedent-у)
79
+ * @returns {{hasCache: boolean, cacheLine: number, jobHasTauriAction: boolean}} результат сканування job-а
80
+ */
81
+ function scanJobForCache(lines, fromLine, dashCol) {
82
+ let hasCache = false
83
+ let cacheLine = -1
84
+ let jobHasTauriAction = false
85
+ for (let j = fromLine; j < lines.length; j++) {
86
+ const line = lines[j]
87
+ if (line.trim() === '') continue
88
+ if (indentOf(line) < dashCol) break // dedent → вийшли зі step-list-а цього job-а
89
+ if (!hasCache && CACHE_RE.test(line)) {
90
+ hasCache = true
91
+ cacheLine = j
92
+ }
93
+ if (TAURI_ACTION_RE.test(line)) jobHasTauriAction = true
94
+ }
95
+ return { hasCache, cacheLine, jobHasTauriAction }
96
+ }
97
+
98
+ /**
99
+ * Чи кеш-крок (`cacheLine`) уже має ключ `with.workspaces` у своєму блоці (до dedent-у).
100
+ * @param {string[]} lines усі рядки файла
101
+ * @param {number} cacheLine рядок кеш-кроку
102
+ * @param {number} dashCol колонка даша step-list-а job-а (межа dedent-у)
103
+ * @returns {boolean} true — ключ `workspaces` уже є
104
+ */
105
+ function cacheStepHasWorkspaces(lines, cacheLine, dashCol) {
106
+ for (let j = cacheLine + 1; j < lines.length; j++) {
107
+ const line = lines[j]
108
+ if (line.trim() === '') continue
109
+ if (indentOf(line) < dashCol) break
110
+ if (WORKSPACES_KEY_RE.test(line)) return true
111
+ }
112
+ return false
113
+ }
114
+
115
+ /**
116
+ * Сканує вміст workflow-файла й повертає по одному запису на кожен
117
+ * `dtolnay/rust-toolchain@…` крок, з інформацією про cache-крок і tauri-action
118
+ * у тому самому job-і (обмежено indentation-dedent-ом).
119
+ * @param {string} content вміст workflow-файла
120
+ * @returns {ToolchainStepScan[]} записи аналізу
121
+ */
122
+ function scanToolchainSteps(content) {
123
+ const lines = content.split('\n')
124
+ /** @type {ToolchainStepScan[]} */
125
+ const out = []
126
+ for (let i = 0; i < lines.length; i++) {
127
+ const usesCol = lines[i].indexOf('uses:')
128
+ if (usesCol === -1 || !TOOLCHAIN_RE.test(lines[i])) continue
129
+ const dashCol = dashColFor(usesCol)
130
+ const { hasCache, cacheLine, jobHasTauriAction } = scanJobForCache(lines, i + 1, dashCol)
131
+ const cacheHasWorkspaces = hasCache && cacheStepHasWorkspaces(lines, cacheLine, dashCol)
132
+ out.push({ line: i, dashCol, hasCache, cacheLine, cacheHasWorkspaces, jobHasTauriAction })
133
+ }
134
+ return out
135
+ }
14
136
 
15
137
  /**
16
138
  * Індекс першого рядка після step-блоку, що починається на `stepLine`
@@ -1,6 +1,6 @@
1
1
  ## Rust-cache після rust-toolchain — у будь-якому workflow
2
2
 
3
- Concern: `rust/toolchain_cache` (JS-detector, `main.mjs` не rego)
3
+ Concern: `rust/toolchain_cache` (`detect_toolchain_cache`, wasm-гість `crates/plugin-ci-github` — не rego; JS-детектора (`main.mjs`) більше немає, подвійну реалізацію знято)
4
4
 
5
5
  Цільові файли: `.github/workflows/*.yml`, `.github/workflows/*.yaml` (кожен файл окремо)
6
6
 
@@ -1,40 +0,0 @@
1
- ---
2
- type: JS Module
3
- title: main.mjs
4
- resource: plugins/ci-github/rules/ga/workflows/main.mjs
5
- docgen:
6
- crc: 0eb11a60
7
- ---
8
-
9
- ## Огляд
10
-
11
- Please provide the code you want me to document.
12
-
13
- ## Поведінка
14
-
15
- Поведінка:
16
- checkShellcheckInstalled перевіряє локальну наявність інструменту `shellcheck` у системному PATH, щоб забезпечити узгодженість логіки CI та локального запуску.
17
- main виконує комплексну перевірку відповідності репозиторію правилам `ga.mdc`, включаючи валідацію структури GitHub Actions workflow, аналіз посилань на файли за допомогою `git ls-files` та пошук залишків MegaLinter. Canonical language config paths не вважаються stale, якщо сам config ще відсутній: workflow має бути готовий підхопити його появу без окремої синхронної правки CI.
18
- lint запускає стандартний лінтинг, який включає перевірку `actionlint` та `zizmor` для забезпечення якості YAML-файлів та робочого середовища, а також ініціює повну перевірку `main`.
19
- runLintGaCli запускає процес лінтингу `ga` через командний інтерфейс.
20
-
21
- ## Публічний API
22
-
23
- Підтверджую. Я дію як технічний письменник.
24
-
25
- Ось переписаний список у вигляді стислих маркерів відповідно до ваших вимог:
26
-
27
- - checkShellcheckInstalled — Визначає, чи є інструмент `shellcheck` у системному шляху, щоб уникнути ложного позитивного результату локального линтування, коли CI-оркестр вимагає його присутності.
28
- - main — Валідує відповідність усієї проєктної конфігурації вимогам, визначеним у `ga.mdc`.
29
- - Plan B-патерн — Виконує первинну валідацію структури CI-workflow за допомогою Rego-полісі у `npm/policy/ga/`; додатково проводить крос-файлові перевірки та аналіз конфігурацій (наприклад, для VS Code), ініційовавши цей процес за допомогою `bun run lint-ga`.
30
- - lint — Виконує повноцінний аналіз конфігурацій CI/CD з використанням `actionlint` та `zizmor`.
31
- - runLintGaCli — Запускає повний набір перевірок, що включають Rego-валідацію, аналіз `actionlint` та зовнішні інструменти для забезпечення консистентності конфігурацій проєкту.
32
-
33
- ## Гарантії поведінки
34
-
35
- - Read-only: не виконує операцій запису (ФС/БД).
36
- - Перехоплює помилки і не пропускає винятків назовні (fail-safe).
37
- - За певних помилок повертає порожнє значення (напр. `null`) замість винятку.
38
- - Свідомо пропускає шляхи: `.github`, `.git`.
39
-
40
- **Плагін:** правило живе у `@7n/rules-ci-github`; спільні lib імпортуються як `@7n/rules/scripts/...`, а policy-теки передаються у `runConftestBatch` через `policyDirAbs` (вбудований rules/ ядра тут недосяжний).
@@ -1,446 +0,0 @@
1
- /** @see ./docs/workflows.md */
2
- import { existsSync } from 'node:fs'
3
- import { readdir, readFile } from 'node:fs/promises'
4
- import { execFileSync } from 'node:child_process'
5
- import { basename, dirname, join, relative } from 'node:path'
6
- import { fileURLToPath } from 'node:url'
7
-
8
- import { createViolationReporter } from '@7n/rules/scripts/lib/lint-surface/violation-reporter.mjs'
9
- import { eventPathsIncludeExact, parseWorkflowYaml } from '@7n/rules/scripts/lib/gha-workflow.mjs'
10
- import { resolveCmd } from '@7n/rules/scripts/utils/resolve-cmd.mjs'
11
- import { runConftestBatch } from '@7n/rules/scripts/lib/run-conftest-batch.mjs'
12
- import { loadTemplate } from '@7n/rules/scripts/lib/template.mjs'
13
- import { ensureTool } from '@7n/rules/scripts/lib/ensure-tool.mjs'
14
- import { runLintStep } from '@7n/rules/scripts/lib/run-lint-step.mjs'
15
-
16
- const HERE = dirname(fileURLToPath(import.meta.url))
17
- const GA_POLICY_DIR = join(HERE, '..')
18
-
19
- /** Шаблони наявності MegaLinter у вмісті workflow */
20
- const MEGALINTER_USE_PATTERNS = [/oxsecurity\/megalinter-action/i, /megalinter\/megalinter/i]
21
-
22
- /** Типові конфіги MegaLinter у корені репо */
23
- const MEGALINTER_CONFIG_NAMES = ['.mega-linter.yml', '.megalinter.yaml', '.mega-linter.yaml']
24
-
25
- /** Обовʼязкові workflow-файли (ga.mdc). */
26
- const REQUIRED_WORKFLOWS = ['clean-ga-workflows.yml', 'clean-merged-branch.yml', 'lint-ga.yml', 'git-ai.yml']
27
-
28
- /** Патерн rego-violation про відсутній `persist-credentials`. */
29
- const CHECKOUT_PERSIST_RE = /persist-credentials/u
30
-
31
- /**
32
- * Language workflow policies вимагають ці config paths наперед, навіть коли
33
- * конкретний consumer ще не має відповідного tool config.
34
- */
35
- const OPTIONAL_CANONICAL_PATH_GLOBS = new Set(['pyproject.toml', 'uv.lock', '**/rustfmt.toml', '**/clippy.toml'])
36
-
37
- /**
38
- * Structured fix-hint (#3) для rego-violation про `actions/checkout` без
39
- * `persist-credentials: false` — щоб T0 (`fix-workflows.mjs`) автофіксив детерміновано,
40
- * не парсячи message. Повертає `{ reason, file, data }` або undefined.
41
- * @param {string} file posix-relative шлях workflow-файла від cwd
42
- * @param {unknown} message текст rego-violation
43
- * @returns {{ reason: string, file: string, data: { kind: string } } | undefined} fix-hint або undefined
44
- */
45
- function checkoutPersistHint(file, message) {
46
- return CHECKOUT_PERSIST_RE.test(String(message ?? ''))
47
- ? { reason: 'checkout-persist-credentials', file, data: { kind: 'checkout-persist-credentials' } }
48
- : undefined
49
- }
50
-
51
- /**
52
- * Повертає true, якщо glob у GitHub Actions `on.*.paths` матчитсья хоча б на один tracked файл у репозиторії.
53
- *
54
- * Використовує `git ls-files` з pathspec-магiєю `:(glob)`, щоб не реалізовувати glob engine вручну
55
- * і не сканувати файлову систему рекурсивно.
56
- * @param {string} globPattern glob з workflow (наприклад "files/**" або "image-migration-new/**")
57
- * @param {string} cwd робочий каталог для `git`
58
- * @returns {boolean} true, якщо є хоча б один збіг
59
- */
60
- function gitHasAnyTrackedFileMatchingGlob(globPattern, cwd) {
61
- const p = String(globPattern ?? '').trim()
62
- if (!p) return false
63
- if (p.startsWith('!')) return true
64
- try {
65
- const out = execFileSync('git', ['ls-files', '-z', '--', `:(glob)${p}`], { encoding: 'utf8', cwd })
66
- return out.length > 0
67
- } catch {
68
- return false
69
- }
70
- }
71
-
72
- /**
73
- * Чи варто перевіряти glob з `on.*.paths` на наявність збігів у репозиторії.
74
- *
75
- * У багатьох workflow (особливо лінтерах) `paths` часто містить “широкі” шаблони по розширеннях
76
- * (наприклад `*.vue`, `*.php`), які можуть бути відсутні в конкретному репозиторії й це ок.
77
- * Запит цієї перевірки — ловити посилання на неіснуючі директорії/шляхи (типово `some-dir/**`).
78
- * @param {string} p glob з workflow
79
- * @returns {boolean} true, якщо треба валідувати наявність файлів
80
- */
81
- function shouldValidateWorkflowPathsGlob(p) {
82
- // Негативні патерни — лише виключають, їх існування не перевіряємо.
83
- if (p.startsWith('!')) return false
84
-
85
- // Canonical language configs можуть зʼявитися пізніше, але мають уже
86
- // активувати відповідний workflow без синхронної правки CI.
87
- if (OPTIONAL_CANONICAL_PATH_GLOBS.has(p)) return false
88
-
89
- // “Розширення-фільтри” (або brace-варіанти) пропускаємо: вони можуть бути заготовками.
90
- return !p.includes('*.')
91
- }
92
-
93
- /**
94
- * Перевіряє один glob з `on.<event>.paths` на наявність збігів у репо.
95
- * @param {string} relPath шлях workflow для повідомлень
96
- * @param {string} eventName назва події (push / pull_request)
97
- * @param {unknown} raw сирий елемент масиву paths
98
- * @param {(msg: string) => void} passFn pass
99
- * @param {(msg: string) => void} failFn fail
100
- * @param {string} cwd робочий каталог для `git`
101
- */
102
- function verifyOnePathsGlob(relPath, eventName, raw, passFn, failFn, cwd) {
103
- const p = String(raw ?? '').trim()
104
- if (!p) return
105
- if (!shouldValidateWorkflowPathsGlob(p)) {
106
- passFn(`${relPath}: on.${eventName}.paths glob пропущено для перевірки існування: ${JSON.stringify(p)}`)
107
- return
108
- }
109
- if (gitHasAnyTrackedFileMatchingGlob(p, cwd)) {
110
- passFn(`${relPath}: on.${eventName}.paths glob матчитсья: ${JSON.stringify(p)}`)
111
- } else {
112
- failFn(`${relPath}: on.${eventName}.paths glob не матчитсья ні на один файл: ${JSON.stringify(p)}`, {
113
- reason: 'unmatched-paths-glob',
114
- file: relPath,
115
- data: { kind: 'unmatched-paths-glob', event: eventName, glob: p }
116
- })
117
- }
118
- }
119
-
120
- /**
121
- * Валідує `on.push.paths` / `on.pull_request.paths`: кожен позитивний glob має мати збіги в репозиторії.
122
- * @param {string} relPath шлях workflow для повідомлень
123
- * @param {Record<string, unknown>} root parsed YAML workflow
124
- * @param {(msg: string) => void} passFn pass
125
- * @param {(msg: string) => void} failFn fail
126
- * @param {string} cwd робочий каталог для `git`
127
- */
128
- function verifyWorkflowEventPathsGlobsExist(relPath, root, passFn, failFn, cwd) {
129
- const on = getObjKey(root, 'on')
130
- if (!on || typeof on !== 'object') return
131
-
132
- /** @type {Array<[eventName: string, paths: unknown]>} */
133
- const candidates = [
134
- ['push', getObjKey(getObjKey(on, 'push'), 'paths')],
135
- ['pull_request', getObjKey(getObjKey(on, 'pull_request'), 'paths')]
136
- ]
137
-
138
- for (const [eventName, paths] of candidates) {
139
- if (!Array.isArray(paths)) continue
140
- for (const raw of paths) {
141
- verifyOnePathsGlob(relPath, eventName, raw, passFn, failFn, cwd)
142
- }
143
- }
144
- }
145
-
146
- const RUN_INLINE_NCURSOR_RE = /^\s*(?:-\s*)?run:\s*n-(?:cursor|rules)\s/u
147
- const BARE_LINE_NCURSOR_RE = /^\s+n-(?:cursor|rules)\s/u
148
- const WRAPPED_NCURSOR_RE = /\b(?:bunx|npx)\s+n-(?:cursor|rules)/u
149
-
150
- /**
151
- * Прапорить кроки, що інвокають **bare `n-rules`** (чи legacy `n-rules`, не `bunx`/`npx`): у CI-раннері
152
- * `n-rules` не на PATH (`node_modules/.bin` не додається), тож `run: n-rules …` падає з
153
- * exit 127. Канон — `bunx n-rules …` (як у npm-publish.yml). Structured fix-hint для T0.
154
- * @param {string} relPath posix-relative шлях workflow
155
- * @param {string} content сирий вміст workflow
156
- * @param {(msg: string, opts?: object) => void} failFn реєстрація порушення
157
- */
158
- function verifyNoBareNCursor(relPath, content, failFn) {
159
- for (const [i, line] of content.split('\n').entries()) {
160
- if (WRAPPED_NCURSOR_RE.test(line)) continue
161
- if (!RUN_INLINE_NCURSOR_RE.test(line) && !BARE_LINE_NCURSOR_RE.test(line)) continue
162
- failFn(
163
- `${relPath}: \`n-rules …\` (рядок ${i + 1}) має бути \`bunx n-rules …\` — n-rules не на PATH у CI (ga.mdc)`,
164
- {
165
- reason: 'bare-n-rules',
166
- file: relPath,
167
- data: { kind: 'bare-n-rules' }
168
- }
169
- )
170
- }
171
- }
172
-
173
- /**
174
- * Безпечний доступ до вкладеного поля (лише для обʼєктів).
175
- * @param {unknown} obj значення-кандидат на обʼєкт
176
- * @param {string} key ключ
177
- * @returns {unknown} значення поля або undefined
178
- */
179
- function getObjKey(obj, key) {
180
- return obj && typeof obj === 'object' && !Array.isArray(obj)
181
- ? /** @type {Record<string, unknown>} */ (obj)[key]
182
- : undefined
183
- }
184
-
185
- /**
186
- * Перевіряє apply-workflow на наявність paths trigger.
187
- * @param {string} wfDir абсолютна директорія workflows
188
- * @param {string[]} files список файлів у директорії
189
- * @param {string} filename параметр filename
190
- * @param {string} expectedPath параметр expectedPath
191
- * @param {(msg: string) => void} passFn callback при успішній перевірці
192
- * @param {(msg: string) => void} failFn callback при помилці
193
- */
194
- async function checkApplyWorkflow(wfDir, files, filename, expectedPath, passFn, failFn) {
195
- if (!files.includes(filename)) return
196
- const content = await readFile(join(wfDir, filename), 'utf8')
197
- const root = parseWorkflowYaml(content)
198
- const ok = root ? eventPathsIncludeExact(root, 'push', expectedPath) : content.includes(expectedPath)
199
- if (ok) {
200
- passFn(`${filename} має правильний paths trigger`)
201
- } else {
202
- failFn(`${filename} не містить paths: ${expectedPath}`)
203
- }
204
- }
205
-
206
- /**
207
- * Перевіряє відсутність MegaLinter у workflows та конфіг-файлах.
208
- * @param {string} wfDir абсолютна директорія workflows
209
- * @param {string[]} ymlWorkflows параметр ymlWorkflows
210
- * @param {string} wfDirRel відносний шлях директорії workflows для повідомлень
211
- * @param {string} cwd корінь репозиторію
212
- * @param {(msg: string) => void} passFn callback при успішній перевірці
213
- * @param {(msg: string) => void} failFn callback при помилці
214
- */
215
- async function checkMegalinter(wfDir, ymlWorkflows, wfDirRel, cwd, passFn, failFn) {
216
- let found = false
217
- for (const f of ymlWorkflows) {
218
- const content = await readFile(join(wfDir, f), 'utf8')
219
- if (MEGALINTER_USE_PATTERNS.some(re => re.test(content))) {
220
- found = true
221
- failFn(`MegaLinter у workflow ${wfDirRel}/${f} — видали інтеграцію (ga.mdc: MegaLinter)`)
222
- }
223
- }
224
- for (const name of MEGALINTER_CONFIG_NAMES) {
225
- if (!existsSync(join(cwd, name))) {
226
- continue
227
- }
228
-
229
- found = true
230
- failFn(`Файл ${name} — видали конфіг MegaLinter (ga.mdc: MegaLinter)`)
231
- }
232
- if (!found) passFn('Залишків MegaLinter не виявлено')
233
- }
234
-
235
- /**
236
- * Перевіряє наявність локального `shellcheck` у PATH. `actionlint` (`bunx github-actionlint`)
237
- * запускає shell-перевірки в кроках `run:` workflow тільки коли `shellcheck` доступний; інакше
238
- * мовчки пропускає SC-правила, через що локальний `bun lint-ga` зелений, а CI на ubuntu-latest
239
- * (де shellcheck передвстановлений) падає. Тому відсутність бінарника локально — `fail`.
240
- *
241
- * Кросплатформно: `resolveCmd` використовує `which`/`where` і однаково знаходить `shellcheck`
242
- * та `shellcheck.exe` на Windows.
243
- * @param {(msg: string) => void} passFn callback при успішній перевірці
244
- * @param {(msg: string) => void} failFn callback при помилці
245
- */
246
- export function checkShellcheckInstalled(passFn, failFn) {
247
- if (resolveCmd('shellcheck')) {
248
- passFn('shellcheck встановлений локально, actionlint виконуватиме SC-правила, як у CI')
249
- return
250
- }
251
- failFn(
252
- [
253
- 'shellcheck не знайдено в PATH — actionlint без нього мовчки пропускає shell-перевірки в run: блоках,',
254
- 'тому локальний `bun lint-ga` буде зелений, а CI на ubuntu-latest (де shellcheck передвстановлений) падатиме.',
255
- 'Встанови: macOS — `brew install shellcheck`; Debian/Ubuntu — `sudo apt-get install -y shellcheck`;',
256
- 'Arch — `sudo pacman -S shellcheck` (ga.mdc)'
257
- ].join(' ')
258
- )
259
- }
260
-
261
- /**
262
- * Перевіряє розширення workflow-файлів і наявність обов'язкових workflow.
263
- * @param {string} wfDirRel відносний шлях директорії workflows для повідомлень
264
- * @param {string[]} files список файлів у wfDir
265
- * @param {(msg: string) => void} pass callback при успішній перевірці
266
- * @param {(msg: string) => void} fail callback при помилці
267
- */
268
- function checkGaWorkflowFiles(wfDirRel, files, pass, fail) {
269
- const yamlFiles = files.filter(f => f.endsWith('.yaml'))
270
- if (yamlFiles.length > 0) {
271
- for (const f of yamlFiles) {
272
- fail(`Workflow з розширенням .yaml: ${wfDirRel}/${f} — перейменуй на .yml`)
273
- }
274
- } else {
275
- pass('Всі workflows мають розширення .yml')
276
- }
277
-
278
- const notYmlFiles = files.filter(f => !f.endsWith('.yml'))
279
- if (notYmlFiles.length > 0) {
280
- for (const f of notYmlFiles) {
281
- fail(`Workflow має бути з розширенням .yml: ${wfDirRel}/${f} (ga.mdc)`)
282
- }
283
- }
284
-
285
- for (const f of REQUIRED_WORKFLOWS) {
286
- if (files.includes(f)) {
287
- pass(`${f} існує`)
288
- } else {
289
- fail(`Відсутній ${wfDirRel}/${f}`)
290
- }
291
- }
292
- }
293
-
294
- /**
295
- * Per-workflow Rego-полісі: namespace ↔ конкретний workflow-файл. Кожен пакет
296
- * у `npm/policy/ga/<name>/` містить правила специфічні для ОДНОГО workflow,
297
- * тому conftest викликаємо з `--namespace` окремо на кожен файл (інакше правила
298
- * чужого workflow застосуються до неправильного файла).
299
- * @type {Array<{ workflow: string, namespace: string, policyDirRel: string }>}
300
- */
301
- const GA_PER_WORKFLOW_REGO_TARGETS = [
302
- {
303
- workflow: '.github/workflows/clean-ga-workflows.yml',
304
- namespace: 'ga.clean_ga_workflows',
305
- policyDirRel: 'ga/clean_ga_workflows'
306
- },
307
- {
308
- workflow: '.github/workflows/clean-merged-branch.yml',
309
- namespace: 'ga.clean_merged_branch',
310
- policyDirRel: 'ga/clean_merged_branch'
311
- },
312
- {
313
- workflow: '.github/workflows/lint-ga.yml',
314
- namespace: 'ga.lint_ga',
315
- policyDirRel: 'ga/lint_ga'
316
- },
317
- {
318
- workflow: '.github/workflows/git-ai.yml',
319
- namespace: 'ga.git_ai',
320
- policyDirRel: 'ga/git_ai'
321
- }
322
- ]
323
-
324
- /**
325
- * Plan B (rego-authoritative): на початку перевірки правила ga прогнати усі
326
- * Rego-полісі з `npm/policy/ga/`. Спочатку — per-workflow (4 окремі спавни,
327
- * бо кожен namespace застосовний лише до свого файла), потім один батч-спавн
328
- * `ga.workflow_common` на всі `.github/workflows/*.yml`. Hard-fail без
329
- * `conftest` у PATH — узгоджено з Plan B (див. `runConftestBatch`).
330
- * @param {string} wfDir абсолютний шлях до `.github/workflows`
331
- * @param {string[]} ymlWorkflows відносні (від `wfDir`) імена файлів `*.yml`
332
- * @param {string} cwd корінь репозиторію
333
- * @param {(msg: string) => void} pass callback при успішній перевірці
334
- * @param {(msg: string) => void} fail callback при помилці
335
- * @returns {void}
336
- */
337
- async function runAllGaRego(wfDir, ymlWorkflows, cwd, pass, fail) {
338
- for (const target of GA_PER_WORKFLOW_REGO_TARGETS) {
339
- const targetAbs = join(cwd, target.workflow)
340
- if (!existsSync(targetAbs)) continue
341
- const concernDir = join(GA_POLICY_DIR, target.policyDirRel.split('/', 2)[1])
342
- const tpl = await loadTemplate(concernDir)
343
- const templateData = tpl[basename(target.workflow)]
344
- const violations = await runConftestBatch({
345
- policyDirRel: target.policyDirRel,
346
- // Правило живе у плагіні поза вбудованим rules/ ядра — резолвимо policy-теку від себе.
347
- policyDirAbs: concernDir,
348
- namespace: target.namespace,
349
- files: [targetAbs],
350
- templateData
351
- })
352
- for (const v of violations)
353
- fail(`${target.workflow}: ${v.message}`, checkoutPersistHint(target.workflow, v.message))
354
- if (violations.length === 0) {
355
- pass(`${target.workflow}: відповідає ${target.namespace} (rego)`)
356
- }
357
- }
358
-
359
- if (ymlWorkflows.length === 0) return
360
- const wfFiles = ymlWorkflows.map(f => join(wfDir, f))
361
- const workflowCommonDir = join(GA_POLICY_DIR, 'workflow_common')
362
- const workflowCommonTpl = await loadTemplate(workflowCommonDir)
363
- const usesMinVersionsSnippet = workflowCommonTpl['uses-min-versions']?.snippet
364
- const violations = await runConftestBatch({
365
- policyDirRel: 'ga/workflow_common',
366
- policyDirAbs: workflowCommonDir,
367
- namespace: 'ga.workflow_common',
368
- files: wfFiles,
369
- templateData: usesMinVersionsSnippet ? { snippet: usesMinVersionsSnippet } : undefined
370
- })
371
- for (const v of violations) {
372
- fail(`${v.filename}: ${v.message}`, checkoutPersistHint(relative(cwd, v.filename), v.message))
373
- }
374
- if (violations.length === 0) {
375
- pass(`${wfFiles.length} workflow(s) відповідають ga.workflow_common (rego)`)
376
- }
377
- }
378
-
379
- /**
380
- * Перевіряє відповідність проєкту правилам ga.mdc.
381
- *
382
- * Plan B-патерн: пер-документна валідація workflow-структури делегована
383
- * Rego-полісі у `npm/policy/ga/`; виклик через `runAllGaRego` (батч-conftest)
384
- * — це перший крок `check()`. Далі — JS-частина (cross-file перевірки на
385
- * наявність файлів, `git ls-files`-залежні `on.push.paths` glob, vscode/zizmor
386
- * config, megalinter залишки тощо). `bun run lint-ga` додатково запускає
387
- * `actionlint` + `zizmor` зовнішніми тулчейнами і **викликає цю ж `check()`** —
388
- * тобто rego-частина живе тут, не в `lint-ga.mjs`.
389
- * @param {import('@7n/rules/scripts/lib/lint-surface/types.mjs').LintContext} ctx detector-контекст (cwd, ruleId, concernId)
390
- * @returns {Promise<import('@7n/rules/scripts/lib/lint-surface/types.mjs').LintResult>} порушення concern-а
391
- */
392
- export async function lint(ctx) {
393
- const reporter = createViolationReporter(ctx)
394
- const { pass, fail } = reporter
395
- const cwd = ctx.cwd
396
-
397
- // Зовнішні тули (read-only): actionlint + zizmor. 127 = тул відсутній → skip.
398
- ensureTool('shellcheck')
399
- ensureTool('conftest')
400
- const stepOpts = { verbose: ctx.verbose === true }
401
- const actionlintCode = runLintStep('actionlint', 'bunx', ['github-actionlint'], stepOpts)
402
- if (actionlintCode !== 0 && actionlintCode !== 127) fail('actionlint знайшов порушення (ga.mdc)', 'actionlint')
403
- if (resolveCmd('uv')) {
404
- const zizmorCode = runLintStep('zizmor', 'uvx', ['zizmor', '--offline', '--collect=workflows', '.'], stepOpts)
405
- if (zizmorCode !== 0 && zizmorCode !== 127) fail('zizmor знайшов ризики у workflow (ga.mdc)', 'zizmor')
406
- }
407
-
408
- const wfDirRel = '.github/workflows'
409
- const wfDir = join(cwd, wfDirRel)
410
- if (!existsSync(wfDir)) {
411
- fail(`Директорія ${wfDirRel} не існує`)
412
- return reporter.result()
413
- }
414
-
415
- const files = await readdir(wfDir)
416
- const ymlWorkflows = files.filter(f => f.endsWith('.yml'))
417
-
418
- // Rego-крок (per-workflow + workflow_common) — оркестрація ga policy sub-concern-ів.
419
- await runAllGaRego(wfDir, ymlWorkflows, cwd, pass, fail)
420
-
421
- const setupBunDepsActionRel = '.github/actions/setup-bun-deps/action.yml'
422
- if (!existsSync(join(cwd, setupBunDepsActionRel))) {
423
- fail(
424
- `Відсутній ${setupBunDepsActionRel} — запустіть npx @7n/rules або скопіюйте з пакету (ga.mdc: composite setup-bun-deps)`
425
- )
426
- }
427
-
428
- checkGaWorkflowFiles(wfDirRel, files, pass, fail)
429
-
430
- await checkApplyWorkflow(wfDir, files, 'apply-k8s.yml', '**/k8s/**/*.yaml', pass, fail)
431
- await checkApplyWorkflow(wfDir, files, 'apply-nats-consumer.yml', '**/consumer.yaml', pass, fail)
432
-
433
- await checkMegalinter(wfDir, ymlWorkflows, wfDirRel, cwd, pass, fail)
434
-
435
- // git-залежна перевірка `on.push.paths` glob-ів (вимагає `git ls-files`) — лишається в JS.
436
- for (const f of ymlWorkflows) {
437
- const content = await readFile(join(wfDir, f), 'utf8')
438
- const parsed = parseWorkflowYaml(content)
439
- if (parsed) verifyWorkflowEventPathsGlobsExist(`${wfDirRel}/${f}`, parsed, pass, fail, cwd)
440
- verifyNoBareNCursor(`${wfDirRel}/${f}`, content, fail)
441
- }
442
-
443
- checkShellcheckInstalled(pass, fail)
444
-
445
- return reporter.result()
446
- }
@@ -1,45 +0,0 @@
1
- ---
2
- type: JS Module
3
- title: main.mjs
4
- resource: plugins/ci-github/rules/rust/toolchain_cache/main.mjs
5
- docgen:
6
- crc: e97443b2
7
- model: openai-codex/gpt-5.5
8
- tier: cloud-avg
9
- score: 100
10
- judgeModel: openai-codex/gpt-5.4-mini
11
- ---
12
-
13
- ## Огляд
14
-
15
- Файл виявляє у `.github/workflows/*.yml` job-и, де `dtolnay/rust-toolchain@stable` встановлює Rust toolchain без подальшого `Swatinem/rust-cache@v2`, щоб workflow не втрачали очікуване кешування Cargo-залежностей. Для job-ів із `tauri-apps/tauri-action` він додатково вимагає `with.workspaces` на `src-tauri/`, коли `Cargo.toml` лежить під `src-tauri/`, а не в корені репозиторію.
16
-
17
- ## Поведінка
18
-
19
- `lint` запускає read-only перевірку GitHub Actions workflow-файлів. Дані з YAML-файлів передаються як текст, щоб зберегти коментарі й мінімізувати зміни формату; результатом є lint-повідомлення без власних записів у файлову систему.
20
-
21
- `scanToolchainSteps` проходить workflow-контент і знаходить кроки Rust toolchain за `TOOLCHAIN_RE`, після чого в межах того самого job-а шукає наступний cache-крок за `CACHE_RE`. Межа job-а визначається відступами, тому перевірка прив’язана до фактичної структури `steps`, а не до повного YAML-парсингу. Якщо cache-крок відсутній після встановлення toolchain, `lint` повертає порушення `MISSING_RUST_CACHE` зі значенням `"missing-rust-cache"` — job має додати `Swatinem/rust-cache@v2`.
22
-
23
- `tauriWorkspaceDir` додає проєктний контекст до текстового сканування: якщо Rust workspace розміщений у типовому Tauri-каталозі, а не в корені репозиторію, `lint` вимагає, щоб cache-крок у job-і з Tauri action мав окремо заданий workspace. За відсутності такого налаштування повертається порушення `MISSING_RUST_CACHE_WORKSPACES` зі значенням `"missing-rust-cache-workspaces"`.
24
-
25
- ## Публічний API
26
-
27
- - MISSING_RUST_CACHE — Reason-код: job ставить Rust toolchain, але не має кроку `Swatinem/rust-cache@v2`.
28
- - MISSING_RUST_CACHE_WORKSPACES — Reason-код: кеш-крок Tauri-job-а без `with.workspaces` на каталог `src-tauri`.
29
- - TOOLCHAIN_RE — Рядок кроку встановлення Rust toolchain (`dtolnay/rust-toolchain@…`).
30
- - CACHE_RE — Рядок кроку кешування Cargo-артефактів (`Swatinem/rust-cache@…`).
31
- - scanToolchainSteps — Сканує вміст workflow-файла й повертає по одному запису на кожен
32
- `dtolnay/rust-toolchain@…` крок, з інформацією про cache-крок і tauri-action
33
- у тому самому job-і (обмежено indentation-dedent-ом).
34
- - tauriWorkspaceDir — Каталог Rust-workspace-а для `Swatinem/rust-cache` `with.workspaces`, якщо
35
- `Cargo.toml` не в корені репо, а під `src-tauri/` (типовий Tauri-layout).
36
- `undefined`, якщо корінь репо вже є workspace-коренем (окремий крок не потрібен).
37
- - lint — знаходить конфігурації Rust без потрібного cache для збірок і workspace, щоб CI не витрачав час на повторне завантаження залежностей.
38
-
39
- Поведінка: повідомлення маркуються як (rust.mdc), щоб порушення було прив’язане до правила Rust.
40
-
41
- Експортовані константи-рядки: MISSING_RUST_CACHE="missing-rust-cache" — позначає відсутній cache для Rust; MISSING_RUST_CACHE_WORKSPACES="missing-rust-cache-workspaces" — позначає відсутній cache для workspace-збірок Rust.
42
-
43
- ## Гарантії поведінки
44
-
45
- - Власних операцій запису (ФС/БД) у файлі немає; виклики імпортованих модулів можуть писати.
@@ -1,181 +0,0 @@
1
- /**
2
- * @see ./docs/main.md
3
- *
4
- * Read-only detector: у кожному `.github/workflows/*.yml` кожен job, що ставить
5
- * Rust toolchain через `dtolnay/rust-toolchain@stable`, повинен мати
6
- * `Swatinem/rust-cache@v2` десь пізніше у тому самому job-і (lint, coverage,
7
- * Tauri release/build — незалежно від job-а чи файлу; rust.mdc). Якщо job також
8
- * запускає `tauri-apps/tauri-action` і Cargo.toml лежить не в корені репо, а під
9
- * `src-tauri/`, кеш-крок повинен мати `with.workspaces` на цей каталог.
10
- *
11
- * Текстовий (не YAML-AST) аналіз — навмисно, як `ga/workflows/main.mjs`: мінімізує
12
- * diff і не залежить від того, чи canonical formatter зберігає коментарі при
13
- * round-trip через YAML-парсер. Job-межа визначається через indentation:
14
- * будь-який рядок з відступом меншим за відступ dash-а кроку означає вихід
15
- * зі step-list-а (наступний job або кінець `jobs:`).
16
- */
17
- import { existsSync } from 'node:fs'
18
- import { readdir, readFile } from 'node:fs/promises'
19
- import { join, relative } from 'node:path'
20
-
21
- import { createViolationReporter } from '@7n/rules/scripts/lib/lint-surface/violation-reporter.mjs'
22
-
23
- /** Reason-код: job ставить Rust toolchain, але не має кроку `Swatinem/rust-cache@v2`. */
24
- export const MISSING_RUST_CACHE = 'missing-rust-cache'
25
- /** Reason-код: кеш-крок Tauri-job-а без `with.workspaces` на каталог `src-tauri`. */
26
- export const MISSING_RUST_CACHE_WORKSPACES = 'missing-rust-cache-workspaces'
27
-
28
- /** Рядок кроку встановлення Rust toolchain (`dtolnay/rust-toolchain@…`). */
29
- export const TOOLCHAIN_RE = /uses:\s*dtolnay\/rust-toolchain@/u
30
- /** Рядок кроку кешування Cargo-артефактів (`Swatinem/rust-cache@…`). */
31
- export const CACHE_RE = /uses:\s*Swatinem\/rust-cache@/u
32
- const TAURI_ACTION_RE = /uses:\s*tauri-apps\/tauri-action@/u
33
- const WORKSPACES_KEY_RE = /^\s*workspaces\s*:/u
34
-
35
- /**
36
- * Відступ рядка (кількість пробілів перед першим непробільним символом).
37
- * @param {string} line рядок файла
38
- * @returns {number} кількість пробілів відступу
39
- */
40
- function indentOf(line) {
41
- return line.length - line.trimStart().length
42
- }
43
-
44
- /**
45
- * Дашова колонка кроку (`- uses: …`) з колонки `uses:`. Захист від негативного
46
- * значення для нетипового форматування (dash не на тому ж рядку).
47
- * @param {number} usesCol колонка підрядка `uses:`
48
- * @returns {number} колонка dash-а (не менше 0)
49
- */
50
- function dashColFor(usesCol) {
51
- return Math.max(usesCol - 2, 0)
52
- }
53
-
54
- /**
55
- * Один запис аналізу `dtolnay/rust-toolchain` кроку в межах його job-а (обмеженого
56
- * indentation-dedent-ом, без явного YAML-парсу job-структури).
57
- * @typedef {object} ToolchainStepScan
58
- * @property {number} line індекс рядка кроку `dtolnay/rust-toolchain@…`
59
- * @property {number} dashCol колонка dash-а кроку (рівень step-list-а job-а)
60
- * @property {boolean} hasCache чи є `Swatinem/rust-cache@…` пізніше в тому самому job-і
61
- * @property {number} cacheLine індекс рядка кеш-кроку (−1, якщо відсутній)
62
- * @property {boolean} cacheHasWorkspaces чи кеш-крок вже має ключ `workspaces`
63
- * @property {boolean} jobHasTauriAction чи job також викликає `tauri-apps/tauri-action`
64
- */
65
-
66
- /**
67
- * Сканує job від рядка ОДРАЗУ ПІСЛЯ toolchain-кроку до dedent-у: шукає перший
68
- * `Swatinem/rust-cache@…` крок і чи job також викликає `tauri-apps/tauri-action`.
69
- * @param {string[]} lines усі рядки файла
70
- * @param {number} fromLine рядок, з якого починати сканування (i + 1)
71
- * @param {number} dashCol колонка dash-а step-list-а job-а (межа dedent-у)
72
- * @returns {{hasCache: boolean, cacheLine: number, jobHasTauriAction: boolean}} результат сканування job-а
73
- */
74
- function scanJobForCache(lines, fromLine, dashCol) {
75
- let hasCache = false
76
- let cacheLine = -1
77
- let jobHasTauriAction = false
78
- for (let j = fromLine; j < lines.length; j++) {
79
- const line = lines[j]
80
- if (line.trim() === '') continue
81
- if (indentOf(line) < dashCol) break // dedent → вийшли зі step-list-а цього job-а
82
- if (!hasCache && CACHE_RE.test(line)) {
83
- hasCache = true
84
- cacheLine = j
85
- }
86
- if (TAURI_ACTION_RE.test(line)) jobHasTauriAction = true
87
- }
88
- return { hasCache, cacheLine, jobHasTauriAction }
89
- }
90
-
91
- /**
92
- * Чи кеш-крок (`cacheLine`) уже має ключ `with.workspaces` у своєму блоці (до dedent-у).
93
- * @param {string[]} lines усі рядки файла
94
- * @param {number} cacheLine рядок кеш-кроку
95
- * @param {number} dashCol колонка dash-а step-list-а job-а (межа dedent-у)
96
- * @returns {boolean} true — ключ `workspaces` уже є
97
- */
98
- function cacheStepHasWorkspaces(lines, cacheLine, dashCol) {
99
- for (let j = cacheLine + 1; j < lines.length; j++) {
100
- const line = lines[j]
101
- if (line.trim() === '') continue
102
- if (indentOf(line) < dashCol) break
103
- if (WORKSPACES_KEY_RE.test(line)) return true
104
- }
105
- return false
106
- }
107
-
108
- /**
109
- * Сканує вміст workflow-файла й повертає по одному запису на кожен
110
- * `dtolnay/rust-toolchain@…` крок, з інформацією про cache-крок і tauri-action
111
- * у тому самому job-і (обмежено indentation-dedent-ом).
112
- * @param {string} content вміст workflow-файла
113
- * @returns {ToolchainStepScan[]} записи аналізу
114
- */
115
- export function scanToolchainSteps(content) {
116
- const lines = content.split('\n')
117
- /** @type {ToolchainStepScan[]} */
118
- const out = []
119
- for (let i = 0; i < lines.length; i++) {
120
- const usesCol = lines[i].indexOf('uses:')
121
- if (usesCol === -1 || !TOOLCHAIN_RE.test(lines[i])) continue
122
- const dashCol = dashColFor(usesCol)
123
- const { hasCache, cacheLine, jobHasTauriAction } = scanJobForCache(lines, i + 1, dashCol)
124
- const cacheHasWorkspaces = hasCache && cacheStepHasWorkspaces(lines, cacheLine, dashCol)
125
- out.push({ line: i, dashCol, hasCache, cacheLine, cacheHasWorkspaces, jobHasTauriAction })
126
- }
127
- return out
128
- }
129
-
130
- /**
131
- * Каталог Rust-workspace-а для `Swatinem/rust-cache` `with.workspaces`, якщо
132
- * `Cargo.toml` не в корені репо, а під `src-tauri/` (типовий Tauri-layout).
133
- * `undefined`, якщо корінь репо вже є workspace-коренем (окремий крок не потрібен).
134
- * @param {string} cwd корінь проєкту
135
- * @returns {string|undefined} відносний шлях workspace-а або `undefined`
136
- */
137
- export function tauriWorkspaceDir(cwd) {
138
- if (existsSync(join(cwd, 'Cargo.toml'))) return
139
- return existsSync(join(cwd, 'src-tauri', 'Cargo.toml')) ? 'src-tauri' : undefined
140
- }
141
-
142
- /**
143
- * @param {import('@7n/rules/scripts/lib/lint-surface/types.mjs').LintContext} ctx контекст лінт-прогону
144
- * @returns {Promise<import('@7n/rules/scripts/lib/lint-surface/types.mjs').LintResult>} результат перевірки
145
- */
146
- export async function lint(ctx) {
147
- const cwd = ctx.cwd
148
- const reporter = createViolationReporter(ctx)
149
- const wfDir = join(cwd, '.github', 'workflows')
150
- if (!existsSync(wfDir)) return reporter.result()
151
-
152
- const workspaceDir = tauriWorkspaceDir(cwd)
153
- const entries = await readdir(wfDir)
154
- const workflowFiles = entries.filter(f => f.endsWith('.yml') || f.endsWith('.yaml'))
155
-
156
- for (const name of workflowFiles) {
157
- const abs = join(wfDir, name)
158
- const rel = relative(cwd, abs)
159
- const content = await readFile(abs, 'utf8')
160
- for (const step of scanToolchainSteps(content)) {
161
- if (!step.hasCache) {
162
- reporter.fail(
163
- `${rel}: job зі \`dtolnay/rust-toolchain@stable\` потребує \`Swatinem/rust-cache@v2\` одразу після (rust.mdc)`,
164
- { reason: MISSING_RUST_CACHE, file: rel, data: { kind: MISSING_RUST_CACHE } }
165
- )
166
- continue
167
- }
168
- if (workspaceDir && step.jobHasTauriAction && !step.cacheHasWorkspaces) {
169
- reporter.fail(
170
- `${rel}: Swatinem/rust-cache@v2 у Tauri-job-і потребує \`with.workspaces: ${workspaceDir}\` (rust.mdc)`,
171
- {
172
- reason: MISSING_RUST_CACHE_WORKSPACES,
173
- file: rel,
174
- data: { kind: MISSING_RUST_CACHE_WORKSPACES, workspaceDir }
175
- }
176
- )
177
- }
178
- }
179
- }
180
- return reporter.result()
181
- }