@nitra/cursor 1.17.1 → 1.17.3

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,36 @@
4
4
 
5
5
  Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
6
6
 
7
+ ## [1.17.3] - 2026-05-24
8
+
9
+ ### Added
10
+
11
+ - Концерн `stryker_config` правила `test` тепер ідемпотентно додає у кореневий `.gitignore` патерни Stryker-output-у:
12
+ - `**/reports/stryker/.tmp/` — in-place backup-каталог (з baseline-у `tempDirName`).
13
+ - `**/reports/stryker/mutation.json` — JSON-репорт мутацій.
14
+ - Header-секція `# Stryker mutation testing (test.mdc)`, sectioning через `ensureGitignoreEntries`.
15
+ - Спільний helper `npm/scripts/utils/ensure-gitignore-entries.mjs` — append-only оновлювач `.gitignore` з header-секціями. Idempotent (точне співпадіння рядка після `trim`), створює файл якщо немає, зберігає trailing-newline. 5 unit-тестів.
16
+
17
+ ### Changed
18
+
19
+ - `test.mdc` 2.0 → 2.1: додано параграф про gitignore-керування Stryker-output-у в секцію «Налаштування mutation-testing».
20
+ - `stryker_config` concern: додано виклик `ensureGitignoreEntries` після копіювання baseline-ів; репортер видає pass-повідомлення про додані патерни.
21
+
22
+ ## [1.17.2] - 2026-05-24
23
+
24
+ ### Added
25
+
26
+ - Правило `test`: два нових концерни — `stryker_config` і `cargo_mutants_config`. Self-gating через `.n-cursor.json#rules`: концерн активний лише якщо відповідне залежне правило (`js-lint` / `rust`) enabled. **Iterate-all-workspaces**: при відсутності цільового файлу копіює canonical baseline у КОЖЕН workspace-каталог (не лише workspaces[0]).
27
+ - `stryker.config.mjs` у кожному JS-root (всі workspaces з package.json, або cwd у single-package) — мінімум для роботи з `bun test`.
28
+ - `.cargo/mutants.toml` у каталозі КОЖНОГО Cargo.toml-маніфесту: корінь + workspaces (з підтримкою Tauri-патерну `<ws>/src-tauri/Cargo.toml`) — комент-плейсхолдер; cargo-mutants має робочі defaults.
29
+ - Спільні резолвери у `npm/scripts/utils/`: `resolveJsRoot` (single, для coverage-провайдера) + `resolveAllJsRoots` (plural, для test-концерну); `resolveCargoManifest` (single) + `resolveAllCargoManifests` (plural). Coverage-провайдери js-lint і rust реюзають single-варіанти.
30
+
31
+ ### Changed
32
+
33
+ - `test.mdc` 1.2 → 2.0 (major): `alwaysApply: true → false`; явні `globs` (`.n-cursor.json`, `package.json`, `Cargo.toml`, mutation-config-цілі, `*.test.mjs`). Нова секція «Налаштування mutation-testing» з посиланнями на baselines.
34
+ - `js-lint/coverage/coverage.mjs`: hint при missing `mutation.json` тепер вказує на `npx @nitra/cursor fix test`. `resolveJsRoot` витягнуто у спільний модуль.
35
+ - `rust/coverage/coverage.mjs`: `resolveCargoManifest` витягнуто у спільний модуль (контракт `null` замість throw для missing manifest; user-facing throw зберігся на callsite).
36
+
7
37
  ## [1.17.1] - 2026-05-24
8
38
 
9
39
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.17.1",
3
+ "version": "1.17.3",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -11,23 +11,7 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises'
11
11
  import { tmpdir } from 'node:os'
12
12
  import { join } from 'node:path'
13
13
 
14
- /**
15
- * Резолвить cwd, у якому стоять JS-тести. Workspace-проєкти — перший workspace
16
- * (наприклад: app/), single-package — корінь.
17
- * @param {string} cwd корінь проєкту
18
- * @returns {Promise<string|null>} абсолютний шлях до JS-root або null без package.json
19
- */
20
- async function resolveJsRoot(cwd) {
21
- const rootPkgPath = join(cwd, 'package.json')
22
- if (!existsSync(rootPkgPath)) return null
23
- const rootPkg = JSON.parse(await readFile(rootPkgPath, 'utf8'))
24
- const workspaces = Array.isArray(rootPkg.workspaces) ? rootPkg.workspaces : []
25
- if (workspaces.length > 0) {
26
- const wsPath = join(cwd, workspaces[0])
27
- if (existsSync(join(wsPath, 'package.json'))) return wsPath
28
- }
29
- return cwd
30
- }
14
+ import { resolveJsRoot } from '../../../scripts/utils/resolve-js-root.mjs'
31
15
 
32
16
  /**
33
17
  * Чи `scripts` містить coverage-сумісну команду.
@@ -140,7 +124,11 @@ export async function collect(cwd, opts = {}) {
140
124
  try {
141
125
  mutationReport = JSON.parse(await readFile(join(jsRoot, 'reports', 'stryker', 'mutation.json'), 'utf8'))
142
126
  } catch {
143
- throw new Error('js-lint coverage: stryker не залишив mutation.json — перевір stryker.config.mjs у проєкті')
127
+ throw new Error(
128
+ 'js-lint coverage: stryker не залишив mutation.json — ' +
129
+ 'запусти `npx @nitra/cursor fix test` для встановлення canonical stryker.config.mjs, ' +
130
+ 'або налаштуй його вручну'
131
+ )
144
132
  }
145
133
  const mutation = parseStrykerReport(mutationReport)
146
134
 
@@ -13,6 +13,7 @@ import { tmpdir } from 'node:os'
13
13
  import { join } from 'node:path'
14
14
 
15
15
  import { hasCargoTomlInTree } from '../lib/has-cargo-toml.mjs'
16
+ import { resolveCargoManifest } from '../../../scripts/utils/resolve-cargo-manifest.mjs'
16
17
 
17
18
  const IGNORED_DIR_NAMES = new Set(['node_modules', '.git', '.next', '.turbo', 'target'])
18
19
 
@@ -26,30 +27,6 @@ export function detect(cwd) {
26
27
  return Promise.resolve(hasCargoTomlInTree(cwd, IGNORED_DIR_NAMES))
27
28
  }
28
29
 
29
- /**
30
- * Знайти Cargo.toml: cwd/Cargo.toml або в одному з workspace-підкаталогів.
31
- * @param {string} cwd корінь проєкту
32
- * @returns {Promise<string>} абсолютний шлях до Cargo.toml
33
- */
34
- async function resolveCargoManifest(cwd) {
35
- const rootManifest = join(cwd, 'Cargo.toml')
36
- if (existsSync(rootManifest)) return rootManifest
37
-
38
- const rootPkgPath = join(cwd, 'package.json')
39
- if (existsSync(rootPkgPath)) {
40
- const rootPkg = JSON.parse(await readFile(rootPkgPath, 'utf8'))
41
- const workspaces = Array.isArray(rootPkg.workspaces) ? rootPkg.workspaces : []
42
- for (const ws of workspaces) {
43
- const tauriManifest = join(cwd, ws, 'src-tauri', 'Cargo.toml')
44
- if (existsSync(tauriManifest)) return tauriManifest
45
- const flatManifest = join(cwd, ws, 'Cargo.toml')
46
- if (existsSync(flatManifest)) return flatManifest
47
- }
48
- }
49
-
50
- throw new Error('rust coverage: Cargo.toml не знайдено (cwd + workspaces)')
51
- }
52
-
53
30
  const defaultRunner = {
54
31
  runLlvmCov({ manifestPath }) {
55
32
  const r = spawnSync('cargo', ['llvm-cov', '--manifest-path', manifestPath, '--json', '--summary-only'], {
@@ -76,6 +53,9 @@ const defaultRunner = {
76
53
  export async function collect(cwd, opts = {}) {
77
54
  const runner = opts.runner ?? defaultRunner
78
55
  const manifestPath = await resolveCargoManifest(cwd)
56
+ if (manifestPath === null) {
57
+ throw new Error('rust coverage: Cargo.toml не знайдено (cwd + workspaces)')
58
+ }
79
59
 
80
60
  // 1. Coverage через cargo llvm-cov
81
61
  const { exitCode: llvmCode, stdout: llvmJson } = await runner.runLlvmCov({ manifestPath })
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Концерн `cargo_mutants_config` правила test (test.mdc): якщо `rust` присутнє
3
+ * в `.n-cursor.json#rules` і не у `disable-rules` — резолвить ВСІ Cargo.toml
4
+ * (cwd і всі workspaces, з підтримкою Tauri-патерну) і копіює canonical
5
+ * baseline `.cargo/mutants.toml` у каталог кожного manifest'а, якщо файлу немає.
6
+ *
7
+ * Self-gating: концерн silently skips коли `rust` не enabled.
8
+ * Якщо `rust` enabled, але жодного Cargo.toml не знайдено — теж silently skip
9
+ * (manifest може з'явитися пізніше; це не помилка).
10
+ *
11
+ * Baseline — порожній файл з коментом; cargo-mutants має робочі defaults.
12
+ */
13
+ import { existsSync } from 'node:fs'
14
+ import { copyFile, mkdir } from 'node:fs/promises'
15
+ import { dirname, join, relative } from 'node:path'
16
+ import { fileURLToPath } from 'node:url'
17
+
18
+ import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
19
+ import { readNCursorConfigLite } from '../../../scripts/lib/read-n-cursor-config-lite.mjs'
20
+ import { resolveAllCargoManifests } from '../../../scripts/utils/resolve-cargo-manifest.mjs'
21
+
22
+ const HERE = dirname(fileURLToPath(import.meta.url))
23
+ const BASELINE_PATH = join(HERE, 'data', 'cargo_mutants_config', 'mutants.toml.baseline')
24
+
25
+ /**
26
+ * @returns {Promise<number>} 0 — OK або silently skipped, 1 — порушення
27
+ */
28
+ export async function check() {
29
+ const reporter = createCheckReporter()
30
+ const cwd = process.cwd()
31
+ const config = await readNCursorConfigLite(cwd)
32
+
33
+ // Self-gate: rust має бути enabled
34
+ if (!config.rules.includes('rust') || config.disableRules.includes('rust')) {
35
+ return reporter.getExitCode()
36
+ }
37
+
38
+ const manifests = await resolveAllCargoManifests(cwd)
39
+ if (manifests.length === 0) {
40
+ // rust enabled, але Cargo.toml ще немає — silently skip (manifest може з'явитися пізніше)
41
+ return reporter.getExitCode()
42
+ }
43
+
44
+ if (!existsSync(BASELINE_PATH)) {
45
+ reporter.fail(
46
+ `.cargo/mutants.toml canonical baseline не знайдено (${BASELINE_PATH}) — перевстанови @nitra/cursor`
47
+ )
48
+ return reporter.getExitCode()
49
+ }
50
+
51
+ for (const manifestPath of manifests) {
52
+ const cargoDir = dirname(manifestPath)
53
+ const target = join(cargoDir, '.cargo', 'mutants.toml')
54
+
55
+ if (existsSync(target)) {
56
+ reporter.pass(`.cargo/mutants.toml існує (${relative(cwd, target)})`)
57
+ continue
58
+ }
59
+
60
+ await mkdir(dirname(target), { recursive: true })
61
+ await copyFile(BASELINE_PATH, target)
62
+ reporter.pass(`.cargo/mutants.toml створено з canonical baseline (${relative(cwd, target)}) (test.mdc)`)
63
+ }
64
+ return reporter.getExitCode()
65
+ }
@@ -0,0 +1,4 @@
1
+ # .cargo/mutants.toml — конфігурація cargo-mutants (опційно).
2
+ # cargo-mutants має робочі defaults; цей файл — стартова точка для customization.
3
+ # Документація: https://mutants.rs/
4
+ # Канон постачає правило `test` (@nitra/cursor).
@@ -0,0 +1,12 @@
1
+ /** @type {import('@stryker-mutator/core').PartialStrykerOptions} */
2
+ export default {
3
+ testRunner: 'command',
4
+ commandRunner: { command: 'bun test' },
5
+ // inPlace: уникає hoisted-node_modules issues у Bun monorepo (sandbox-копія втрачає resolution).
6
+ // Також тести, що читають git/fs-state (integration checks), працюють тільки in-place.
7
+ inPlace: true,
8
+ tempDirName: 'reports/stryker/.tmp',
9
+ reporters: ['json', 'clear-text'],
10
+ jsonReporter: { fileName: 'reports/stryker/mutation.json' },
11
+ coverageAnalysis: 'off'
12
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Концерн `stryker_config` правила test (test.mdc): якщо `js-lint` присутнє в
3
+ * `.n-cursor.json#rules` і не у `disable-rules` — резолвить ВСІ JS-roots
4
+ * (всі workspaces з package.json, або cwd у single-package) і копіює canonical
5
+ * baseline `stryker.config.mjs` у кожен root, де файлу немає.
6
+ *
7
+ * Self-gating: концерн silently skips коли `js-lint` не enabled — це навмисно,
8
+ * щоб не шуміти у single-language проєктах без JS coverage tooling.
9
+ *
10
+ * Baseline — мінімум для запуску Stryker з bun test runner; mutate-патерни
11
+ * лишаються на Stryker defaults (`src/**\/*.{js,mjs,ts,jsx,tsx,cjs}`).
12
+ */
13
+ import { existsSync } from 'node:fs'
14
+ import { copyFile } from 'node:fs/promises'
15
+ import { dirname, join, relative } from 'node:path'
16
+ import { fileURLToPath } from 'node:url'
17
+
18
+ import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
19
+ import { readNCursorConfigLite } from '../../../scripts/lib/read-n-cursor-config-lite.mjs'
20
+ import { ensureGitignoreEntries } from '../../../scripts/utils/ensure-gitignore-entries.mjs'
21
+ import { resolveAllJsRoots } from '../../../scripts/utils/resolve-js-root.mjs'
22
+
23
+ const HERE = dirname(fileURLToPath(import.meta.url))
24
+ const BASELINE_PATH = join(HERE, 'data', 'stryker_config', 'stryker.config.baseline.mjs')
25
+
26
+ // Stryker-output патерни для .gitignore: temp-каталог з backup-файлами
27
+ // (`tempDirName: 'reports/stryker/.tmp'` у baseline) і JSON-репорт мутацій.
28
+ // Канон in-place mode у baseline лишає backup'и у `reports/stryker/.tmp/backup-XXX/`,
29
+ // які НЕ можна комітити. Подвійний-зірочка-префікс покриває всі workspaces.
30
+ const STRYKER_GITIGNORE_ENTRIES = ['**/reports/stryker/.tmp/', '**/reports/stryker/mutation.json']
31
+
32
+ /**
33
+ * @returns {Promise<number>} 0 — OK або silently skipped, 1 — порушення
34
+ */
35
+ export async function check() {
36
+ const reporter = createCheckReporter()
37
+ const cwd = process.cwd()
38
+ const config = await readNCursorConfigLite(cwd)
39
+
40
+ // Self-gate: js-lint має бути enabled
41
+ if (!config.rules.includes('js-lint') || config.disableRules.includes('js-lint')) {
42
+ return reporter.getExitCode()
43
+ }
44
+
45
+ const jsRoots = await resolveAllJsRoots(cwd)
46
+ if (jsRoots.length === 0) {
47
+ reporter.fail('test: js-lint enabled, але кореневий package.json не знайдено (test.mdc)')
48
+ return reporter.getExitCode()
49
+ }
50
+
51
+ if (!existsSync(BASELINE_PATH)) {
52
+ reporter.fail(
53
+ `stryker.config.mjs canonical baseline не знайдено (${BASELINE_PATH}) — перевстанови @nitra/cursor`
54
+ )
55
+ return reporter.getExitCode()
56
+ }
57
+
58
+ for (const jsRoot of jsRoots) {
59
+ const target = join(jsRoot, 'stryker.config.mjs')
60
+ if (existsSync(target)) {
61
+ reporter.pass(`stryker.config.mjs існує (${relative(cwd, target)})`)
62
+ continue
63
+ }
64
+ await copyFile(BASELINE_PATH, target)
65
+ reporter.pass(`stryker.config.mjs створено з canonical baseline (${relative(cwd, target)}) (test.mdc)`)
66
+ }
67
+
68
+ // Гарантуємо що Stryker temp/output ніколи не комітяться. Patterns
69
+ // покривають усі workspaces через `**/`-префікс (єдиний root .gitignore).
70
+ const { added } = await ensureGitignoreEntries(cwd, STRYKER_GITIGNORE_ENTRIES, 'Stryker mutation testing (test.mdc)')
71
+ if (added.length > 0) {
72
+ reporter.pass(`.gitignore: додано Stryker-патерни (${added.join(', ')}) (test.mdc)`)
73
+ }
74
+ return reporter.getExitCode()
75
+ }
@@ -1,7 +1,8 @@
1
1
  ---
2
- description: JS-тести (*.test.mjs) живуть у каталозі tests/ поряд із джерельним файлом, а не безпосередньо в тій же директорії
3
- version: '1.2'
4
- alwaysApply: true
2
+ description: JS-тести (*.test.mjs) живуть у tests/. Правило `test` керує stryker.config.mjs (якщо js-lint enabled) і .cargo/mutants.toml (якщо rust enabled).
3
+ version: '2.1'
4
+ globs: "**/{.n-cursor.json,package.json,Cargo.toml,stryker.config.mjs,.cargo/mutants.toml},**/*.test.mjs"
5
+ alwaysApply: false
5
6
  ---
6
7
 
7
8
  ## Конвенція розміщення тестів
@@ -68,3 +69,17 @@ Recursive globs ловлять файли всередині `tests/` так с
68
69
  У `package.json` (корінь) має бути `scripts.coverage` із викликом `n-cursor coverage`:
69
70
 
70
71
  Канон `scripts.coverage` (substring requirement): [package.json.contains.json](./policy/package_json/template/package.json.contains.json)
72
+
73
+ ## Налаштування mutation-testing
74
+
75
+ Якщо у `.n-cursor.json#rules` присутнє правило `js-lint` — правило `test` створює canonical baseline `stryker.config.mjs` у **кожному** JS-root проєкту: у кожному workspace з власним `package.json` (або в корені для single-package). У monorepo з `workspaces: ['app', 'scripts']` отримаєте `app/stryker.config.mjs` і `scripts/stryker.config.mjs`.
76
+
77
+ Канон Stryker config (мінімум для роботи з `bun test`): [stryker.config.baseline.mjs](./js/data/stryker_config/stryker.config.baseline.mjs)
78
+
79
+ Аналогічно, якщо `rust` присутнє в `rules` — створюється `.cargo/mutants.toml` у каталозі **кожного** Cargo.toml-маніфесту: кореневий `Cargo.toml`, `<workspace>/src-tauri/Cargo.toml` (Tauri-патерн) і `<workspace>/Cargo.toml` (flat workspace).
80
+
81
+ Канон cargo-mutants config: [mutants.toml.baseline](./js/data/cargo_mutants_config/mutants.toml.baseline)
82
+
83
+ Customization (mutate patterns, exclude rules, timeout) — відповідальність проєкту-споживача; концерни лише забезпечують наявність файлу як стартового baseline в кожному з виявлених workspace-каталогів.
84
+
85
+ Додатково: коли `js-lint` enabled, концерн `stryker_config` ідемпотентно додає у кореневий `.gitignore` патерни Stryker-output-у — `**/reports/stryker/.tmp/` (in-place backup-каталог з baseline-у) і `**/reports/stryker/mutation.json` (JSON-репорт). Це запобігає випадковому коміту backup-копій вихідного коду та мутаційного звіту як build-артефактів.
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Idempotent append-only оновлювач `.gitignore` у корені проєкту. Перевіряє,
3
+ * чи задані entries уже присутні (точне співпадіння рядка після `trim`); відсутні
4
+ * дописує під header-комент, не порушуючи решту файлу. Якщо `.gitignore` немає —
5
+ * створюється з заданими entries + header.
6
+ *
7
+ * Викликається з test-концерну `stryker_config` (gitignore Stryker temp dirs).
8
+ */
9
+ import { existsSync } from 'node:fs'
10
+ import { readFile, writeFile } from 'node:fs/promises'
11
+ import { join } from 'node:path'
12
+
13
+ /**
14
+ * @param {string} cwd корінь репо (де знаходиться `.gitignore`)
15
+ * @param {string[]} entries патерни для .gitignore (порядок збережено)
16
+ * @param {string} sectionLabel header-коментар над секцією (без `#`-префікса)
17
+ * @returns {Promise<{added: string[]}>} перелік патернів, що були дописані
18
+ */
19
+ export async function ensureGitignoreEntries(cwd, entries, sectionLabel) {
20
+ const gitignorePath = join(cwd, '.gitignore')
21
+ const existing = existsSync(gitignorePath) ? await readFile(gitignorePath, 'utf8') : ''
22
+ const existingLines = new Set(existing.split('\n').map(line => line.trim()))
23
+ const missing = entries.filter(entry => !existingLines.has(entry))
24
+ if (missing.length === 0) return { added: [] }
25
+
26
+ const prefix = existing.length === 0 || existing.endsWith('\n') ? '' : '\n'
27
+ const block = `${prefix}\n# ${sectionLabel}\n${missing.join('\n')}\n`
28
+ await writeFile(gitignorePath, existing + block)
29
+ return { added: missing }
30
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Резолвить шлях до Cargo.toml у проєкті: cwd/Cargo.toml або в одному з
3
+ * workspace-підкаталогів (з підтримкою Tauri-патерну `<workspace>/src-tauri/`).
4
+ * Спільна утиліта для coverage-провайдера rust і test-концерну cargo_mutants_config.
5
+ * Повертає null (а не throw) щоб callsite-и могли gracefully skip-нути.
6
+ */
7
+ import { existsSync } from 'node:fs'
8
+ import { readFile } from 'node:fs/promises'
9
+ import { join } from 'node:path'
10
+
11
+ /**
12
+ * @param {string} cwd корінь проєкту
13
+ * @returns {Promise<string|null>} абсолютний шлях до Cargo.toml або null
14
+ */
15
+ export async function resolveCargoManifest(cwd) {
16
+ const rootManifest = join(cwd, 'Cargo.toml')
17
+ if (existsSync(rootManifest)) return rootManifest
18
+
19
+ const rootPkgPath = join(cwd, 'package.json')
20
+ if (existsSync(rootPkgPath)) {
21
+ const rootPkg = JSON.parse(await readFile(rootPkgPath, 'utf8'))
22
+ const workspaces = Array.isArray(rootPkg.workspaces) ? rootPkg.workspaces : []
23
+ for (const ws of workspaces) {
24
+ const tauri = join(cwd, ws, 'src-tauri', 'Cargo.toml')
25
+ if (existsSync(tauri)) return tauri
26
+ const flat = join(cwd, ws, 'Cargo.toml')
27
+ if (existsSync(flat)) return flat
28
+ }
29
+ }
30
+ return null
31
+ }
32
+
33
+ /**
34
+ * Plural-варіант: повертає всі Cargo.toml-маніфести в проєкті — корінь
35
+ * (`cwd/Cargo.toml`) і у workspace-підкаталогах (`<ws>/src-tauri/Cargo.toml`
36
+ * пріоритетніше за `<ws>/Cargo.toml`). Порожній масив якщо нічого не знайдено.
37
+ * Використовується test-концерном `cargo_mutants_config` для per-manifest
38
+ * baseline-копіювання.
39
+ * @param {string} cwd корінь проєкту
40
+ * @returns {Promise<string[]>} абсолютні шляхи до знайдених Cargo.toml
41
+ */
42
+ export async function resolveAllCargoManifests(cwd) {
43
+ const manifests = []
44
+ const rootManifest = join(cwd, 'Cargo.toml')
45
+ if (existsSync(rootManifest)) manifests.push(rootManifest)
46
+
47
+ const rootPkgPath = join(cwd, 'package.json')
48
+ if (existsSync(rootPkgPath)) {
49
+ const rootPkg = JSON.parse(await readFile(rootPkgPath, 'utf8'))
50
+ const workspaces = Array.isArray(rootPkg.workspaces) ? rootPkg.workspaces : []
51
+ for (const ws of workspaces) {
52
+ const tauri = join(cwd, ws, 'src-tauri', 'Cargo.toml')
53
+ if (existsSync(tauri)) {
54
+ manifests.push(tauri)
55
+ continue
56
+ }
57
+ const flat = join(cwd, ws, 'Cargo.toml')
58
+ if (existsSync(flat)) manifests.push(flat)
59
+ }
60
+ }
61
+ return manifests
62
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Резолвить корінь JS-коду в проєкті: для workspace-projects — перший workspace
3
+ * (наприклад `app/` у mlmail), для single-package — корінь cwd. Спільна утиліта
4
+ * для coverage-провайдера js-lint і test-концерну stryker_config (DRY).
5
+ */
6
+ import { existsSync } from 'node:fs'
7
+ import { readFile } from 'node:fs/promises'
8
+ import { join } from 'node:path'
9
+
10
+ /**
11
+ * @param {string} cwd корінь проєкту (де `.n-cursor.json` і кореневий package.json)
12
+ * @returns {Promise<string|null>} абсолютний шлях до JS-root або null без кореневого package.json
13
+ */
14
+ 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
24
+ }
25
+
26
+ /**
27
+ * Plural-варіант: повертає всі JS-roots проєкту. Для workspace-projects — кожен
28
+ * workspace з власним `package.json`; для single-package — `[cwd]`. Порожній
29
+ * масив без кореневого package.json. Використовується test-концерном
30
+ * `stryker_config` для per-workspace baseline-копіювання.
31
+ * @param {string} cwd корінь проєкту
32
+ * @returns {Promise<string[]>} абсолютні шляхи до всіх JS-roots
33
+ */
34
+ export async function resolveAllJsRoots(cwd) {
35
+ const rootPkgPath = join(cwd, 'package.json')
36
+ if (!existsSync(rootPkgPath)) return []
37
+ const rootPkg = JSON.parse(await readFile(rootPkgPath, 'utf8'))
38
+ const workspaces = Array.isArray(rootPkg.workspaces) ? rootPkg.workspaces : []
39
+ if (workspaces.length === 0) return [cwd]
40
+ const roots = []
41
+ for (const ws of workspaces) {
42
+ const wsPath = join(cwd, ws)
43
+ if (existsSync(join(wsPath, 'package.json'))) roots.push(wsPath)
44
+ }
45
+ return roots.length > 0 ? roots : [cwd]
46
+ }