@nitra/cursor 1.8.22 → 1.8.29
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/README.md +0 -1
- package/mdc/k8s.mdc +6 -8
- package/mdc/vue.mdc +2 -2
- package/package.json +2 -1
- package/scripts/check-ga.mjs +85 -23
- package/scripts/check-js-lint.mjs +31 -18
- package/scripts/check-k8s.mjs +99 -1
- package/scripts/check-npm-module.mjs +45 -12
- package/scripts/check-style-lint.mjs +5 -2
- package/scripts/check-text.mjs +4 -1
- package/scripts/check-vue.mjs +58 -2
- package/scripts/utils/gha-workflow.mjs +350 -0
- package/scripts/utils/vue-forbidden-imports.mjs +178 -0
package/README.md
CHANGED
|
@@ -38,7 +38,6 @@
|
|
|
38
38
|
Коротко:
|
|
39
39
|
|
|
40
40
|
- **Структура Kustomize:** спільне виноситься в **`base`**; вміст **base** відповідає тому, як має виглядати середовище **dev**; окремої директорії **`dev/`** немає — за dev відповідає **`base`**. У інших середовищах — тонкі **overlays** (часто лише **`kustomization.yaml`** і patches / оверрайди).
|
|
41
|
-
- У каталозі **`k8s`** має бути **`README.md`** з описом дерев і застосування.
|
|
42
41
|
- **Namespace** задається в **`kustomization.yaml`** (`namespace:`), а не через **`metadata.namespace`** у кожному ресурсі; окремі patches лише на зміну **namespace** не потрібні.
|
|
43
42
|
- У **Deployment** для кожного контейнера: **`resources`**, **`imagePullPolicy: Always`** (перевіряє **`npx @nitra/cursor check k8s`**).
|
|
44
43
|
- Рядки в **base**, які змінюються в overlays, позначайте коментарем на рядку (узгоджено в команді), наприклад: `# буде замінено через kustomize`.
|
package/mdc/k8s.mdc
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: K8s YAML — $schema (yaml-language-server); lint-k8s (kubeconform, kubescape); check-k8s
|
|
3
|
-
version: '1.
|
|
3
|
+
version: '1.18'
|
|
4
4
|
globs: "**/k8s/**/*.{yaml,yml}"
|
|
5
5
|
alwaysApply: false
|
|
6
6
|
---
|
|
@@ -145,7 +145,7 @@ resources: {}
|
|
|
145
145
|
### Namespace
|
|
146
146
|
|
|
147
147
|
- У **`base/kustomization.yaml`** задай **`namespace: dev`** (або узгоджене ім’я для dev **namespace**), щоб **усі ресурси base** потрапляли в цей namespace через Kustomize.
|
|
148
|
-
|
|
148
|
+
|
|
149
149
|
- **Не додавай** окремі **patches** Kustomize, які лише змінюють **namespace**: **namespace** визначає Kustomize; у overlays додаткові зміни — без дублювання логіки **namespace**.
|
|
150
150
|
|
|
151
151
|
### Рядки, що змінюються між середовищами
|
|
@@ -158,10 +158,6 @@ resources: {}
|
|
|
158
158
|
|
|
159
159
|
Текст коментаря узгодь у команді; важливо, щоб було видно, що значення **навіть у base** може бути замінене overlay.
|
|
160
160
|
|
|
161
|
-
### Документація в дереві k8s
|
|
162
|
-
|
|
163
|
-
- У каталозі **`k8s`** (корінь оголошеного дерева маніфестів) має бути **`README.md`**: коротко опиши структуру (`base`, overlays), як зібрати/застосувати середовища, посилання на внутрішні правила команди.
|
|
164
|
-
|
|
165
161
|
### Міграція зі старої структури
|
|
166
162
|
|
|
167
163
|
- Після перенесення маніфестів у **`base`** та overlays і перевірки (**`check k8s`**, **`lint-k8s`**) **видали** застарілі файли та директорії, які замінені новою схемою (дубльовані копії, колишні шляхи без Kustomize), щоб у репозиторії не залишалося зайвих або суперечливих маніфестів.
|
|
@@ -199,7 +195,7 @@ resources: {}
|
|
|
199
195
|
|
|
200
196
|
## Перевірка
|
|
201
197
|
|
|
202
|
-
**`npx @nitra/cursor check k8s`** — узгодженість першого рядка (`$schema`), один рядок `# yaml-language-server` на файл, правило для `.yml`, відповідність URL першому YAML-документу (до `---`) за логікою нижче; для кожного документа **`Deployment`** — **`containers[].resources`**, **`imagePullPolicy: Always`**;
|
|
198
|
+
**`npx @nitra/cursor check k8s`** — узгодженість першого рядка (`$schema`), один рядок `# yaml-language-server` на файл, правило для `.yml`, відповідність URL першому YAML-документу (до `---`) за логікою нижче; для кожного документа **`Deployment`** — **`containers[].resources`**, **`imagePullPolicy: Always`**; заборона **`kind: Ingress`**; наявність **`HealthCheckPolicy`** — вимога до **`ru/kustomization.yaml`** з **`$patch: delete`** (див. **Ingress → Gateway API**); заборона шляхів **`…/k8s/dev/…`**; якщо існує **`k8s/base/kustomization.yaml`** (або **`.yml`**) — непорожній **`namespace`** у першому документі. Якщо під `k8s` немає yaml/yml — перевірку пропущено. Інший зміст маніфесту — вручну / **`lint-k8s`**.
|
|
203
199
|
|
|
204
200
|
Після змін у маніфестах: **`bun run lint-k8s`** (kubeconform + kubescape; обов'язково для проєктів з правилом **`k8s`** у **`.n-cursor.json`**).
|
|
205
201
|
|
|
@@ -213,13 +209,15 @@ resources: {}
|
|
|
213
209
|
- У файлах, ім’я яких **не** `kustomization.yaml` / `kustomization.yml`, у кожному документі — заборона поля **`metadata.namespace`** (**namespace** задається в Kustomize).
|
|
214
210
|
- Документи з **`kind: Ingress`** — заборонені (потрібен перехід на Gateway API, див. розділ **Ingress → Gateway API**).
|
|
215
211
|
- Якщо в будь-якому файлі під **`k8s`** є **`kind: HealthCheckPolicy`**, серед файлів має бути **`ru/kustomization.yaml`** (сегмент шляху **`ru`** перед іменем файлу), а його вміст — patch видалення **HealthCheckPolicy** з **`$patch: delete`** (див. той самий розділ).
|
|
212
|
+
- Заборона шляхів **`…/k8s/dev/…`** (окремої директорії **`dev`** під **`k8s`** не має бути).
|
|
213
|
+
- Якщо існує **`k8s/base/kustomization.yaml`** (або **`.yml`**), у першому документі має бути непорожнє поле **`namespace`** (типово **`dev`**; див. розділ **Namespace**).
|
|
216
214
|
|
|
217
215
|
## Коли застосовувати (агентам)
|
|
218
216
|
|
|
219
217
|
- Зміни в k8s YAML — після правок **`check k8s`**.
|
|
220
218
|
- Якщо перший рядок уже коректний і URL відповідає `apiVersion` / `kind` — не дублюй; змінився ресурс — онови лише `$schema`.
|
|
221
219
|
- У **`Deployment`** без поля **`resources`** у контейнері — додай **`resources: {}`** (див. розділ **Deployment: `resources`**); додай **`imagePullPolicy: Always`** для кожного контейнера.
|
|
222
|
-
- Дотримуйся структури **Kustomize** (`base` = dev, overlays без дублювання `base`,
|
|
220
|
+
- Дотримуйся структури **Kustomize** (`base` = dev, overlays без дублювання `base`, коментарі для рядків, що змінюються в overlay).
|
|
223
221
|
- Після міграції на нову структуру **видали** застарілі файли та каталоги, які вже замінені (див. **Міграція зі старої структури**).
|
|
224
222
|
|
|
225
223
|
## Визначення схеми YAML (канон)
|
package/mdc/vue.mdc
CHANGED
|
@@ -217,10 +217,10 @@ export default defineConfig({
|
|
|
217
217
|
|
|
218
218
|
Потрібно використовувати Vite версії 8 та вище для frontend проекту на Vue.
|
|
219
219
|
|
|
220
|
-
Потрібно використовувати unplugin-auto-import для автоматичного імпортування компонентів, composables, utils та інших функцій і прибирати з Vue
|
|
220
|
+
Потрібно використовувати unplugin-auto-import для автоматичного імпортування компонентів, composables, utils та інших функцій і прибирати з файлів усередині Vite проектів відповідні ручні імпорти, зокрема рядки виду `import { … } from 'vue'` — API Vue (`ref`, `computed`, `watch` тощо) мають підставлятися через auto-import, а не дублюватися явним імпортом з модуля `vue`.
|
|
221
221
|
|
|
222
222
|
Потрібно використовувати vite-plugin-vue-layouts-next для автоматичного імпортування layout компонентів.
|
|
223
223
|
|
|
224
224
|
## Перевірка
|
|
225
225
|
|
|
226
|
-
`npx @nitra/cursor check vue`
|
|
226
|
+
`npx @nitra/cursor check vue` — перевіряє залежності, `vite.config`, а також обходить джерела Vue-пакета (`.vue`, `.ts`, `.js` тощо) на заборонені value-імпорти з модуля `vue`; дозволені лише type-only та side-effect `import 'vue'`. Імпорти аналізуються через **oxc-parser** (`module.staticImports`); для `.vue` вміст `<script>` витягується з SFC, далі той самий парсер (логіка в `npm/scripts/utils/vue-forbidden-imports.mjs`).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nitra/cursor",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.29",
|
|
4
4
|
"description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"test": "bun test tests"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
+
"oxc-parser": "^0.124.0",
|
|
39
40
|
"yaml": "^2.8.3"
|
|
40
41
|
},
|
|
41
42
|
"engines": {
|
package/scripts/check-ga.mjs
CHANGED
|
@@ -7,13 +7,22 @@
|
|
|
7
7
|
* перед `uses: ./…/setup-bun-deps` у workflow — `actions/checkout` (runner інакше не бачить локальний action).
|
|
8
8
|
*
|
|
9
9
|
* Заборонено дублювати кроки встановлення Bun та кешування безпосередньо у workflow файлах
|
|
10
|
-
* (oven-sh/setup-bun, actions/cache, bun install).
|
|
10
|
+
* (oven-sh/setup-bun, actions/cache, bun install). Перевірки `uses`/`run` виконуються після **YAML parse**
|
|
11
|
+
* (`yaml`), щоб не спрацьовувати на випадкові збіги в коментарях або поза кроками.
|
|
11
12
|
*/
|
|
12
13
|
import { existsSync } from 'node:fs'
|
|
13
14
|
import { readdir, readFile } from 'node:fs/promises'
|
|
14
15
|
import { join } from 'node:path'
|
|
15
16
|
|
|
16
17
|
import { pass } from './utils/pass.mjs'
|
|
18
|
+
import {
|
|
19
|
+
anyRunStepIncludes,
|
|
20
|
+
eventPathsIncludeExact,
|
|
21
|
+
findForbiddenUsesOrRunPatterns,
|
|
22
|
+
hasAnyStepUsesContaining,
|
|
23
|
+
hasCheckoutBeforeLocalSetupBunDeps,
|
|
24
|
+
parseWorkflowYaml
|
|
25
|
+
} from './utils/gha-workflow.mjs'
|
|
17
26
|
|
|
18
27
|
/** Шаблони наявності MegaLinter у вмісті workflow */
|
|
19
28
|
const MEGALINTER_USE_PATTERNS = [/oxsecurity\/megalinter-action/i, /megalinter\/megalinter/i]
|
|
@@ -21,8 +30,19 @@ const MEGALINTER_USE_PATTERNS = [/oxsecurity\/megalinter-action/i, /megalinter\/
|
|
|
21
30
|
/** Типові конфіги MegaLinter у корені репо */
|
|
22
31
|
const MEGALINTER_CONFIG_NAMES = ['.mega-linter.yml', '.megalinter.yaml', '.mega-linter.yaml']
|
|
23
32
|
|
|
33
|
+
/** Локальні composite setup-bun-deps (ga.mdc). */
|
|
34
|
+
const SETUP_BUN_PATTERNS = ['./.github/actions/setup-bun-deps', './npm/github-actions/setup-bun-deps']
|
|
35
|
+
|
|
36
|
+
/** Заборонені підрядки лише в кроках uses/run. */
|
|
37
|
+
const FORBIDDEN_BUN_PATTERNS = [
|
|
38
|
+
{ pattern: 'oven-sh/setup-bun', msg: 'використовуй .github/actions/setup-bun-deps замість oven-sh/setup-bun' },
|
|
39
|
+
{ pattern: 'actions/cache', msg: 'використовуй .github/actions/setup-bun-deps замість actions/cache' },
|
|
40
|
+
{ pattern: 'bun install', msg: 'використовуй .github/actions/setup-bun-deps замість bun install' }
|
|
41
|
+
]
|
|
42
|
+
|
|
24
43
|
/**
|
|
25
44
|
* Якщо workflow викликає локальний setup-bun-deps, раніше у файлі має бути `actions/checkout@v…` (ga.mdc).
|
|
45
|
+
* Fallback: сирий текст, якщо YAML не вдається розібрати.
|
|
26
46
|
* @param {string} relPath шлях для повідомлень
|
|
27
47
|
* @param {string} content вміст YAML
|
|
28
48
|
* @param {(msg: string) => void} failFn реєструє порушення (exit 1)
|
|
@@ -30,9 +50,22 @@ const MEGALINTER_CONFIG_NAMES = ['.mega-linter.yml', '.megalinter.yaml', '.mega-
|
|
|
30
50
|
* @returns {void}
|
|
31
51
|
*/
|
|
32
52
|
function verifyCheckoutBeforeLocalSetupBunDeps(relPath, content, failFn, passFn) {
|
|
33
|
-
const
|
|
53
|
+
const root = parseWorkflowYaml(content)
|
|
54
|
+
if (root) {
|
|
55
|
+
if (!hasAnyStepUsesContaining(root, SETUP_BUN_PATTERNS)) {
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
if (!hasCheckoutBeforeLocalSetupBunDeps(root, SETUP_BUN_PATTERNS)) {
|
|
59
|
+
failFn(
|
|
60
|
+
`${relPath}: перед локальним setup-bun-deps потрібен крок actions/checkout@v6 — інакше runner не знайде action.yml (ga.mdc)`
|
|
61
|
+
)
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
passFn(`${relPath}: перед setup-bun-deps є checkout`)
|
|
65
|
+
return
|
|
66
|
+
}
|
|
34
67
|
let idxSetup = -1
|
|
35
|
-
for (const p of
|
|
68
|
+
for (const p of SETUP_BUN_PATTERNS) {
|
|
36
69
|
const i = content.indexOf(p)
|
|
37
70
|
if (i !== -1 && (idxSetup === -1 || i < idxSetup)) {
|
|
38
71
|
idxSetup = i
|
|
@@ -52,27 +85,33 @@ function verifyCheckoutBeforeLocalSetupBunDeps(relPath, content, failFn, passFn)
|
|
|
52
85
|
}
|
|
53
86
|
|
|
54
87
|
/**
|
|
55
|
-
*
|
|
88
|
+
* Перевіряє заборонені кроки Bun/cache/install у `uses` та `run`.
|
|
56
89
|
* @param {string} relPath шлях для повідомлень
|
|
57
90
|
* @param {string} content вміст YAML
|
|
58
91
|
* @param {(msg: string) => void} failFn реєструє порушення (exit 1)
|
|
59
92
|
* @param {(msg: string) => void} passFn реєструє успішну перевірку
|
|
93
|
+
* @returns {void}
|
|
60
94
|
*/
|
|
61
95
|
function verifyNoDirectBunOrCache(relPath, content, failFn, passFn) {
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
96
|
+
const root = parseWorkflowYaml(content)
|
|
97
|
+
if (root) {
|
|
98
|
+
const hits = findForbiddenUsesOrRunPatterns(root, FORBIDDEN_BUN_PATTERNS)
|
|
99
|
+
if (hits.length === 0) {
|
|
100
|
+
passFn(`${relPath}: не містить заборонених кроків setup-bun/cache/install`)
|
|
101
|
+
} else {
|
|
102
|
+
for (const h of hits) {
|
|
103
|
+
failFn(`${relPath}: ${h.msg} (ga.mdc)`)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return
|
|
107
|
+
}
|
|
68
108
|
let foundForbidden = false
|
|
69
|
-
for (const { pattern, msg } of
|
|
109
|
+
for (const { pattern, msg } of FORBIDDEN_BUN_PATTERNS) {
|
|
70
110
|
if (content.includes(pattern)) {
|
|
71
111
|
failFn(`${relPath}: ${msg} (ga.mdc)`)
|
|
72
112
|
foundForbidden = true
|
|
73
113
|
}
|
|
74
114
|
}
|
|
75
|
-
|
|
76
115
|
if (!foundForbidden) {
|
|
77
116
|
passFn(`${relPath}: не містить заборонених кроків setup-bun/cache/install`)
|
|
78
117
|
}
|
|
@@ -109,7 +148,9 @@ export async function check() {
|
|
|
109
148
|
|
|
110
149
|
const yamlFiles = files.filter(f => f.endsWith('.yaml'))
|
|
111
150
|
if (yamlFiles.length > 0) {
|
|
112
|
-
for (const f of yamlFiles)
|
|
151
|
+
for (const f of yamlFiles) {
|
|
152
|
+
fail(`Workflow з розширенням .yaml: ${wfDir}/${f} — перейменуй на .yml`)
|
|
153
|
+
}
|
|
113
154
|
} else {
|
|
114
155
|
pass('Всі workflows мають розширення .yml')
|
|
115
156
|
}
|
|
@@ -124,7 +165,10 @@ export async function check() {
|
|
|
124
165
|
|
|
125
166
|
if (files.includes('apply-k8s.yml')) {
|
|
126
167
|
const content = await readFile(`${wfDir}/apply-k8s.yml`, 'utf8')
|
|
127
|
-
|
|
168
|
+
const root = parseWorkflowYaml(content)
|
|
169
|
+
const ok =
|
|
170
|
+
root && eventPathsIncludeExact(root, 'push', '**/k8s/**/*.yaml') ? true : content.includes('**/k8s/**/*.yaml')
|
|
171
|
+
if (ok) {
|
|
128
172
|
pass('apply-k8s.yml має правильний paths trigger')
|
|
129
173
|
} else {
|
|
130
174
|
fail('apply-k8s.yml не містить paths: **/k8s/**/*.yaml')
|
|
@@ -133,7 +177,10 @@ export async function check() {
|
|
|
133
177
|
|
|
134
178
|
if (files.includes('apply-nats-consumer.yml')) {
|
|
135
179
|
const content = await readFile(`${wfDir}/apply-nats-consumer.yml`, 'utf8')
|
|
136
|
-
|
|
180
|
+
const root = parseWorkflowYaml(content)
|
|
181
|
+
const ok =
|
|
182
|
+
root && eventPathsIncludeExact(root, 'push', '**/consumer.yaml') ? true : content.includes('**/consumer.yaml')
|
|
183
|
+
if (ok) {
|
|
137
184
|
pass('apply-nats-consumer.yml має правильний paths trigger')
|
|
138
185
|
} else {
|
|
139
186
|
fail('apply-nats-consumer.yml не містить paths: **/consumer.yaml')
|
|
@@ -216,15 +263,30 @@ export async function check() {
|
|
|
216
263
|
const lintGaWf = join(wfDir, 'lint-ga.yml')
|
|
217
264
|
if (existsSync(lintGaWf)) {
|
|
218
265
|
const lgContent = await readFile(lintGaWf, 'utf8')
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
266
|
+
const root = parseWorkflowYaml(lgContent)
|
|
267
|
+
if (root) {
|
|
268
|
+
if (anyRunStepIncludes(root, 'bun run lint-ga')) {
|
|
269
|
+
pass('lint-ga.yml викликає bun run lint-ga')
|
|
270
|
+
} else {
|
|
271
|
+
fail('lint-ga.yml: крок має містити bun run lint-ga')
|
|
272
|
+
}
|
|
273
|
+
const usesFlat = hasAnyStepUsesContaining(root, ['astral-sh/setup-uv'])
|
|
274
|
+
if (usesFlat || lgContent.includes('astral-sh/setup-uv')) {
|
|
275
|
+
pass('lint-ga.yml містить astral-sh/setup-uv')
|
|
276
|
+
} else {
|
|
277
|
+
fail('lint-ga.yml: додай astral-sh/setup-uv для uvx zizmor (ga.mdc)')
|
|
278
|
+
}
|
|
226
279
|
} else {
|
|
227
|
-
|
|
280
|
+
if (lgContent.includes('bun run lint-ga')) {
|
|
281
|
+
pass('lint-ga.yml викликає bun run lint-ga')
|
|
282
|
+
} else {
|
|
283
|
+
fail('lint-ga.yml: крок має містити bun run lint-ga')
|
|
284
|
+
}
|
|
285
|
+
if (lgContent.includes('astral-sh/setup-uv')) {
|
|
286
|
+
pass('lint-ga.yml містить astral-sh/setup-uv')
|
|
287
|
+
} else {
|
|
288
|
+
fail('lint-ga.yml: додай astral-sh/setup-uv для uvx zizmor (ga.mdc)')
|
|
289
|
+
}
|
|
228
290
|
}
|
|
229
291
|
}
|
|
230
292
|
|
|
@@ -10,6 +10,7 @@ import { existsSync } from 'node:fs'
|
|
|
10
10
|
import { readFile } from 'node:fs/promises'
|
|
11
11
|
|
|
12
12
|
import { pass } from './utils/pass.mjs'
|
|
13
|
+
import { parseWorkflowYaml, verifyLintJsWorkflowStructure } from './utils/gha-workflow.mjs'
|
|
13
14
|
|
|
14
15
|
/** Очікуваний локальний скрипт (oxlint без bunx; eslint/jscpd через bunx). */
|
|
15
16
|
export const CANONICAL_LINT_JS = 'oxlint --fix && bunx eslint --fix . && bunx jscpd .'
|
|
@@ -154,26 +155,38 @@ export async function check() {
|
|
|
154
155
|
if (existsSync('.github/workflows/lint-js.yml')) {
|
|
155
156
|
const content = await readFile('.github/workflows/lint-js.yml', 'utf8')
|
|
156
157
|
pass('lint-js.yml існує')
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
['bunx eslint .', 'lint-js.yml: у run має бути bunx eslint . (без --fix у CI)'],
|
|
163
|
-
['bunx jscpd .', 'lint-js.yml: у run має бути bunx jscpd .']
|
|
164
|
-
]
|
|
165
|
-
for (const [needle, errMsg] of checks) {
|
|
166
|
-
if (content.includes(needle)) {
|
|
167
|
-
pass(`lint-js.yml містить: ${needle}`)
|
|
158
|
+
const root = parseWorkflowYaml(content)
|
|
159
|
+
if (root) {
|
|
160
|
+
const v = verifyLintJsWorkflowStructure(root)
|
|
161
|
+
if (v.ok) {
|
|
162
|
+
pass('lint-js.yml: кроки checkout, setup-bun-deps, oxlint/eslint/jscpd (YAML + кроки)')
|
|
168
163
|
} else {
|
|
169
|
-
|
|
164
|
+
for (const msg of v.failures) {
|
|
165
|
+
fail(`lint-js.yml: ${msg}`)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
} else {
|
|
169
|
+
const checks = [
|
|
170
|
+
['actions/checkout@v6', 'lint-js.yml: потрібен крок actions/checkout@v6 (ga.mdc)'],
|
|
171
|
+
['persist-credentials: false', 'lint-js.yml: checkout з persist-credentials: false'],
|
|
172
|
+
['./.github/actions/setup-bun-deps', 'lint-js.yml: потрібен uses: ./.github/actions/setup-bun-deps'],
|
|
173
|
+
['bunx oxlint', 'lint-js.yml: у run має бути bunx oxlint'],
|
|
174
|
+
['bunx eslint .', 'lint-js.yml: у run має бути bunx eslint . (без --fix у CI)'],
|
|
175
|
+
['bunx jscpd .', 'lint-js.yml: у run має бути bunx jscpd .']
|
|
176
|
+
]
|
|
177
|
+
for (const [needle, errMsg] of checks) {
|
|
178
|
+
if (content.includes(needle)) {
|
|
179
|
+
pass(`lint-js.yml містить: ${needle}`)
|
|
180
|
+
} else {
|
|
181
|
+
fail(errMsg)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (content.includes('bunx oxlint') && /bunx\s+oxlint[^\n]*--fix/u.test(content)) {
|
|
185
|
+
fail('lint-js.yml: у CI не використовуй oxlint --fix (лише bunx oxlint)')
|
|
186
|
+
}
|
|
187
|
+
if (content.includes('eslint --fix')) {
|
|
188
|
+
fail('lint-js.yml: у CI не використовуй eslint --fix (лише bunx eslint .)')
|
|
170
189
|
}
|
|
171
|
-
}
|
|
172
|
-
if (content.includes('bunx oxlint') && /bunx\s+oxlint[^\n]*--fix/u.test(content)) {
|
|
173
|
-
fail('lint-js.yml: у CI не використовуй oxlint --fix (лише bunx oxlint)')
|
|
174
|
-
}
|
|
175
|
-
if (content.includes('eslint --fix')) {
|
|
176
|
-
fail('lint-js.yml: у CI не використовуй eslint --fix (лише bunx eslint .)')
|
|
177
190
|
}
|
|
178
191
|
} else {
|
|
179
192
|
fail('.github/workflows/lint-js.yml не існує — створи його (див. check-js-lint.mjs / js-lint.mdc)')
|
package/scripts/check-k8s.mjs
CHANGED
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
* **`kind: Ingress`** заборонено (потрібен перехід на Gateway API). Якщо є **`HealthCheckPolicy`**,
|
|
16
16
|
* має існувати **`ru/kustomization.yaml`** з patch видалення цього kind (`$patch: delete`).
|
|
17
17
|
*
|
|
18
|
+
* Структура **Kustomize** (див. k8s.mdc): заборона шляхів **`…/k8s/dev/…`**; якщо є **`…/k8s/base/kustomization.yaml`**
|
|
19
|
+
* (або **`.yml`**), у першому документі має бути непорожнє поле **`namespace`**.
|
|
20
|
+
*
|
|
18
21
|
* Явні винятки до загальної логіки yannh/datree — таблиця **`EXPLICIT_K8S_SCHEMAS`** (`Map`): ключ
|
|
19
22
|
* **`apiVersion`, `kind`, `type`** (для CRD без поля `type` у маніфесті — зірочка **`*`** як третій
|
|
20
23
|
* компонент). Спочатку шукається збіг за фактичним `type`, потім за **`*`**.
|
|
@@ -150,6 +153,43 @@ export function pathHasK8sSegment(filePath) {
|
|
|
150
153
|
return parts.includes('k8s')
|
|
151
154
|
}
|
|
152
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Чи заборонений шлях з окремою директорією **`dev`** під **`k8s`** (джерело правди — **`base`**).
|
|
158
|
+
* @param {string} rel шлях від кореня репозиторію
|
|
159
|
+
* @returns {boolean} true для `…/k8s/dev/…`
|
|
160
|
+
*/
|
|
161
|
+
export function isForbiddenK8sDevPath(rel) {
|
|
162
|
+
const n = rel.replaceAll('\\', '/')
|
|
163
|
+
return n.includes('/k8s/dev/')
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Чи це **`k8s/base/kustomization.yaml`** або **`kustomization.yml`** (перевірка поля **`namespace`**).
|
|
168
|
+
* @param {string} rel шлях від кореня репозиторію
|
|
169
|
+
* @returns {boolean} true, якщо це `…/k8s/base/kustomization.yaml` або `…/k8s/base/kustomization.yml`
|
|
170
|
+
*/
|
|
171
|
+
export function isBaseKustomizationPath(rel) {
|
|
172
|
+
const n = rel.replaceAll('\\', '/')
|
|
173
|
+
return /(^|\/)k8s\/base\/kustomization\.yaml$/u.test(n) || /(^|\/)k8s\/base\/kustomization\.yml$/u.test(n)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Чи коректне поле **`namespace`** у розібраному Kustomization для **`base`**.
|
|
178
|
+
* @param {unknown} obj перший документ YAML
|
|
179
|
+
* @returns {string | null} текст порушення або null, якщо ок
|
|
180
|
+
*/
|
|
181
|
+
export function baseKustomizationNamespaceViolation(obj) {
|
|
182
|
+
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
183
|
+
return 'у base/kustomization.yaml має бути непорожній namespace (див. k8s.mdc)'
|
|
184
|
+
}
|
|
185
|
+
const rec = /** @type {Record<string, unknown>} */ (obj)
|
|
186
|
+
const ns = rec.namespace
|
|
187
|
+
if (typeof ns === 'string' && ns.trim() !== '') {
|
|
188
|
+
return null
|
|
189
|
+
}
|
|
190
|
+
return 'у base/kustomization.yaml має бути непорожній namespace (наприклад namespace: dev; див. k8s.mdc)'
|
|
191
|
+
}
|
|
192
|
+
|
|
153
193
|
/**
|
|
154
194
|
* Збирає всі yaml/yml під деревом від кореня cwd, якщо шлях містить сегмент `k8s`.
|
|
155
195
|
* @param {string} root корінь репозиторію (cwd)
|
|
@@ -163,7 +203,8 @@ async function findK8sYamlFiles(root) {
|
|
|
163
203
|
if (!/\.ya?ml$/iu.test(p)) return
|
|
164
204
|
out.push(p)
|
|
165
205
|
})
|
|
166
|
-
|
|
206
|
+
// eslint-disable-next-line unicorn/no-array-sort -- toSorted потребує lib ES2023 у перевірці типів IDE
|
|
207
|
+
return [...out].sort((a, b) => a.localeCompare(b))
|
|
167
208
|
}
|
|
168
209
|
|
|
169
210
|
/**
|
|
@@ -601,6 +642,59 @@ async function checkK8sYamlFile(abs, root, fail, pass, healthCheckPolicyFiles) {
|
|
|
601
642
|
validateK8sYamlPolicyDocuments(rel, baseLower, body, fail)
|
|
602
643
|
}
|
|
603
644
|
|
|
645
|
+
/**
|
|
646
|
+
* Реєструє порушення для шляхів виду **`…/k8s/dev/…`** (окремої директорії **dev** не має бути).
|
|
647
|
+
* @param {string[]} yamlFiles абсолютні шляхи
|
|
648
|
+
* @param {string} root корінь репозиторію
|
|
649
|
+
* @param {(msg: string) => void} fail callback для реєстрації порушення
|
|
650
|
+
* @returns {void}
|
|
651
|
+
*/
|
|
652
|
+
function assertNoForbiddenK8sDevPaths(yamlFiles, root, fail) {
|
|
653
|
+
for (const abs of yamlFiles) {
|
|
654
|
+
const rel = relative(root, abs).replaceAll('\\', '/')
|
|
655
|
+
if (isForbiddenK8sDevPath(rel)) {
|
|
656
|
+
fail(`${rel}: заборонена директорія k8s/dev/ — середовище dev відповідає base (див. k8s.mdc)`)
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Якщо є **`k8s/base/kustomization.yaml`**, у ньому має бути непорожній **`namespace`**.
|
|
663
|
+
* @param {string} root корінь репозиторію
|
|
664
|
+
* @param {string[]} yamlFiles абсолютні шляхи
|
|
665
|
+
* @param {(msg: string) => void} fail callback для реєстрації порушення
|
|
666
|
+
* @returns {Promise<void>}
|
|
667
|
+
*/
|
|
668
|
+
async function ensureBaseKustomizationHasNamespace(root, yamlFiles, fail) {
|
|
669
|
+
for (const abs of yamlFiles) {
|
|
670
|
+
const rel = relative(root, abs).replaceAll('\\', '/')
|
|
671
|
+
if (isBaseKustomizationPath(rel)) {
|
|
672
|
+
try {
|
|
673
|
+
const raw = await readFile(abs, 'utf8')
|
|
674
|
+
const lines = toLines(raw)
|
|
675
|
+
const body = yamlBodyAfterModeline(lines)
|
|
676
|
+
/** @type {import('yaml').Document[] | undefined} */
|
|
677
|
+
let docs
|
|
678
|
+
try {
|
|
679
|
+
docs = parseAllDocuments(body)
|
|
680
|
+
} catch {
|
|
681
|
+
fail(`${rel}: не вдалося розпарсити YAML для перевірки namespace у base (див. k8s.mdc)`)
|
|
682
|
+
}
|
|
683
|
+
if (docs !== undefined) {
|
|
684
|
+
const first = docs[0]?.toJSON()
|
|
685
|
+
const v = baseKustomizationNamespaceViolation(first)
|
|
686
|
+
if (v) {
|
|
687
|
+
fail(`${rel}: ${v}`)
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
} catch (error) {
|
|
691
|
+
const msg = error instanceof Error ? error.message : String(error)
|
|
692
|
+
fail(`${rel}: не вдалося прочитати (${msg})`)
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
604
698
|
/**
|
|
605
699
|
* Перевіряє відповідність проєкту правилам k8s.mdc.
|
|
606
700
|
* @returns {Promise<number>} 0 — все OK, 1 — є проблеми
|
|
@@ -622,6 +716,8 @@ export async function check() {
|
|
|
622
716
|
|
|
623
717
|
pass(`YAML у k8s: ${yamlFiles.length} файл(ів)`)
|
|
624
718
|
|
|
719
|
+
assertNoForbiddenK8sDevPaths(yamlFiles, root, fail)
|
|
720
|
+
|
|
625
721
|
/** @type {string[]} */
|
|
626
722
|
const healthCheckPolicyFiles = []
|
|
627
723
|
|
|
@@ -631,5 +727,7 @@ export async function check() {
|
|
|
631
727
|
|
|
632
728
|
await ensureRuKustomizationHealthCheckDelete(root, yamlFiles, healthCheckPolicyFiles, fail)
|
|
633
729
|
|
|
730
|
+
await ensureBaseKustomizationHasNamespace(root, yamlFiles, fail)
|
|
731
|
+
|
|
634
732
|
return exitCode
|
|
635
733
|
}
|
|
@@ -2,11 +2,19 @@
|
|
|
2
2
|
* Перевіряє структуру npm-модуля в монорепо за правилом npm-module.mdc.
|
|
3
3
|
*
|
|
4
4
|
* Workspace `npm/`, `npm/package.json`, workflow `npm-publish.yml` з OIDC, `on.push.paths` з glob для каталогу npm (див. npm-module.mdc).
|
|
5
|
+
* Поля workflow перевіряються після **YAML parse**, щоб не плутати з коментарями.
|
|
5
6
|
*/
|
|
6
7
|
import { existsSync } from 'node:fs'
|
|
7
8
|
import { readFile, stat } from 'node:fs/promises'
|
|
8
9
|
|
|
9
10
|
import { pass } from './utils/pass.mjs'
|
|
11
|
+
import {
|
|
12
|
+
hasIdTokenWritePermission,
|
|
13
|
+
hasNpmPublishStepWithPackage,
|
|
14
|
+
parseWorkflowYaml,
|
|
15
|
+
pushHasMainBranch,
|
|
16
|
+
pushPathsIncludeNpmGlob
|
|
17
|
+
} from './utils/gha-workflow.mjs'
|
|
10
18
|
|
|
11
19
|
/**
|
|
12
20
|
* Перевіряє відповідність проєкту правилам npm-module.mdc
|
|
@@ -62,19 +70,44 @@ export async function check() {
|
|
|
62
70
|
if (existsSync(publishWf)) {
|
|
63
71
|
pass(`${publishWf} існує`)
|
|
64
72
|
const pub = await readFile(publishWf, 'utf8')
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
73
|
+
const root = parseWorkflowYaml(pub)
|
|
74
|
+
|
|
75
|
+
if (root) {
|
|
76
|
+
if (pushPathsIncludeNpmGlob(root)) {
|
|
77
|
+
pass(`${publishWf}: on.push.paths містить npm/**`)
|
|
78
|
+
} else {
|
|
79
|
+
fail(`${publishWf}: у on.push.paths має бути npm/**`)
|
|
80
|
+
}
|
|
81
|
+
if (pushHasMainBranch(root)) {
|
|
82
|
+
pass(`${publishWf}: очікується branch main`)
|
|
83
|
+
} else {
|
|
84
|
+
fail(`${publishWf}: очікується branch main`)
|
|
85
|
+
}
|
|
86
|
+
if (hasIdTokenWritePermission(root)) {
|
|
87
|
+
pass(`${publishWf}: permissions містить id-token: write (OIDC)`)
|
|
76
88
|
} else {
|
|
77
|
-
fail(
|
|
89
|
+
fail(`${publishWf}: permissions має містити id-token: write (OIDC)`)
|
|
90
|
+
}
|
|
91
|
+
if (hasNpmPublishStepWithPackage(root)) {
|
|
92
|
+
pass(`${publishWf}: uses JS-DevTools/npm-publish та with.package npm/package.json`)
|
|
93
|
+
} else {
|
|
94
|
+
fail(`${publishWf}: очікується uses: JS-DevTools/npm-publish та with.package: npm/package.json`)
|
|
95
|
+
}
|
|
96
|
+
} else {
|
|
97
|
+
const need = [
|
|
98
|
+
{ sub: 'npm/**', msg: `${publishWf}: у on.push.paths має бути npm/**` },
|
|
99
|
+
{ sub: 'branches:', msg: `${publishWf}: очікується on.push.branches` },
|
|
100
|
+
{ sub: 'main', msg: `${publishWf}: очікується branch main` },
|
|
101
|
+
{ sub: 'id-token: write', msg: `${publishWf}: permissions має містити id-token: write (OIDC)` },
|
|
102
|
+
{ sub: 'JS-DevTools/npm-publish', msg: `${publishWf}: очікується uses: JS-DevTools/npm-publish` },
|
|
103
|
+
{ sub: 'package: npm/package.json', msg: `${publishWf}: with.package має бути npm/package.json` }
|
|
104
|
+
]
|
|
105
|
+
for (const { sub, msg } of need) {
|
|
106
|
+
if (pub.includes(sub)) {
|
|
107
|
+
pass(`${publishWf} містить «${sub}»`)
|
|
108
|
+
} else {
|
|
109
|
+
fail(msg)
|
|
110
|
+
}
|
|
78
111
|
}
|
|
79
112
|
}
|
|
80
113
|
} else {
|
|
@@ -8,6 +8,7 @@ import { existsSync } from 'node:fs'
|
|
|
8
8
|
import { readFile } from 'node:fs/promises'
|
|
9
9
|
|
|
10
10
|
import { pass } from './utils/pass.mjs'
|
|
11
|
+
import { anyRunStepIncludesStylelint, parseWorkflowYaml } from './utils/gha-workflow.mjs'
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Перевіряє відповідність проєкту правилам style-lint.mdc
|
|
@@ -54,8 +55,10 @@ export async function check() {
|
|
|
54
55
|
if (existsSync('.github/workflows/lint-style.yml')) {
|
|
55
56
|
const content = await readFile('.github/workflows/lint-style.yml', 'utf8')
|
|
56
57
|
pass('lint-style.yml існує')
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
const root = parseWorkflowYaml(content)
|
|
59
|
+
const ok = root ? anyRunStepIncludesStylelint(root) : content.includes('stylelint')
|
|
60
|
+
if (ok) {
|
|
61
|
+
pass('lint-style.yml містить stylelint у кроці run')
|
|
59
62
|
} else {
|
|
60
63
|
fail('lint-style.yml не містить виклик stylelint')
|
|
61
64
|
}
|
package/scripts/check-text.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { existsSync } from 'node:fs'
|
|
|
13
13
|
import { readFile } from 'node:fs/promises'
|
|
14
14
|
|
|
15
15
|
import { pass } from './utils/pass.mjs'
|
|
16
|
+
import { anyRunStepIncludes, parseWorkflowYaml } from './utils/gha-workflow.mjs'
|
|
16
17
|
|
|
17
18
|
/** Заголовок абзацу про апостроф у text.mdc / n-text.mdc. */
|
|
18
19
|
const UK_APOSTROPHE_HEADING = '**Український апостроф:**'
|
|
@@ -194,7 +195,9 @@ export async function check() {
|
|
|
194
195
|
|
|
195
196
|
if (existsSync('.github/workflows/lint-text.yml')) {
|
|
196
197
|
const wf = await readFile('.github/workflows/lint-text.yml', 'utf8')
|
|
197
|
-
|
|
198
|
+
const root = parseWorkflowYaml(wf)
|
|
199
|
+
const ok = root ? anyRunStepIncludes(root, 'bun run lint-text') : wf.includes('bun run lint-text')
|
|
200
|
+
if (ok) {
|
|
198
201
|
pass('lint-text.yml викликає bun run lint-text')
|
|
199
202
|
} else {
|
|
200
203
|
fail('lint-text.yml має містити крок bun run lint-text')
|
package/scripts/check-vue.mjs
CHANGED
|
@@ -3,12 +3,21 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Версії Vite та плагінів, vue-macros, auto-import, layouts, вміст `vite.config`;
|
|
5
5
|
* у репозиторії — рекомендацію розширення Vue.volar.
|
|
6
|
+
*
|
|
7
|
+
* Заборонені явні value-імпорти з `vue` у джерелах пакета — сканування `.vue`/`.ts`/`.js` тощо
|
|
8
|
+
* через **oxc-parser** (`module.staticImports`; див. `utils/vue-forbidden-imports.mjs`); дозволені лише type-only та side-effect `import 'vue'`.
|
|
6
9
|
*/
|
|
7
10
|
import { existsSync } from 'node:fs'
|
|
8
11
|
import { readFile } from 'node:fs/promises'
|
|
9
|
-
import { join } from 'node:path'
|
|
12
|
+
import { join, relative } from 'node:path'
|
|
10
13
|
|
|
11
14
|
import { pass } from './utils/pass.mjs'
|
|
15
|
+
import {
|
|
16
|
+
findForbiddenVueImportsInSourceFile,
|
|
17
|
+
isVueImportScanSourceFile,
|
|
18
|
+
shouldSkipFileForVueImportScan
|
|
19
|
+
} from './utils/vue-forbidden-imports.mjs'
|
|
20
|
+
import { walkDir } from './utils/walkDir.mjs'
|
|
12
21
|
import { getMonorepoPackageRootDirs } from './utils/workspaces.mjs'
|
|
13
22
|
|
|
14
23
|
/**
|
|
@@ -20,11 +29,31 @@ function packageLabel(rootDir) {
|
|
|
20
29
|
return rootDir === '.' ? 'корінь' : rootDir
|
|
21
30
|
}
|
|
22
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Текст кількості файлів українською (1 файл, 2 файли, 5 файлів, 11 файлів).
|
|
34
|
+
* @param {number} n невід’ємна кількість
|
|
35
|
+
* @returns {string} фраза виду «N файл» / «N файли» / «N файлів»
|
|
36
|
+
*/
|
|
37
|
+
function ukFilesCountPhrase(n) {
|
|
38
|
+
const m100 = n % 100
|
|
39
|
+
if (m100 >= 11 && m100 <= 14) {
|
|
40
|
+
return `${n} файлів`
|
|
41
|
+
}
|
|
42
|
+
const m10 = n % 10
|
|
43
|
+
if (m10 === 1) {
|
|
44
|
+
return `${n} файл`
|
|
45
|
+
}
|
|
46
|
+
if (m10 >= 2 && m10 <= 4) {
|
|
47
|
+
return `${n} файли`
|
|
48
|
+
}
|
|
49
|
+
return `${n} файлів`
|
|
50
|
+
}
|
|
51
|
+
|
|
23
52
|
/**
|
|
24
53
|
* Перевіряє залежності та vite.config одного Vue-пакета.
|
|
25
54
|
* @param {string} rootDir відносний шлях до пакета
|
|
26
55
|
* @param {(msg: string) => void} fail функція зворотного виклику для реєстрації помилки перевірки
|
|
27
|
-
* @returns {Promise<void>} завершується після перевірок
|
|
56
|
+
* @returns {Promise<void>} завершується після перевірок залежностей, `vite.config` і сканування джерел на імпорти з `vue`
|
|
28
57
|
*/
|
|
29
58
|
async function checkVuePackage(rootDir, fail) {
|
|
30
59
|
const label = packageLabel(rootDir)
|
|
@@ -95,6 +124,33 @@ async function checkVuePackage(rootDir, fail) {
|
|
|
95
124
|
} else {
|
|
96
125
|
fail(`${prefix}немає vite.config.js|ts|mjs у каталозі пакета`)
|
|
97
126
|
}
|
|
127
|
+
|
|
128
|
+
const absPackageRoot = join(process.cwd(), rootDir)
|
|
129
|
+
/** @type {string[]} */
|
|
130
|
+
const sourcePaths = []
|
|
131
|
+
await walkDir(absPackageRoot, absPath => {
|
|
132
|
+
const rel = relative(absPackageRoot, absPath).split('\\').join('/')
|
|
133
|
+
if (shouldSkipFileForVueImportScan(rel) || !isVueImportScanSourceFile(rel)) {
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
sourcePaths.push(absPath)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
let importViolations = 0
|
|
140
|
+
for (const absPath of sourcePaths) {
|
|
141
|
+
const rel = relative(absPackageRoot, absPath).split('\\').join('/')
|
|
142
|
+
const content = await readFile(absPath, 'utf8')
|
|
143
|
+
const hits = findForbiddenVueImportsInSourceFile(content, rel)
|
|
144
|
+
for (const v of hits) {
|
|
145
|
+
importViolations++
|
|
146
|
+
fail(`${prefix}${rel}:${v.line} — прибери явний value-імпорт з 'vue' (unplugin-auto-import): ${v.snippet}`)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (importViolations === 0) {
|
|
150
|
+
pass(
|
|
151
|
+
`${prefix}немає заборонених value-імпортів з 'vue' у джерелах (проскановано ${ukFilesCountPhrase(sourcePaths.length)})`
|
|
152
|
+
)
|
|
153
|
+
}
|
|
98
154
|
}
|
|
99
155
|
|
|
100
156
|
/**
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Допоміжні функції для аналізу GitHub Actions workflow (`.yml`) після структурного розбору YAML.
|
|
3
|
+
*
|
|
4
|
+
* Використовується в check-ga, check-js-lint, check-text, check-style-lint, check-npm-module замість
|
|
5
|
+
* пошуку підрядків у сирому тексті там, де важливі лише значення `uses:` та `run:` кроків.
|
|
6
|
+
*/
|
|
7
|
+
import { parse } from 'yaml'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Парсить workflow YAML у звичайний об’єкт; при синтаксичній помилці — `null`.
|
|
11
|
+
* @param {string} content вміст файлу
|
|
12
|
+
* @returns {Record<string, unknown> | null} корінь документа або `null`
|
|
13
|
+
*/
|
|
14
|
+
export function parseWorkflowYaml(content) {
|
|
15
|
+
try {
|
|
16
|
+
const root = parse(content)
|
|
17
|
+
return root && typeof root === 'object' ? /** @type {Record<string, unknown>} */ (root) : null
|
|
18
|
+
} catch {
|
|
19
|
+
return null
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Збирає всі кроки з усіх jobs.
|
|
25
|
+
* @param {Record<string, unknown>} root корінь workflow
|
|
26
|
+
* @returns {{ jobId: string, stepIndex: number, step: Record<string, unknown> }[]} плоский список кроків з метаданими
|
|
27
|
+
*/
|
|
28
|
+
export function flattenWorkflowSteps(root) {
|
|
29
|
+
const jobs = root?.jobs
|
|
30
|
+
if (!jobs || typeof jobs !== 'object') {
|
|
31
|
+
return []
|
|
32
|
+
}
|
|
33
|
+
/** @type {{ jobId: string, stepIndex: number, step: Record<string, unknown> }[]} */
|
|
34
|
+
const out = []
|
|
35
|
+
for (const [jobId, job] of Object.entries(jobs)) {
|
|
36
|
+
if (job && typeof job === 'object') {
|
|
37
|
+
const steps = /** @type {{ steps?: unknown }} */ (job).steps
|
|
38
|
+
if (Array.isArray(steps)) {
|
|
39
|
+
for (const [stepIndex, step] of steps.entries()) {
|
|
40
|
+
if (step && typeof step === 'object') {
|
|
41
|
+
out.push({
|
|
42
|
+
jobId,
|
|
43
|
+
stepIndex,
|
|
44
|
+
step: /** @type {Record<string, unknown>} */ (step)
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return out
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Значення `uses:` кроку.
|
|
56
|
+
* @param {Record<string, unknown>} step об’єкт одного елемента `steps`
|
|
57
|
+
* @returns {string} рядок `uses` або порожній рядок
|
|
58
|
+
*/
|
|
59
|
+
export function getStepUses(step) {
|
|
60
|
+
return typeof step.uses === 'string' ? step.uses : ''
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Значення `run:` кроку (багаторядковий рядок або масив рядків у YAML).
|
|
65
|
+
* @param {Record<string, unknown>} step об’єкт одного елемента `steps`
|
|
66
|
+
* @returns {string} текст команди
|
|
67
|
+
*/
|
|
68
|
+
export function getStepRun(step) {
|
|
69
|
+
const r = step.run
|
|
70
|
+
if (typeof r === 'string') {
|
|
71
|
+
return r
|
|
72
|
+
}
|
|
73
|
+
if (Array.isArray(r)) {
|
|
74
|
+
return r.map(String).join('\n')
|
|
75
|
+
}
|
|
76
|
+
return ''
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Чи є крок, у якого `uses` містить будь-який з підрядків.
|
|
81
|
+
* @param {Record<string, unknown>} root корінь workflow
|
|
82
|
+
* @param {string[]} substrings підрядки для пошуку в `uses`
|
|
83
|
+
* @returns {boolean} `true`, якщо знайдено хоча б один збіг
|
|
84
|
+
*/
|
|
85
|
+
export function hasAnyStepUsesContaining(root, substrings) {
|
|
86
|
+
for (const { step } of flattenWorkflowSteps(root)) {
|
|
87
|
+
const uses = getStepUses(step)
|
|
88
|
+
if (substrings.some(s => uses.includes(s))) {
|
|
89
|
+
return true
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return false
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Чи перед першим кроком з локальним `setup-bun-deps` у кожному job є `actions/checkout@`.
|
|
97
|
+
* Якщо `setup-bun-deps` у файлі немає — `true`.
|
|
98
|
+
* @param {Record<string, unknown>} root корінь workflow
|
|
99
|
+
* @param {string[]} setupPathSubstrings підрядки `uses`, що означають локальний composite (наприклад `./.github/actions/setup-bun-deps`)
|
|
100
|
+
* @returns {boolean} `false`, якщо є setup без попереднього checkout
|
|
101
|
+
*/
|
|
102
|
+
export function hasCheckoutBeforeLocalSetupBunDeps(root, setupPathSubstrings) {
|
|
103
|
+
const jobs = root?.jobs
|
|
104
|
+
if (!jobs || typeof jobs !== 'object') {
|
|
105
|
+
return true
|
|
106
|
+
}
|
|
107
|
+
for (const job of Object.values(jobs)) {
|
|
108
|
+
if (job && typeof job === 'object') {
|
|
109
|
+
const steps = /** @type {{ steps?: unknown }} */ (job).steps
|
|
110
|
+
if (Array.isArray(steps)) {
|
|
111
|
+
for (let i = 0; i < steps.length; i++) {
|
|
112
|
+
const step = steps[i]
|
|
113
|
+
if (step && typeof step === 'object') {
|
|
114
|
+
const uses = getStepUses(/** @type {Record<string, unknown>} */ (step))
|
|
115
|
+
const isSetup = setupPathSubstrings.some(s => uses.includes(s))
|
|
116
|
+
if (isSetup) {
|
|
117
|
+
let foundCheckout = false
|
|
118
|
+
for (let j = 0; j < i; j++) {
|
|
119
|
+
const prev = steps[j]
|
|
120
|
+
if (prev && typeof prev === 'object') {
|
|
121
|
+
const u = getStepUses(/** @type {Record<string, unknown>} */ (prev))
|
|
122
|
+
if (u.includes('actions/checkout@')) {
|
|
123
|
+
foundCheckout = true
|
|
124
|
+
break
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (!foundCheckout) {
|
|
129
|
+
return false
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return true
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Шукає заборонені підрядки лише в `uses` та `run` кроків (не в коментарях YAML поза кроками).
|
|
142
|
+
* @param {Record<string, unknown>} root корінь workflow
|
|
143
|
+
* @param {{ pattern: string, msg: string }[]} forbidden список заборонених фрагментів і повідомлень
|
|
144
|
+
* @returns {{ jobId: string, stepIndex: number, pattern: string, msg: string }[]} знайдені збіги
|
|
145
|
+
*/
|
|
146
|
+
export function findForbiddenUsesOrRunPatterns(root, forbidden) {
|
|
147
|
+
/** @type {{ jobId: string, stepIndex: number, pattern: string, msg: string }[]} */
|
|
148
|
+
const hits = []
|
|
149
|
+
for (const { jobId, stepIndex, step } of flattenWorkflowSteps(root)) {
|
|
150
|
+
const uses = getStepUses(step)
|
|
151
|
+
const run = getStepRun(step)
|
|
152
|
+
const blob = `${uses}\n${run}`
|
|
153
|
+
for (const { pattern, msg } of forbidden) {
|
|
154
|
+
if (blob.includes(pattern)) {
|
|
155
|
+
hits.push({ jobId, stepIndex, pattern, msg })
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return hits
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Чи є в `on.push.paths` (або `on.pull_request.paths`) елемент з точним значенням.
|
|
164
|
+
* @param {Record<string, unknown>} root корінь workflow
|
|
165
|
+
* @param {'push' | 'pull_request'} event ім’я ключа в `on`
|
|
166
|
+
* @param {string} exact очікуваний glob
|
|
167
|
+
* @returns {boolean} `true`, якщо шлях присутній у масиві `paths`
|
|
168
|
+
*/
|
|
169
|
+
export function eventPathsIncludeExact(root, event, exact) {
|
|
170
|
+
const on = root?.on
|
|
171
|
+
if (!on || typeof on !== 'object') {
|
|
172
|
+
return false
|
|
173
|
+
}
|
|
174
|
+
const ev = /** @type {Record<string, unknown>} */ (on)[event]
|
|
175
|
+
if (!ev || typeof ev !== 'object') {
|
|
176
|
+
return false
|
|
177
|
+
}
|
|
178
|
+
const paths = /** @type {Record<string, unknown>} */ (ev).paths
|
|
179
|
+
if (!Array.isArray(paths)) {
|
|
180
|
+
return false
|
|
181
|
+
}
|
|
182
|
+
return paths.includes(exact)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Чи містить `on.push.paths` підрядок `npm/**` (npm-module).
|
|
187
|
+
* @param {Record<string, unknown>} root корінь workflow
|
|
188
|
+
* @returns {boolean} `true`, якщо серед `paths` є рядок з `npm/**`
|
|
189
|
+
*/
|
|
190
|
+
export function pushPathsIncludeNpmGlob(root) {
|
|
191
|
+
const on = root?.on
|
|
192
|
+
if (!on || typeof on !== 'object') {
|
|
193
|
+
return false
|
|
194
|
+
}
|
|
195
|
+
const push = /** @type {Record<string, unknown>} */ (on).push
|
|
196
|
+
if (!push || typeof push !== 'object') {
|
|
197
|
+
return false
|
|
198
|
+
}
|
|
199
|
+
const paths = push.paths
|
|
200
|
+
if (!Array.isArray(paths)) {
|
|
201
|
+
return false
|
|
202
|
+
}
|
|
203
|
+
return paths.some(p => typeof p === 'string' && p.includes('npm/**'))
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Перевіряє наявність `branches` з `main` у `on.push`.
|
|
208
|
+
* @param {Record<string, unknown>} root корінь workflow
|
|
209
|
+
* @returns {boolean} `true`, якщо `main` є в `on.push.branches`
|
|
210
|
+
*/
|
|
211
|
+
export function pushHasMainBranch(root) {
|
|
212
|
+
const on = root?.on
|
|
213
|
+
if (!on || typeof on !== 'object') {
|
|
214
|
+
return false
|
|
215
|
+
}
|
|
216
|
+
const push = /** @type {Record<string, unknown>} */ (on).push
|
|
217
|
+
if (!push || typeof push !== 'object') {
|
|
218
|
+
return false
|
|
219
|
+
}
|
|
220
|
+
const branches = push.branches
|
|
221
|
+
if (!Array.isArray(branches)) {
|
|
222
|
+
return false
|
|
223
|
+
}
|
|
224
|
+
return branches.includes('main')
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Чи є крок з `uses: JS-DevTools/npm-publish` та `with.package` для npm-пакета.
|
|
229
|
+
* @param {Record<string, unknown>} root корінь workflow
|
|
230
|
+
* @returns {boolean} `true`, якщо знайдено крок publish з `package: npm/package.json`
|
|
231
|
+
*/
|
|
232
|
+
export function hasNpmPublishStepWithPackage(root) {
|
|
233
|
+
for (const { step } of flattenWorkflowSteps(root)) {
|
|
234
|
+
const uses = getStepUses(step)
|
|
235
|
+
if (uses.includes('JS-DevTools/npm-publish')) {
|
|
236
|
+
const w = step.with
|
|
237
|
+
if (w && typeof w === 'object' && /** @type {Record<string, unknown>} */ (w).package === 'npm/package.json') {
|
|
238
|
+
return true
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return false
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Чи є у job `permissions.id-token: write`.
|
|
247
|
+
* @param {Record<string, unknown>} root корінь workflow
|
|
248
|
+
* @returns {boolean} `true`, якщо OIDC-дозвіл для npm publish налаштований
|
|
249
|
+
*/
|
|
250
|
+
export function hasIdTokenWritePermission(root) {
|
|
251
|
+
const jobs = root?.jobs
|
|
252
|
+
if (!jobs || typeof jobs !== 'object') {
|
|
253
|
+
return false
|
|
254
|
+
}
|
|
255
|
+
for (const job of Object.values(jobs)) {
|
|
256
|
+
if (job && typeof job === 'object') {
|
|
257
|
+
const perm = /** @type {Record<string, unknown>} */ (job).permissions
|
|
258
|
+
if (perm && typeof perm === 'object' && /** @type {Record<string, unknown>} */ (perm)['id-token'] === 'write') {
|
|
259
|
+
return true
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return false
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Перевірки для `lint-js.yml`: checkout@v6, persist-credentials, setup-bun-deps, run-команди.
|
|
268
|
+
* @param {Record<string, unknown> | null} root корінь workflow або `null` якщо parse не вдався
|
|
269
|
+
* @returns {{ ok: boolean, failures: string[] }} результат перевірки та список причин відмови
|
|
270
|
+
*/
|
|
271
|
+
export function verifyLintJsWorkflowStructure(root) {
|
|
272
|
+
/** @type {string[]} */
|
|
273
|
+
const failures = []
|
|
274
|
+
if (!root) {
|
|
275
|
+
return { ok: false, failures: ['YAML не вдалося розібрати — перевір синтаксис workflow'] }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const steps = flattenWorkflowSteps(root)
|
|
279
|
+
const usesList = steps.map(s => getStepUses(s.step))
|
|
280
|
+
const runBlob = steps.map(s => getStepRun(s.step)).join('\n')
|
|
281
|
+
|
|
282
|
+
if (!usesList.some(u => u.includes('actions/checkout@v6'))) {
|
|
283
|
+
failures.push('немає кроку uses: actions/checkout@v6')
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
let checkoutOk = false
|
|
287
|
+
for (const { step } of steps) {
|
|
288
|
+
const u = getStepUses(step)
|
|
289
|
+
if (u.includes('actions/checkout@v6')) {
|
|
290
|
+
const w = step.with
|
|
291
|
+
if (w && typeof w === 'object' && /** @type {Record<string, unknown>} */ (w)['persist-credentials'] === false) {
|
|
292
|
+
checkoutOk = true
|
|
293
|
+
break
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (!checkoutOk) {
|
|
298
|
+
failures.push('checkout@v6 без with.persist-credentials: false')
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (!usesList.some(u => u.includes('./.github/actions/setup-bun-deps'))) {
|
|
302
|
+
failures.push('немає uses: ./.github/actions/setup-bun-deps')
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (!runBlob.includes('bunx oxlint')) {
|
|
306
|
+
failures.push('у run немає bunx oxlint')
|
|
307
|
+
}
|
|
308
|
+
if (!runBlob.includes('bunx eslint .')) {
|
|
309
|
+
failures.push('у run немає bunx eslint .')
|
|
310
|
+
}
|
|
311
|
+
if (!runBlob.includes('bunx jscpd .')) {
|
|
312
|
+
failures.push('у run немає bunx jscpd .')
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
for (const { step } of steps) {
|
|
316
|
+
const run = getStepRun(step)
|
|
317
|
+
if (/bunx\s+oxlint[^\n]*--fix/u.test(run)) {
|
|
318
|
+
failures.push('у run є oxlint з --fix (у CI заборонено)')
|
|
319
|
+
}
|
|
320
|
+
if (run.includes('eslint --fix')) {
|
|
321
|
+
failures.push('у run є eslint --fix (у CI заборонено)')
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return failures.length === 0 ? { ok: true, failures: [] } : { ok: false, failures }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Чи є в будь-якому `run` кроку підрядок (наприклад `bun run lint-text`).
|
|
330
|
+
* @param {Record<string, unknown>} root корінь workflow
|
|
331
|
+
* @param {string} needle підрядок для пошуку
|
|
332
|
+
* @returns {boolean} `true`, якщо хоча б один `run` містить `needle`
|
|
333
|
+
*/
|
|
334
|
+
export function anyRunStepIncludes(root, needle) {
|
|
335
|
+
for (const { step } of flattenWorkflowSteps(root)) {
|
|
336
|
+
if (getStepRun(step).includes(needle)) {
|
|
337
|
+
return true
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return false
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Чи є в будь-якому `run` підрядок `stylelint`.
|
|
345
|
+
* @param {Record<string, unknown>} root корінь workflow
|
|
346
|
+
* @returns {boolean} `true`, якщо stylelint згадано в команді
|
|
347
|
+
*/
|
|
348
|
+
export function anyRunStepIncludesStylelint(root) {
|
|
349
|
+
return anyRunStepIncludes(root, 'stylelint')
|
|
350
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Визначає явні імпорти з модуля `vue`, які суперечать vue.mdc (має працювати unplugin-auto-import).
|
|
3
|
+
*
|
|
4
|
+
* Аналіз import виконується через **oxc-parser** (`parseSync`, поле `module.staticImports`) — ESTree-сумісний
|
|
5
|
+
* розбір без евристик по рядках. Дозволені лише side-effect `import 'vue'`, повністю type-only імпорти
|
|
6
|
+
* та `import { type A, type B } from 'vue'` (перевірка `entries[].isType`).
|
|
7
|
+
*
|
|
8
|
+
* Для `.vue` з шаблону витягуються лише теги `<script>` / `<script setup>` (регулярний вираз); далі той самий Oxc-парсинг
|
|
9
|
+
* вмісту скрипта з віртуальним ім’ям `*.ts` для режиму TypeScript.
|
|
10
|
+
*/
|
|
11
|
+
import { parseSync } from 'oxc-parser'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Мова для Oxc за шляхом файлу (розширення).
|
|
15
|
+
* @param {string} filePath віртуальний або реальний шлях до файлу
|
|
16
|
+
* @returns {'js' | 'jsx' | 'ts' | 'tsx'} значення опції `lang` для `parseSync`
|
|
17
|
+
*/
|
|
18
|
+
function langFromPath(filePath) {
|
|
19
|
+
const lower = filePath.toLowerCase()
|
|
20
|
+
if (lower.endsWith('.tsx')) {
|
|
21
|
+
return 'tsx'
|
|
22
|
+
}
|
|
23
|
+
if (lower.endsWith('.ts') || lower.endsWith('.mts') || lower.endsWith('.cts')) {
|
|
24
|
+
return 'ts'
|
|
25
|
+
}
|
|
26
|
+
if (lower.endsWith('.jsx')) {
|
|
27
|
+
return 'jsx'
|
|
28
|
+
}
|
|
29
|
+
return 'js'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Номер рядка (1-based) за зміщенням у буфері.
|
|
34
|
+
* @param {string} content повний текст файлу
|
|
35
|
+
* @param {number} offset байтове зміщення початку import
|
|
36
|
+
* @returns {number} номер рядка від 1
|
|
37
|
+
*/
|
|
38
|
+
function offsetToLine(content, offset) {
|
|
39
|
+
let line = 1
|
|
40
|
+
const n = Math.min(offset, content.length)
|
|
41
|
+
for (let i = 0; i < n; i++) {
|
|
42
|
+
if (content.codePointAt(i) === 10) {
|
|
43
|
+
line++
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return line
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Стискає пробіли для повідомлення про порушення.
|
|
51
|
+
* @param {string} s фрагмент коду
|
|
52
|
+
* @returns {string} скорочений однорядковий рядок
|
|
53
|
+
*/
|
|
54
|
+
function normalizeSnippet(s) {
|
|
55
|
+
return s.replaceAll(/\s+/g, ' ').trim().slice(0, 160)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Чи цей static import з `vue` дозволено правилом (усі записи type-only або порожній side-effect).
|
|
60
|
+
* @param {{ moduleRequest: { value: string }, entries: { isType: boolean }[] }} imp запис з `module.staticImports`
|
|
61
|
+
* @returns {boolean} `true`, якщо імпорт дозволено (type-only або `import 'vue'`)
|
|
62
|
+
*/
|
|
63
|
+
function isAllowedVueStaticImport(imp) {
|
|
64
|
+
if (imp.entries.length === 0) {
|
|
65
|
+
return true
|
|
66
|
+
}
|
|
67
|
+
return imp.entries.every(e => e.isType)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Віртуальний шлях для парсера: вміст з `<script>` у `.vue` розбираємо як TypeScript.
|
|
72
|
+
* @param {string} relativePath шлях до файлу в пакеті
|
|
73
|
+
* @returns {string} той самий шлях або з `.vue` заміненим на `.ts`
|
|
74
|
+
*/
|
|
75
|
+
function virtualPathForParse(relativePath) {
|
|
76
|
+
if (relativePath.endsWith('.vue')) {
|
|
77
|
+
return relativePath.replace(/\.vue$/u, '.ts')
|
|
78
|
+
}
|
|
79
|
+
return relativePath
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Витягує з SFC лише код усередині `<script>`, щоб не чіпати шаблон.
|
|
84
|
+
* @param {string} sfc вміст .vue файлу
|
|
85
|
+
* @returns {string} текст усередині тегів `<script>` (усі блоки поспіль)
|
|
86
|
+
*/
|
|
87
|
+
export function extractVueScriptBlocks(sfc) {
|
|
88
|
+
const chunks = []
|
|
89
|
+
const re = /<script\b[^>]*>([\s\S]*?)<\/script>/gi
|
|
90
|
+
let m = re.exec(sfc)
|
|
91
|
+
while (m) {
|
|
92
|
+
chunks.push(m[1])
|
|
93
|
+
m = re.exec(sfc)
|
|
94
|
+
}
|
|
95
|
+
return chunks.join('\n\n')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Підбирає текст для сканування: для .vue — лише script-блоки, інакше — увесь вміст.
|
|
100
|
+
* @param {string} content сирий вміст файлу
|
|
101
|
+
* @param {string} filePath відносний шлях (для вибору режиму)
|
|
102
|
+
* @returns {string} текст для `parseSync`
|
|
103
|
+
*/
|
|
104
|
+
export function contentForVueImportScan(content, filePath) {
|
|
105
|
+
if (filePath.endsWith('.vue')) {
|
|
106
|
+
return extractVueScriptBlocks(content)
|
|
107
|
+
}
|
|
108
|
+
return content
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Знаходить заборонені static import з `vue` у вже підготовленому тексті (без `<template>`).
|
|
113
|
+
* Використовує **oxc-parser**; при синтаксичних помилках повертає порожній масив (спочатку виправ синтаксис).
|
|
114
|
+
* @param {string} content вихідний код
|
|
115
|
+
* @param {string} [virtualPath] шлях для вибору `lang` (наприклад `app/src/foo.ts` або віртуальний після `.vue` → `.ts`)
|
|
116
|
+
* @returns {{ line: number, snippet: string }[]} список порушень з номером рядка початку import
|
|
117
|
+
*/
|
|
118
|
+
export function findForbiddenVueImportsInText(content, virtualPath = 'scan.ts') {
|
|
119
|
+
const pathForLang = virtualPath || 'scan.ts'
|
|
120
|
+
const lang = langFromPath(pathForLang)
|
|
121
|
+
let result
|
|
122
|
+
try {
|
|
123
|
+
result = parseSync(pathForLang, content, { lang, sourceType: 'module' })
|
|
124
|
+
} catch {
|
|
125
|
+
return []
|
|
126
|
+
}
|
|
127
|
+
if (result.errors?.length) {
|
|
128
|
+
return []
|
|
129
|
+
}
|
|
130
|
+
/** @type {{ line: number, snippet: string }[]} */
|
|
131
|
+
const out = []
|
|
132
|
+
for (const imp of result.module.staticImports) {
|
|
133
|
+
if (imp.moduleRequest.value === 'vue' && !isAllowedVueStaticImport(imp)) {
|
|
134
|
+
out.push({
|
|
135
|
+
line: offsetToLine(content, imp.start),
|
|
136
|
+
snippet: normalizeSnippet(content.slice(imp.start, imp.end))
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return out
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Чи слід пропустити файл під час обходу пакета (генерація, типи).
|
|
145
|
+
* @param {string} relativePosix шлях з posix-слешами
|
|
146
|
+
* @returns {boolean} `true`, якщо файл не сканувати (`.d.ts`, згенеровані імена)
|
|
147
|
+
*/
|
|
148
|
+
export function shouldSkipFileForVueImportScan(relativePosix) {
|
|
149
|
+
const base = relativePosix.split('/').pop() || ''
|
|
150
|
+
if (base === 'auto-imports.d.ts' || base === 'components.d.ts') {
|
|
151
|
+
return true
|
|
152
|
+
}
|
|
153
|
+
if (relativePosix.endsWith('.d.ts')) {
|
|
154
|
+
return true
|
|
155
|
+
}
|
|
156
|
+
return false
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Чи сканувати цей файл за розширенням.
|
|
161
|
+
* @param {string} relativePath відносний шлях до файлу
|
|
162
|
+
* @returns {boolean} `true`, якщо розширення підходить для пошуку import
|
|
163
|
+
*/
|
|
164
|
+
export function isVueImportScanSourceFile(relativePath) {
|
|
165
|
+
return /\.(vue|[cm]?[jt]sx?)$/.test(relativePath)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Знаходить порушення в одному файлі (з урахуванням .vue script extraction).
|
|
170
|
+
* @param {string} content сирий вміст файлу
|
|
171
|
+
* @param {string} relativePath шлях відносно кореня пакета або репо
|
|
172
|
+
* @returns {{ line: number, snippet: string }[]} список порушень для цього файлу
|
|
173
|
+
*/
|
|
174
|
+
export function findForbiddenVueImportsInSourceFile(content, relativePath) {
|
|
175
|
+
const scan = contentForVueImportScan(content, relativePath)
|
|
176
|
+
const virtualPath = virtualPathForParse(relativePath)
|
|
177
|
+
return findForbiddenVueImportsInText(scan, virtualPath)
|
|
178
|
+
}
|