@nitra/cursor 1.28.4 → 1.28.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/package.json +1 -1
- package/rules/js-bun-db/js-bun-db.mdc +74 -0
- package/rules/js-lint/coverage/coverage.mjs +92 -38
- package/rules/style-lint/style-lint.mdc +175 -0
- package/rules/vue/vue.mdc +303 -5
- package/scripts/utils/resolve-js-root.mjs +34 -20
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
|
|
6
6
|
|
|
7
|
+
## [1.28.5] - 2026-05-28
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **`scripts/utils/resolve-js-root.mjs`** — `resolveAllJsRoots()` тепер розгортає glob-патерни (`cf/*`, `packages/*` тощо) у списку `workspaces` через `node:fs/promises#glob`. Без цього `cf/*` як літерал не резолвився через `existsSync`, і `resolveJsRoot()` падав на fallback → `cwd`; `n-cursor coverage` із кореня запускав vitest у root-у проєкту без `vitest.config.js` (через `gt/tests/setup.mjs` не підвантажувався). `resolveJsRoot()` тепер тонкий wrapper над `resolveAllJsRoots()[0]`. Додано 2 тести: glob-розгортання `cf/*` і fallback на `[cwd]` при порожньому збігу.
|
|
12
|
+
- **`rules/js-lint/coverage/coverage.mjs#collect()`** — у monorepo тепер ітерує **всі** JS-roots з `resolveAllJsRoots()`, запускає vitest + Stryker у кожному окремо та сумує `lcov` (через локальний `addCoverage`) і `mutation` (через `addMutation`). Шляхи у `survived[].file` і `survived[].exampleTest.testFile` рібейзяться відносно `cwd` (`relative(cwd, jsRoot)`/`join(wsRel, file)`), щоб `coverage-fix.mjs#buildFixPrompt` коректно читав source-файли через `join(projectRoot, file)`. `detect()` повертає `true`, якщо vitest є хоча б в одному workspace АБО в кореневому `package.json`. Додано тест агрегування у monorepo з `cf/*`-glob.
|
|
13
|
+
|
|
7
14
|
## [1.28.4] - 2026-05-28
|
|
8
15
|
|
|
9
16
|
### Changed
|
package/package.json
CHANGED
|
@@ -46,6 +46,60 @@ PostgreSQL 18+, MariaDB 10.6+ (сумісний з MySQL-протоколом,
|
|
|
46
46
|
|
|
47
47
|
Для multi-row `VALUES` у `MERGE` / `INSERT` з конкретними типами — паралельні масиви по колонках і `unnest($1::TYPE[], $2::TYPE[], ...) AS t(col1, col2, ...)`. Кожна колонка передається одним параметром-масивом; типи задаються кастом масиву (`::uuid[]`, `::bigint[]`, `::numeric[]`, `::text[]`, …).
|
|
48
48
|
|
|
49
|
+
#### Приклад: MERGE з UNNEST і динамічними колонками
|
|
50
|
+
|
|
51
|
+
```javascript
|
|
52
|
+
// ❌ format + pgWrite.unsafe — N×7 окремих значень, план змінюється при кожному batch.length
|
|
53
|
+
const valuesSql = batch
|
|
54
|
+
.map(row => format('(%L::int, %L::date, %L::jsonb)', row.id, row.date, JSON.stringify(row.data)))
|
|
55
|
+
.join(',')
|
|
56
|
+
const sql = format(`MERGE INTO t USING (VALUES %s) AS s(id, date, data) ON ...`, valuesSql)
|
|
57
|
+
await pgWrite.unsafe(sql)
|
|
58
|
+
|
|
59
|
+
// ✅ UNNEST — 3 параметри незалежно від розміру batch; план стабільний і може кешуватись
|
|
60
|
+
const ids = batch.map(r => r.id)
|
|
61
|
+
const dates = batch.map(r => r.date)
|
|
62
|
+
const data = batch.map(r => JSON.stringify(r.data))
|
|
63
|
+
|
|
64
|
+
await pgWrite`
|
|
65
|
+
WITH s(id, date, data) AS (
|
|
66
|
+
SELECT * FROM unnest(
|
|
67
|
+
${ids}::int[],
|
|
68
|
+
${dates}::date[],
|
|
69
|
+
${data}::jsonb[]
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
MERGE INTO my_table AS t
|
|
73
|
+
USING s ON t.id = s.id
|
|
74
|
+
WHEN MATCHED THEN
|
|
75
|
+
UPDATE SET date = s.date, data = s.data
|
|
76
|
+
WHEN NOT MATCHED THEN
|
|
77
|
+
INSERT (id, date, data) VALUES (s.id, s.date, s.data)
|
|
78
|
+
`
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Якщо частина колонок у SET/INSERT залежить від параметра (plan/fact, тип тощо) — динамічні імена колонок не можна параметризувати через `${value}`; використовуй умовні Bun SQL фрагменти:
|
|
82
|
+
|
|
83
|
+
```javascript
|
|
84
|
+
// ✅ умовні фрагменти для динамічних ідентифікаторів колонок
|
|
85
|
+
const colFrag = isPlan ? pgWrite`plan_value` : pgWrite`fact_value`
|
|
86
|
+
const hashFrag = isPlan ? pgWrite`hash = s.hash,` : pgWrite``
|
|
87
|
+
|
|
88
|
+
await pgWrite`
|
|
89
|
+
...
|
|
90
|
+
WHEN MATCHED THEN
|
|
91
|
+
UPDATE SET
|
|
92
|
+
${colFrag} = s.value,
|
|
93
|
+
${hashFrag}
|
|
94
|
+
updated_by = s.updated_by
|
|
95
|
+
WHEN NOT MATCHED THEN
|
|
96
|
+
INSERT (id, ${colFrag}, updated_by)
|
|
97
|
+
VALUES (s.id, s.value, s.updated_by)
|
|
98
|
+
`
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`UNION ALL`-цикл замість `unnest` підходить для малих динамічних запитів (2–5 рядків), де кожна гілка семантично різна. Для bulk upsert — завжди `unnest`.
|
|
102
|
+
|
|
49
103
|
### Заборонений «drop-in» шим
|
|
50
104
|
|
|
51
105
|
```javascript
|
|
@@ -285,6 +339,26 @@ const ids = [1, 2, 3]
|
|
|
285
339
|
await sql`SELECT * FROM users WHERE id IN ${sql(ids)}`
|
|
286
340
|
```
|
|
287
341
|
|
|
342
|
+
Multi-row INSERT з масиву об'єктів — `sql(rows)` генерує column list і VALUES автоматично:
|
|
343
|
+
|
|
344
|
+
```javascript
|
|
345
|
+
// ❌ format + pgWrite.unsafe — ручне склеювання рядків, injection-вектор
|
|
346
|
+
const insertWfQry = `insert into approval.workflow (request_id, job_title_id, name, status)
|
|
347
|
+
values ${approverJobs.map(job => `('${request.id}', ${job.id}, '${job.short_name}', 'pending')`).join(', ')}`
|
|
348
|
+
await pgWrite.unsafe(insertWfQry)
|
|
349
|
+
|
|
350
|
+
// ✅ sql(rows) — один параметр-масив, bind через wire-protocol
|
|
351
|
+
const wfRows = approverJobs.map(job => ({
|
|
352
|
+
request_id: request.id,
|
|
353
|
+
job_title_id: job.id,
|
|
354
|
+
name: job.short_name,
|
|
355
|
+
status: job.id === nextJobId ? 'current' : 'pending'
|
|
356
|
+
}))
|
|
357
|
+
await sql`INSERT INTO approval.workflow ${sql(wfRows)}`
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
Коли потрібен стабільний план для великих batch'ів (N > 20) або строгі типи колонок — використовуй `unnest` (деталі у `#### Приклад: MERGE з UNNEST і динамічними колонками`). Для невеликих INSERT'ів де колонки відомі — `sql(rows)` коротший і зрозуміліший.
|
|
361
|
+
|
|
288
362
|
## `IN (...)`: значення з template literal — тільки через змінну + guard на пустоту
|
|
289
363
|
|
|
290
364
|
Якщо список для `IN (...)` підставляється через `${...}` у template literal, його **потрібно**:
|
|
@@ -10,9 +10,9 @@ import { spawnSync } from 'node:child_process'
|
|
|
10
10
|
import { existsSync, readFileSync } from 'node:fs'
|
|
11
11
|
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
|
12
12
|
import { tmpdir } from 'node:os'
|
|
13
|
-
import { join } from 'node:path'
|
|
13
|
+
import { join, relative } from 'node:path'
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import { resolveAllJsRoots } from '../../../scripts/utils/resolve-js-root.mjs'
|
|
16
16
|
|
|
17
17
|
const TEST_BLOCK_START = /^\s*(it|test)\(/
|
|
18
18
|
const FILE_EXTENSION = /\.[^.]+$/
|
|
@@ -30,21 +30,24 @@ function hasVitestDep(pkg) {
|
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
32
|
* Чи провайдер застосовний у поточному cwd. Активується, коли `vitest`
|
|
33
|
-
* декларовано
|
|
34
|
-
* hoisted node_modules — типовий патерн bun monorepo,
|
|
35
|
-
* забороняє devDeps у published workspace-у, тож вони
|
|
36
|
-
* Інакше silent skip із hint у stderr (одноразово).
|
|
33
|
+
* декларовано хоча б в одному JS-root АБО у кореневому `package.json`
|
|
34
|
+
* (workspace-проєкт із hoisted node_modules — типовий патерн bun monorepo,
|
|
35
|
+
* де npm-module rule забороняє devDeps у published workspace-у, тож вони
|
|
36
|
+
* живуть у корені). Інакше silent skip із hint у stderr (одноразово).
|
|
37
37
|
* @param {string} cwd корінь проєкту
|
|
38
38
|
* @returns {Promise<boolean>} true, якщо проєкт сумісний з vitest-based coverage
|
|
39
39
|
*/
|
|
40
40
|
export async function detect(cwd) {
|
|
41
|
-
const
|
|
42
|
-
if (
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
41
|
+
const jsRoots = await resolveAllJsRoots(cwd)
|
|
42
|
+
if (jsRoots.length === 0) return false
|
|
43
|
+
for (const jsRoot of jsRoots) {
|
|
44
|
+
const pkgPath = join(jsRoot, 'package.json')
|
|
45
|
+
if (!existsSync(pkgPath)) continue
|
|
46
|
+
const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
|
|
47
|
+
if (hasVitestDep(pkg)) return true
|
|
48
|
+
}
|
|
49
|
+
const rootInJsRoots = jsRoots.includes(cwd)
|
|
50
|
+
if (!rootInJsRoots) {
|
|
48
51
|
const rootPkgPath = join(cwd, 'package.json')
|
|
49
52
|
if (existsSync(rootPkgPath)) {
|
|
50
53
|
const rootPkg = JSON.parse(await readFile(rootPkgPath, 'utf8'))
|
|
@@ -224,40 +227,91 @@ const defaultRunner = {
|
|
|
224
227
|
}
|
|
225
228
|
|
|
226
229
|
/**
|
|
227
|
-
* Збирає JS-метрики покриття + мутаційного тестування.
|
|
230
|
+
* Збирає JS-метрики покриття + мутаційного тестування. У monorepo ітерує кожен
|
|
231
|
+
* JS-root з `resolveAllJsRoots()` (включно з glob-патернами на кшталт `cf/*`),
|
|
232
|
+
* запускає vitest+Stryker у кожному та сумує lcov/mutation. Шляхи файлів у
|
|
233
|
+
* `survived` рібейзяться відносно `cwd`, щоб `coverage-fix.mjs` знаходив джерела.
|
|
228
234
|
* @param {string} cwd корінь проєкту
|
|
229
235
|
* @param {{runner?: typeof defaultRunner}} [opts] runner-ін'єкція для тестів
|
|
230
236
|
* @returns {Promise<Array<{area:string, coverage:object, mutation:{caught:number,total:number}}>>} рядки для COVERAGE.md
|
|
231
237
|
*/
|
|
232
238
|
export async function collect(cwd, opts = {}) {
|
|
233
239
|
const runner = opts.runner ?? defaultRunner
|
|
234
|
-
const
|
|
235
|
-
if (
|
|
240
|
+
const jsRoots = await resolveAllJsRoots(cwd)
|
|
241
|
+
if (jsRoots.length === 0) throw new Error('js-lint coverage: package.json не знайдено')
|
|
242
|
+
|
|
243
|
+
let coverage = { lines: { covered: 0, total: 0 }, functions: { covered: 0, total: 0 } }
|
|
244
|
+
let mutation = { caught: 0, total: 0 }
|
|
245
|
+
const survived = []
|
|
246
|
+
|
|
247
|
+
for (const jsRoot of jsRoots) {
|
|
248
|
+
const wsRel = relative(cwd, jsRoot)
|
|
249
|
+
|
|
250
|
+
// 1. Coverage через vitest run --coverage (v8 provider пише lcov.info у lcovDir)
|
|
251
|
+
const lcovDir = await mkdtemp(join(tmpdir(), 'js-lint-cov-'))
|
|
252
|
+
try {
|
|
253
|
+
const code = await runner.runJsCoverage({ cwd: jsRoot, lcovDir })
|
|
254
|
+
if (code !== 0) throw new Error(`JS coverage exit ${code}`)
|
|
255
|
+
coverage = addCoverage(coverage, parseLcov(await readFile(join(lcovDir, 'lcov.info'), 'utf8')))
|
|
256
|
+
} finally {
|
|
257
|
+
await rm(lcovDir, { recursive: true, force: true })
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 2. Mutation через Stryker
|
|
261
|
+
await runner.runStryker({ cwd: jsRoot })
|
|
262
|
+
let mutationReport
|
|
263
|
+
try {
|
|
264
|
+
mutationReport = JSON.parse(await readFile(join(jsRoot, 'reports', 'stryker', 'mutation.json'), 'utf8'))
|
|
265
|
+
} catch {
|
|
266
|
+
throw new Error(
|
|
267
|
+
'js-lint coverage: stryker не залишив mutation.json — ' +
|
|
268
|
+
'запусти `npx @nitra/cursor fix test` для встановлення canonical stryker.config.mjs, ' +
|
|
269
|
+
'або налаштуй його вручну'
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
const parsed = parseStrykerReport(mutationReport, jsRoot)
|
|
273
|
+
mutation = addMutation(mutation, { caught: parsed.caught, total: parsed.total })
|
|
236
274
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
275
|
+
for (const group of parsed.survived) {
|
|
276
|
+
survived.push({
|
|
277
|
+
...group,
|
|
278
|
+
file: wsRel === '' ? group.file : join(wsRel, group.file),
|
|
279
|
+
exampleTest: group.exampleTest
|
|
280
|
+
? {
|
|
281
|
+
...group.exampleTest,
|
|
282
|
+
testFile: wsRel === '' ? group.exampleTest.testFile : join(wsRel, group.exampleTest.testFile)
|
|
283
|
+
}
|
|
284
|
+
: null
|
|
285
|
+
})
|
|
286
|
+
}
|
|
246
287
|
}
|
|
247
288
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
289
|
+
return [{ area: 'JS', coverage, mutation, survived }]
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Сумування coverage-totals — імпортується з оркестратора при потребі; тут
|
|
294
|
+
* локальна копія, щоб уникнути циклу test/coverage → js-lint/coverage.
|
|
295
|
+
* @param {{lines:{covered:number,total:number}, functions:{covered:number,total:number}}} a перший
|
|
296
|
+
* @param {{lines:{covered:number,total:number}, functions:{covered:number,total:number}}} b другий
|
|
297
|
+
* @returns {{lines:{covered:number,total:number}, functions:{covered:number,total:number}}} сума
|
|
298
|
+
*/
|
|
299
|
+
function addCoverage(a, b) {
|
|
300
|
+
return {
|
|
301
|
+
lines: { covered: a.lines.covered + b.lines.covered, total: a.lines.total + b.lines.total },
|
|
302
|
+
functions: {
|
|
303
|
+
covered: a.functions.covered + b.functions.covered,
|
|
304
|
+
total: a.functions.total + b.functions.total
|
|
305
|
+
}
|
|
259
306
|
}
|
|
260
|
-
|
|
307
|
+
}
|
|
261
308
|
|
|
262
|
-
|
|
309
|
+
/**
|
|
310
|
+
* Сумування mutation-counts.
|
|
311
|
+
* @param {{caught:number,total:number}} a перший
|
|
312
|
+
* @param {{caught:number,total:number}} b другий
|
|
313
|
+
* @returns {{caught:number,total:number}} сума
|
|
314
|
+
*/
|
|
315
|
+
function addMutation(a, b) {
|
|
316
|
+
return { caught: a.caught + b.caught, total: a.total + b.total }
|
|
263
317
|
}
|
|
@@ -5,6 +5,92 @@ globs: "**/*.{css,scss,vue}"
|
|
|
5
5
|
alwaysApply: false
|
|
6
6
|
---
|
|
7
7
|
|
|
8
|
+
## Quasar як стильова основа
|
|
9
|
+
|
|
10
|
+
У Vue-проектах використовуй **Quasar** як базову стильову систему:
|
|
11
|
+
|
|
12
|
+
- **Класи компонентів:** `q-pa-*`, `q-ma-*`, `q-mt-*`, `q-mb-*`, `row`, `col`, `col-*`, `items-center`, `justify-between`, `text-*`, `bg-*`, `shadow-*` тощо — стандартні утиліти Quasar.
|
|
13
|
+
- **Кольори:** використовуй Quasar CSS-змінні через класи (`text-primary`, `bg-accent`, `text-negative` тощо) або через SCSS-змінні (`$primary`, `$accent`) у `.scss`-файлах.
|
|
14
|
+
- **Не пиши власні утиліти**, якщо є відповідний Quasar-клас.
|
|
15
|
+
|
|
16
|
+
## Кольори: quasar-variables.scss і app.scss
|
|
17
|
+
|
|
18
|
+
Основні кольори визначай виключно через стандартні Quasar-змінні: `$primary`, `$secondary`, `$accent`, `$positive`, `$negative`, `$warning`.
|
|
19
|
+
|
|
20
|
+
Якщо потрібен додатковий колір — визнач його в `quasar-variables.scss`, і **обов'язково** додай до `app.scss` відповідні `.text-*` / `.bg-*` класи:
|
|
21
|
+
|
|
22
|
+
```scss
|
|
23
|
+
/* quasar-variables.scss */
|
|
24
|
+
$white-a1: color.adjust(white, $alpha: -0.85);
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```scss
|
|
28
|
+
/* app.scss */
|
|
29
|
+
.text-white-a1 {
|
|
30
|
+
color: $white-a1 !important;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.bg-white-a1 {
|
|
34
|
+
background-color: $white-a1 !important;
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Gap між flex/grid-елементами — .n-gap-*
|
|
39
|
+
|
|
40
|
+
Для відступів між плаваючими елементами (flex, grid) використовуй класи `.n-gap-*` замість `q-gutter-*`. Якщо класів немає в `app.scss` — додай:
|
|
41
|
+
|
|
42
|
+
```scss
|
|
43
|
+
// GAP
|
|
44
|
+
.n-gap-xs {
|
|
45
|
+
gap: 4px;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.n-gap-sm {
|
|
49
|
+
gap: 8px;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.n-gap-md {
|
|
53
|
+
gap: 16px;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.n-gap-lg {
|
|
57
|
+
gap: 24px;
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Фікси компонентів Quasar
|
|
62
|
+
|
|
63
|
+
При використанні компонента `q-scroll-area` або `q-tooltip` додай до `app.scss` відповідні фікси. Аналогічно — фікс масштабування на iOS.
|
|
64
|
+
|
|
65
|
+
```scss
|
|
66
|
+
// FIX стилі для прокручуваної області по висоті екрана
|
|
67
|
+
.q-scrollarea {
|
|
68
|
+
display: flex;
|
|
69
|
+
flex-direction: column;
|
|
70
|
+
height: auto;
|
|
71
|
+
min-height: 0;
|
|
72
|
+
max-height: 100%;
|
|
73
|
+
|
|
74
|
+
.scroll {
|
|
75
|
+
flex: 10000 1 0%;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// FIX tooltip
|
|
80
|
+
.q-tooltip {
|
|
81
|
+
font-size: 1em;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// FIX не зумувати на iOS
|
|
85
|
+
@media (width <= 600px) {
|
|
86
|
+
input,
|
|
87
|
+
textarea,
|
|
88
|
+
select {
|
|
89
|
+
font-size: 16px;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
8
94
|
## Генерація та редагування стилів (Cursor і інші агенти)
|
|
9
95
|
|
|
10
96
|
- **Джерело правил:** перед тим як писати або суттєво змінювати **`.css`**, **`.scss`** або стилі в **`.vue`**, переглянь у корені проєкту (і в релевантних пакетах монорепо, якщо є) поле **`stylelint`** у **`package.json`** (зокрема `extends`), наявні **`.stylelintrc.*`**, **`stylelint.config.*`** та **`.stylelintignore`**. Не покладайся на «типові» правила stylelint з пам’яті — дотримуйся **проєктного** **`@nitra/stylelint-config`** і будь-яких локальних доповнень у репозиторії.
|
|
@@ -94,3 +180,92 @@ jobs:
|
|
|
94
180
|
```text title=".stylelintignore"
|
|
95
181
|
dist/
|
|
96
182
|
```
|
|
183
|
+
|
|
184
|
+
## Адмінські таблиці — n-admin-table
|
|
185
|
+
|
|
186
|
+
Для таблиць, що мають займати всю висоту сторінки (або блоку), додавай клас `n-admin-table` і атрибути `hide-no-data`, `dense`, `flat`. Таблиця автоматично розтягнеться на весь доступний простір. У блоках меншої висоти — задай їм явний `height`.
|
|
187
|
+
|
|
188
|
+
```vue
|
|
189
|
+
<!-- ТАБЛИЦЯ -->
|
|
190
|
+
<q-table class="n-admin-table" hide-no-data flat dense />
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Якщо клас `.n-admin-table` не визначено в `app.scss` — додай:
|
|
194
|
+
|
|
195
|
+
```scss
|
|
196
|
+
// ADMIN TABLE
|
|
197
|
+
.n-admin-table {
|
|
198
|
+
height: calc(100vh - 106px);
|
|
199
|
+
|
|
200
|
+
thead {
|
|
201
|
+
position: sticky;
|
|
202
|
+
z-index: 3;
|
|
203
|
+
top: 0;
|
|
204
|
+
|
|
205
|
+
tr th {
|
|
206
|
+
background-color: $grey-3;
|
|
207
|
+
height: 36px !important;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// tbody td {
|
|
212
|
+
// font-size: 14px;
|
|
213
|
+
// }
|
|
214
|
+
|
|
215
|
+
tbody tr:last-child td {
|
|
216
|
+
border-bottom: 1px solid rgb(0 0 0 / 12%) !important;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
th:first-child,
|
|
220
|
+
td:first-child,
|
|
221
|
+
th:last-child,
|
|
222
|
+
td:last-child {
|
|
223
|
+
width: 1px;
|
|
224
|
+
white-space: nowrap;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.q-table__bottom {
|
|
228
|
+
background-color: $grey-3;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.q-table__progress th {
|
|
232
|
+
height: 1px !important;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.text-wrap {
|
|
236
|
+
max-width: 250px;
|
|
237
|
+
white-space: normal;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// sticky columns
|
|
241
|
+
td.sticky-left {
|
|
242
|
+
position: sticky;
|
|
243
|
+
left: 0;
|
|
244
|
+
z-index: 2;
|
|
245
|
+
background-color: white;
|
|
246
|
+
box-shadow: 2px 0 5px #0002;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
td.sticky-right {
|
|
250
|
+
position: sticky;
|
|
251
|
+
right: 0;
|
|
252
|
+
z-index: 2;
|
|
253
|
+
background-color: white;
|
|
254
|
+
box-shadow: -2px 0 5px #0002;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
th.sticky-left {
|
|
258
|
+
position: sticky;
|
|
259
|
+
left: 0;
|
|
260
|
+
z-index: 3;
|
|
261
|
+
box-shadow: 2px 0 5px #0002;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
th.sticky-right {
|
|
265
|
+
position: sticky;
|
|
266
|
+
right: 0;
|
|
267
|
+
z-index: 3;
|
|
268
|
+
box-shadow: -2px 0 5px #0002;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
```
|
package/rules/vue/vue.mdc
CHANGED
|
@@ -12,18 +12,30 @@ alwaysApply: false
|
|
|
12
12
|
```javascript
|
|
13
13
|
const vue3CompositionApiBestPractices = [
|
|
14
14
|
'Використовуй функцію setup() для логіки компонента',
|
|
15
|
-
'Реалізуй computed
|
|
16
|
-
'
|
|
15
|
+
'Реалізуй computed змінні через $computed()',
|
|
16
|
+
'Реалізуй ref змінні через $ref',
|
|
17
|
+
'Використовуй watch і watchEffect для побічних ефектів',
|
|
17
18
|
'Підключай lifecycle hooks: onMounted, onUpdated тощо',
|
|
18
|
-
'
|
|
19
|
+
'Для глибоко вкладених залежностей використовуй composables, props/emits або store'
|
|
20
|
+
'не використовуй provide/inject для залежностей'
|
|
19
21
|
]
|
|
20
22
|
```
|
|
21
23
|
|
|
24
|
+
## Quasar як UI-основа
|
|
25
|
+
|
|
26
|
+
У Vue-проектах використовуй **Quasar** як базовий UI-фреймворк:
|
|
27
|
+
|
|
28
|
+
- **Компоненти:** `q-btn`, `q-input`, `q-select`, `q-table`, `q-dialog`, `q-card`, `q-layout`, `q-page`, `q-drawer` тощо — основа UI.
|
|
29
|
+
- **Плагіни:** `Notify`, `Dialog`, `Loading` та інші Quasar-плагіни.
|
|
30
|
+
- **Кольори:** використовуй Quasar CSS-змінні (`primary`, `secondary`, `accent`, `positive`, `negative`, `warning`, `info`, `dark`) і утиліти (`text-primary`, `bg-accent` тощо).
|
|
31
|
+
- **Утиліти:** flex-layout (`row`, `col`, `items-center`), spacing (`q-pa-md`, `q-mt-sm`), shadow (`shadow-2`) — зі стандартної бібліотеки Quasar.
|
|
32
|
+
- **Кастомні компоненти** `@nitra/components` — **надбудова** над Quasar, а не заміна; їх слід надавати перевагу лише там, де вони є (див. розділ `@nitra/components` нижче).
|
|
33
|
+
|
|
22
34
|
## Структура папок
|
|
23
35
|
|
|
24
36
|
```javascript
|
|
25
37
|
const folderStructure = `
|
|
26
|
-
src/
|
|
38
|
+
src/
|
|
27
39
|
components/
|
|
28
40
|
composables/
|
|
29
41
|
views/
|
|
@@ -76,7 +88,7 @@ const additionalInstructions = `
|
|
|
76
88
|
|
|
77
89
|
### Патерни та антипатерни
|
|
78
90
|
|
|
79
|
-
-
|
|
91
|
+
- Для глибоко вкладених залежностей використовуй **composables**, **props/emits** або **store**; **renderless**-компоненти / **slots** — коли логіку відділяєш від розмітки.
|
|
80
92
|
- **HTTP:** окремі модулі **services** або **composables** для API; **async/await**.
|
|
81
93
|
- **Події:** батько–дитина через **emits**; для не пов’язаних гілок — **store**.
|
|
82
94
|
- Не мутуй **props** напряму — оновлення через подію вгору або v-model.
|
|
@@ -343,6 +355,292 @@ import path from 'node:path'
|
|
|
343
355
|
Правило стосується саме `.vue` файлів. Допоміжні `.ts`/`.js` модулі, які споживаються лише server-side
|
|
344
356
|
(наприклад, окремий пакет утіліт), можуть імпортувати Node-built-ins без обмежень.
|
|
345
357
|
|
|
358
|
+
## @nitra/components — надавай перевагу перед Quasar-компонентами
|
|
359
|
+
|
|
360
|
+
При створенні нової функціональності використовуй компоненти `@nitra/components`, якщо логіка компонента дозволяє отримати потрібний функціонал. Заміни:
|
|
361
|
+
|
|
362
|
+
| Завдання | `@nitra/components` | Замість Quasar |
|
|
363
|
+
| --- | --- | --- |
|
|
364
|
+
| Діалоги | `NDialog` | `q-dialog` |
|
|
365
|
+
| Multi-вибір | `NSelectMulti` | `q-select` (multiple) |
|
|
366
|
+
| Текстовий редактор | `NEditor` | `q-editor` |
|
|
367
|
+
| Вибір дати | `NDate` | — |
|
|
368
|
+
| Вибір місяця/року | `NDateMonthYear` | — |
|
|
369
|
+
| Діапазон дат | `NDateRange` | — |
|
|
370
|
+
| Дата і час | `NDateTime` | — |
|
|
371
|
+
| Drag&drop список | `NDraggableList` | — |
|
|
372
|
+
| Редаговане значення | `NEditableString` | — |
|
|
373
|
+
| Повідомлення | `NCallout` | — |
|
|
374
|
+
| Хедер проекту | `NHeader` | — |
|
|
375
|
+
| Перемикання мов | `NLang` | — (якщо `NHeader` не використовується) |
|
|
376
|
+
| Меню проекту | `NMenu` | — |
|
|
377
|
+
| Зображення (масив / одиночне) | `NImages` | — |
|
|
378
|
+
| Завантаження файлів | `NUploader` | — |
|
|
379
|
+
| Вибір колонок `q-table` | `NTableColumns` | — |
|
|
380
|
+
|
|
381
|
+
## NHeader — телепорт-слоти на сторінках
|
|
382
|
+
|
|
383
|
+
У проектах з `NHeader` використовуй `<teleport>` для вбудовування контенту сторінки в шапку:
|
|
384
|
+
|
|
385
|
+
| Слот | Призначення |
|
|
386
|
+
| --- | --- |
|
|
387
|
+
| `#header-subtitle` | Заголовок сторінки (якщо потрібно перевизначити subtitle) |
|
|
388
|
+
| `#header-center` | Важливі повідомлення та елементи управління |
|
|
389
|
+
| `#header-filters` | Фільтри, кнопки та інші елементи управління сторінки |
|
|
390
|
+
|
|
391
|
+
Оскільки `NHeader` за замовчуванням темний, додавай до полів і селектів у телепортах: `dark`, `standout="bg-white text-primary"`, `:options-dark="false"`.
|
|
392
|
+
|
|
393
|
+
Завжди обгортай `<teleport>` в `v-if="mounted"` (де `mounted` — `$ref(false)`, що встановлюється в `onMounted`), щоб уникнути помилки відсутності target-елемента при SSR / першому рендері.
|
|
394
|
+
|
|
395
|
+
```vue
|
|
396
|
+
<template>
|
|
397
|
+
<q-page>
|
|
398
|
+
<!-- ФІЛЬТРИ В ШАПЦІ -->
|
|
399
|
+
<teleport v-if="mounted" to="#header-filters">
|
|
400
|
+
<div class="col row items-center n-gap-sm q-pa-sm">
|
|
401
|
+
<q-input
|
|
402
|
+
v-model="pageStore.filterName"
|
|
403
|
+
:label="t`Поиск по названию`"
|
|
404
|
+
debounce="200"
|
|
405
|
+
standout="bg-white text-primary"
|
|
406
|
+
clearable
|
|
407
|
+
dense
|
|
408
|
+
dark
|
|
409
|
+
class="col"
|
|
410
|
+
style="min-width: 120px">
|
|
411
|
+
<template #prepend>
|
|
412
|
+
<q-icon name="search" />
|
|
413
|
+
</template>
|
|
414
|
+
</q-input>
|
|
415
|
+
|
|
416
|
+
<n-select-multi
|
|
417
|
+
v-model="pageStore.filterRequestTypes"
|
|
418
|
+
:options="requestTypeOptions"
|
|
419
|
+
:label="t`Тип запроса`"
|
|
420
|
+
dark
|
|
421
|
+
standout="bg-white text-primary"
|
|
422
|
+
:options-dark="false"
|
|
423
|
+
style="min-width: 180px; max-width: 400px"
|
|
424
|
+
dense
|
|
425
|
+
emit-value
|
|
426
|
+
map-options
|
|
427
|
+
clearable
|
|
428
|
+
searchable
|
|
429
|
+
:stack-label="false" />
|
|
430
|
+
|
|
431
|
+
<q-btn
|
|
432
|
+
@click="addItem"
|
|
433
|
+
icon="add"
|
|
434
|
+
:label="t`Добавить`"
|
|
435
|
+
color="primary"
|
|
436
|
+
no-caps
|
|
437
|
+
padding="8px 12px"
|
|
438
|
+
unelevated />
|
|
439
|
+
</div>
|
|
440
|
+
</teleport>
|
|
441
|
+
|
|
442
|
+
<!-- ОСНОВНИЙ КОНТЕНТ -->
|
|
443
|
+
...
|
|
444
|
+
</q-page>
|
|
445
|
+
</template>
|
|
446
|
+
<script setup>
|
|
447
|
+
const mounted = $ref(false)
|
|
448
|
+
onMounted(() => { mounted = true })
|
|
449
|
+
</script>
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
## @nitra/tfm — переклади
|
|
453
|
+
|
|
454
|
+
Використовуй `@nitra/tfm` для всіх текстів. Переклади для всіх мов проекту оголошуй наприкінці `<script setup>` у функції `getTr()`. Змінна `lang` із `@nitra/tfm` — для визначення або зміни поточної мови застосунку.
|
|
455
|
+
|
|
456
|
+
```vue
|
|
457
|
+
<template>
|
|
458
|
+
{{ lang }}
|
|
459
|
+
{{ t`Анкеты` }}
|
|
460
|
+
{{ subtitle }}
|
|
461
|
+
</template>
|
|
462
|
+
<script setup>
|
|
463
|
+
import { lang, tf as tfm } from '@nitra/tfm'
|
|
464
|
+
const t = tfm.bind({ tr: getTr() })
|
|
465
|
+
const subtitle = $computed(() => t`Анкеты`)
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* LOCALIZATION
|
|
469
|
+
* @returns {object} translations
|
|
470
|
+
*/
|
|
471
|
+
function getTr() {
|
|
472
|
+
return {
|
|
473
|
+
Анкеты: { en: 'Surveys', ro: 'Sondaje', tr: 'Anketler' }
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
</script>
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
## Layout з NHeader
|
|
480
|
+
|
|
481
|
+
Для нових layout-ів використовуй `NHeader` з вбудованими `NLang` і `NMenu`.
|
|
482
|
+
|
|
483
|
+
```vue
|
|
484
|
+
<template>
|
|
485
|
+
<q-layout view="hHh Lpr lFf">
|
|
486
|
+
<n-header
|
|
487
|
+
v-model="leftSideOpened"
|
|
488
|
+
:logo="baseUrl + 'logo.png'"
|
|
489
|
+
:logo-url="homeUrl"
|
|
490
|
+
title="My App"
|
|
491
|
+
:subtitle="subtitle"
|
|
492
|
+
:username="userName"
|
|
493
|
+
toolbar-dark>
|
|
494
|
+
<template #top-toolbar>
|
|
495
|
+
<div class="platform-ios-only q-py-lg" />
|
|
496
|
+
</template>
|
|
497
|
+
<div>default slot content</div>
|
|
498
|
+
</n-header>
|
|
499
|
+
|
|
500
|
+
<!-- ЛЕВАЯ КОЛОНКА -->
|
|
501
|
+
<q-drawer v-model="leftSideOpened" :breakpoint="700" :width="300" overlay class="col column shadow-5 bg-grey-1">
|
|
502
|
+
<div class="platform-ios-only q-py-lg" />
|
|
503
|
+
<div v-if="!$q.screen.gt.xs" class="q-pa-sm row items-center">
|
|
504
|
+
<q-icon name="account_circle" color="primary" size="32px" class="q-mr-sm" />
|
|
505
|
+
<div class="text-subtitle1">{{ user.name }}</div>
|
|
506
|
+
</div>
|
|
507
|
+
<n-menu v-model="activeMenu" :menu="menu" />
|
|
508
|
+
<q-space />
|
|
509
|
+
<n-menu :menu="homeMenu" />
|
|
510
|
+
<div class="platform-ios-only q-py-md" />
|
|
511
|
+
</q-drawer>
|
|
512
|
+
|
|
513
|
+
<q-page-container>
|
|
514
|
+
<router-view />
|
|
515
|
+
</q-page-container>
|
|
516
|
+
</q-layout>
|
|
517
|
+
</template>
|
|
518
|
+
<script setup>
|
|
519
|
+
import { lang, tf as tfm } from '@nitra/tfm'
|
|
520
|
+
const t = tfm.bind({ tr: getTr() })
|
|
521
|
+
|
|
522
|
+
const baseUrl = import.meta.env.BASE_URL
|
|
523
|
+
const homeUrl = String.raw`https:\\` + import.meta.env.VITE_DOMAIN
|
|
524
|
+
|
|
525
|
+
// Ліва колонка
|
|
526
|
+
const leftSideOpened = $ref(false)
|
|
527
|
+
const activeMenu = $ref(null)
|
|
528
|
+
|
|
529
|
+
// Заголовок у шапці з поточного пункту меню
|
|
530
|
+
const subtitle = computed(() => (activeMenu ? getTr()[activeMenu.labelKey]?.[lang.value] || activeMenu?.labelKey : ''))
|
|
531
|
+
|
|
532
|
+
// Меню
|
|
533
|
+
const menu = computed(() =>
|
|
534
|
+
[
|
|
535
|
+
{
|
|
536
|
+
icon: 'sym_o_store',
|
|
537
|
+
label: t`Клиенты`,
|
|
538
|
+
labelKey: t`Клиенты`,
|
|
539
|
+
routeName: 'customer'
|
|
540
|
+
},
|
|
541
|
+
{
|
|
542
|
+
icon: 'sym_o_route',
|
|
543
|
+
label: t`Маршруты и визиты`,
|
|
544
|
+
items: [
|
|
545
|
+
{
|
|
546
|
+
icon: 'sym_o_route',
|
|
547
|
+
label: t`Маршруты`,
|
|
548
|
+
labelKey: t`Маршруты`,
|
|
549
|
+
routeName: 'route'
|
|
550
|
+
},
|
|
551
|
+
{
|
|
552
|
+
icon: 'sym_o_event_upcoming',
|
|
553
|
+
label: t`Переносы маршрутов`,
|
|
554
|
+
labelKey: t`Переносы маршрутов`,
|
|
555
|
+
routeName: 'route_postpone'
|
|
556
|
+
}
|
|
557
|
+
]
|
|
558
|
+
}
|
|
559
|
+
].filter(item => {
|
|
560
|
+
if (item.items?.length) {
|
|
561
|
+
item.items = item.items.filter(i => can[i.permissionRoute || i.routeName])
|
|
562
|
+
return item.items.length > 0
|
|
563
|
+
}
|
|
564
|
+
return can[item.routeName]
|
|
565
|
+
})
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* LOCALIZATION
|
|
570
|
+
* @returns {object} translations
|
|
571
|
+
*/
|
|
572
|
+
function getTr() {
|
|
573
|
+
return {
|
|
574
|
+
Клиенты: { en: 'Customers', ro: 'Clienți', tr: 'Müşteriler' }
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
</script>
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
## Pinia store для стану сторінки
|
|
581
|
+
|
|
582
|
+
Зберігай у Pinia store:
|
|
583
|
+
|
|
584
|
+
- вибрані значення фільтрів
|
|
585
|
+
- вибрані для відображення колонки (`NTableColumns`)
|
|
586
|
+
- кількість записів на сторінці (pagination)
|
|
587
|
+
|
|
588
|
+
Називай store за назвою сторінки або компонента — `customerPageStore`, `routePageStore` тощо. На сторінці звертайся до нього через змінну `pageStore`.
|
|
589
|
+
|
|
590
|
+
```javascript
|
|
591
|
+
// store/customerPage.js
|
|
592
|
+
export const useCustomerPageStore = defineStore('customerPage', {
|
|
593
|
+
state: () => ({
|
|
594
|
+
filterName: '',
|
|
595
|
+
filterStatus: [],
|
|
596
|
+
columns: [],
|
|
597
|
+
rowsPerPage: 20
|
|
598
|
+
})
|
|
599
|
+
})
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
```vue
|
|
603
|
+
<script setup>
|
|
604
|
+
const pageStore = useCustomerPageStore()
|
|
605
|
+
</script>
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
## Коментарі в `<template>`
|
|
609
|
+
|
|
610
|
+
Додавай коментарі в `<template>` відповідно до логічного призначення блоку. Коментарі допомагають швидко орієнтуватися в розмітці.
|
|
611
|
+
|
|
612
|
+
```vue
|
|
613
|
+
<template>
|
|
614
|
+
<q-page>
|
|
615
|
+
<!-- ФІЛЬТРИ В ШАПЦІ -->
|
|
616
|
+
<teleport v-if="mounted" to="#header-filters">...</teleport>
|
|
617
|
+
|
|
618
|
+
<!-- ТАБЛИЦЯ -->
|
|
619
|
+
<q-table ... />
|
|
620
|
+
|
|
621
|
+
<!-- ДІАЛОГ РЕДАГУВАННЯ -->
|
|
622
|
+
<n-dialog v-model="editDialog">...</n-dialog>
|
|
623
|
+
</q-page>
|
|
624
|
+
</template>
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
## Статичні файли — BASE_URL
|
|
628
|
+
|
|
629
|
+
Для підключення статичних файлів не використовуй відносні шляхи. Завжди будуй URL через `import.meta.env.BASE_URL`:
|
|
630
|
+
|
|
631
|
+
```vue
|
|
632
|
+
<!-- Погано -->
|
|
633
|
+
<img src="/logo.png" />
|
|
634
|
+
<img src="./assets/logo.png" />
|
|
635
|
+
|
|
636
|
+
<!-- Добре -->
|
|
637
|
+
<img :src="baseUrl + 'logo.png'" />
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
```javascript
|
|
641
|
+
const baseUrl = import.meta.env.BASE_URL
|
|
642
|
+
```
|
|
643
|
+
|
|
346
644
|
## Перевірка
|
|
347
645
|
|
|
348
646
|
`npx @nitra/cursor fix vue` — перевіряє залежності, `vite.config`, наявність **`src/vite-env.d.ts`** з `/// <reference types="vite/client" />` та **`jsconfig.json`** у корені Vue-пакета; обходить джерела Vue-пакета (`.vue`, `.ts`, `.js` тощо) на заборонені value-імпорти з модуля `vue` (дозволені лише type-only та side-effect `import 'vue'`) і додатково сканує `.vue` SFC на імпорти Node-нативних модулів (`node:*` префікс або bare-ім’я вбудованого модуля Node — `fs`, `path`, `timers/promises` тощо). Імпорти аналізуються через **oxc-parser** (`module.staticImports`); для `.vue` вміст `<script>` витягується з SFC, далі той самий парсер (логіка в `npm/rules/vue/js/packages/vue-forbidden-imports.mjs`).
|
|
@@ -1,33 +1,48 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Резолвить корінь JS-коду в проєкті: для workspace-projects — перший workspace
|
|
3
|
-
* (
|
|
4
|
-
* для coverage-провайдера js-lint і test-концерну stryker_config (DRY).
|
|
3
|
+
* (з підтримкою glob-патернів типу `cf/*`), для single-package — корінь cwd.
|
|
4
|
+
* Спільна утиліта для coverage-провайдера js-lint і test-концерну stryker_config (DRY).
|
|
5
5
|
*/
|
|
6
6
|
import { existsSync } from 'node:fs'
|
|
7
|
-
import { readFile } from 'node:fs/promises'
|
|
7
|
+
import { glob, readFile } from 'node:fs/promises'
|
|
8
8
|
import { join } from 'node:path'
|
|
9
9
|
|
|
10
|
+
const WORKSPACE_GLOB_IGNORE = ['**/node_modules/**', '**/.git/**']
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Розгортає один workspace-патерн у список абсолютних шляхів каталогів з package.json.
|
|
14
|
+
* Літеральні патерни перевіряються через existsSync; glob-патерни — через node:fs/promises#glob.
|
|
15
|
+
* @param {string} cwd корінь проєкту
|
|
16
|
+
* @param {string} pattern workspace-патерн з package.json (наприклад, `app` або `cf/*`)
|
|
17
|
+
* @returns {Promise<string[]>} абсолютні шляхи до workspace-каталогів
|
|
18
|
+
*/
|
|
19
|
+
async function expandWorkspacePattern(cwd, pattern) {
|
|
20
|
+
if (!pattern.includes('*')) {
|
|
21
|
+
const wsPath = join(cwd, pattern)
|
|
22
|
+
return existsSync(join(wsPath, 'package.json')) ? [wsPath] : []
|
|
23
|
+
}
|
|
24
|
+
const results = []
|
|
25
|
+
for await (const rel of glob(`${pattern}/package.json`, { cwd, exclude: WORKSPACE_GLOB_IGNORE })) {
|
|
26
|
+
const wsRel = rel.replace(/[/\\]package\.json$/, '')
|
|
27
|
+
results.push(join(cwd, wsRel))
|
|
28
|
+
}
|
|
29
|
+
return results.sort()
|
|
30
|
+
}
|
|
31
|
+
|
|
10
32
|
/**
|
|
11
33
|
* @param {string} cwd корінь проєкту (де `.n-cursor.json` і кореневий package.json)
|
|
12
34
|
* @returns {Promise<string|null>} абсолютний шлях до JS-root або null без кореневого package.json
|
|
13
35
|
*/
|
|
14
36
|
export async function resolveJsRoot(cwd) {
|
|
15
|
-
const
|
|
16
|
-
if (
|
|
17
|
-
|
|
18
|
-
const workspaces = Array.isArray(rootPkg.workspaces) ? rootPkg.workspaces : []
|
|
19
|
-
if (workspaces.length > 0) {
|
|
20
|
-
const wsPath = join(cwd, workspaces[0])
|
|
21
|
-
if (existsSync(join(wsPath, 'package.json'))) return wsPath
|
|
22
|
-
}
|
|
23
|
-
return cwd
|
|
37
|
+
const roots = await resolveAllJsRoots(cwd)
|
|
38
|
+
if (roots.length === 0) return null
|
|
39
|
+
return roots[0]
|
|
24
40
|
}
|
|
25
41
|
|
|
26
42
|
/**
|
|
27
43
|
* Plural-варіант: повертає всі JS-roots проєкту. Для workspace-projects — кожен
|
|
28
|
-
* workspace з власним `package.json
|
|
29
|
-
* масив без кореневого package.json.
|
|
30
|
-
* `stryker_config` для per-workspace baseline-копіювання.
|
|
44
|
+
* workspace з власним `package.json` (з розгортанням glob-патернів); для
|
|
45
|
+
* single-package — `[cwd]`. Порожній масив без кореневого package.json.
|
|
31
46
|
* @param {string} cwd корінь проєкту
|
|
32
47
|
* @returns {Promise<string[]>} абсолютні шляхи до всіх JS-roots
|
|
33
48
|
*/
|
|
@@ -35,12 +50,11 @@ export async function resolveAllJsRoots(cwd) {
|
|
|
35
50
|
const rootPkgPath = join(cwd, 'package.json')
|
|
36
51
|
if (!existsSync(rootPkgPath)) return []
|
|
37
52
|
const rootPkg = JSON.parse(await readFile(rootPkgPath, 'utf8'))
|
|
38
|
-
const
|
|
39
|
-
if (
|
|
53
|
+
const patterns = Array.isArray(rootPkg.workspaces) ? rootPkg.workspaces : []
|
|
54
|
+
if (patterns.length === 0) return [cwd]
|
|
40
55
|
const roots = []
|
|
41
|
-
for (const
|
|
42
|
-
|
|
43
|
-
if (existsSync(join(wsPath, 'package.json'))) roots.push(wsPath)
|
|
56
|
+
for (const pattern of patterns) {
|
|
57
|
+
roots.push(...(await expandWorkspacePattern(cwd, pattern)))
|
|
44
58
|
}
|
|
45
59
|
return roots.length > 0 ? roots : [cwd]
|
|
46
60
|
}
|