@7n/rules-ci-github 2.3.2 → 2.4.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 +6 -0
- package/package.json +1 -1
- package/rules/ga/workflows/docs/index.md +0 -1
- package/rules/ga/workflows/workflows.mdc +36 -0
- package/rules/rust/toolchain_cache/docs/index.md +0 -1
- package/rules/rust/toolchain_cache/fix-toolchain_cache.mjs +123 -1
- package/rules/rust/toolchain_cache/toolchain_cache.mdc +1 -1
- package/rules/ga/workflows/docs/main.md +0 -40
- package/rules/ga/workflows/main.mjs +0 -446
- package/rules/rust/toolchain_cache/docs/main.md +0 -45
- package/rules/rust/toolchain_cache/main.mjs +0 -181
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.4.0] - 2026-08-25
|
|
4
|
+
|
|
5
|
+
### Removed
|
|
6
|
+
|
|
7
|
+
- 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
|
|
8
|
+
|
|
3
9
|
## [2.3.2] - 2026-08-25
|
|
4
10
|
|
|
5
11
|
### Fixed
|
package/package.json
CHANGED
|
@@ -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,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
|
-
|
|
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` (
|
|
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
|
-
}
|