@7n/rules-ci-github 1.9.8 → 2.0.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,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.0.1] - 2026-07-27
4
+
5
+ ### Fixed
6
+
7
+ - peerDependency @7n/rules піднято до >=1.52.0 — перша core-версія з universal slot bus (plugin API v2)
8
+
9
+ ## [2.0.0] - 2026-07-27
10
+
11
+ ### Added
12
+
13
+ - Generic ci.artifact@1 consumer: deep-subset merge, GitHub Actions workflow artifacts (required-file/patch-existing), T0-фікс
14
+
15
+ ### Changed
16
+
17
+ - Маніфест плагіна переведено на universal slot bus (`requiresPluginApi: 2`, `slots.provides` з `rules.directory@1` замість `contributes.rules`) — spec 2026-07-27-universal-plugin-slots-lang-php-extraction, Фаза 2.
18
+
19
+ ### Removed
20
+
21
+ - PHP-specific mixin-концерн `rules/php/lint_php_yml/**` видалено (Фаза 4 spec universal-plugin-slots-lang-php-extraction) — GitHub `lint-php.yml` тепер обслуговує generic `ci.artifact@1` consumer через contribution `@7n/rules-lang-php`; статичний `lint-text.yml` template більше не містить безумовний `**/*.php` glob — тепер `patch-existing` contribution того самого плагіна
22
+
3
23
  ## [1.9.8] - 2026-07-26
4
24
 
5
25
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/rules-ci-github",
3
- "version": "1.9.8",
3
+ "version": "2.0.1",
4
4
  "description": "Плагін @7n/rules: канон GitHub Actions (.github/workflows) — обов'язкові workflow, zizmor, VS Code",
5
5
  "keywords": [
6
6
  "github-actions",
@@ -22,6 +22,7 @@
22
22
  "type": "module",
23
23
  "files": [
24
24
  "rules",
25
+ "slots",
25
26
  "CHANGELOG.md",
26
27
  "!**/*.test.mjs",
27
28
  "!**/*_test.rego",
@@ -29,15 +30,21 @@
29
30
  "!**/tests/**"
30
31
  ],
31
32
  "n-rules": {
33
+ "requiresPluginApi": 2,
32
34
  "capabilities": [
33
35
  "ci:github"
34
36
  ],
35
- "contributes": {
36
- "rules": true
37
+ "slots": {
38
+ "provides": [
39
+ { "slot": "rules.directory", "version": 1, "id": "github-rules", "resource": "./rules" }
40
+ ],
41
+ "consumes": [
42
+ { "slot": "ci.artifact", "versions": [1], "handler": "./slots/ci-artifact-consumer.mjs" }
43
+ ]
37
44
  }
38
45
  },
39
46
  "peerDependencies": {
40
- "@7n/rules": ">=1.2.0"
47
+ "@7n/rules": ">=1.52.0"
41
48
  },
42
49
  "publishConfig": {
43
50
  "access": "public"
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Тонка обгортка над `collectCiArtifactContributions`
3
+ * (`@7n/rules/scripts/lib/ci-artifact-collect.mjs`) з capability цього consumer-а (spec
4
+ * `2026-07-27-universal-plugin-slots-lang-php-extraction`, §7.2, Фаза 3). Спільна для
5
+ * detector-а (`main.mjs`) і T0-фіксу (`fix-consume.mjs`) — щоб обидва бачили ОДИН і той самий
6
+ * набір contributions в ОДНОМУ й тому ж graph-порядку (детермінований порядок застосування при
7
+ * двох contributors в один target file, spec §10 Фаза 3 п.4).
8
+ */
9
+ import { collectCiArtifactContributions } from '@7n/rules/scripts/lib/ci-artifact-collect.mjs'
10
+
11
+ /** Capability, для якої релевантні contributions цього consumer-а. */
12
+ const TARGET_CAPABILITY = 'ci:github'
13
+
14
+ /**
15
+ * @typedef {import('@7n/rules/scripts/lib/slot-contracts-ci.mjs').CiArtifactCandidate} ArtifactCandidate
16
+ */
17
+
18
+ /**
19
+ * @param {string} cwd абсолютний корінь consumer-репо
20
+ * @returns {ReturnType<typeof collectCiArtifactContributions>} результат {@link collectCiArtifactContributions} для `ci:github`
21
+ */
22
+ export function collectArtifacts(cwd) {
23
+ return collectCiArtifactContributions(cwd, TARGET_CAPABILITY)
24
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "$schema": "https://unpkg.com/@7n/rules/schemas/concern.json",
3
+ "fixability": "config",
4
+ "lint": { "scope": "full" }
5
+ }
@@ -0,0 +1,26 @@
1
+ ## Generic `ci.artifact@1` consumer (GitHub Actions)
2
+
3
+ Правило матеріалізує CI-артефакти, які постачають language-плагіни (напр. майбутній
4
+ `@7n/rules-lang-php`) через universal typed slot `ci.artifact@1` (mixin
5
+ `@7n/rules-ci-github`) — без жодного PHP чи іншого мовного literal у цьому пакеті: уся
6
+ domain-семантика приходить з payload-у contribution-а (`targetCapability`, `artifactId`,
7
+ `targetPath`, `template`, `mergeStrategy`, `mode`, `fix`).
8
+
9
+ ### Поведінка
10
+
11
+ | mode | ціль існує | ціль відсутня |
12
+ | --- | --- | --- |
13
+ | `required-file` | deep-subset diff проти `template` | violation, T0 створює файл з `template` |
14
+ | `patch-existing` | deep-subset diff проти `template` | без violation (файл належить іншому концерну) |
15
+
16
+ `mergeStrategy: "deep-subset"`: обʼєкти мерджаться рекурсивно по ключах canonical-фрагмента
17
+ (природно покриває GitHub `jobs` — вже keyed object); scalar-масиви (напр. `on.push.paths`) —
18
+ ordered set-union, наявні consumer-specific записи не видаляються; масиви обʼєктів (напр.
19
+ `steps`) — identity-based: canonical-елемент шукається в actual за першим наявним полем
20
+ `id` → `uses` → `name`, мерджаться лише його поля.
21
+
22
+ T0-фікс (`fixability: "config"`, без LLM ladder) застосовується лише коли
23
+ `descriptor.fix === true`; ідемпотентний — повторний прогін після фіксу дає 0 violations.
24
+
25
+ Дві contributions з ОДНАКОВИМ `artifactId` від РІЗНИХ плагінів — domain collision (spec §9.10):
26
+ жодна з них не матеріалізується, замість цього — один violation з provenance обох сторін.
@@ -0,0 +1,22 @@
1
+ ---
2
+ type: JS Module
3
+ title: collect-artifacts.mjs
4
+ resource: plugins/ci-github/rules/ci_artifact/consume/collect-artifacts.mjs
5
+ docgen:
6
+ crc: c6f96721
7
+ ---
8
+
9
+ Тонка обгортка над `collectCiArtifactContributions` (`@7n/rules/scripts/lib/ci-artifact-collect.mjs`) з capability цього consumer-а (spec `2026-07-27-universal-plugin-slots-lang-php-extraction`, §7.2). Спільна для detector-а (`main.mjs`) і T0-фіксу (`fix-consume.mjs`) — щоб обидва бачили один і той самий набір contributions в одному й тому ж graph-порядку.
10
+
11
+ ## Поведінка
12
+
13
+ `collectArtifacts(cwd)` викликає `collectCiArtifactContributions(cwd, 'ci:github')` — лише фіксує `targetCapability` цього consumer-а, уся інша логіка (collect + collision-детекція) живе в спільному `@7n/rules` helper-і.
14
+
15
+ ## Публічний API
16
+
17
+ `collectArtifacts` — повертає `{ relevant, collisions, errors }` для `ci:github`.
18
+
19
+ ## Гарантії поведінки
20
+
21
+ * **Один consumer — одна capability**: `TARGET_CAPABILITY = 'ci:github'` — фіксоване значення, не параметризується ззовні.
22
+ * **Той самий контракт, що й Azure-consumer**: обидва просто передають різну capability у спільний `collectCiArtifactContributions`.
@@ -0,0 +1,29 @@
1
+ ---
2
+ type: JS Module
3
+ title: fix-consume.mjs
4
+ resource: plugins/ci-github/rules/ci_artifact/consume/fix-consume.mjs
5
+ docgen:
6
+ crc: 28c6361c
7
+ model: openai-codex/gpt-5.4-mini
8
+ tier: cloud-min
9
+ score: 80
10
+ judgeModel: openai-codex/gpt-5.4-mini
11
+ ---
12
+
13
+ ## Огляд
14
+
15
+ `patterns` — T0-правило для `ci.artifact@1`, що детерміновано приводить кожен цільовий файл до канонічного `template` без LLM. Воно застосовує всі `descriptor.fix === true` contributions у тому самому graph-порядку, що й detector у `collectArtifacts`: якщо дві contributions впливають на один target file, кожен наступний виклик `applyDeepSubsetFix` читає файл уже після merge попередньої contribution, тож наступна contribution бачить застосовану попередню.
16
+
17
+ ## Поведінка
18
+
19
+ 1. `patterns` визначає T0-правило для `ci.artifact@1`, яке виправляє лише ті порушення, де артефакт позначено як придатний до автоматичного виправлення.
20
+ 2. Правило спрацьовує тільки для дефіциту або розбіжності артефакта, якщо для цього порушення явно дозволено fix.
21
+ 3. Після спрацювання воно збирає релевантні артефактні внески й відбирає лише ті цілі, які мають увімкнений fix.
22
+ 4. Для кожного такого цільового файла воно відновлює канонічний стан на основі узгодженого шаблону.
23
+ 5. Якщо для внеску шаблон не вдається отримати, правило пропускає цей випадок і не маскує проблему: її далі має показати detector.
24
+ 6. Коли один і той самий файл має кілька придатних внесків, правило застосовує їх у тому ж порядку, що й detector, щоб кожен наступний внесок бачив уже попередні зміни.
25
+ 7. Результатом є список файлів, які були фактично змінені під час виправлення.
26
+
27
+ ## Гарантії поведінки
28
+
29
+ - Власних операцій запису (ФС/БД) у файлі немає; виклики імпортованих модулів можуть писати.
@@ -0,0 +1,11 @@
1
+ ---
2
+ type: Directory Index
3
+ title: plugins/ci-github/rules/ci_artifact/consume
4
+ resource: plugins/ci-github/rules/ci_artifact/consume/
5
+ ---
6
+
7
+ | Файл | Тип |
8
+ | --------------------------------------------- | --------- |
9
+ | [collect-artifacts.mjs](collect-artifacts.md) | JS Module |
10
+ | [fix-consume.mjs](fix-consume.md) | JS Module |
11
+ | [main.mjs](main.md) | JS Module |
@@ -0,0 +1,39 @@
1
+ ---
2
+ type: JS Module
3
+ title: main.mjs
4
+ resource: plugins/ci-github/rules/ci_artifact/consume/main.mjs
5
+ docgen:
6
+ crc: 89ed27be
7
+ model: openai-codex/gpt-5.4-mini
8
+ tier: cloud-min
9
+ score: 100
10
+ issues: judge-refine:kept-original,judge:inaccurate:0.95
11
+ judgeModel: openai-codex/gpt-5.4-mini
12
+ ---
13
+
14
+ ## Огляд
15
+
16
+ Публічною точкою входу є `lint`, яка перевіряє відповідність знайденого внеску канонічному стану цільового файла й повертає результат без змін у репозиторії. Вона окремо фіксує, коли відсутній обов’язковий файл, і коли відсутність не є критичною.
17
+
18
+ ## Поведінка
19
+
20
+ 1. Збирає поточні правила для `ci:github` у межах робочого каталогу й використовує `package.json` як джерело пакування та публікації пов’язаних артефактів.
21
+ 2. Перевіряє кожну релевантну contribution на відповідність канонічному стану цільового файлу та фіксує діагностику для всього набору знайдених артефактів.
22
+ 3. Якщо для contribution не вдається отримати канонічний шаблон, позначає це як помилку шаблону й прив’язує її до цільового файлу.
23
+ 4. Якщо цільовий файл відсутній, розрізняє два бізнес-випадки: для обов’язкового файла створення вважається порушенням, для інших сценаріїв відсутність мовчки пропускається.
24
+ 5. Якщо файл існує, порівнює його фактичний вміст із канонічним станом і фіксує всі розбіжності як окремі порушення.
25
+ 6. Повертає підсумок перевірки через `lint` без зміни файлів у репозиторії.
26
+
27
+ ## Публічний API
28
+
29
+ - lint — Detector generic-consumer-а слоту `ci.artifact@1` для `ci:github` (spec §7.2, Фаза 3):
30
+ матеріалізує КОЖНУ активну contribution проти поточного стану consumer-репо — без жодного
31
+ PHP/lang-specific literal тут, уся domain-семантика приходить із payload-у contribution-а.
32
+
33
+ ## Сценарії використання
34
+
35
+ - `plugins/ci-github/rules/ci_artifact/consume/tests/consume.test.mjs` (ci-github ci.artifact consumer) — required-file: файл відсутній → 1 violation, T0 створює canonical файл; deep-subset: відсутній canonical крок → violation, T0 idempotent-фікс; set-union scalar-масивів: consumer-specific шлях у on.push.paths не видаляється; step identity за id/uses/name: зайві поля кроку не викликають дублювання; patch-existing: target відсутній → 0 violations (файл належить іншому концерну); ще 3
36
+
37
+ ## Гарантії поведінки
38
+
39
+ - Власних операцій запису (ФС/БД) у файлі немає; виклики імпортованих модулів можуть писати.
@@ -0,0 +1,56 @@
1
+ /**
2
+ * T0-фікс generic-consumer-а слоту `ci.artifact@1` (`mergeStrategy: "deep-subset"`, spec §7.2):
3
+ * приводить кожен цільовий файл до канонічного `template` — детерміновано, без LLM.
4
+ *
5
+ * Застосовує ВСІ `descriptor.fix === true` contributions у ТОМУ САМОМУ graph-порядку, що
6
+ * detector (`collectArtifacts`, spec §10 Фаза 3 п.4 — deterministic order при двох contributors
7
+ * в один target file): кожен наступний виклик `applyDeepSubsetFix` читає файл ПІСЛЯ merge-у
8
+ * попередньої contribution, тож contribution Б (напр. `patch-existing`) бачить уже застосовану
9
+ * contribution А (напр. `required-file`).
10
+ */
11
+ import { applyDeepSubsetFix, loadCanonicalTemplate } from '../../../slots/ci-artifact-consumer.mjs'
12
+ import { collectArtifacts } from './collect-artifacts.mjs'
13
+
14
+ /**
15
+ * Чи `data` — violation цього concern-а, для якої дозволений deterministic fix.
16
+ * @param {Record<string, unknown> | undefined} data `violation.data`
17
+ * @returns {boolean} true — fix-eligible
18
+ */
19
+ function isFixableViolationData(data) {
20
+ return (data?.kind === 'artifact-missing' || data?.kind === 'artifact-mismatch') && data?.fix === true
21
+ }
22
+
23
+ /** @type {import('@7n/rules/scripts/lib/lint-surface/types.mjs').T0Pattern[]} */
24
+ export const patterns = [
25
+ {
26
+ id: 'ci-github-ci-artifact-consume',
27
+ test: violations => violations.some(v => isFixableViolationData(v.data)),
28
+ apply: async (violations, ctx) => {
29
+ const targeted = violations.filter(v => isFixableViolationData(v.data))
30
+ if (targeted.length === 0) return { touchedFiles: [] }
31
+
32
+ const { relevant } = await collectArtifacts(ctx.cwd)
33
+ const touched = new Set()
34
+ for (const { contribution, descriptor } of relevant) {
35
+ if (!descriptor.fix) continue
36
+ const isTargeted = targeted.some(
37
+ v => v.data?.artifactId === descriptor.artifactId && v.file === descriptor.targetPath
38
+ )
39
+ if (!isTargeted) continue
40
+
41
+ const templateResult = await loadCanonicalTemplate(contribution, descriptor)
42
+ if (!templateResult.ok) continue // template-error — детектор уже репортить, T0 нічого не пише
43
+
44
+ const result = await applyDeepSubsetFix({
45
+ cwd: ctx.cwd,
46
+ targetPath: descriptor.targetPath,
47
+ canonical: templateResult.canonical,
48
+ templateText: templateResult.templateText,
49
+ recordWrite: ctx.recordWrite
50
+ })
51
+ for (const f of result.touchedFiles) touched.add(f)
52
+ }
53
+ return { touchedFiles: [...touched] }
54
+ }
55
+ }
56
+ ]
@@ -0,0 +1,84 @@
1
+ /** @see ./docs/main.md */
2
+ import { reportCiArtifactCollectionDiagnostics } from '@7n/rules/scripts/lib/ci-artifact-collect.mjs'
3
+ import { createViolationReporter } from '@7n/rules/scripts/lib/lint-surface/violation-reporter.mjs'
4
+
5
+ // `slots/` — публікується разом з пакетом (spec §7.2); `package.json#files` цього плагіна
6
+ // доповнить оркестратор інтеграції (Фаза 3 явно НЕ чіпає plugin manifests, spec-задача).
7
+ import { diagnoseArtifact, loadCanonicalTemplate } from '../../../slots/ci-artifact-consumer.mjs'
8
+ import { collectArtifacts } from './collect-artifacts.mjs'
9
+
10
+ /**
11
+ * Діагностує ОДИН artifact (mode `required-file` → missing-check; інакше deep-subset diff проти
12
+ * поточного стану `targetPath`). Винесено з {@link lint} — та сама причина.
13
+ * @param {ReturnType<typeof createViolationReporter>} reporter репортер порушень
14
+ * @param {{ cwd: string }} ctx контекст лінту (лише `cwd` тут потрібен)
15
+ * @param {import('./collect-artifacts.mjs').ArtifactCandidate} candidate одна валідна (без колізій) contribution
16
+ * @returns {Promise<void>}
17
+ */
18
+ async function reportArtifact(reporter, ctx, { contribution, descriptor }) {
19
+ const templateResult = await loadCanonicalTemplate(contribution, descriptor)
20
+ if (!templateResult.ok) {
21
+ reporter.fail(`ci.artifact "${descriptor.artifactId}" (${contribution.pluginName}): ${templateResult.reason}`, {
22
+ reason: 'template-error',
23
+ file: descriptor.targetPath
24
+ })
25
+ return
26
+ }
27
+
28
+ const diag = await diagnoseArtifact({
29
+ cwd: ctx.cwd,
30
+ targetPath: descriptor.targetPath,
31
+ canonical: templateResult.canonical
32
+ })
33
+ if (diag.missing) {
34
+ // patch-existing: target-файл належить ІНШОМУ, окремому концерну (spec §7.1) — його
35
+ // відсутність не порушення ЦЬОГО concern-а, generic consumer мовчки пропускає.
36
+ if (descriptor.mode !== 'required-file') return
37
+ reporter.fail(
38
+ `${descriptor.targetPath} відсутній — T0 створить canonical файл (contribution "${descriptor.artifactId}" від ${contribution.pluginName})`,
39
+ {
40
+ reason: 'artifact-missing',
41
+ file: descriptor.targetPath,
42
+ data: {
43
+ kind: 'artifact-missing',
44
+ artifactId: descriptor.artifactId,
45
+ contributorPlugin: contribution.pluginName,
46
+ contributionId: contribution.id,
47
+ fix: descriptor.fix
48
+ }
49
+ }
50
+ )
51
+ return
52
+ }
53
+ for (const v of diag.violations) {
54
+ reporter.fail(
55
+ `${descriptor.targetPath}: ${v} (contribution "${descriptor.artifactId}" від ${contribution.pluginName})`,
56
+ {
57
+ reason: 'artifact-mismatch',
58
+ file: descriptor.targetPath,
59
+ data: {
60
+ kind: 'artifact-mismatch',
61
+ artifactId: descriptor.artifactId,
62
+ contributorPlugin: contribution.pluginName,
63
+ contributionId: contribution.id,
64
+ fix: descriptor.fix
65
+ }
66
+ }
67
+ )
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Detector generic-consumer-а слоту `ci.artifact@1` для `ci:github` (spec §7.2, Фаза 3):
73
+ * матеріалізує КОЖНУ активну contribution проти поточного стану consumer-репо — без жодного
74
+ * PHP/lang-specific literal тут, уся domain-семантика приходить із payload-у contribution-а.
75
+ * @param {import('@7n/rules/scripts/lib/lint-surface/types.mjs').LintContext} ctx контекст лінту
76
+ * @returns {Promise<import('@7n/rules/scripts/lib/lint-surface/types.mjs').LintResult>} результат перевірки
77
+ */
78
+ export async function lint(ctx) {
79
+ const reporter = createViolationReporter(ctx)
80
+ const collected = await collectArtifacts(ctx.cwd)
81
+ reportCiArtifactCollectionDiagnostics(reporter, collected)
82
+ for (const candidate of collected.relevant) await reportArtifact(reporter, ctx, candidate)
83
+ return reporter.result()
84
+ }
@@ -0,0 +1 @@
1
+ { "auto": "завжди" }
@@ -0,0 +1,16 @@
1
+ ---
2
+ description: Generic-споживач ci.artifact@1 — CI-артефакти, які декларують активні мовні плагіни (required-file/patch-existing), матеріалізуються й перевіряються CI-плагіном провайдера
3
+ version: '1.0'
4
+ alwaysApply: false
5
+ ---
6
+
7
+ Правило `ci_artifact` — generic-споживач slot-у `ci.artifact@1` (spec
8
+ `2026-07-27-universal-plugin-slots-lang-php-extraction`). Мовні плагіни (напр.
9
+ `@7n/rules-lang-php`) декларують CI-артефакти декларативно — descriptor + template; це правило
10
+ матеріалізує їх для свого CI-провайдера: перевіряє наявність/канонічність цільових файлів
11
+ (`required-file`), домерджує provider-specific патчі до чинних generic-артефактів
12
+ (`patch-existing`) і, де дозволено descriptor-ом (`fix: true`), виконує детермінований T0-фікс.
13
+
14
+ Правило активне завжди при встановленому CI-плагіні: без активних contributions воно — тихий
15
+ no-op (zero-touch), тому мовний плагін не потребує CI-плагіна і навпаки. Колізія `artifactId`
16
+ між двома плагінами — помилка з provenance обох сторін, без тихого перезапису.
@@ -25,7 +25,6 @@ on:
25
25
  - '**/*.txt'
26
26
  - '**/*.go'
27
27
  - '**/*.py'
28
- - '**/*.php'
29
28
  - '**/*.sh'
30
29
 
31
30
  pull_request:
@@ -52,7 +51,6 @@ on:
52
51
  - '**/*.txt'
53
52
  - '**/*.go'
54
53
  - '**/*.py'
55
- - '**/*.php'
56
54
  - '**/*.sh'
57
55
 
58
56
  concurrency:
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Generic consumer слоту `ci.artifact@1` для `@7n/rules-ci-github` (spec
3
+ * `2026-07-27-universal-plugin-slots-lang-php-extraction`, §7.2, Фаза 3).
4
+ *
5
+ * `mergeStrategy: "deep-subset"` — GitHub Actions workflow YAML — рекурсивний structural
6
+ * merge: об'єкти мерджаться по ключах (природно покриває "jobs за key" — `jobs` вже keyed
7
+ * object у GH workflow YAML, окремої identity-логіки не треба); scalar-масиви (напр.
8
+ * `on.push.paths`) — ordered set-union без видалення consumer-specific записів; масиви
9
+ * обʼєктів (напр. `steps`) — identity-based: кожен canonical елемент шукається в actual-масиві
10
+ * за першим наявним полем з `id` → `uses` → `name` (spec §7.2), і лише ЙОГО поля мерджаться —
11
+ * решта полів actual-елемента (і сусідні елементи) не чіпаються.
12
+ *
13
+ * Default export сумісний з `loadSlotConsumer` (`plugin-slots.mjs`, spec §3.3): стабільний
14
+ * `id` + `validate(payload)`. Generic rule (`plugins/ci-github/rules/ci_artifact/consume/`)
15
+ * імпортує цей модуль напряму (той самий package) для `diagnose`/`computeFix` — surface-specific
16
+ * методи, не частина universal broker-контракту.
17
+ */
18
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
19
+ import { dirname, join } from 'node:path'
20
+
21
+ import { resolveArtifactTemplatePath, validateCiArtifactPayload } from '@7n/rules/plugin-api'
22
+
23
+ /** Пріоритет полів-ідентичності для елементів масиву обʼєктів (`steps`), spec §7.2. */
24
+ const ARRAY_IDENTITY_KEYS = ['id', 'uses', 'name']
25
+
26
+ /**
27
+ * Поле-ідентичність canonical-елемента масиву (перше наявне з {@link ARRAY_IDENTITY_KEYS}).
28
+ * @param {unknown} el елемент canonical-масиву
29
+ * @returns {string | null} назва поля або `null`, якщо жодного немає (fallback — позиційний індекс)
30
+ */
31
+ function identityKeyOf(el) {
32
+ if (el === null || typeof el !== 'object' || Array.isArray(el)) return null
33
+ for (const k of ARRAY_IDENTITY_KEYS) {
34
+ if (typeof el[k] === 'string') return k
35
+ }
36
+ return null
37
+ }
38
+
39
+ /**
40
+ * Індекс елемента `actual`-масиву з тією самою identity, що й `canonEl` — `null` identity
41
+ * (жодного з id/uses/name) шукає точний structural збіг (JSON-рівність) як fallback.
42
+ * @param {unknown[]} actualArray наявний масив
43
+ * @param {unknown} canonEl canonical-елемент
44
+ * @returns {number} індекс або `-1`
45
+ */
46
+ function findIdentityIndex(actualArray, canonEl) {
47
+ const idKey = identityKeyOf(canonEl)
48
+ if (idKey !== null) {
49
+ return actualArray.findIndex(a => a !== null && typeof a === 'object' && a[idKey] === canonEl[idKey])
50
+ }
51
+ return actualArray.findIndex(a => JSON.stringify(a) === JSON.stringify(canonEl))
52
+ }
53
+
54
+ /**
55
+ * Людинозрозумілий опис елемента масиву для тексту порушення.
56
+ * @param {unknown} el елемент canonical-масиву
57
+ * @returns {string} опис
58
+ */
59
+ function describeElement(el) {
60
+ const idKey = identityKeyOf(el)
61
+ if (idKey !== null) return `${idKey}: ${JSON.stringify(el[idKey])}`
62
+ return JSON.stringify(el)
63
+ }
64
+
65
+ /**
66
+ * Чи `canon`-масив — масив plain-обʼєктів (identity-branch) на відміну від scalar-масиву
67
+ * (set-union branch). Порожній масив трактуємо як scalar (немає що ідентифікувати).
68
+ * @param {unknown[]} canon canonical-масив
69
+ * @returns {boolean} true — identity-branch
70
+ */
71
+ function isObjectArray(canon) {
72
+ return canon.length > 0 && canon.every(e => e !== null && typeof e === 'object' && !Array.isArray(e))
73
+ }
74
+
75
+ /**
76
+ * Diff-гілка масиву обʼєктів (`steps`): identity-match кожного canonical-елемента + рекурсія в
77
+ * знайдений елемент. Винесена окремо — інакше {@link diffDeepSubset} перевищує поріг когнітивної
78
+ * складності лінтера.
79
+ * @param {unknown[]} actual фактичний масив
80
+ * @param {unknown[]} canon canonical-масив обʼєктів
81
+ * @param {string[]} path поточний шлях (для повідомлень)
82
+ * @param {string} here відформатований `path` (кеш — уникає повторного `join`)
83
+ * @param {string[]} out акумулятор повідомлень (мутується)
84
+ * @returns {void}
85
+ */
86
+ function diffObjectArrayBranch(actual, canon, path, here, out) {
87
+ for (const c of canon) {
88
+ const idx = findIdentityIndex(actual, c)
89
+ if (idx === -1) {
90
+ out.push(`${here}: відсутній елемент (${describeElement(c)})`)
91
+ continue
92
+ }
93
+ diffDeepSubset(actual[idx], c, [...path, `[${describeElement(c)}]`], out)
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Diff-гілка scalar-масиву (напр. `on.push.paths`): ordered set-subset — кожен canonical
99
+ * елемент має бути присутній десь в `actual` (порядок і зайві consumer-specific записи не
100
+ * порушення).
101
+ * @param {unknown[]} actual фактичний масив
102
+ * @param {unknown[]} canon canonical-масив скалярів
103
+ * @param {string} here відформатований шлях (для повідомлень)
104
+ * @param {string[]} out акумулятор повідомлень (мутується)
105
+ * @returns {void}
106
+ */
107
+ function diffScalarArrayBranch(actual, canon, here, out) {
108
+ for (const needle of canon) {
109
+ const present = actual.some(a => JSON.stringify(a) === JSON.stringify(needle))
110
+ if (!present) out.push(`${here}: має містити ${JSON.stringify(needle)}`)
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Рекурсивний deep-subset diff (`mergeStrategy: "deep-subset"`, spec §7.2): кожен шлях
116
+ * canonical-дерева має бути присутній в `actual` з тим самим значенням (об'єкти — рекурсія по
117
+ * ключах canon; scalar-масиви — set-subset; масиви обʼєктів — identity-match + рекурсія в
118
+ * знайдений елемент). Не мутує вхід.
119
+ * @param {unknown} actual фактичне значення з документа
120
+ * @param {unknown} canon canonical-фрагмент
121
+ * @param {string[]} path поточний шлях (для повідомлень)
122
+ * @param {string[]} out акумулятор повідомлень (мутується)
123
+ * @returns {void}
124
+ */
125
+ function diffDeepSubset(actual, canon, path, out) {
126
+ if (canon === null || canon === undefined) return
127
+ const here = path.join('.') || '(root)'
128
+ if (Array.isArray(canon)) {
129
+ if (!Array.isArray(actual)) {
130
+ out.push(`${here}: має бути масивом`)
131
+ return
132
+ }
133
+ if (isObjectArray(canon)) diffObjectArrayBranch(actual, canon, path, here, out)
134
+ else diffScalarArrayBranch(actual, canon, here, out)
135
+ return
136
+ }
137
+ if (typeof canon === 'object') {
138
+ if (actual === null || typeof actual !== 'object' || Array.isArray(actual)) {
139
+ out.push(`${here}: має бути обʼєктом`)
140
+ return
141
+ }
142
+ for (const [k, v] of Object.entries(canon)) diffDeepSubset(actual[k], v, [...path, k], out)
143
+ return
144
+ }
145
+ if (actual !== canon) out.push(`${here}: має бути ${JSON.stringify(canon)} (отримано ${JSON.stringify(actual)})`)
146
+ }
147
+
148
+ /**
149
+ * Merge-гілка масиву обʼєктів (`steps`) у YAML `Document`: identity-match + рекурсія у поля
150
+ * знайденого елемента, або додавання нового елемента, якщо identity не знайдено. Винесена
151
+ * окремо — інакше {@link mergeYamlDocIdentity} перевищує поріг когнітивної складності лінтера.
152
+ * @param {import('yaml').Document} doc YAML-документ (мутується)
153
+ * @param {unknown[]} canon canonical-масив обʼєктів
154
+ * @param {Array<string|number>} path шлях масиву в документі
155
+ * @returns {void}
156
+ */
157
+ function mergeObjectArrayBranch(doc, canon, path) {
158
+ for (const c of canon) {
159
+ const existingJs = doc.getIn(path)?.toJS(doc) ?? []
160
+ const idx = findIdentityIndex(existingJs, c)
161
+ if (idx === -1) {
162
+ doc.addIn(path, c)
163
+ continue
164
+ }
165
+ for (const [k, v] of Object.entries(c)) mergeYamlDocIdentity(doc, v, [...path, idx, k])
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Merge-гілка scalar-масиву у YAML `Document`: ordered set-union — додає лише елементи canon,
171
+ * яких ще немає в `actual` (structural JSON-рівність), нічого не видаляє.
172
+ * @param {import('yaml').Document} doc YAML-документ (мутується)
173
+ * @param {unknown[]} canon canonical-масив скалярів
174
+ * @param {Array<string|number>} path шлях масиву в документі
175
+ * @returns {void}
176
+ */
177
+ function mergeScalarArrayBranch(doc, canon, path) {
178
+ const existingJs = doc.getIn(path)?.toJS(doc) ?? []
179
+ for (const needle of canon) {
180
+ const present = existingJs.some(a => JSON.stringify(a) === JSON.stringify(needle))
181
+ if (!present) doc.addIn(path, needle)
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Deep-merge `canon` у YAML `Document` за `path` (мутує `doc`) — identity-aware варіант
187
+ * `template-deep-merge.mjs#mergeYamlDoc`: масиви обʼєктів мерджаться за identity (лише поля
188
+ * знайденого елемента), а не «додати ще одну копію, якщо не structurally contained».
189
+ * @param {import('yaml').Document} doc YAML-документ (мутується)
190
+ * @param {unknown} canon canonical-фрагмент на цьому шляху
191
+ * @param {Array<string|number>} path шлях у документі
192
+ * @returns {void}
193
+ */
194
+ function mergeYamlDocIdentity(doc, canon, path) {
195
+ if (Array.isArray(canon)) {
196
+ if (!doc.hasIn(path)) doc.setIn(path, doc.createNode([]))
197
+ if (isObjectArray(canon)) mergeObjectArrayBranch(doc, canon, path)
198
+ else mergeScalarArrayBranch(doc, canon, path)
199
+ return
200
+ }
201
+ if (canon !== null && typeof canon === 'object') {
202
+ for (const [k, v] of Object.entries(canon)) mergeYamlDocIdentity(doc, v, [...path, k])
203
+ return
204
+ }
205
+ doc.setIn(path, canon)
206
+ }
207
+
208
+ /**
209
+ * Читає й парсить canonical template (YAML) contribution-у за безпечним шляхом. Не кидає —
210
+ * повертає diagnostic-сумісний результат, узгоджено з фейл-safe стилем `plugin-slots.mjs`.
211
+ * @param {{ packageRoot: string, resourcePath: string | null }} contribution provenance
212
+ * @param {import('@7n/rules/plugin-api').CiArtifactDescriptor} descriptor валідований payload
213
+ * @returns {{ ok: true, canonical: unknown, templateText: string } | { ok: false, reason: string }} результат
214
+ */
215
+ export async function loadCanonicalTemplate(contribution, descriptor) {
216
+ const resolved = resolveArtifactTemplatePath(contribution, descriptor)
217
+ if (!resolved.ok) return { ok: false, reason: resolved.reason }
218
+ if (!resolved.exists) return { ok: false, reason: `template не знайдено (${resolved.absPath})` }
219
+ const { parse } = await import('yaml')
220
+ const templateText = readFileSync(resolved.absPath, 'utf8')
221
+ return { ok: true, canonical: parse(templateText), templateText }
222
+ }
223
+
224
+ /**
225
+ * Діагностує один `ci.artifact@1` artifact (mergeStrategy `deep-subset`) проти поточного стану
226
+ * `targetPath` у consumer-репо.
227
+ * @param {{ cwd: string, targetPath: string, canonical: unknown }} args вхід
228
+ * @returns {{ missing: boolean, violations: string[] }} `missing` — файл відсутній (mode-специфічна обробка — рішення викликача); `violations` — mismatch-и, коли файл існує
229
+ */
230
+ export async function diagnoseArtifact({ cwd, targetPath, canonical }) {
231
+ const absTarget = join(cwd, targetPath)
232
+ if (!existsSync(absTarget)) return { missing: true, violations: [] }
233
+ const { parse } = await import('yaml')
234
+ let actual
235
+ try {
236
+ actual = parse(readFileSync(absTarget, 'utf8'))
237
+ } catch {
238
+ return { missing: false, violations: [`${targetPath}: не парситься як YAML`] }
239
+ }
240
+ const out = []
241
+ diffDeepSubset(actual, canonical, [], out)
242
+ return { missing: false, violations: out }
243
+ }
244
+
245
+ /**
246
+ * Застосовує T0-фікс одного `ci.artifact@1` artifact-у до `targetPath` (deep-subset,
247
+ * idempotent): файл відсутній → копіюється `templateText` як є; файл існує → identity-aware
248
+ * deep-merge через YAML `Document` (зберігає коментарі/форматування наявного файлу, як
249
+ * `template-deep-merge.mjs`).
250
+ * @param {{ cwd: string, targetPath: string, canonical: unknown, templateText: string, recordWrite?: (absPath: string) => void }} args вхід
251
+ * @returns {Promise<{ touchedFiles: string[] }>} torn-файли (T0Result-сумісно)
252
+ */
253
+ export async function applyDeepSubsetFix({ cwd, targetPath, canonical, templateText, recordWrite }) {
254
+ const absTarget = join(cwd, targetPath)
255
+ if (!existsSync(absTarget)) {
256
+ recordWrite?.(absTarget)
257
+ const { mkdirSync } = await import('node:fs')
258
+ mkdirSync(dirname(absTarget), { recursive: true })
259
+ writeFileSync(absTarget, templateText, 'utf8')
260
+ return { touchedFiles: [absTarget] }
261
+ }
262
+ const prevText = readFileSync(absTarget, 'utf8')
263
+ const before = []
264
+ const { parse, parseDocument } = await import('yaml')
265
+ diffDeepSubset(parse(prevText), canonical, [], before)
266
+ if (before.length === 0) return { touchedFiles: [] } // вже канонічний — idempotent no-op
267
+
268
+ const doc = parseDocument(prevText)
269
+ if (doc.errors.length > 0) return { touchedFiles: [] } // невалідний YAML — не чіпаємо детермінованим фіксом
270
+ if (canonical !== null && typeof canonical === 'object' && !Array.isArray(canonical)) {
271
+ for (const [k, v] of Object.entries(canonical)) mergeYamlDocIdentity(doc, v, [k])
272
+ }
273
+ const nextText = doc.toString()
274
+ if (nextText === prevText) return { touchedFiles: [] }
275
+ recordWrite?.(absTarget)
276
+ writeFileSync(absTarget, nextText, 'utf8')
277
+ return { touchedFiles: [absTarget] }
278
+ }
279
+
280
+ /** `loadSlotConsumer`-сумісний default export (spec §3.3): стабільний `id` + `validate(payload)`. */
281
+ export default {
282
+ id: 'ci-github-artifact',
283
+ /**
284
+ * @param {unknown} payload сирий (розпарсений) `ci.artifact@1` payload
285
+ * @returns {boolean} true — валідний payload
286
+ */
287
+ validate(payload) {
288
+ return validateCiArtifactPayload(payload).ok
289
+ }
290
+ }
@@ -0,0 +1,30 @@
1
+ ---
2
+ type: JS Module
3
+ title: ci-artifact-consumer.mjs
4
+ resource: plugins/ci-github/slots/ci-artifact-consumer.mjs
5
+ docgen:
6
+ crc: fae878c9
7
+ ---
8
+
9
+ Generic consumer слоту `ci.artifact@1` для `@7n/rules-ci-github` (spec `2026-07-27-universal-plugin-slots-lang-php-extraction`, §7.2). Матеріалізує GitHub Actions workflow-артефакти будь-якого language-плагіна — без жодного PHP чи іншого мовного literal у цьому пакеті.
10
+
11
+ ## Поведінка
12
+
13
+ `mergeStrategy: "deep-subset"` — рекурсивний structural merge: об'єкти мерджаться по ключах (природно покриває `jobs` — вже keyed object у GH workflow YAML); scalar-масиви (напр. `on.push.paths`) — ordered set-union без видалення consumer-specific записів; масиви обʼєктів (напр. `steps`) — identity-based: canonical-елемент шукається в actual-масиві за першим наявним полем `id` → `uses` → `name`, і лише його поля мерджаться — решта не чіпається.
14
+
15
+ 1. **`loadCanonicalTemplate(contribution, descriptor)`** резолвить безпечний шлях `template` (`resolveArtifactTemplatePath`), читає й парсить його як YAML.
16
+ 2. **`diagnoseArtifact({ cwd, targetPath, canonical })`** порівнює поточний стан `targetPath` з canonical-фрагментом (`diffDeepSubset`). Файл відсутній → `{ missing: true, violations: [] }` (mode-специфічна обробка — рішення викликача); файл є → список mismatch-повідомлень.
17
+ 3. **`applyDeepSubsetFix({ cwd, targetPath, canonical, templateText, recordWrite })`** — T0-фікс: файл відсутній → копіюється `templateText` як є; файл існує → identity-aware deep-merge через YAML `Document` (зберігає коментарі/форматування наявного файлу). Idempotent: якщо diff порожній — жодного запису.
18
+
19
+ ## Публічний API
20
+
21
+ `loadCanonicalTemplate` — читає й парсить canonical template одного artifact-у.
22
+ `diagnoseArtifact` — діагностує стан `targetPath` проти canonical-фрагмента.
23
+ `applyDeepSubsetFix` — застосовує T0-фікс (створення або identity-aware merge).
24
+ Default export — `loadSlotConsumer`-сумісний `{ id: 'ci-github-artifact', validate(payload) }`.
25
+
26
+ ## Гарантії поведінки
27
+
28
+ * **Identity, не позиційний match**: елементи масивів обʼєктів ідентифікуються за `id`/`uses`/`name`, тому зайві consumer-specific поля на тому самому кроці не спричиняють дублювання при фіксі.
29
+ * **Ідемпотентність**: `applyDeepSubsetFix` спершу перевіряє diff і не пише файл, якщо канон уже задоволений.
30
+ * **Не володіє discovery**: не імпортує `plugin-slots.mjs` напряму — отримує `contribution`/`descriptor` вже перевіреними від викликача (generic rule).
@@ -0,0 +1,9 @@
1
+ ---
2
+ type: Directory Index
3
+ title: plugins/ci-github/slots
4
+ resource: plugins/ci-github/slots/
5
+ ---
6
+
7
+ | Файл | Тип |
8
+ | --- | --- |
9
+ | [ci-artifact-consumer.mjs](ci-artifact-consumer.md) | JS Module |
@@ -1,11 +0,0 @@
1
- {
2
- "$schema": "https://unpkg.com/@7n/rules/schemas/concern.json",
3
- "fixability": "config",
4
- "policy": {
5
- "files": {
6
- "single": ".github/workflows/lint-php.yml",
7
- "required": true
8
- },
9
- "missingMessage": ".github/workflows/lint-php.yml не існує — створи за каноном правила (mixin @7n/rules-ci-github)"
10
- }
11
- }
@@ -1,37 +0,0 @@
1
- ---
2
- type: JS Module
3
- title: fix-lint_php_yml.mjs
4
- resource: plugins/ci-github/rules/php/lint_php_yml/fix-lint_php_yml.mjs
5
- docgen:
6
- crc: 0699f2b1
7
- model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
- tier: local-min
9
- score: 100
10
- issues: judge:error
11
- judgeModel: openai-codex/gpt-5.4-mini
12
- ---
13
-
14
- ## Огляд
15
-
16
- Приводить конфігурації CI-workflow для PHP-домену у консюмер-репозиторії до канонічного шаблону концерну `lint_php_yml`. Гарантує відповідність YAML-файлів лінту встановленому стандарту, додаючи відсутні ключі та застосовуючи канонічні значення, зберігаючи при цьому вихідні коментарі та ключі, що не входять до шаблону. Це забезпечує єдність збірки та лінтування в системі.
17
-
18
- ## Поведінка
19
-
20
- 1. Перевіряє файли, що знаходяться у каталозі `.github/workflows/lint-php.yml`.
21
- 2. Використовує `patterns` для застосування канонічного шаблону концерну `lint_php_yml` до цільового файлу.
22
- 3. При застосуванні шаблону відсутні ключі додаються, а канонічні значення мають пріоритет.
23
- 4. Коментарі та ключі, які знаходяться поза шаблоном, залишаються без змін.
24
- 5. Якщо файл цільового шляху відсутній, він створюється на основі наданого snippet-а.
25
- 6. Ігнорує шляхи `.github` та `.git` під час перевірки.
26
-
27
- ## Публічний API
28
-
29
- - patterns — Один детермінований патерн: deep-merge канонічного snippet-а концерну в
30
- `.github/workflows/lint-php.yml` консюмер-репо — відсутні ключі додаються,
31
- канонічні значення мають пріоритет, коментарі й ключі поза шаблоном не
32
- чіпаються; якщо файлу немає — створюється зі snippet-а.
33
-
34
- ## Гарантії поведінки
35
-
36
- - Власних операцій запису (ФС/БД) у файлі немає; виклики імпортованих модулів можуть писати.
37
- - Свідомо пропускає шляхи: `.github`, `.git`.
@@ -1,9 +0,0 @@
1
- ---
2
- type: Directory Index
3
- title: plugins/ci-github/rules/php/lint_php_yml
4
- resource: plugins/ci-github/rules/php/lint_php_yml/
5
- ---
6
-
7
- | Файл | Тип |
8
- | ------------------------------------------- | --------- |
9
- | [fix-lint_php_yml.mjs](fix-lint_php_yml.md) | JS Module |
@@ -1,15 +0,0 @@
1
- /**
2
- * T0-фікс концерну lint_php_yml: приводить CI-workflow лінту php-домену
3
- * у консюмер-репо до канонічного шаблону концерну.
4
- */
5
- import { createTemplateFixPattern } from '@7n/rules/scripts/lib/fix/template-deep-merge.mjs'
6
-
7
- /**
8
- * Один детермінований патерн: deep-merge канонічного snippet-а концерну в
9
- * `.github/workflows/lint-php.yml` консюмер-репо — відсутні ключі додаються,
10
- * канонічні значення мають пріоритет, коментарі й ключі поза шаблоном не
11
- * чіпаються; якщо файлу немає — створюється зі snippet-а.
12
- */
13
- export const patterns = [
14
- createTemplateFixPattern({ id: 'php-lint_php_yml-template', targetPath: '.github/workflows/lint-php.yml' })
15
- ]
@@ -1,23 +0,0 @@
1
- ## Структура `.github/workflows/lint-php.yml`
2
-
3
- Rego-пакет: `php.lint_php_yml`
4
-
5
- Цільовий файл: `.github/workflows/lint-php.yml` (парситься як YAML).
6
-
7
- Перевіряє, що у workflow присутні всі канонічні `run:`-кроки з template-сніпету — крок `n-rules lint php --no-fix` має бути в одному зі steps jobs.
8
-
9
- Канонічний template-сніпет: [lint-php.yml.snippet.yml](./template/lint-php.yml.snippet.yml)
10
-
11
- **✓ Правильно** — job `php` містить крок:
12
-
13
- ```yaml
14
- - name: Lint PHP
15
- run: n-rules lint php --no-fix
16
- ```
17
-
18
- **✗ Неправильно** — крок відсутній або містить інший run-рядок:
19
-
20
- ```yaml
21
- - name: Lint PHP
22
- run: echo something
23
- ```
@@ -1,34 +0,0 @@
1
- # Перевірка `lint-php.yml` (php.mdc).
2
- #
3
- # Канон надходить через --data: { "template": { "snippet": ... } }
4
- # Структура --data сформована з template/lint-php.yml.snippet.yml.
5
- # Маркер `run:` збирається з template's php-job steps.
6
- # Універсальні workflow-перевірки — у `ga.workflow_common`.
7
- package php.lint_php_yml
8
-
9
- import rego.v1
10
-
11
- # Очікуваний `run:` маркер — конкатенація всіх run-блоків з template.
12
- expected_run_blob := concat("\n", [r |
13
- some step in data.template.snippet.jobs.php.steps
14
- r := object.get(step, "run", "")
15
- r != ""
16
- ])
17
-
18
- all_run_text := concat("\n", [run_text |
19
- some job in object.get(input, "jobs", {})
20
- some step in object.get(job, "steps", [])
21
- run_text := step_run_to_text(step)
22
- ])
23
-
24
- deny contains msg if {
25
- expected_run_blob != ""
26
- not contains(all_run_text, expected_run_blob)
27
- msg := sprintf("lint-php.yml: жоден крок run не містить %q (php.mdc)", [expected_run_blob])
28
- }
29
-
30
- step_run_to_text(step) := step.run if is_string(step.run)
31
-
32
- else := concat("\n", [s | some s in step.run]) if is_array(step.run)
33
-
34
- else := ""
@@ -1,47 +0,0 @@
1
- name: Lint PHP
2
-
3
- on:
4
- push:
5
- branches:
6
- - dev
7
- - main
8
- paths:
9
- - '**/*.php'
10
- - 'composer.json'
11
- - 'composer.lock'
12
- - 'phpstan.neon'
13
- - 'phpstan.neon.dist'
14
- - 'psalm.xml'
15
- - '.github/workflows/lint-php.yml'
16
-
17
- pull_request:
18
- branches:
19
- - dev
20
- - main
21
-
22
- concurrency:
23
- group: ${{ github.ref }}-${{ github.workflow }}
24
- cancel-in-progress: true
25
-
26
- jobs:
27
- php:
28
- runs-on: ubuntu-latest
29
- permissions:
30
- contents: read
31
- steps:
32
- - uses: actions/checkout@v6
33
- with:
34
- persist-credentials: false
35
-
36
- - uses: ./.github/actions/setup-bun-deps
37
-
38
- - name: Install PHP
39
- uses: shivammathur/setup-php@v2
40
- with:
41
- php-version: '8.5'
42
-
43
- - name: Install Composer dependencies
44
- run: composer install --no-interaction --no-progress --prefer-dist
45
-
46
- - name: Lint PHP
47
- run: bunx n-rules lint php --no-fix