@nitra/cursor 1.28.4 → 1.28.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.28.4",
3
+ "version": "1.28.5",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -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 { resolveJsRoot } from '../../../scripts/utils/resolve-js-root.mjs'
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
- * декларовано у JS-root АБО у кореневому `package.json` (workspace-проєкт із
34
- * hoisted node_modules — типовий патерн bun monorepo, де npm-module rule
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 jsRoot = await resolveJsRoot(cwd)
42
- if (jsRoot === null) return false
43
- const pkgPath = join(jsRoot, 'package.json')
44
- if (!existsSync(pkgPath)) return false
45
- const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
46
- if (hasVitestDep(pkg)) return true
47
- if (jsRoot !== cwd) {
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 jsRoot = await resolveJsRoot(cwd)
235
- if (jsRoot === null) throw new Error('js-lint coverage: package.json не знайдено')
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
- // 1. Coverage через vitest run --coverage (v8 provider пише lcov.info у lcovDir)
238
- const lcovDir = await mkdtemp(join(tmpdir(), 'js-lint-cov-'))
239
- let coverage
240
- try {
241
- const code = await runner.runJsCoverage({ cwd: jsRoot, lcovDir })
242
- if (code !== 0) throw new Error(`JS coverage exit ${code}`)
243
- coverage = parseLcov(await readFile(join(lcovDir, 'lcov.info'), 'utf8'))
244
- } finally {
245
- await rm(lcovDir, { recursive: true, force: true })
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
- // 2. Mutation через Stryker
249
- await runner.runStryker({ cwd: jsRoot })
250
- let mutationReport
251
- try {
252
- mutationReport = JSON.parse(await readFile(join(jsRoot, 'reports', 'stryker', 'mutation.json'), 'utf8'))
253
- } catch {
254
- throw new Error(
255
- 'js-lint coverage: stryker не залишив mutation.json — ' +
256
- 'запусти `npx @nitra/cursor fix test` для встановлення canonical stryker.config.mjs, ' +
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
- const { caught, total, survived } = parseStrykerReport(mutationReport, jsRoot)
307
+ }
261
308
 
262
- return [{ area: 'JS', coverage, mutation: { caught, total }, survived }]
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
  }
@@ -1,33 +1,48 @@
1
1
  /**
2
2
  * Резолвить корінь JS-коду в проєкті: для workspace-projects — перший workspace
3
- * (наприклад `app/` у mail app), для single-package — корінь cwd. Спільна утиліта
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 rootPkgPath = join(cwd, 'package.json')
16
- if (!existsSync(rootPkgPath)) return null
17
- const rootPkg = JSON.parse(await readFile(rootPkgPath, 'utf8'))
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`; для single-package `[cwd]`. Порожній
29
- * масив без кореневого package.json. Використовується test-концерном
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 workspaces = Array.isArray(rootPkg.workspaces) ? rootPkg.workspaces : []
39
- if (workspaces.length === 0) return [cwd]
53
+ const patterns = Array.isArray(rootPkg.workspaces) ? rootPkg.workspaces : []
54
+ if (patterns.length === 0) return [cwd]
40
55
  const roots = []
41
- for (const ws of workspaces) {
42
- const wsPath = join(cwd, ws)
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
  }