@nitra/cursor 1.27.7 → 1.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/package.json +1 -1
  3. package/rules/abie/js/applies.mjs +3 -2
  4. package/rules/abie/js/env_dns.mjs +4 -2
  5. package/rules/abie/js/firebase_hosting.mjs +3 -2
  6. package/rules/abie/js/hc_pairing.mjs +3 -2
  7. package/rules/abie/js/ua_http_route.mjs +4 -2
  8. package/rules/abie/js/ua_node_selector.mjs +4 -2
  9. package/rules/adr/js/hooks.mjs +36 -28
  10. package/rules/bun/js/layout.mjs +16 -11
  11. package/rules/capacitor/js/platforms.mjs +3 -2
  12. package/rules/changelog/js/consistency.mjs +85 -63
  13. package/rules/changelog/lib/package-manifest.mjs +5 -4
  14. package/rules/docker/js/lint.mjs +3 -2
  15. package/rules/ga/js/workflows.mjs +41 -32
  16. package/rules/graphql/js/tooling.mjs +15 -11
  17. package/rules/hasura/js/internal_urls.mjs +14 -10
  18. package/rules/image-avif/js/avif_generation.mjs +36 -23
  19. package/rules/image-compress/js/package_setup.mjs +18 -12
  20. package/rules/js-bun-db/js/safety.mjs +3 -2
  21. package/rules/js-lint/js/tooling.mjs +45 -32
  22. package/rules/js-run/js/runtime.mjs +21 -15
  23. package/rules/k8s/js/manifests.mjs +3 -2
  24. package/rules/nginx-default-tpl/js/template.mjs +3 -2
  25. package/rules/npm-module/js/package_structure.mjs +82 -57
  26. package/rules/rego/js/applies.mjs +4 -4
  27. package/rules/rust/js/applies.mjs +5 -3
  28. package/rules/security/js/sample_secret.mjs +2 -2
  29. package/rules/security/js/trufflehog.mjs +6 -4
  30. package/rules/style-lint/js/tooling.mjs +15 -8
  31. package/rules/test/coverage/coverage.mjs +2 -1
  32. package/rules/test/js/data/vitest_config/vitest.config.baseline.js +7 -0
  33. package/rules/test/js/location.mjs +3 -2
  34. package/rules/test/js/no-process-chdir.mjs +89 -0
  35. package/rules/test/js/vitest-config-pool-forks.mjs +52 -0
  36. package/rules/test/test.mdc +17 -0
  37. package/rules/text/js/forbidden-prettier.mjs +4 -2
  38. package/rules/text/js/formatting.mjs +25 -16
  39. package/rules/vue/js/packages.mjs +33 -25
@@ -44,14 +44,15 @@ const REQUIRED_WORKFLOWS = ['clean-ga-workflows.yml', 'clean-merged-branch.yml',
44
44
  * Використовує `git ls-files` з pathspec-магiєю `:(glob)`, щоб не реалізовувати glob engine вручну
45
45
  * і не сканувати файлову систему рекурсивно.
46
46
  * @param {string} globPattern glob з workflow (наприклад "files/**" або "image-migration-new/**")
47
+ * @param {string} cwd робочий каталог для `git`
47
48
  * @returns {boolean} true, якщо є хоча б один збіг
48
49
  */
49
- function gitHasAnyTrackedFileMatchingGlob(globPattern) {
50
+ function gitHasAnyTrackedFileMatchingGlob(globPattern, cwd) {
50
51
  const p = String(globPattern ?? '').trim()
51
52
  if (!p) return false
52
53
  if (p.startsWith('!')) return true
53
54
  try {
54
- const out = execFileSync('git', ['ls-files', '-z', '--', `:(glob)${p}`], { encoding: 'utf8' })
55
+ const out = execFileSync('git', ['ls-files', '-z', '--', `:(glob)${p}`], { encoding: 'utf8', cwd })
55
56
  return out.length > 0
56
57
  } catch {
57
58
  return false
@@ -84,15 +85,16 @@ function shouldValidateWorkflowPathsGlob(p) {
84
85
  * @param {unknown} raw сирий елемент масиву paths
85
86
  * @param {(msg: string) => void} passFn pass
86
87
  * @param {(msg: string) => void} failFn fail
88
+ * @param {string} cwd робочий каталог для `git`
87
89
  */
88
- function verifyOnePathsGlob(relPath, eventName, raw, passFn, failFn) {
90
+ function verifyOnePathsGlob(relPath, eventName, raw, passFn, failFn, cwd) {
89
91
  const p = String(raw ?? '').trim()
90
92
  if (!p) return
91
93
  if (!shouldValidateWorkflowPathsGlob(p)) {
92
94
  passFn(`${relPath}: on.${eventName}.paths glob пропущено для перевірки існування: ${JSON.stringify(p)}`)
93
95
  return
94
96
  }
95
- if (gitHasAnyTrackedFileMatchingGlob(p)) {
97
+ if (gitHasAnyTrackedFileMatchingGlob(p, cwd)) {
96
98
  passFn(`${relPath}: on.${eventName}.paths glob матчитсья: ${JSON.stringify(p)}`)
97
99
  } else {
98
100
  failFn(`${relPath}: on.${eventName}.paths glob не матчитсья ні на один файл: ${JSON.stringify(p)}`)
@@ -105,8 +107,9 @@ function verifyOnePathsGlob(relPath, eventName, raw, passFn, failFn) {
105
107
  * @param {Record<string, unknown>} root parsed YAML workflow
106
108
  * @param {(msg: string) => void} passFn pass
107
109
  * @param {(msg: string) => void} failFn fail
110
+ * @param {string} cwd робочий каталог для `git`
108
111
  */
109
- function verifyWorkflowEventPathsGlobsExist(relPath, root, passFn, failFn) {
112
+ function verifyWorkflowEventPathsGlobsExist(relPath, root, passFn, failFn, cwd) {
110
113
  const on = getObjKey(root, 'on')
111
114
  if (!on || typeof on !== 'object') return
112
115
 
@@ -119,7 +122,7 @@ function verifyWorkflowEventPathsGlobsExist(relPath, root, passFn, failFn) {
119
122
  for (const [eventName, paths] of candidates) {
120
123
  if (!Array.isArray(paths)) continue
121
124
  for (const raw of paths) {
122
- verifyOnePathsGlob(relPath, eventName, raw, passFn, failFn)
125
+ verifyOnePathsGlob(relPath, eventName, raw, passFn, failFn, cwd)
123
126
  }
124
127
  }
125
128
  }
@@ -138,7 +141,7 @@ function getObjKey(obj, key) {
138
141
 
139
142
  /**
140
143
  * Перевіряє apply-workflow на наявність paths trigger.
141
- * @param {string} wfDir директорія workflows
144
+ * @param {string} wfDir абсолютна директорія workflows
142
145
  * @param {string[]} files список файлів у директорії
143
146
  * @param {string} filename параметр filename
144
147
  * @param {string} expectedPath параметр expectedPath
@@ -147,7 +150,7 @@ function getObjKey(obj, key) {
147
150
  */
148
151
  async function checkApplyWorkflow(wfDir, files, filename, expectedPath, passFn, failFn) {
149
152
  if (!files.includes(filename)) return
150
- const content = await readFile(`${wfDir}/${filename}`, 'utf8')
153
+ const content = await readFile(join(wfDir, filename), 'utf8')
151
154
  const root = parseWorkflowYaml(content)
152
155
  const ok = root ? eventPathsIncludeExact(root, 'push', expectedPath) : content.includes(expectedPath)
153
156
  if (ok) {
@@ -159,22 +162,24 @@ async function checkApplyWorkflow(wfDir, files, filename, expectedPath, passFn,
159
162
 
160
163
  /**
161
164
  * Перевіряє відсутність MegaLinter у workflows та конфіг-файлах.
162
- * @param {string} wfDir директорія workflows
165
+ * @param {string} wfDir абсолютна директорія workflows
163
166
  * @param {string[]} ymlWorkflows параметр ymlWorkflows
167
+ * @param {string} wfDirRel відносний шлях директорії workflows для повідомлень
168
+ * @param {string} cwd корінь репозиторію
164
169
  * @param {(msg: string) => void} passFn callback при успішній перевірці
165
170
  * @param {(msg: string) => void} failFn callback при помилці
166
171
  */
167
- async function checkMegalinter(wfDir, ymlWorkflows, passFn, failFn) {
172
+ async function checkMegalinter(wfDir, ymlWorkflows, wfDirRel, cwd, passFn, failFn) {
168
173
  let found = false
169
174
  for (const f of ymlWorkflows) {
170
175
  const content = await readFile(join(wfDir, f), 'utf8')
171
176
  if (MEGALINTER_USE_PATTERNS.some(re => re.test(content))) {
172
177
  found = true
173
- failFn(`MegaLinter у workflow ${wfDir}/${f} — видали інтеграцію (ga.mdc: MegaLinter)`)
178
+ failFn(`MegaLinter у workflow ${wfDirRel}/${f} — видали інтеграцію (ga.mdc: MegaLinter)`)
174
179
  }
175
180
  }
176
181
  for (const name of MEGALINTER_CONFIG_NAMES) {
177
- if (existsSync(name)) {
182
+ if (existsSync(join(cwd, name))) {
178
183
  found = true
179
184
  failFn(`Файл ${name} — видали конфіг MegaLinter (ga.mdc: MegaLinter)`)
180
185
  }
@@ -210,16 +215,16 @@ export function checkShellcheckInstalled(passFn, failFn) {
210
215
 
211
216
  /**
212
217
  * Перевіряє розширення workflow-файлів і наявність обов'язкових workflow.
213
- * @param {string} wfDir шлях до директорії workflows
218
+ * @param {string} wfDirRel відносний шлях директорії workflows для повідомлень
214
219
  * @param {string[]} files список файлів у wfDir
215
220
  * @param {(msg: string) => void} pass callback при успішній перевірці
216
221
  * @param {(msg: string) => void} fail callback при помилці
217
222
  */
218
- function checkGaWorkflowFiles(wfDir, files, pass, fail) {
223
+ function checkGaWorkflowFiles(wfDirRel, files, pass, fail) {
219
224
  const yamlFiles = files.filter(f => f.endsWith('.yaml'))
220
225
  if (yamlFiles.length > 0) {
221
226
  for (const f of yamlFiles) {
222
- fail(`Workflow з розширенням .yaml: ${wfDir}/${f} — перейменуй на .yml`)
227
+ fail(`Workflow з розширенням .yaml: ${wfDirRel}/${f} — перейменуй на .yml`)
223
228
  }
224
229
  } else {
225
230
  pass('Всі workflows мають розширення .yml')
@@ -228,7 +233,7 @@ function checkGaWorkflowFiles(wfDir, files, pass, fail) {
228
233
  const notYmlFiles = files.filter(f => !f.endsWith('.yml'))
229
234
  if (notYmlFiles.length > 0) {
230
235
  for (const f of notYmlFiles) {
231
- fail(`Workflow має бути з розширенням .yml: ${wfDir}/${f} (ga.mdc)`)
236
+ fail(`Workflow має бути з розширенням .yml: ${wfDirRel}/${f} (ga.mdc)`)
232
237
  }
233
238
  }
234
239
 
@@ -236,7 +241,7 @@ function checkGaWorkflowFiles(wfDir, files, pass, fail) {
236
241
  if (files.includes(f)) {
237
242
  pass(`${f} існує`)
238
243
  } else {
239
- fail(`Відсутній ${wfDir}/${f}`)
244
+ fail(`Відсутній ${wfDirRel}/${f}`)
240
245
  }
241
246
  }
242
247
  }
@@ -277,22 +282,24 @@ const GA_PER_WORKFLOW_REGO_TARGETS = [
277
282
  * бо кожен namespace застосовний лише до свого файла), потім один батч-спавн
278
283
  * `ga.workflow_common` на всі `.github/workflows/*.yml`. Hard-fail без
279
284
  * `conftest` у PATH — узгоджено з Plan B (див. `runConftestBatch`).
280
- * @param {string} wfDir шлях до `.github/workflows`
285
+ * @param {string} wfDir абсолютний шлях до `.github/workflows`
281
286
  * @param {string[]} ymlWorkflows відносні (від `wfDir`) імена файлів `*.yml`
287
+ * @param {string} cwd корінь репозиторію
282
288
  * @param {(msg: string) => void} pass callback при успішній перевірці
283
289
  * @param {(msg: string) => void} fail callback при помилці
284
290
  * @returns {void}
285
291
  */
286
- async function runAllGaRego(wfDir, ymlWorkflows, pass, fail) {
292
+ async function runAllGaRego(wfDir, ymlWorkflows, cwd, pass, fail) {
287
293
  for (const target of GA_PER_WORKFLOW_REGO_TARGETS) {
288
- if (!existsSync(target.workflow)) continue
294
+ const targetAbs = join(cwd, target.workflow)
295
+ if (!existsSync(targetAbs)) continue
289
296
  const concernDir = join(GA_POLICY_DIR, target.policyDirRel.split('/')[1])
290
297
  const tpl = await loadTemplate(concernDir)
291
298
  const templateData = tpl[basename(target.workflow)]
292
299
  const violations = runConftestBatch({
293
300
  policyDirRel: target.policyDirRel,
294
301
  namespace: target.namespace,
295
- files: [target.workflow],
302
+ files: [targetAbs],
296
303
  templateData
297
304
  })
298
305
  for (const v of violations) fail(`${target.workflow}: ${v.message}`)
@@ -328,16 +335,18 @@ async function runAllGaRego(wfDir, ymlWorkflows, pass, fail) {
328
335
  * config, megalinter залишки тощо). `bun run lint-ga` додатково запускає
329
336
  * `actionlint` + `zizmor` зовнішніми тулчейнами і **викликає цю ж `check()`** —
330
337
  * тобто rego-частина живе тут, не в `lint-ga.mjs`.
338
+ * @param {string} [cwd] корінь репозиторію
331
339
  * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
332
340
  */
333
- export async function check() {
341
+ export async function check(cwd = process.cwd()) {
334
342
  const reporter = createCheckReporter()
335
343
  const { pass, fail } = reporter
336
344
 
337
- const wfDir = '.github/workflows'
345
+ const wfDirRel = '.github/workflows'
346
+ const wfDir = join(cwd, wfDirRel)
338
347
 
339
348
  if (!existsSync(wfDir)) {
340
- fail(`Директорія ${wfDir} не існує`)
349
+ fail(`Директорія ${wfDirRel} не існує`)
341
350
  return reporter.getExitCode()
342
351
  }
343
352
 
@@ -346,23 +355,23 @@ export async function check() {
346
355
 
347
356
  // Rego-крок (per-workflow + workflow_common) — на початку, як єдине джерело
348
357
  // істини для пер-документних структурних правил workflow-файлів.
349
- await runAllGaRego(wfDir, ymlWorkflows, pass, fail)
358
+ await runAllGaRego(wfDir, ymlWorkflows, cwd, pass, fail)
350
359
 
351
- const setupBunDepsAction = '.github/actions/setup-bun-deps/action.yml'
352
- if (existsSync(setupBunDepsAction)) {
353
- pass(`${setupBunDepsAction} існує`)
360
+ const setupBunDepsActionRel = '.github/actions/setup-bun-deps/action.yml'
361
+ if (existsSync(join(cwd, setupBunDepsActionRel))) {
362
+ pass(`${setupBunDepsActionRel} існує`)
354
363
  } else {
355
364
  fail(
356
- `Відсутній ${setupBunDepsAction} — запустіть npx @nitra/cursor або скопіюйте з пакету (ga.mdc: composite setup-bun-deps)`
365
+ `Відсутній ${setupBunDepsActionRel} — запустіть npx @nitra/cursor або скопіюйте з пакету (ga.mdc: composite setup-bun-deps)`
357
366
  )
358
367
  }
359
368
 
360
- checkGaWorkflowFiles(wfDir, files, pass, fail)
369
+ checkGaWorkflowFiles(wfDirRel, files, pass, fail)
361
370
 
362
371
  await checkApplyWorkflow(wfDir, files, 'apply-k8s.yml', '**/k8s/**/*.yaml', pass, fail)
363
372
  await checkApplyWorkflow(wfDir, files, 'apply-nats-consumer.yml', '**/consumer.yaml', pass, fail)
364
373
 
365
- await checkMegalinter(wfDir, ymlWorkflows, pass, fail)
374
+ await checkMegalinter(wfDir, ymlWorkflows, wfDirRel, cwd, pass, fail)
366
375
 
367
376
  // git-залежна перевірка `on.push.paths` glob-ів (вимагає `git ls-files`) —
368
377
  // лишається в JS, бо conftest не має доступу до файлової системи репо.
@@ -370,7 +379,7 @@ export async function check() {
370
379
  const content = await readFile(join(wfDir, f), 'utf8')
371
380
  const parsed = parseWorkflowYaml(content)
372
381
  if (parsed) {
373
- verifyWorkflowEventPathsGlobsExist(`${wfDir}/${f}`, parsed, pass, fail)
382
+ verifyWorkflowEventPathsGlobsExist(`${wfDirRel}/${f}`, parsed, pass, fail, cwd)
374
383
  }
375
384
  }
376
385
 
@@ -5,10 +5,11 @@
5
5
  * Обхід репозиторію — **`walkDir`** від **`process.cwd()`** (пропуски як у інших check). Кандидати — **`.vue`** та **`.js`/`.ts`/`.jsx`/`.tsx`** тощо; пропуск **`.d.ts`**, **auto-imports.d.ts** тощо — **`shouldSkipFileForGqlScan`**.
6
6
  *
7
7
  * Виявлення **`gql`** — **oxc-parser** після витягування `<script>` з SFC (**`graphql-gql-scan.mjs`**). Якщо збігів немає — перевірка завершується успішно без вимог до конфігів.
8
+ * @param {string} cwd корінь репозиторію
8
9
  */
9
10
  import { existsSync } from 'node:fs'
10
11
  import { readFile } from 'node:fs/promises'
11
- import { relative } from 'node:path'
12
+ import { join, relative } from 'node:path'
12
13
 
13
14
  import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
14
15
  import {
@@ -75,20 +76,22 @@ async function collectGqlHits(root, candidates) {
75
76
  * @param {(msg: string) => void} pass success-репортер
76
77
  * @param {(msg: string) => void} fail fail-репортер
77
78
  * @returns {void}
79
+ * @param {string} cwd корінь репозиторію
78
80
  */
79
- function checkExtensionsRecommendation(pass, fail) {
80
- const path = '.vscode/extensions.json'
81
- if (!existsSync(path)) {
82
- fail(`${path} не існує — створи файл і додай у recommendations ${REQUIRED_GRAPHQL_VSCODE_EXTENSION} (graphql.mdc)`)
81
+ function checkExtensionsRecommendation(pass, fail, cwd) {
82
+ const pathRel = '.vscode/extensions.json'
83
+ const pathAbs = join(cwd, pathRel)
84
+ if (!existsSync(pathAbs)) {
85
+ fail(`${pathRel} не існує — створи файл і додай у recommendations ${REQUIRED_GRAPHQL_VSCODE_EXTENSION} (graphql.mdc)`)
83
86
  return
84
87
  }
85
88
  const violations = runConftestBatch({
86
89
  policyDirRel: 'graphql/vscode_extensions',
87
90
  namespace: 'graphql.vscode_extensions',
88
- files: [path]
91
+ files: [pathAbs]
89
92
  })
90
93
  if (violations.length === 0) {
91
- pass(`${path} відповідає graphql.vscode_extensions (rego)`)
94
+ pass(`${pathRel} відповідає graphql.vscode_extensions (rego)`)
92
95
  return
93
96
  }
94
97
  for (const v of violations) fail(v.message)
@@ -98,12 +101,13 @@ function checkExtensionsRecommendation(pass, fail) {
98
101
  * Перевіряє graphql.mdc: умовна вимога .graphqlrc.yml і graphql.vscode-graphql
99
102
  * за наявності gql tagged templates.
100
103
  * @returns {Promise<number>} 0 — OK, 1 — порушення
104
+ * @param {string} [cwd] корінь репозиторію
101
105
  */
102
- export async function check() {
106
+ export async function check(cwd = process.cwd()) {
103
107
  const reporter = createCheckReporter()
104
108
  const { pass, fail } = reporter
105
109
 
106
- const root = process.cwd()
110
+ const root = cwd
107
111
  const ignorePaths = await loadCursorIgnorePaths(root)
108
112
  const candidates = await collectScanCandidates(root, ignorePaths)
109
113
  const hits = await collectGqlHits(root, candidates)
@@ -117,7 +121,7 @@ export async function check() {
117
121
 
118
122
  pass(`Знайдено gql\`…\` у ${hits.length} файлі(ах): ${hits.slice(0, 5).join(', ')}${hits.length > 5 ? '…' : ''}`)
119
123
 
120
- if (existsSync(GRAPHQL_RC_FILENAME)) {
124
+ if (existsSync(join(root, GRAPHQL_RC_FILENAME))) {
121
125
  pass(`${GRAPHQL_RC_FILENAME} існує`)
122
126
  } else {
123
127
  fail(
@@ -125,7 +129,7 @@ export async function check() {
125
129
  )
126
130
  }
127
131
 
128
- checkExtensionsRecommendation(pass, fail)
132
+ checkExtensionsRecommendation(pass, fail, root)
129
133
 
130
134
  return reporter.getExitCode()
131
135
  }
@@ -129,14 +129,15 @@ async function collectEnvFiles(root, ignorePaths) {
129
129
  /**
130
130
  * Перевіряє один `.env` файл на коректність `HASURA_GRAPHQL_ENDPOINT`.
131
131
  * Якщо в файлі немає змінної — вважаємо OK.
132
- * @param {string} relPath відносний шлях файла
132
+ * @param {string} relPath відносний (posix) шлях файла відносно `cwd` (для повідомлень)
133
+ * @param {string} cwd корінь репозиторію
133
134
  * @param {{ service: string | null, namespace: string | null }} expected очікувані сегменти з YAML
134
135
  * @param {{ pass: (msg: string) => void, fail: (msg: string) => void }} reporter репортер
135
136
  * @returns {Promise<void>}
136
137
  */
137
- async function checkEnvFile(relPath, expected, reporter) {
138
+ async function checkEnvFile(relPath, cwd, expected, reporter) {
138
139
  const { pass, fail } = reporter
139
- const content = await readFile(relPath, 'utf8')
140
+ const content = await readFile(join(cwd, relPath), 'utf8')
140
141
  const m = content.match(HASURA_ENDPOINT_LINE_RE)
141
142
  if (!m) {
142
143
  return
@@ -169,14 +170,16 @@ async function checkEnvFile(relPath, expected, reporter) {
169
170
 
170
171
  /**
171
172
  * Зчитує URL репозиторію з кореневого `package.json` (або null, якщо файла немає / не валідний).
173
+ * @param {string} cwd корінь репозиторію
172
174
  * @returns {Promise<string | null>} URL з поля `repository`
173
175
  */
174
- async function readRootRepositoryUrl() {
175
- if (!existsSync('package.json')) {
176
+ async function readRootRepositoryUrl(cwd) {
177
+ const pkgPath = join(cwd, 'package.json')
178
+ if (!existsSync(pkgPath)) {
176
179
  return null
177
180
  }
178
181
  try {
179
- const pkg = JSON.parse(await readFile('package.json', 'utf8'))
182
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
180
183
  return getRepositoryUrl(pkg?.repository)
181
184
  } catch {
182
185
  return null
@@ -198,19 +201,20 @@ export function isNitraOrAbieRepository(url) {
198
201
 
199
202
  /**
200
203
  * Перевіряє hasura.mdc для поточного робочого каталогу.
204
+ * @param {string} [cwd] корінь репозиторію
201
205
  * @returns {Promise<number>} 0 — OK / правило не застосовується, 1 — порушення
202
206
  */
203
- export async function check() {
207
+ export async function check(cwd = process.cwd()) {
204
208
  const reporter = createCheckReporter()
205
209
  const { pass } = reporter
206
210
 
207
- const repositoryUrl = await readRootRepositoryUrl()
211
+ const repositoryUrl = await readRootRepositoryUrl(cwd)
208
212
  if (!isNitraOrAbieRepository(repositoryUrl)) {
209
213
  pass('Пропущено: репозиторій не nitra і не abie (hasura.mdc застосовується лише до них)')
210
214
  return reporter.getExitCode()
211
215
  }
212
216
 
213
- const root = process.cwd()
217
+ const root = cwd
214
218
  const expected = {
215
219
  service: await readYamlMetadataName(join(root, HASURA_SVC_HL_FILE), 'Service'),
216
220
  namespace: await readYamlMetadataName(join(root, HASURA_NAMESPACE_FILE), 'Namespace')
@@ -224,7 +228,7 @@ export async function check() {
224
228
  }
225
229
 
226
230
  for (const rel of envFiles) {
227
- await checkEnvFile(rel, expected, reporter)
231
+ await checkEnvFile(rel, root, expected, reporter)
228
232
  }
229
233
 
230
234
  // Якщо у файлах не було жодної згадки HASURA_GRAPHQL_ENDPOINT — повідом про це.
@@ -23,6 +23,7 @@
23
23
  * підтримується сучасними браузерами) і не вмикати в публічних сайтах. Перевірка скрипта
24
24
  * `lint-image` (заборона `--avif` у ньому) залишається у `image-compress` — тут вона не
25
25
  * дублюється.
26
+ * @param {string} cwd корінь репозиторію
26
27
  */
27
28
  import { existsSync } from 'node:fs'
28
29
  import { readFile, unlink, writeFile } from 'node:fs/promises'
@@ -47,6 +48,7 @@ const PKG_CONFIG_FIELD = '@nitra/minify-image'
47
48
  * платформи — `.avif` всередині — це продукт попереднього `bun run build`/Capacitor sync,
48
49
  * а не кандидати на видалення. `walkDir` уже скіпає `node_modules`, `.git`, `dist`,
49
50
  * `coverage`, `.turbo`, `.next` — додатково для cleanup ігноруємо ще ці.
51
+ * @param {string} cwd корінь репозиторію
50
52
  */
51
53
  const CLEANUP_EXTRA_IGNORE_DIR_NAMES = new Set(['build', 'android', 'ios', '.output', '.nuxt', '.cache'])
52
54
 
@@ -54,6 +56,7 @@ const CLEANUP_EXTRA_IGNORE_DIR_NAMES = new Set(['build', 'android', 'ios', '.out
54
56
  * Регексп для імпортів raster-зображень у `.vue` файлах.
55
57
  * Захоплює `import name from '...ext'` (як default, так і type-only форми не потрібні —
56
58
  * type-imports asset-ів не існує). Захоплюється повний шлях у групі 1.
59
+ * @param {string} cwd корінь репозиторію
57
60
  */
58
61
  const VUE_RASTER_IMPORT_RE = /import\s+\w[\w$]*\s+from\s+['"]([^'"\n]+\.(?:png|jpe?g|gif))['"]/giu
59
62
 
@@ -64,6 +67,7 @@ const VUE_RASTER_IMPORT_RE = /import\s+\w[\w$]*\s+from\s+['"]([^'"\n]+\.(?:png|j
64
67
  *
65
68
  * Лукбехайнд `(?<![:\-_.])` виключає реактивне `:src="..."` (там JS-вираз — змінна або виклик,
66
69
  * перевіряється через імпорт), `data-src="..."` і `obj.src=...` у `<script>`.
70
+ * @param {string} cwd корінь репозиторію
67
71
  */
68
72
  const VUE_RASTER_STATIC_SRC_RE = /(?<![:\-_.])\bsrc\s*=\s*['"]([^'"\s]+\.(?:png|jpe?g|gif))['"]/giu
69
73
 
@@ -72,6 +76,7 @@ const VUE_RASTER_STATIC_SRC_RE = /(?<![:\-_.])\bsrc\s*=\s*['"]([^'"\s]+\.(?:png|
72
76
  * так і `<img src="....png.avif" />`). Потрібен лише для збору множини «живих» AVIF —
73
77
  * щоб після авто-заміни знати, які `<...>.avif` файли ще на щось посилаються, а які
74
78
  * є сиротами і підлягають видаленню.
79
+ * @param {string} cwd корінь репозиторію
75
80
  */
76
81
  const VUE_AVIF_REF_RE = /['"]([^'"\s]+\.(?:png|jpe?g|gif)\.avif)['"]/giu
77
82
 
@@ -144,6 +149,7 @@ function resolveImageCandidates(importPath, sourceAbsPath, packageRootAbs) {
144
149
  * @property {number} rewrittenRefs скільки конкретних посилань переписано на `.avif`
145
150
  * @property {number} rewrittenFiles у скількох `.vue`/`.html` файлах хоч одне посилання змінилося
146
151
  * @property {number} failedRefs скільки конкретних посилань не вдалося переписати (`.avif` не існував)
152
+ * @param {string} cwd корінь репозиторію
147
153
  */
148
154
 
149
155
  /**
@@ -166,9 +172,10 @@ function resolveImageCandidates(importPath, sourceAbsPath, packageRootAbs) {
166
172
  * @param {RewriteStats} stats глобальні лічильники, що мутуються тут
167
173
  * @param {(msg: string) => void} fail callback при помилці
168
174
  * @returns {Promise<void>} визначається по завершенню перевірки одного пакета
175
+ * @param {string} cwd корінь репозиторію
169
176
  */
170
- async function checkVueAvifImportsInPackage(packageRoot, otherRootsAbs, ignorePaths, usedAvifAbs, stats, fail) {
171
- const absRoot = join(process.cwd(), packageRoot)
177
+ async function checkVueAvifImportsInPackage(packageRoot, otherRootsAbs, ignorePaths, usedAvifAbs, stats, fail, cwd) {
178
+ const absRoot = join(cwd, packageRoot)
172
179
  const label = packageRoot === '.' ? 'корінь' : packageRoot
173
180
  /** @type {string[]} */
174
181
  const targetFiles = []
@@ -184,7 +191,7 @@ async function checkVueAvifImportsInPackage(packageRoot, otherRootsAbs, ignorePa
184
191
  if (targetFiles.length === 0) return
185
192
 
186
193
  for (const absPath of targetFiles) {
187
- const rel = relative(process.cwd(), absPath).split('\\').join('/')
194
+ const rel = relative(cwd, absPath).split('\\').join('/')
188
195
  const original = await readFile(absPath, 'utf8')
189
196
  let updated = original
190
197
 
@@ -258,26 +265,27 @@ async function checkVueAvifImportsInPackage(packageRoot, otherRootsAbs, ignorePa
258
265
  * @param {RewriteStats} stats глобальні лічильники rewrite/fail (мутуються нижче)
259
266
  * @param {(msg: string) => void} pass callback при успішній перевірці
260
267
  * @param {(msg: string) => void} fail callback при помилці
268
+ * @param {string} cwd корінь репозиторію
261
269
  * @returns {Promise<string[]>} абсолютні шляхи коренів пакетів з активним opt-out
262
270
  */
263
- async function checkVueAvifImports(ignorePaths, usedAvifAbs, stats, pass, fail) {
264
- const roots = await getMonorepoPackageRootDirs()
265
- const absRootsByRel = new Map(roots.map(r => [r, join(process.cwd(), r)]))
271
+ async function checkVueAvifImports(ignorePaths, usedAvifAbs, stats, pass, fail, cwd) {
272
+ const roots = await getMonorepoPackageRootDirs(cwd)
273
+ const absRootsByRel = new Map(roots.map(r => [r, join(cwd, r)]))
266
274
  /** @type {string[]} */
267
275
  const optedOutAbs = []
268
276
  for (const root of roots) {
269
- const pkgPath = join(root, 'package.json')
277
+ const pkgPath = join(cwd, root, 'package.json')
270
278
  if (!existsSync(pkgPath)) continue
271
279
  const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
272
280
  if (packageHasAvifDisabled(pkg)) {
273
281
  pass(
274
282
  `[${root === '.' ? 'корінь' : root}] avif-import enforcement вимкнено через "@nitra/minify-image.disable-avif"`
275
283
  )
276
- optedOutAbs.push(absRootsByRel.get(root) ?? join(process.cwd(), root))
284
+ optedOutAbs.push(absRootsByRel.get(root) ?? join(cwd, root))
277
285
  continue
278
286
  }
279
287
  const otherRootsAbs = roots.filter(r => r !== root && r !== '.').map(r => absRootsByRel.get(r) ?? '')
280
- await checkVueAvifImportsInPackage(root, otherRootsAbs, ignorePaths, usedAvifAbs, stats, fail)
288
+ await checkVueAvifImportsInPackage(root, otherRootsAbs, ignorePaths, usedAvifAbs, stats, fail, cwd)
281
289
  }
282
290
  return optedOutAbs
283
291
  }
@@ -296,17 +304,18 @@ async function checkVueAvifImports(ignorePaths, usedAvifAbs, stats, pass, fail)
296
304
  * все одно не сканували б, тож вони не мають провокувати запуск AVIF-етапу).
297
305
  * @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
298
306
  * @returns {Promise<boolean>} `true`, якщо знайдено принаймні одне raster-посилання
307
+ * @param {string} cwd корінь репозиторію
299
308
  */
300
- async function hasAnyVueRasterReference(ignorePaths) {
301
- const roots = await getMonorepoPackageRootDirs()
302
- const absRootsByRel = new Map(roots.map(r => [r, join(process.cwd(), r)]))
309
+ async function hasAnyVueRasterReference(ignorePaths, cwd) {
310
+ const roots = await getMonorepoPackageRootDirs(cwd)
311
+ const absRootsByRel = new Map(roots.map(r => [r, join(cwd, r)]))
303
312
  for (const root of roots) {
304
- const pkgPath = join(root, 'package.json')
313
+ const pkgPath = join(cwd, root, 'package.json')
305
314
  if (existsSync(pkgPath)) {
306
315
  const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
307
316
  if (packageHasAvifDisabled(pkg)) continue
308
317
  }
309
- const absRoot = absRootsByRel.get(root) ?? join(process.cwd(), root)
318
+ const absRoot = absRootsByRel.get(root) ?? join(cwd, root)
310
319
  const otherRootsAbs = roots.filter(r => r !== root && r !== '.').map(r => absRootsByRel.get(r) ?? '')
311
320
  /** @type {string[]} */
312
321
  const targetFiles = []
@@ -338,8 +347,9 @@ async function hasAnyVueRasterReference(ignorePaths) {
338
347
  * відсутні `.avif` фейлять окремо). У тестах та інших ізольованих середовищах npx
339
348
  * можна вимкнути через `NITRA_CURSOR_NO_AVIF_RUN=1` — тоді ця функція no-op.
340
349
  * @returns {void}
350
+ * @param {string} cwd корінь репозиторію
341
351
  */
342
- function runAvifGeneration() {
352
+ function runAvifGeneration(cwd) {
343
353
  if (env.NITRA_CURSOR_NO_AVIF_RUN === '1') return
344
354
  const npxPath = resolveCmd('npx')
345
355
  if (!npxPath) {
@@ -350,6 +360,7 @@ function runAvifGeneration() {
350
360
  }
351
361
  const result = spawnSync(npxPath, [MINIFY_PACKAGE_NAME, '--src=.', '--write', '--avif'], {
352
362
  stdio: 'inherit',
363
+ cwd,
353
364
  env
354
365
  })
355
366
  if (result.error) {
@@ -379,12 +390,13 @@ function runAvifGeneration() {
379
390
  * `.avif` під ними не вважаємо сиротами і не видаляємо
380
391
  * @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
381
392
  * @returns {Promise<number>} кількість видалених сиріт
393
+ * @param {string} cwd корінь репозиторію
382
394
  */
383
- async function cleanupOrphanAvifs(usedAvifAbs, optedOutAbs, ignorePaths) {
395
+ async function cleanupOrphanAvifs(usedAvifAbs, optedOutAbs, ignorePaths, cwd) {
384
396
  /** @type {string[]} */
385
397
  const orphans = []
386
398
  await walkDir(
387
- process.cwd(),
399
+ cwd,
388
400
  absPath => {
389
401
  if (!absPath.endsWith('.avif')) return
390
402
  if (usedAvifAbs.has(absPath)) return
@@ -404,27 +416,28 @@ async function cleanupOrphanAvifs(usedAvifAbs, optedOutAbs, ignorePaths) {
404
416
  /**
405
417
  * Виконує AVIF-етап: запуск AVIF-генерації, авто-заміна raster-посилань у `.vue`/`.html`,
406
418
  * видалення AVIF-сиріт. Не валідує `package.json`/`lint-image` — це вже у `image-compress`.
419
+ * @param {string} [cwd] корінь репозиторію
407
420
  * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
408
421
  */
409
- export async function check() {
422
+ export async function check(cwd = process.cwd()) {
410
423
  const reporter = createCheckReporter()
411
424
  const { pass, fail } = reporter
412
425
 
413
- const ignorePaths = await loadCursorIgnorePaths(process.cwd())
426
+ const ignorePaths = await loadCursorIgnorePaths(cwd)
414
427
 
415
- if (!(await hasAnyVueRasterReference(ignorePaths))) {
428
+ if (!(await hasAnyVueRasterReference(ignorePaths, cwd))) {
416
429
  pass('image-avif: у .vue/.html немає raster-посилань для переписування — AVIF-генерація і cleanup пропущені')
417
430
  return reporter.getExitCode()
418
431
  }
419
432
 
420
- runAvifGeneration()
433
+ runAvifGeneration(cwd)
421
434
 
422
435
  /** @type {Set<string>} */
423
436
  const usedAvifAbs = new Set()
424
437
  /** @type {RewriteStats} */
425
438
  const stats = { rewrittenRefs: 0, rewrittenFiles: 0, failedRefs: 0 }
426
- const optedOutAbs = await checkVueAvifImports(ignorePaths, usedAvifAbs, stats, pass, fail)
427
- const orphansDeleted = await cleanupOrphanAvifs(usedAvifAbs, optedOutAbs, ignorePaths)
439
+ const optedOutAbs = await checkVueAvifImports(ignorePaths, usedAvifAbs, stats, pass, fail, cwd)
440
+ const orphansDeleted = await cleanupOrphanAvifs(usedAvifAbs, optedOutAbs, ignorePaths, cwd)
428
441
 
429
442
  pass(
430
443
  `image-avif: rewrote ${stats.rewrittenRefs} reference${stats.rewrittenRefs === 1 ? '' : 's'} in ${stats.rewrittenFiles} file${stats.rewrittenFiles === 1 ? '' : 's'}; ` +
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import { existsSync } from 'node:fs'
20
20
  import { readFile } from 'node:fs/promises'
21
+ import { join } from 'node:path'
21
22
 
22
23
  import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
23
24
 
@@ -29,11 +30,13 @@ const LEGACY_CACHE_FILENAME = '.minify-image-cache.tsv'
29
30
 
30
31
  /**
31
32
  * Зчитує всі змістовні рядки `.gitignore` (без коментарів і порожніх). Якщо файла нема — `null`.
33
+ * @param {string} cwd корінь репозиторію
32
34
  * @returns {Promise<string[] | null>} список trim-нутих рядків або `null`
33
35
  */
34
- async function readGitignoreLines() {
35
- if (!existsSync('.gitignore')) return null
36
- const raw = await readFile('.gitignore', 'utf8')
36
+ async function readGitignoreLines(cwd) {
37
+ const gitignorePath = join(cwd, '.gitignore')
38
+ if (!existsSync(gitignorePath)) return null
39
+ const raw = await readFile(gitignorePath, 'utf8')
37
40
  return raw
38
41
  .split('\n')
39
42
  .map(l => l.trim())
@@ -49,9 +52,10 @@ async function readGitignoreLines() {
49
52
  * @param {(msg: string) => void} pass callback при успішній перевірці
50
53
  * @param {(msg: string) => void} fail callback при помилці
51
54
  * @returns {Promise<void>}
55
+ * @param {string} cwd корінь репозиторію
52
56
  */
53
- async function checkHashCacheNotIgnored(pass, fail) {
54
- const lines = await readGitignoreLines()
57
+ async function checkHashCacheNotIgnored(pass, fail, cwd) {
58
+ const lines = await readGitignoreLines(cwd)
55
59
  if (lines && lines.includes(HASH_CACHE_FILENAME)) {
56
60
  fail(
57
61
  `.gitignore: прибери рядок \`${HASH_CACHE_FILENAME}\` — це закомічений source of truth split-cache 3.2.0 (image-compress.mdc)`
@@ -68,9 +72,10 @@ async function checkHashCacheNotIgnored(pass, fail) {
68
72
  * @param {(msg: string) => void} pass callback при успішній перевірці
69
73
  * @param {(msg: string) => void} fail callback при помилці
70
74
  * @returns {Promise<void>}
75
+ * @param {string} cwd корінь репозиторію
71
76
  */
72
- async function checkLegacyCacheRemoved(pass, fail) {
73
- if (existsSync(LEGACY_CACHE_FILENAME)) {
77
+ async function checkLegacyCacheRemoved(pass, fail, cwd) {
78
+ if (existsSync(join(cwd, LEGACY_CACHE_FILENAME))) {
74
79
  fail(
75
80
  `${LEGACY_CACHE_FILENAME} застарілий (split-cache 3.2.0) — видали: ` +
76
81
  `\`git rm --cached ${LEGACY_CACHE_FILENAME} 2>/dev/null || true && rm -f ${LEGACY_CACHE_FILENAME}\` ` +
@@ -78,7 +83,7 @@ async function checkLegacyCacheRemoved(pass, fail) {
78
83
  )
79
84
  return
80
85
  }
81
- const lines = await readGitignoreLines()
86
+ const lines = await readGitignoreLines(cwd)
82
87
  if (lines && lines.includes(LEGACY_CACHE_FILENAME)) {
83
88
  fail(`.gitignore: прибери застарілий рядок \`${LEGACY_CACHE_FILENAME}\` — split-cache 3.2.0 його не використовує`)
84
89
  return
@@ -90,20 +95,21 @@ async function checkLegacyCacheRemoved(pass, fail) {
90
95
  * Перевіряє відповідність проєкту правилу `image-compress.mdc`: `.n-minify-image.tsv` НЕ
91
96
  * в `.gitignore`, застарілий `.minify-image-cache.tsv` видалений. CI-workflow для image
92
97
  * не вимагається — лінт зображень виконується лише локально.
98
+ * @param {string} [cwd] корінь репозиторію
93
99
  * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
94
100
  */
95
- export async function check() {
101
+ export async function check(cwd = process.cwd()) {
96
102
  const reporter = createCheckReporter()
97
103
  const { pass, fail } = reporter
98
104
 
99
- if (!existsSync('package.json')) {
105
+ if (!existsSync(join(cwd, 'package.json'))) {
100
106
  fail('package.json не знайдено в корені — додай (image-compress.mdc)')
101
107
  return reporter.getExitCode()
102
108
  }
103
109
  pass('package.json є (структуру перевіряє npx @nitra/cursor fix → image_compress.package_json)')
104
110
 
105
- await checkHashCacheNotIgnored(pass, fail)
106
- await checkLegacyCacheRemoved(pass, fail)
111
+ await checkHashCacheNotIgnored(pass, fail, cwd)
112
+ await checkLegacyCacheRemoved(pass, fail, cwd)
107
113
 
108
114
  return reporter.getExitCode()
109
115
  }