@nitra/cursor 1.30.0 → 1.34.1

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
@@ -1,9 +1,73 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.34.1] - 2026-05-30
4
+
5
+ ### Added
6
+
7
+ - Нові тест-файли (10 файлів, 108+ тестів): rules/abie/js/tests/ (firebase_hosting, env_dns, hc_pairing, ua_node_selector, ua_http_route), rules/abie/lib/tests/hc-yaml.test.mjs, rules/abie/lib/tests/k8s-tree.test.mjs, rules/docker/lib/tests/docker-hadolint.test.mjs, rules/docker/lint/tests/lint.test.mjs, scripts/tests/coverage-fix.test.mjs; розширено scripts/tests/upgrade-nitra-cursor-and-install.test.mjs. Lines coverage: 77.62% → 78.80%, Functions: 84.47% → 86.13%.
8
+
9
+ ### Changed
10
+
11
+ - coverage: не додавати підсумковий рядок "Разом" коли провайдер один (дублював би єдиний рядок)
12
+
3
13
  Усі помітні зміни цього модуля документуються тут.
4
14
 
5
15
  Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
6
- # [1.30.0] - 2026-05-29
16
+
17
+ ## [1.34.0] - 2026-05-30
18
+
19
+ ### Added
20
+
21
+ - Тести для `bun-sql-scan.mjs`: `findPgFormatShimDefinitionInText`, `findPgFormatLikeQueryWrapperInText`, `findUnsafeBunSqlInListMissingEmptyGuardInText`, `findPgListenNotifyUsageInText`
22
+ - Тести для `capacitor/platforms.mjs`: semver edge-cases, `recordCapacitorFromOnePackageJson`, `collectCapacitorDataFromAllPackageJson`, nitra-exceptions via config files
23
+ - Тести для `ga/workflows.mjs`: no .github/workflows dir, .yaml extension, MegaLinter detection, apply-k8s paths trigger
24
+ - Тести для `nginx-default-tpl/template.mjs` і `vue/packages.mjs` через check-rule-fixtures.test.mjs
25
+
26
+ ## [1.33.0] - 2026-05-30
27
+
28
+ ### Added
29
+
30
+ - `n-cursor change` / `n-cursor release` — change-файли `<ws>/.changes/*.md` замість ручного bump/CHANGELOG; реліз агрегує їх у CI, ставить git-тег `<name>@<version>`. Підтримка npm і Python workspace.
31
+
32
+ ### Changed
33
+
34
+ - `n-changelog.mdc` v3.0: feature-флоу кладе change-файл; `fix changelog` приймає change-файл або ручний bump.
35
+
36
+ ## [1.32.0] - 2026-05-30
37
+
38
+ ### Added
39
+
40
+ - **`rules/ci4/js/marksman_config.mjs`** + **`rules/ci4/js/data/marksman_config/marksman.baseline.toml`** — новий JS-концерн `marksman_config` за зразком `test.stryker_config`: при `npx @nitra/cursor fix ci4` копіює canonical `.marksman.toml` baseline у корінь cwd, якщо файлу ще немає. Idempotent через паттерн `ensureBaselineFile` (existsSync → pass+skip vs copyFile → pass+create) — ручні правки користувача між прогонами зберігаються, повторний прогон не перетирає. Baseline містить три ключові опції: `[core] markdown.glfm = true` (GLFM-фічі portable subset — alerts/таблиці/todo), `[completion] wiki.style = "file-stem"` (ADR slug == ім'я файла; стабільний ідентифікатор у AUTOGEN `sources`/manifest/валідаторі — заголовок міняється, посилання не ламається), `[code_action] toc.enable = true` (Insert/Update TOC code action для довгих arc42-сторінок). Дефолти marksman (`title-slug-ref` + вимкнений GLFM) ламали б частину задокументованої навігації. Розміщення в `cwd` (корені репо) робить весь монорепо одним marksman-workspace — README.md і docs/ перехресно навігуються. Тести: `+4` сценарії у `rules/ci4/js/tests/marksman_config.test.mjs` через `withTmpDir` (порожній cwd → файл створюється; idempotency — кастомний контент не перетирається; валідні TOML-секції `[core]`/`[completion]`/`[code_action]`; exit 0 у обох сценаріях). 4/4 PASS через `bunx vitest run rules/ci4/js/tests/marksman_config.test.mjs`.
41
+
42
+ ### Changed
43
+
44
+ - **`rules/ci4/ci4.mdc`** (`version` 3.1 → 3.2) — у секцію «Viewer/editor: Zed + marksman LSP» додано параграф про авто-створення `.marksman.toml` правилом ci4 з посиланням на canonical baseline і поясненням кожної з трьох ключових опцій (glfm/wiki.style/toc.enable). У frontmatter `description` додано пункт про `.marksman.toml`. Дзеркало `.cursor/rules/n-ci4.mdc` синхронізується наступним прогоном `npx @nitra/cursor`.
45
+
46
+ ## [1.31.0] - 2026-05-30
47
+
48
+ ### Added
49
+
50
+ - **`rules/ci4/policy/vscode_extensions/`** — нова policy за зразком text/style-lint/rego/ga/js-lint/graphql/nginx-default-tpl/rust/tauri: `vscode_extensions.rego` (deny якщо рекомендація з template-snippet відсутня в `.vscode/extensions.json`) + `vscode_extensions_test.rego` (5 сценаріїв: canonical, missing marksman, empty recommendations, extra recommendations пропускаються, drift-test що канон керується через `data.template`) + `template/extensions.json.snippet.json` (`{"recommendations": ["arr.marksman"]}`) + `target.json` (single `.vscode/extensions.json`). Призначення — контрибʼютори, що працюють у VSCode/Cursor замість Zed, отримують той самий шар marksman-навігації (cmd+click по `[link](file.md)`/`[[wiki-link]]`, find-references, refactor-перейменування заголовків) через офіційне розширення marksman LSP. Дзеркальна правка у репо: `.vscode/extensions.json` отримав `arr.marksman` у `recommendations`. Тести: 5/5 PASS через `opa test npm/rules/ci4/policy/vscode_extensions/`.
51
+
52
+ ### Changed
53
+
54
+ - **`rules/ci4/ci4.mdc`** (`version` 3.0 → 3.1) — у секцію «Viewer/editor: Zed + marksman LSP» додано підсекцію **«VSCode-альтернатива»** з канонічним JSON-блоком `.vscode/extensions.json` (`recommendations: ["arr.marksman"]`) і посиланням на snippet-файл policy (за стилем `text.mdc`); також згадка про marksman-сумісні редактори (Neovim, Helix, Emacs). У frontmatter `description` додано пункт про VSCode-розширення. Дзеркало `.cursor/rules/n-ci4.mdc` синхронізується наступним прогоном `npx @nitra/cursor`.
55
+
56
+ ### Fixed
57
+
58
+ - **`vitest.config.js`** — git-залежні тести (`rules/changelog/check.test.mjs`, `rules/ga/workflows.test.mjs`) масово таймаутили локально (`23 failed | 13 passed`, 187s; усі фейли — `Test timed out in 5000ms`, не assertion), хоча в CI зелені. Першопричина: глобальний `~/.gitconfig` тестової машини має `trace2.eventtarget=af_unix:stream:~/.git-ai/.../trace2.sock` (tooling `git-ai`), який успадковується tmp-репо в тестах → кожна git-команда під'єднується до Unix-сокета даемона; коли даемон деградований, запис у сокет блокується (~1s/команда не на CPU), а під `pool: 'forks'` десятки паралельних git-операцій × латентність > 5000ms `testTimeout`. Фікс: `env: { GIT_TRACE2_EVENT: '0' }` прибирає trace2-залежність із гарячого шляху тестового git (root-cause), плюс `testTimeout: 20000` як defence-in-depth проти будь-якої залишкової локальної I/O-латентності. Після фіксу `bun run vitest run rules/changelog/` → `36 passed` за ~7s (відтворювано); повний suite — `1248 passed | 2 skipped`. CI не зачеплено (там немає trace2-таргета).
59
+
60
+ ## [1.30.1] - 2026-05-29
61
+
62
+ ### Added
63
+
64
+ - **`rules/test/coverage/tests/coverage.test.mjs`** — три нові тести `/n-coverage-fix`-ітерації, що вбивають усі чотири вцілілі мутанти на `rules/test/coverage/coverage.mjs`. (1) `opts.fix=false → fixSurvivedMutants НЕ викликається` і (2) `opts.fix=true → fixSurvivedMutants викликається` фіксують умовну гілку `if (opts.fix)` (L189) через лог `'✓ Всі мутанти вбиті — доповнення тестів не потрібне'`, який друкує `fixSurvivedMutants` для порожнього `survived[]`. (3) `source 2-ї стрілки містить fix:false` перевіряє джерело захопленої callback-стрілки 2-го `withLock` через `Function.prototype.toString()`: токени `fix` і `false` мають бути присутні, а `fix: true` — заборонений; це поведінково невловимо (`{}` і `{ fix: false }` дають однаковий falsy `opts.fix`), тому інваріант на рівні джерела.
65
+
66
+ ### Fixed
67
+
68
+ - **`CHANGELOG.md`** — заголовок секції `[1.30.0]` піднято з `#` на `##` (Keep a Changelog vN.M.M вимагає H2 для версій). Чек `npm-module.mdc` зчитував h1 як «без версії», знаходив `[1.29.5]` першою і скаржився на розбіжність із `package.json#version "1.30.0"`.
69
+
70
+ ## [1.30.0] - 2026-05-29
7
71
 
8
72
  ### Changed
9
73
 
package/bin/n-cursor.js CHANGED
@@ -1501,6 +1501,18 @@ try {
1501
1501
 
1502
1502
  break
1503
1503
  }
1504
+ case 'change': {
1505
+ const { runChangeCli } = await import('../rules/release/change.mjs')
1506
+ process.exitCode = await runChangeCli(args)
1507
+
1508
+ break
1509
+ }
1510
+ case 'release': {
1511
+ const { runReleaseCli } = await import('../rules/release/release.mjs')
1512
+ process.exitCode = await runReleaseCli(args)
1513
+
1514
+ break
1515
+ }
1504
1516
  case 'skill': {
1505
1517
  process.exitCode = runSkillsCli(args)
1506
1518
 
@@ -1515,7 +1527,7 @@ try {
1515
1527
  default: {
1516
1528
  console.error(`❌ Невідома команда: ${command}`)
1517
1529
  console.error(
1518
- ` Очікується: (без аргументів) синхронізація правил, check, rename-yaml-extensions, post-tool-use-fix, lint, lint-ga, lint-rego, lint-k8s, lint-docker, lint-text, coverage, skill`
1530
+ ` Очікується: (без аргументів) синхронізація правил, check, rename-yaml-extensions, post-tool-use-fix, lint, lint-ga, lint-rego, lint-k8s, lint-docker, lint-text, coverage, change, release, skill`
1519
1531
  )
1520
1532
  process.exitCode = 1
1521
1533
  }
@@ -0,0 +1,9 @@
1
+ name: n-cursor release
2
+ description: Aggregate .changes/* into version bump + CHANGELOG, tag <name>@<version>, commit back
3
+
4
+ runs:
5
+ using: composite
6
+ steps:
7
+ - name: Release (bump + CHANGELOG + tag)
8
+ shell: bash
9
+ run: npx @nitra/cursor release
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.30.0",
3
+ "version": "1.34.1",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  description: CHANGELOG.md в кожному workspace, з двома моделями бази порівняння (npm і Python)
3
- version: '2.6'
3
+ version: '3.0'
4
4
  alwaysApply: true
5
5
  ---
6
6
 
@@ -8,9 +8,11 @@ alwaysApply: true
8
8
 
9
9
  > **Якщо в цій сесії ти змінив(ла) файли в пакетному workspace** (код, rego, правила, скіли, скрипти, конфіги, тести — **не** лише `docs/` / `doc/`) — **не завершуй задачу**, поки не виконаєш **усі три** кроки нижче в **тому ж** наборі змін. Це не «опційно після синку» — це частина PR.
10
10
 
11
- 1. **`version`** у `<ws>/package.json` (або `[project].version` у `pyproject.toml`) **patch +1** відносно `git show HEAD:<ws>/package.json`, якщо ще не піднято.
12
- 2. **`CHANGELOG.md`** того workspace **нова** секція `## [версія] - YYYY-MM-DD` **зверху** (не bullet-и в стару версію).
13
- 3. **`npx @nitra/cursor fix changelog`** (у репо `@nitra/cursor`: `bun ./npm/bin/n-cursor.js check changelog`) → exit **`0`**.
11
+ 1. **Поклади change-файл** `<ws>/.changes/<timestamp>-<rand>.md` з frontmatter `bump:` (`major|minor|patch`) + `section:` (`Added|Changed|Fixed|Removed`) і текстом опису. Команда: `npx @nitra/cursor change --bump <…> --section <…> --message "<…>" [--ws <шлях>]`.
12
+ 2. **Не** редагуй `version` і `CHANGELOG.md` вручну їх формує `n-cursor release` у CI на `main` (агрегує change-файли, ставить git-тег `<name>@<version>`).
13
+ 3. **`npx @nitra/cursor fix changelog`** → exit **`0`** (м'яка перевірка: достатньо наявності change-файлу **або** вже піднятого `version`).
14
+
15
+ **Legacy / hotfix:** ручний bump `version` + новий запис у `CHANGELOG.md` усе ще приймається перевіркою як альтернатива change-файлу.
14
16
 
15
17
  **Тригер шляхів (приклади):** `npm/**`, `packages/foo/**`, будь-який каталог з власним `package.json` / `pyproject.toml`, куди потрапили правки.
16
18
 
@@ -29,6 +29,7 @@ import {
29
29
  parsePyprojectFields,
30
30
  readPackageManifest
31
31
  } from '../lib/package-manifest.mjs'
32
+ import { readChangeFiles } from '../../release/lib/change-file.mjs'
32
33
 
33
34
  const execFileAsync = promisify(execFile)
34
35
 
@@ -395,6 +396,17 @@ function workspaceLabel(manifest) {
395
396
  return manifest.ws === '.' ? '<root>' : manifest.ws
396
397
  }
397
398
 
399
+ /**
400
+ * Чи має workspace незрелізні change-файли (намір зафіксовано — bump зробить CI).
401
+ * @param {string} ws workspace
402
+ * @param {string} cwd корінь
403
+ * @returns {Promise<boolean>} результат
404
+ */
405
+ async function hasPendingChangeFiles(ws, cwd) {
406
+ const files = await readChangeFiles(ws, cwd)
407
+ return files.length > 0
408
+ }
409
+
398
410
  /**
399
411
  * @param {import('../lib/package-manifest.mjs').PackageManifest} manifest параметр
400
412
  * @param {string} Vcurrent параметр
@@ -407,6 +419,10 @@ function workspaceLabel(manifest) {
407
419
  async function checkPublishedWorkspacePendingGitChanges(manifest, Vcurrent, subWorkspaces, pass, fail, cwd) {
408
420
  const label = workspaceLabel(manifest)
409
421
  const mf = manifestFilePath(manifest.ws, manifest)
422
+ if (await hasPendingChangeFiles(manifest.ws, cwd)) {
423
+ pass(`${label}: є change-файл(и) у .changes/ — bump зробить CI (n-changelog.mdc)`)
424
+ return
425
+ }
410
426
  if (!(await isInsideGitRepo(cwd))) {
411
427
  return
412
428
  }
@@ -502,6 +518,10 @@ async function checkPublishedWorkspace(manifest, subWorkspaces, getPublishedVers
502
518
  async function checkLocalOnlyChangedWorkspace(comparisonRef, manifest, baseLabel, pass, fail, cwd) {
503
519
  const label = workspaceLabel(manifest)
504
520
  const mf = manifestFilePath(manifest.ws, manifest)
521
+ if (await hasPendingChangeFiles(manifest.ws, cwd)) {
522
+ pass(`${label}: є change-файл(и) у .changes/ — bump зробить CI (n-changelog.mdc)`)
523
+ return
524
+ }
505
525
  const Vcurrent = manifest.version
506
526
  if (!Vcurrent) {
507
527
  fail(`${label}: у ${mf} відсутнє поле version (потрібне для запису в CHANGELOG)`)
package/rules/ci4/ci4.mdc CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- description: Архітектурна документація продукту — Markdown як джерело істини; рекомендований стек arc42 + Diátaxis + ADR (MADR v4, формат описаний у правилі `adr`) + C4 як набір нотацій; гібридна модель manual + autogen-зон, що регенеруються з accepted ADR; Zed + marksman LSP як viewer без site-generator-а, portable subset (CommonMark + GFM + Mermaid + KaTeX), collapsible engineer-блоки через нативний `<details>`
2
+ description: Архітектурна документація продукту — Markdown як джерело істини; рекомендований стек arc42 + Diátaxis + ADR (MADR v4, формат описаний у правилі `adr`) + C4 як набір нотацій; гібридна модель manual + autogen-зон, що регенеруються з accepted ADR; Zed + marksman LSP як viewer без site-generator-а, portable subset (CommonMark + GFM + Mermaid + KaTeX), collapsible engineer-блоки через нативний `<details>`; рекомендоване VSCode-розширення `arr.marksman` для контрибʼюторів поза Zed; `.marksman.toml` авто-створюється у корені проєкту з canonical baseline
3
3
  alwaysApply: true
4
- version: '3.0'
4
+ version: '3.2'
5
5
  ---
6
6
 
7
7
  Архітектурна документація проєкту живе у Markdown поряд із кодом. Це не довідник «для людей із порталу архітектора» — це **джерело істини**, з якого LLM-агент і людина читають намір системи перед будь-якою зміною коду. Тому правила нижче — не оформлення, а робочий процес: який стек використовуємо, як зберігаємо рішення, як автоматично перегенеровуємо проекції з ADR і як рендеримо для змішаної аудиторії (менеджери + інженери + ops).
@@ -208,6 +208,18 @@ User Service відповідає за автентифікацію та про
208
208
  }
209
209
  ```
210
210
 
211
+ **`.marksman.toml`** у корені проєкту авто-створюється правилом ci4 при першому `npx @nitra/cursor fix ci4` із canonical baseline ([data/marksman_config/marksman.baseline.toml](./js/data/marksman_config/marksman.baseline.toml)). Ключові опції — `markdown.glfm = true` (потрібен для GFM-alerts/таблиць/todo з portable subset), `[completion] wiki.style = "file-stem"` (ADR slug == ім'я файла, стабільний ідентифікатор у AUTOGEN `sources`/manifest/валідаторі — заголовок змінюється, посилання не ламається), `[code_action] toc.enable = true` (TOC code action для довгих arc42-сторінок). Без явного конфіга marksman використовує `title-slug-ref` і вимкнений GLFM — частина задокументованої навігації працювала б інакше. Ручні правки конфіга не перетираються — `ensureBaselineFile` ідемпотентний.
212
+
213
+ **VSCode-альтернатива.** Контрибʼютори, що працюють у VSCode/Cursor замість Zed, отримують той самий шар навігації через офіційне розширення marksman LSP. Канонічний запис у `.vscode/extensions.json`:
214
+
215
+ ```json title=".vscode/extensions.json"
216
+ {
217
+ "recommendations": ["arr.marksman"]
218
+ }
219
+ ```
220
+
221
+ Канон `recommendations` (substring requirement): [extensions.json.snippet.json](./policy/vscode_extensions/template/extensions.json.snippet.json). Інші marksman-сумісні редактори (Neovim, Helix, Emacs) налаштовують `marksman` як LSP-сервер за документацією свого редактора — поведінка ідентична (cmd+click по `[link](file.md)`/`[[wiki-link]]`, find-references, refactor-перейменування).
222
+
211
223
  **Portable-only синтаксис.** Усе, що пишемо в `docs/`, обмежене **CommonMark + GFM + Mermaid у fenced code (` ```mermaid `) + KaTeX (`$...$`) + нативний HTML5 `<details>`**. Заборонено:
212
224
 
213
225
  - pymdownx admonitions (`!!! note`, `??? engineer`, `=== "tab"`)
@@ -0,0 +1,27 @@
1
+ # Workspace-маркер marksman LSP. У корені репо робить весь монорепо одним
2
+ # marksman-workspace — README.md і docs/ перехресно навігуються через
3
+ # [text](file.md) і [[wiki-link]]. Файли .mdc не індексуються — marksman
4
+ # дивиться лише на розширення нижче.
5
+ #
6
+ # Створюється правилом ci4 (npx @nitra/cursor fix ci4) із canonical baseline.
7
+ # Ручні правки не перетираються: повторні прогони ідемпотентні (no-op).
8
+
9
+ [core]
10
+ markdown.file_extensions = ["md", "markdown"]
11
+
12
+ # GitHub-Flavored Markdown (таблиці, todo, alerts, math) — наш portable subset.
13
+ markdown.glfm = true
14
+
15
+ [completion]
16
+ # Стиль резолву [[wiki-link]]:
17
+ # "file-stem" → [[oidc-pkce-flow]] резолвиться у docs/adr/oidc-pkce-flow.md
18
+ # "title-slug-ref" → резолв за slugified H1 заголовком
19
+ # Беремо file-stem: ADR-slug == ім'я файла, стабільний ідентифікатор у
20
+ # AUTOGEN sources, manifest, валідаторі. Заголовок може мінятися — посилання
21
+ # не ламається.
22
+ wiki.style = "file-stem"
23
+
24
+ [code_action]
25
+ # Code action "Insert/Update TOC" — корисно для довгих arc42-сторінок
26
+ # (architecture.md з 12 розділами).
27
+ toc.enable = true
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Концерн `marksman_config` правила ci4 (ci4.mdc): копіює canonical
3
+ * `.marksman.toml` baseline у корінь cwd, якщо файлу ще немає.
4
+ *
5
+ * Marksman LSP читає `.marksman.toml` для визначення workspace-роота,
6
+ * GLFM-флага (GitHub-Flavored Markdown), стилю wiki-links і code actions.
7
+ * Дефолти marksman не вмикають GLFM і використовують `title-slug-ref` —
8
+ * але portable subset з ci4.mdc вимагає GLFM (alerts/таблиці/todo) +
9
+ * `file-stem` (ADR slug == ім'я файла). Без явного конфіга частина
10
+ * marksman-функцій працює інакше, ніж задокументовано у правилі.
11
+ *
12
+ * Idempotent: якщо `.marksman.toml` вже існує (навіть з кастомним вмістом)
13
+ * — не перетирається, тільки рапортується факт існування. Ручні правки
14
+ * користувача зберігаються між прогонами.
15
+ *
16
+ * Файл скопійовано в `cwd`, бо marksman визначає workspace-root за
17
+ * розташуванням свого `.marksman.toml`. У корені репо марксман бачить
18
+ * і docs/, і README.md усіх workspaces одним workspace-ом.
19
+ */
20
+ import { existsSync } from 'node:fs'
21
+ import { copyFile } from 'node:fs/promises'
22
+ import { dirname, join, relative } from 'node:path'
23
+ import { fileURLToPath } from 'node:url'
24
+
25
+ import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
26
+
27
+ const HERE = dirname(fileURLToPath(import.meta.url))
28
+ const MARKSMAN_BASELINE_PATH = join(HERE, 'data', 'marksman_config', 'marksman.baseline.toml')
29
+ const MARKSMAN_TARGET_FILENAME = '.marksman.toml'
30
+
31
+ /**
32
+ * @param {string} [cwd] корінь проєкту (default: `process.cwd()` — CLI-сумісність)
33
+ * @returns {Promise<number>} 0 — OK (створено або вже існує), 1 — baseline-файл пакета зламаний
34
+ */
35
+ export async function check(cwd = process.cwd()) {
36
+ const reporter = createCheckReporter()
37
+
38
+ if (!existsSync(MARKSMAN_BASELINE_PATH)) {
39
+ reporter.fail(`canonical baseline не знайдено (${MARKSMAN_BASELINE_PATH}) — перевстанови @nitra/cursor`)
40
+ return reporter.getExitCode()
41
+ }
42
+
43
+ const target = join(cwd, MARKSMAN_TARGET_FILENAME)
44
+ if (existsSync(target)) {
45
+ reporter.pass(`${MARKSMAN_TARGET_FILENAME} існує (${relative(cwd, target)})`)
46
+ return reporter.getExitCode()
47
+ }
48
+
49
+ await copyFile(MARKSMAN_BASELINE_PATH, target)
50
+ reporter.pass(`${MARKSMAN_TARGET_FILENAME} створено з canonical baseline (${relative(cwd, target)}) (ci4.mdc)`)
51
+ return reporter.getExitCode()
52
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "$schema": "https://unpkg.com/@nitra/cursor/schemas/target.json",
3
+ "files": { "single": ".vscode/extensions.json" }
4
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "recommendations": ["arr.marksman"]
3
+ }
@@ -0,0 +1,12 @@
1
+ # Перевірка `.vscode/extensions.json` для ci4 (ci4.mdc).
2
+ #
3
+ # Канон надходить через --data: { "template": { "snippet": ... } }
4
+ package ci4.vscode_extensions
5
+
6
+ import rego.v1
7
+
8
+ deny contains msg if {
9
+ some rec in data.template.snippet.recommendations
10
+ not rec in {r | some r in object.get(input, "recommendations", [])}
11
+ msg := sprintf(".vscode/extensions.json: recommendations має містити %q (ci4.mdc)", [rec])
12
+ }
@@ -359,6 +359,7 @@ async function checkDockerfiles(root, ignorePaths, passFn, failFn) {
359
359
  * `default.conf.template` (умовне правило — без шаблона цей крок не запускається).
360
360
  * @param {(msg: string) => void} passFn callback при успішній перевірці
361
361
  * @param {(msg: string) => void} failFn callback при помилці
362
+ * @param {string} cwd корінь репозиторію
362
363
  * @returns {void}
363
364
  */
364
365
  function checkVscodeNginx(passFn, failFn, cwd) {
@@ -0,0 +1,57 @@
1
+ /**
2
+ * `n-cursor change` — пише один change-файл `<ws>/.changes/<timestamp>-<rand>.md`.
3
+ * Замінює ручне редагування CHANGELOG у feature-флоу (n-changelog.mdc v3.0).
4
+ */
5
+ import { mkdir, writeFile } from 'node:fs/promises'
6
+ import { join } from 'node:path'
7
+
8
+ import { CHANGES_DIR, newChangeFileName, parseChangeFile, serializeChangeFile } from './lib/change-file.mjs'
9
+
10
+ /**
11
+ * @param {object} params параметри
12
+ * @param {string} params.bump `major|minor|patch`
13
+ * @param {string} params.section `Added|Changed|Fixed|Removed`
14
+ * @param {string} params.message опис
15
+ * @param {string} [params.ws] workspace (за замовчуванням `.`)
16
+ * @param {string} [params.cwd] корінь
17
+ * @returns {Promise<string>} відносний шлях створеного файлу (від ws)
18
+ */
19
+ export async function writeChange({ bump, section, message, ws = '.', cwd = process.cwd() }) {
20
+ const description = (message ?? '').trim()
21
+ const content = serializeChangeFile({ bump, section, description })
22
+ // Валідація полів: parseChangeFile кидає зрозумілу помилку на невалідних bump/section/порожньому описі.
23
+ parseChangeFile(content)
24
+
25
+ const dir = join(cwd, ws, CHANGES_DIR)
26
+ await mkdir(dir, { recursive: true })
27
+ const name = newChangeFileName()
28
+ await writeFile(join(dir, name), content)
29
+ return join(CHANGES_DIR, name)
30
+ }
31
+
32
+ /**
33
+ * @param {string[]} args аргументи CLI (`--bump`, `--section`, `--message`, `--ws`)
34
+ * @returns {Promise<number>} exit-код
35
+ */
36
+ export async function runChangeCli(args) {
37
+ const get = flag => {
38
+ const i = args.indexOf(flag)
39
+ return i !== -1 && i + 1 < args.length ? args[i + 1] : undefined
40
+ }
41
+ const bump = get('--bump')
42
+ const section = get('--section')
43
+ const message = get('--message')
44
+ const ws = get('--ws') ?? '.'
45
+ if (!bump || !section || !message) {
46
+ console.error('❌ Використання: n-cursor change --bump <major|minor|patch> --section <Added|Changed|Fixed|Removed> --message "<опис>" [--ws <шлях>]')
47
+ return 1
48
+ }
49
+ try {
50
+ const rel = await writeChange({ bump, section, message, ws })
51
+ console.log(`✅ ${join(ws, rel)}`)
52
+ return 0
53
+ } catch (error) {
54
+ console.error(`❌ ${error instanceof Error ? error.message : String(error)}`)
55
+ return 1
56
+ }
57
+ }
@@ -0,0 +1,17 @@
1
+ import { isRunAsCli, runRuleCli } from '../../scripts/lib/run-rule-cli.mjs'
2
+ import { runStandardRule } from '../../scripts/lib/run-standard-rule.mjs'
3
+
4
+ /**
5
+ * Запускає правило: applies → JS-concerns → policy → mdc-refs (через runStandardRule).
6
+ * Library mode: викликається CLI orchestration через `import + run(ctx)`.
7
+ * @param {import('../../scripts/lib/run-standard-rule.mjs').RuleContext} [ctx] контекст прогону (walkCache тощо)
8
+ * @returns {Promise<number>} 0 — OK, 1 — порушення
9
+ */
10
+ export function run(ctx) {
11
+ return runStandardRule(import.meta.dirname, ctx)
12
+ }
13
+
14
+ if (isRunAsCli(import.meta.url)) {
15
+ // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE
16
+ process.exit(await runRuleCli(import.meta.dirname))
17
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Агрегація change-файлів одного workspace у version-bump + секцію CHANGELOG
3
+ * (Keep a Changelog 1.1.0, новіше зверху). Без побічних ефектів — лише обчислення/рендер;
4
+ * запис на диск і git — у release.mjs.
5
+ */
6
+ import { VALID_BUMPS, VALID_SECTIONS } from './change-file.mjs'
7
+
8
+ const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/
9
+ const CHANGELOG_HEADER = '# Changelog'
10
+
11
+ /**
12
+ * @param {string} version `x.y.z`
13
+ * @param {string} bump `major|minor|patch`
14
+ * @returns {string} нова версія
15
+ */
16
+ export function bumpVersion(version, bump) {
17
+ const m = SEMVER_RE.exec(version)
18
+ if (!m) throw new Error(`aggregate: невалідний semver «${version}»`)
19
+ const [major, minor, patch] = [Number(m[1]), Number(m[2]), Number(m[3])]
20
+ if (bump === 'major') return `${major + 1}.0.0`
21
+ if (bump === 'minor') return `${major}.${minor + 1}.0`
22
+ return `${major}.${minor}.${patch + 1}`
23
+ }
24
+
25
+ /**
26
+ * @param {string[]} bumps непорожній список
27
+ * @returns {string} найвищий bump (major > minor > patch)
28
+ */
29
+ export function maxBump(bumps) {
30
+ return VALID_BUMPS.find(level => bumps.includes(level)) ?? 'patch'
31
+ }
32
+
33
+ /**
34
+ * @param {string} version нова версія
35
+ * @param {string} date `YYYY-MM-DD`
36
+ * @param {Array<{ section: string, description: string }>} entries записи change-файлів
37
+ * @returns {string} markdown-блок секції
38
+ */
39
+ export function renderChangelogSection(version, date, entries) {
40
+ let out = `## [${version}] - ${date}\n`
41
+ for (const section of VALID_SECTIONS) {
42
+ const bullets = entries.filter(e => e.section === section)
43
+ if (bullets.length === 0) continue
44
+ const bulletLines = bullets.map(b => '- ' + b.description).join('\n')
45
+ out += `\n### ${section}\n\n${bulletLines}\n`
46
+ }
47
+ return out
48
+ }
49
+
50
+ /**
51
+ * @param {string} existingText наявний CHANGELOG.md (може бути порожнім)
52
+ * @param {string} sectionBlock новий блок версії
53
+ * @returns {string} CHANGELOG із секцією зверху
54
+ */
55
+ export function prependChangelogSection(existingText, sectionBlock) {
56
+ const text = existingText.trimStart()
57
+ if (!text.startsWith(CHANGELOG_HEADER)) {
58
+ return `${CHANGELOG_HEADER}\n\n${sectionBlock}`
59
+ }
60
+ const nl = text.indexOf('\n')
61
+ const head = text.slice(0, nl === -1 ? text.length : nl)
62
+ const rest = nl === -1 ? '' : text.slice(nl + 1).trimStart()
63
+ return `${head}\n\n${sectionBlock}\n${rest}`
64
+ }
65
+
66
+ /**
67
+ * @param {object} params параметри
68
+ * @param {string} params.currentVersion поточна version маніфесту
69
+ * @param {Array<{ file: string, entry: { bump: string, section: string, description: string } }>} params.changeFiles change-файли workspace
70
+ * @param {string} params.date `YYYY-MM-DD`
71
+ * @returns {{ newVersion: string, sectionBlock: string, consumedFiles: string[] } | null} результат або null, якщо змін нема
72
+ */
73
+ export function aggregateWorkspace({ currentVersion, changeFiles, date }) {
74
+ if (changeFiles.length === 0) return null
75
+ const newVersion = bumpVersion(currentVersion, maxBump(changeFiles.map(c => c.entry.bump)))
76
+ const sectionBlock = renderChangelogSection(
77
+ newVersion,
78
+ date,
79
+ changeFiles.map(c => c.entry)
80
+ )
81
+ return { newVersion, sectionBlock, consumedFiles: changeFiles.map(c => c.file) }
82
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Один change-файл `<ws>/.changes/<timestamp>-<rand>.md`: YAML-подібний frontmatter
3
+ * із двома ключами (`bump`, `section`) + текст опису. Парсер мінімальний — лише ці два
4
+ * ключі, без зовнішніх залежностей.
5
+ */
6
+
7
+ import { randomBytes } from 'node:crypto'
8
+ import { existsSync } from 'node:fs'
9
+ import { readdir, readFile } from 'node:fs/promises'
10
+ import { join } from 'node:path'
11
+
12
+ /** Дозволені semver-бампи, від найбільшого до найменшого (порядок використовується для max). */
13
+ export const VALID_BUMPS = Object.freeze(['major', 'minor', 'patch'])
14
+
15
+ /** Дозволені Keep a Changelog секції (заголовок `### {section}`). */
16
+ export const VALID_SECTIONS = Object.freeze(['Added', 'Changed', 'Fixed', 'Removed'])
17
+
18
+ const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/
19
+
20
+ /**
21
+ * @param {string} block тіло frontmatter (між `---`)
22
+ * @returns {Record<string, string>} пари ключ→значення
23
+ */
24
+ function parseFrontmatterBlock(block) {
25
+ /** @type {Record<string, string>} */
26
+ const out = {}
27
+ for (const line of block.split('\n')) {
28
+ const idx = line.indexOf(':')
29
+ if (idx === -1) continue
30
+ out[line.slice(0, idx).trim()] = line.slice(idx + 1).trim()
31
+ }
32
+ return out
33
+ }
34
+
35
+ /**
36
+ * @param {string} text вміст change-файлу
37
+ * @returns {{ bump: string, section: string, description: string }} розпарсений запис
38
+ */
39
+ export function parseChangeFile(text) {
40
+ const m = FRONTMATTER_RE.exec(text)
41
+ if (!m) throw new Error('change-файл: відсутній frontmatter `---`')
42
+ const fm = parseFrontmatterBlock(m[1])
43
+ const description = m[2].trim()
44
+ if (!VALID_BUMPS.includes(fm.bump)) {
45
+ throw new Error(`change-файл: bump має бути одним із ${VALID_BUMPS.join('|')} (отримано «${fm.bump ?? ''}»)`)
46
+ }
47
+ if (!VALID_SECTIONS.includes(fm.section)) {
48
+ throw new Error(`change-файл: section має бути одним із ${VALID_SECTIONS.join('|')} (отримано «${fm.section ?? ''}»)`)
49
+ }
50
+ if (!description) throw new Error('change-файл: порожній опис')
51
+ return { bump: fm.bump, section: fm.section, description }
52
+ }
53
+
54
+ /**
55
+ * @param {{ bump: string, section: string, description: string }} entry запис
56
+ * @returns {string} вміст change-файлу
57
+ */
58
+ export function serializeChangeFile(entry) {
59
+ return `---\nbump: ${entry.bump}\nsection: ${entry.section}\n---\n${entry.description}\n`
60
+ }
61
+
62
+ /** Підкаталог зі change-файлами всередині workspace. */
63
+ export const CHANGES_DIR = '.changes'
64
+
65
+ /**
66
+ * @param {number} timestamp `Date.now()`
67
+ * @param {string} suffix короткий випадковий суфікс (hex)
68
+ * @returns {string} `<timestamp>-<suffix>.md`
69
+ */
70
+ export function changeFileName(timestamp, suffix) {
71
+ return `${timestamp}-${suffix}.md`
72
+ }
73
+
74
+ /**
75
+ * Унікальне ім'я для нового change-файлу: timestamp (порядок) + rand (анти-колізія
76
+ * для паралельних агентів у різних worktree, що пишуть у ту саму мілісекунду).
77
+ * @returns {string} результат
78
+ */
79
+ export function newChangeFileName() {
80
+ return changeFileName(Date.now(), randomBytes(3).toString('hex'))
81
+ }
82
+
83
+ /**
84
+ * @param {string} ws шлях workspace (відносно `cwd`)
85
+ * @param {string} [cwd] корінь репозиторію
86
+ * @returns {Promise<Array<{ file: string, entry: { bump: string, section: string, description: string } }>>} розпарсені change-файли
87
+ */
88
+ export async function readChangeFiles(ws, cwd = process.cwd()) {
89
+ const dir = join(cwd, ws, CHANGES_DIR)
90
+ if (!existsSync(dir)) return []
91
+ const entries = await readdir(dir)
92
+ const names = entries.filter(n => n.endsWith('.md')).toSorted()
93
+ const result = []
94
+ for (const file of names) {
95
+ const text = await readFile(join(dir, file), 'utf8')
96
+ result.push({ file, entry: parseChangeFile(text) })
97
+ }
98
+ return result
99
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Fallback (n-cursor-release-design рішення 3): коли в workspace є релевантні зміни,
3
+ * але жодного change-файлу — синтезуємо один запис із commit-subjects від останнього
4
+ * релізного тегу `<name>@*`. Усі git-виклики через `runGit` (ін'єкція для тестів).
5
+ */
6
+ import { execFile } from 'node:child_process'
7
+ import { promisify } from 'node:util'
8
+
9
+ const execFileAsync = promisify(execFile)
10
+
11
+ /**
12
+ * @param {string} cwd робочий каталог
13
+ * @returns {(args: string[]) => Promise<string | null>} тихий git-раннер (null при помилці)
14
+ */
15
+ export function defaultRunGit(cwd) {
16
+ return async args => {
17
+ try {
18
+ const { stdout } = await execFileAsync('git', args, { cwd })
19
+ return stdout
20
+ } catch {
21
+ return null
22
+ }
23
+ }
24
+ }
25
+
26
+ /**
27
+ * @param {string} name ім'я пакета (для тегу `<name>@*`)
28
+ * @param {string} ws workspace (pathspec для `git log`; `.` → без обмеження шляху)
29
+ * @param {object} [opts] опції
30
+ * @param {(args: string[]) => Promise<string | null>} [opts.runGit] git-раннер
31
+ * @returns {Promise<{ bump: string, section: string, description: string } | null>} синтезований запис або null
32
+ */
33
+ export async function synthesizeChangeFromCommits(name, ws, opts = {}) {
34
+ const runGit = opts.runGit ?? defaultRunGit(process.cwd())
35
+ const lastTagRaw = await runGit(['describe', '--tags', '--abbrev=0', '--match', `${name}@*`, 'HEAD'])
36
+ const lastTag = lastTagRaw?.trim()
37
+ // Bootstrap: якщо жодного попереднього тегу немає — перший реліз зроблено вручну;
38
+ // fallback-синтез не запускаємо, щоб не подвоїти bump.
39
+ if (!lastTag) return null
40
+ const pathspec = ws === '.' ? [] : ['--', `${ws}/`]
41
+ const logRaw = await runGit(['log', '--no-merges', '--format=%s', `${lastTag}..HEAD`, ...pathspec])
42
+ const subjects = (logRaw ?? '')
43
+ .split('\n')
44
+ .map(s => s.trim())
45
+ .filter(Boolean)
46
+ if (subjects.length === 0) return null
47
+ return { bump: 'patch', section: 'Changed', description: subjects.join('; ') }
48
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `n-cursor release` — агрегує per-workspace change-файли у version-bump + CHANGELOG,
3
+ * комітить, ставить тег `<name>@<version>`, видаляє use-up change-файли. Запускається
4
+ * у CI на `main` (n-cursor-release-design, варіант A). Сам нічого не публікує.
5
+ */
6
+ import { existsSync } from 'node:fs'
7
+ import { readFile, rm, writeFile } from 'node:fs/promises'
8
+ import { join } from 'node:path'
9
+
10
+ import { getMonorepoProjectRootDirs, readPackageManifest } from '../changelog/lib/package-manifest.mjs'
11
+ import { aggregateWorkspace, prependChangelogSection } from './lib/aggregate.mjs'
12
+ import { CHANGES_DIR, readChangeFiles } from './lib/change-file.mjs'
13
+ import { defaultRunGit, synthesizeChangeFromCommits } from './lib/fallback.mjs'
14
+
15
+ const SEMVER_LINE_RE = /("version"\s*:\s*")[^"]*(")/
16
+ const PY_VERSION_LINE_RE = /^(version\s*=\s*")[^"]*(")/m
17
+
18
+ /**
19
+ * Записує нову version у маніфест, зберігаючи форматування файлу.
20
+ * @param {string} cwd корінь
21
+ * @param {import('../changelog/lib/package-manifest.mjs').PackageManifest} manifest маніфест
22
+ * @param {string} newVersion нова версія
23
+ * @returns {Promise<void>} результат
24
+ */
25
+ async function writeManifestVersion(cwd, manifest, newVersion) {
26
+ const path = join(cwd, manifest.ws === '.' ? manifest.manifestRel : `${manifest.ws}/${manifest.manifestRel}`)
27
+ const text = await readFile(path, 'utf8')
28
+ const re = manifest.kind === 'npm' ? SEMVER_LINE_RE : PY_VERSION_LINE_RE
29
+ const replaced = text.replace(re, `$1${newVersion}$2`)
30
+ if (replaced === text) {
31
+ throw new Error(`release: не вдалося оновити version у ${manifest.ws}/${manifest.manifestRel} — патерн version не знайдено`)
32
+ }
33
+ await writeFile(path, replaced)
34
+ }
35
+
36
+ /**
37
+ * @param {string} cwd корінь
38
+ * @param {string} ws workspace
39
+ * @param {string} sectionBlock новий блок CHANGELOG
40
+ * @returns {Promise<void>} результат
41
+ */
42
+ async function prependWorkspaceChangelog(cwd, ws, sectionBlock) {
43
+ const path = join(cwd, ws, 'CHANGELOG.md')
44
+ const existing = existsSync(path) ? await readFile(path, 'utf8') : ''
45
+ await writeFile(path, prependChangelogSection(existing, sectionBlock))
46
+ }
47
+
48
+ /**
49
+ * Зібрати change-файли workspace (явні + fallback-синтез, якщо явних нема, але є коміти).
50
+ * @param {string} cwd корінь
51
+ * @param {import('../changelog/lib/package-manifest.mjs').PackageManifest} manifest маніфест
52
+ * @param {(args: string[]) => Promise<string | null>} runGit git-раннер
53
+ * @returns {Promise<Array<{ file: string | null, entry: { bump: string, section: string, description: string } }>>} change-файли
54
+ */
55
+ async function collectChangeFiles(cwd, manifest, runGit) {
56
+ const explicit = await readChangeFiles(manifest.ws, cwd)
57
+ if (explicit.length > 0) return explicit
58
+ if (!manifest.name) return []
59
+ const synthesized = await synthesizeChangeFromCommits(manifest.name, manifest.ws, { runGit })
60
+ if (!synthesized) return []
61
+ console.warn(`⚠️ ${manifest.ws}: немає change-файлів — синтезовано запис із комітів (fallback)`)
62
+ return [{ file: null, entry: synthesized }]
63
+ }
64
+
65
+ /**
66
+ * @param {object} [opts] опції
67
+ * @param {string} [opts.cwd] корінь
68
+ * @param {string} [opts.date] `YYYY-MM-DD` (за замовчуванням сьогодні)
69
+ * @param {(args: string[]) => Promise<string | null>} [opts.runGit] git-раннер
70
+ * @returns {Promise<Array<{ ws: string, name: string | null, newVersion: string }>>} зрелізовані пакети
71
+ */
72
+ export async function release(opts = {}) {
73
+ const cwd = opts.cwd ?? process.cwd()
74
+ const date = opts.date ?? new Date().toISOString().slice(0, 10)
75
+ const runGit = opts.runGit ?? defaultRunGit(cwd)
76
+
77
+ const workspaces = await getMonorepoProjectRootDirs(cwd)
78
+ const subWorkspaces = workspaces.filter(w => w !== '.')
79
+ const isMonorepoRoot = subWorkspaces.length > 0
80
+
81
+ /** @type {Array<{ ws: string, name: string | null, newVersion: string }>} */
82
+ const released = []
83
+ const tags = []
84
+
85
+ for (const ws of workspaces) {
86
+ if (ws === '.' && isMonorepoRoot) continue
87
+ const manifest = await readPackageManifest(ws, cwd)
88
+ if (!manifest || !manifest.version) continue
89
+
90
+ const changeFiles = await collectChangeFiles(cwd, manifest, runGit)
91
+ const agg = aggregateWorkspace({ currentVersion: manifest.version, changeFiles, date })
92
+ if (!agg) continue
93
+
94
+ await writeManifestVersion(cwd, manifest, agg.newVersion)
95
+ await prependWorkspaceChangelog(cwd, ws, agg.sectionBlock)
96
+ for (const file of agg.consumedFiles.filter(Boolean)) {
97
+ await rm(join(cwd, ws, CHANGES_DIR, file))
98
+ }
99
+ released.push({ ws, name: manifest.name, newVersion: agg.newVersion })
100
+ if (manifest.name) tags.push(`${manifest.name}@${agg.newVersion}`)
101
+ }
102
+
103
+ if (released.length > 0) {
104
+ const subject = tags.length > 0 ? tags.join(', ') : released.map(r => `${r.ws}@${r.newVersion}`).join(', ')
105
+ await runGit(['add', '-A'])
106
+ const committed = await runGit(['commit', '-m', `release: ${subject}`])
107
+ if (committed === null) {
108
+ throw new Error('release: git commit не вдався — теги та push скасовано')
109
+ }
110
+ for (const tag of tags) {
111
+ await runGit(['tag', tag])
112
+ }
113
+ await runGit(['push', '--follow-tags'])
114
+ }
115
+ return released
116
+ }
117
+
118
+ /**
119
+ * @param {string[]} _args аргументи CLI (наразі без опцій)
120
+ * @returns {Promise<number>} exit-код
121
+ */
122
+ export async function runReleaseCli(_args) {
123
+ try {
124
+ const released = await release()
125
+ if (released.length === 0) {
126
+ console.log('release: немає змін для релізу')
127
+ } else {
128
+ for (const r of released) console.log(`✅ ${r.name ?? r.ws}@${r.newVersion}`)
129
+ }
130
+ return 0
131
+ } catch (error) {
132
+ console.error(`❌ ${error instanceof Error ? error.message : String(error)}`)
133
+ return 1
134
+ }
135
+ }
@@ -180,7 +180,9 @@ export async function runCoverageSteps(opts = {}) {
180
180
  return 1
181
181
  }
182
182
 
183
- rows.push(buildTotalsRow(rows))
183
+ // Підсумок «Разом» має сенс лише коли провайдерів ≥2; для єдиного рядка він
184
+ // дублює його значення, тож не додаємо.
185
+ if (rows.length > 1) rows.push(buildTotalsRow(rows))
184
186
  const md = renderMarkdown(rows)
185
187
  // Stryker disable next-line StringLiteral: equivalent – writeFile(path, str, '') behaves identically to 'utf8' in Node/Bun
186
188
  await writeFile(join(cwd, 'COVERAGE.md'), md, 'utf8')
@@ -145,7 +145,7 @@ function extractFsFunctionName(callee) {
145
145
  /**
146
146
  * Чи файл — JS-тест (`*.test.mjs` / `*.test.js`).
147
147
  * @param {string} absPath абсолютний шлях
148
- * @returns {boolean}
148
+ * @returns {boolean} true якщо файл є тестом
149
149
  */
150
150
  function isTestFile(absPath) {
151
151
  const name = basename(absPath)
@@ -187,7 +187,7 @@ function findOffendersInBody(body) {
187
187
  */
188
188
  function computeLineOffsets(body) {
189
189
  const offsets = [0]
190
- for (let i = 0; i < body.length; i += 1) {
190
+ for (let i = 0; i < body.length; i++) {
191
191
  if (body[i] === '\n') offsets.push(i + 1)
192
192
  }
193
193
  return offsets
@@ -202,7 +202,7 @@ function offsetToLineFromCache(offsets, offset) {
202
202
  let lo = 0
203
203
  let hi = offsets.length - 1
204
204
  while (lo < hi) {
205
- const mid = (lo + hi + 1) >>> 1
205
+ const mid = Math.floor((lo + hi + 1) / 2)
206
206
  if (offsets[mid] <= offset) lo = mid
207
207
  else hi = mid - 1
208
208
  }