@nitra/cursor 3.10.0 → 3.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.12.0] - 2026-06-02
4
+
5
+ ### Added
6
+
7
+ - flow: cwd-незалежний резолвинг активного стану — spec/plan/verify/review/gate/release знаходять .flow.json поточної гілки навіть із головного дерева (швидкий шлях без git, toplevel-резолв, авторезолв єдиного активного flow), + опційний --branch <гілка>. Гейти виконуються у теці worktree.
8
+ - flow release: інференс зміненого воркспейсу з diff від base_commit — авто-додає --ws у change, якщо не задано явно (один змінений subworkspace → його .changes/; кілька → fail з підказкою явного --ws; лише корінь → дефолт). Усуває потрапляння change-файлу в корінь монорепо при змінах під підпакетом.
9
+
10
+ ### Fixed
11
+
12
+ - Усунуто суперечність n-changelog.mdc ↔ n-npm-module.mdc: прибрано перевірки version/CHANGELOG у package_structure.mjs, що штовхали до ручного bump (єдиний артефакт змін — change-файл; узгодженість валідує changelog/consistency.mjs); npm-module.mdc делегує bump/CHANGELOG у changelog.mdc, який отримав post-release-інваріант.
13
+ - trace: резолв лінків front-matter відносно теки артефакту (+ root-relative fallback) — file-relative spec/plan лінки (`../specs/…`) більше не дають хибний «розрив ланцюга»; поле `flow` (runtime `.flow.json`) показується, але не рахується розривом. Розрив визначають лише chain-поля (adr/spec/plan/change/task).
14
+
15
+ ## [3.11.0] - 2026-06-02
16
+
17
+ ### Changed
18
+
19
+ - coverage: scoped режим --changed — flow-турнікет (DEFAULT_GATES) перевіряє лише змінені від base_commit файли (vitest --changed + Stryker --mutate), однаково для закомічених і незакомічених змін; повний coverage лишається для bun run coverage / n-coverage-fix
20
+
3
21
  ## [3.10.0] - 2026-06-01
4
22
 
5
23
  ### Added
package/bin/n-cursor.js CHANGED
@@ -1527,9 +1527,10 @@ try {
1527
1527
  }
1528
1528
  case 'coverage': {
1529
1529
  // n-cursor coverage — оркестратор покриття + мутаційного тестування з discovery
1530
- // провайдерів через .n-cursor.json#rules (test.mdc).
1530
+ // провайдерів через .n-cursor.json#rules (test.mdc). --changed звужує scope до
1531
+ // змінених від base файлів (flow-турнікет: лише vitest/Stryker по diff).
1531
1532
  const { runCoverageCli } = await import('../rules/test/coverage/coverage.mjs')
1532
- process.exitCode = await runCoverageCli({ fix: args.includes('--fix') })
1533
+ process.exitCode = await runCoverageCli({ fix: args.includes('--fix'), changed: args.includes('--changed') })
1533
1534
 
1534
1535
  break
1535
1536
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "3.10.0",
3
+ "version": "3.12.0",
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: '3.1'
3
+ version: '3.2'
4
4
  alwaysApply: true
5
5
  ---
6
6
 
@@ -97,3 +97,9 @@ alwaysApply: true
97
97
  ```
98
98
 
99
99
  Секції — підмножина `### Added`, `### Changed`, `### Fixed`, `### Removed` (одна або кілька).
100
+
101
+ ## Post-release інваріант (гарантує CI)
102
+
103
+ Перша (верхня) секція `## [version]` у `CHANGELOG.md` дорівнює полю `version` у маніфесті — але це **post-release** твердження, яке забезпечує `n-cursor release` у CI, агрегуючи change-файли (bump `version` + генерація секції + git-тег `<name>@<version>`). **Локально цю рівність руками не підтримують**: у feature-флоу `version`/`CHANGELOG.md` не чіпають, тож верхня секція може відставати від майбутньої версії — це нормально. Drift `version` поза CI (vs реєстр / vs git-база) ловить `check changelog` як заборонений ручний bump.
104
+
105
+ Інструкції щодо bump `version` і редагування `CHANGELOG.md` живуть **лише** в цьому правилі — джерелі істини. Інші правила (зокрема `n-npm-module.mdc`) їй підпорядковані й власних інструкцій bump/CHANGELOG не дублюють.
@@ -78,9 +78,12 @@ Composer, Claude Code) працює **Пасивний Турнікет**: ти,
78
78
  npx @nitra/cursor flow verify
79
79
  ```
80
80
 
81
- Проганяє Quality Gate (lint). Повертає `0` (pass) або `1` із виводом
82
- проваленого gate. Coverage (тести + Stryker-мутації) **поза** turnstile —
83
- запускай окремо `npx @nitra/cursor coverage` або в CI.
81
+ Проганяє Quality Gates (lint + coverage). Повертає `0` (pass) або `1` із
82
+ виводом проваленого gate. **Обидва гейти перевіряють лише змінені файли**
83
+ (diff від `base_commit`): `lint` — quick-режим, `coverage --changed` vitest
84
+ `--changed` + Stryker `--mutate` по diff. Працює однаково, незалежно від того,
85
+ чи зміни вже закомічені у worktree. Повний coverage (увесь проєкт) — окремо:
86
+ `bun run coverage` або `/n-coverage-fix`.
84
87
 
85
88
  6. **Review (adversarial)** — рекомендовано перед release:
86
89
 
@@ -10,13 +10,36 @@ 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, relative } from 'node:path'
13
+ import { isAbsolute, join, relative } from 'node:path'
14
14
 
15
15
  import { resolveAllJsRoots } from '../../../scripts/utils/resolve-js-root.mjs'
16
16
  import { addCoverage, addMutation } from '../../test/coverage/coverage.mjs'
17
17
 
18
18
  const TEST_BLOCK_START = /^\s*(it|test)\(/
19
19
  const FILE_EXTENSION = /\.[^.]+$/
20
+ /** JS/TS-розширення — файли, які мутує Stryker і покриває vitest. */
21
+ const JS_FILE = /\.(c|m)?[jt]sx?$/
22
+ /** Тест-файли (`*.test.*` / `*.spec.*`) — НЕ production-код, не йдуть у Stryker `--mutate`. */
23
+ const TEST_FILE = /\.(test|spec)\.[^.]+$/
24
+
25
+ /**
26
+ * Звужує список змінених файлів (relative до cwd) до тих, що лежать під `jsRoot`,
27
+ * мають JS/TS-розширення, і рібейзить їх відносно `jsRoot`.
28
+ * @param {string[]} changedFiles relative-до-cwd шляхи змінених файлів
29
+ * @param {string} cwd корінь проєкту
30
+ * @param {string} jsRoot абсолютний шлях workspace-кореня
31
+ * @returns {string[]} JS-файли під jsRoot, шляхи relative до jsRoot
32
+ */
33
+ export function scopeToRoot(changedFiles, cwd, jsRoot) {
34
+ const out = []
35
+ for (const f of changedFiles) {
36
+ if (!JS_FILE.test(f)) continue
37
+ const rel = relative(jsRoot, join(cwd, f))
38
+ if (rel.startsWith('..') || isAbsolute(rel)) continue
39
+ out.push(rel)
40
+ }
41
+ return out
42
+ }
20
43
  const VITEST_HINT =
21
44
  'js-lint coverage: vitest відсутній у package.json — додай `vitest`, `@vitest/coverage-v8` та `@stryker-mutator/vitest-runner` у devDependencies (див. test.mdc)'
22
45
 
@@ -217,7 +240,11 @@ export function parseStrykerReport(report, jsRoot) {
217
240
  * у такому випадку сигналізує "no tests" → collectOneRoot пропускає workspace.
218
241
  */
219
242
  const defaultRunner = {
220
- runJsCoverage({ cwd, lcovDir }) {
243
+ runJsCoverage({ cwd, lcovDir, base }) {
244
+ // base !== undefined ⇔ --changed-режим: vitest сам рахує зачеплені змінами тести
245
+ // через граф імпортів. `--changed <base>` порівнює base↔робоче дерево (committed і
246
+ // uncommitted разом); `--changed` без аргументу — uncommitted vs HEAD.
247
+ const changedArgs = base === undefined ? [] : base === null ? ['--changed'] : ['--changed', base]
221
248
  const r = spawnSync(
222
249
  'bunx',
223
250
  [
@@ -226,13 +253,14 @@ const defaultRunner = {
226
253
  '--passWithNoTests',
227
254
  '--coverage',
228
255
  '--coverage.reporter=lcov',
229
- `--coverage.reportsDirectory=${lcovDir}`
256
+ `--coverage.reportsDirectory=${lcovDir}`,
257
+ ...changedArgs
230
258
  ],
231
259
  { cwd, stdio: 'inherit', env: process.env }
232
260
  )
233
261
  return r.status ?? 1
234
262
  },
235
- runStryker({ cwd }) {
263
+ runStryker({ cwd, mutate }) {
236
264
  // `npx`, не `bunx`: bunx завжди ставить пакет у `T/bunx-<uid>-<pkg>@latest` і запускає
237
265
  // Stryker звідти. Плагін-discovery у Stryker (`@stryker-mutator/*`) globится відносно
238
266
  // CORE-install-каталогу (`core/dist/src/di/plugin-loader.js` → `../../../../../@stryker-mutator/*`),
@@ -240,30 +268,48 @@ const defaultRunner = {
240
268
  // встановлений `@stryker-mutator/vitest-runner` залишається невидимим, і workers падають з
241
269
  // `Cannot find TestRunner plugin "vitest"`. `npx` ходить угору по `node_modules/.bin/` і
242
270
  // запускає Stryker з локального hoisted-install, де поряд лежить vitest-runner.
243
- const r = spawnSync('npx', ['@stryker-mutator/core', 'run'], { cwd, stdio: 'inherit', env: process.env })
271
+ // mutate (непорожній) --changed-режим: мутуємо лише змінені production-файли цього root.
272
+ const mutateArgs = mutate && mutate.length > 0 ? ['--mutate', mutate.join(',')] : []
273
+ const r = spawnSync('npx', ['@stryker-mutator/core', 'run', ...mutateArgs], {
274
+ cwd,
275
+ stdio: 'inherit',
276
+ env: process.env
277
+ })
244
278
  return r.status ?? 1
245
279
  }
246
280
  }
247
281
 
248
282
  /**
249
283
  * Збирає метрики покриття + мутаційного тестування для **одного** JS-root.
250
- * Пропускає workspace без тестів (повертає `null`): vitest у такому випадку
251
- * пройшов з `--passWithNoTests`, але lcov порожній нема сенсу запускати
252
- * Stryker. Реальні помилки (vitest exit 0, mutation.json відсутній попри
253
- * наявні тести) кидаютьсяу multi-root режимі це не маскує справжній збій.
284
+ *
285
+ * Full-режим (`scope === null`): vitest на всьому suite + Stryker на всіх файлах
286
+ * config-глоба. Пропускає workspace без тестів (повертає `null`): vitest пройшов з
287
+ * `--passWithNoTests`, але lcov порожній нема сенсу запускати Stryker.
288
+ *
289
+ * Changed-режим (`scope = { files, base }`): vitest `--changed <base>` (лише
290
+ * зачеплені тести) + Stryker `--mutate` лише по змінених production-файлах. Тут
291
+ * **не** пропускаємо на порожньому lcov — змінений src без тестів має дати
292
+ * NoCoverage-мутанти (gate впаде, як і має). Якщо змінено лише тест-файли (нема
293
+ * production-src) — Stryker не запускаємо (мутувати нічого), повертаємо лише coverage.
294
+ *
295
+ * Реальні помилки (vitest exit ≠ 0, відсутній mutation.json попри запуск Stryker)
296
+ * кидаються — у multi-root режимі це не маскує справжній збій.
254
297
  * @param {string} jsRoot абсолютний шлях до workspace-кореня
255
298
  * @param {string} cwd корінь проєкту (для рібейзингу `survived[].file`)
256
299
  * @param {{runJsCoverage:Function, runStryker:Function}} runner spawn-ін'єкція
257
- * @returns {Promise<{coverage:object, mutation:{caught:number,total:number}, survived:Array<object>} | null>} результати або null коли workspace без тестів
300
+ * @param {{files:string[], base:string|null}|null} [scope] changed-scope (null = full-режим)
301
+ * @returns {Promise<{coverage:object, mutation:{caught:number,total:number}, survived:Array<object>} | null>} результати або null коли full-режим і workspace без тестів
258
302
  */
259
- async function collectOneRoot(jsRoot, cwd, runner) {
303
+ async function collectOneRoot(jsRoot, cwd, runner, scope = null) {
260
304
  const wsRel = relative(cwd, jsRoot)
305
+ // У changed-режимі production-файли для мутації = змінені JS цього root без тест-файлів.
306
+ const mutateSrc = scope ? scope.files.filter(f => !TEST_FILE.test(f)) : null
261
307
 
262
- // 1. Coverage через vitest run --passWithNoTests --coverage
308
+ // 1. Coverage через vitest run --passWithNoTests --coverage (+ --changed у changed-режимі)
263
309
  const lcovDir = await mkdtemp(join(tmpdir(), 'js-lint-cov-'))
264
310
  let coverage
265
311
  try {
266
- const code = await runner.runJsCoverage({ cwd: jsRoot, lcovDir })
312
+ const code = await runner.runJsCoverage(scope ? { cwd: jsRoot, lcovDir, base: scope.base } : { cwd: jsRoot, lcovDir })
267
313
  if (code !== 0) throw new Error(`JS coverage exit ${code}`)
268
314
  const lcovPath = join(lcovDir, 'lcov.info')
269
315
  coverage = existsSync(lcovPath)
@@ -273,13 +319,20 @@ async function collectOneRoot(jsRoot, cwd, runner) {
273
319
  await rm(lcovDir, { recursive: true, force: true })
274
320
  }
275
321
 
276
- // Порожній lcov ⇔ vitest з --passWithNoTests не знайшов тестів. Пропускаємо
277
- // workspace, щоб не запускати Stryker марно і не псувати агрегат.
278
- const hasTests = coverage.lines.total > 0 || coverage.functions.total > 0
279
- if (!hasTests) return null
322
+ // Full-режим: порожній lcov ⇔ vitest не знайшов тестів → пропускаємо workspace,
323
+ // щоб не ганяти Stryker марно. У changed-режимі НЕ пропускаємо (див. JSDoc).
324
+ if (!scope) {
325
+ const hasTests = coverage.lines.total > 0 || coverage.functions.total > 0
326
+ if (!hasTests) return null
327
+ }
280
328
 
281
- // 2. Mutation через Stryker
282
- await runner.runStryker({ cwd: jsRoot })
329
+ // Changed-режим без production-src (змінено лише тест-файли) → мутувати нічого.
330
+ if (scope && mutateSrc.length === 0) {
331
+ return { coverage, mutation: { caught: 0, total: 0 }, survived: [] }
332
+ }
333
+
334
+ // 2. Mutation через Stryker (у changed-режимі — лише по mutateSrc)
335
+ await runner.runStryker(scope ? { cwd: jsRoot, mutate: mutateSrc } : { cwd: jsRoot })
283
336
  const mutationPath = join(jsRoot, 'reports', 'stryker', 'mutation.json')
284
337
  if (!existsSync(mutationPath)) {
285
338
  throw new Error(
@@ -317,22 +370,36 @@ async function collectOneRoot(jsRoot, cwd, runner) {
317
370
  * з зрозумілим повідомленням ("Жодного провайдера покриття не знайдено").
318
371
  * Шляхи у `survived` рібейзяться відносно `cwd`, щоб `coverage-fix.mjs`
319
372
  * знаходив джерела через `join(projectRoot, file)`.
373
+ *
374
+ * Changed-режим (`opts.changedFiles` задано): кожен root отримує лише свої змінені
375
+ * JS-файли (`scopeToRoot`); roots без змінених JS пропускаються повністю (ні vitest,
376
+ * ні Stryker). Якщо змін нема ніде — повертає `[]` без error-логу (оркестратор
377
+ * трактує порожній changed-scope як pass).
320
378
  * @param {string} cwd корінь проєкту
321
- * @param {{runner?: typeof defaultRunner}} [opts] runner-ін'єкція для тестів
322
- * @returns {Promise<Array<{area:string, coverage:object, mutation:{caught:number,total:number}, survived:Array<object>}>>} рядок `JS` або `[]` коли тестів нема ніде
379
+ * @param {{runner?: typeof defaultRunner, changedFiles?: string[], base?: string|null}} [opts] runner-ін'єкція + changed-scope
380
+ * @returns {Promise<Array<{area:string, coverage:object, mutation:{caught:number,total:number}, survived:Array<object>}>>} рядок `JS` або `[]` коли тестів/змін нема ніде
323
381
  */
324
382
  export async function collect(cwd, opts = {}) {
325
383
  const runner = opts.runner ?? defaultRunner
384
+ const changed = Array.isArray(opts.changedFiles)
326
385
  const jsRoots = await resolveAllJsRoots(cwd)
327
386
  if (jsRoots.length === 0) throw new Error('js-lint coverage: package.json не знайдено')
328
387
 
329
388
  const results = []
330
389
  for (const jsRoot of jsRoots) {
331
- const r = await collectOneRoot(jsRoot, cwd, runner)
390
+ let scope = null
391
+ if (changed) {
392
+ const files = scopeToRoot(opts.changedFiles, cwd, jsRoot)
393
+ if (files.length === 0) continue // root без змінених JS — пропускаємо
394
+ scope = { files, base: opts.base ?? null }
395
+ }
396
+ const r = await collectOneRoot(jsRoot, cwd, runner, scope)
332
397
  if (r !== null) results.push(r)
333
398
  }
334
399
 
335
400
  if (results.length === 0) {
401
+ // Changed-режим: нема змінених JS у жодному root → тихо порожньо (це pass, не помилка).
402
+ if (changed) return []
336
403
  console.error(
337
404
  'js-lint coverage: жоден workspace не має тестів ' +
338
405
  '(`*.test.{js,mjs}` у `tests/` або поряд із джерелом) — ' +
@@ -20,16 +20,15 @@
20
20
  * test-фреймворків (`bun:test`, `node:test`, `vitest`, `@jest/globals`, `mocha`, `jest`, `ava`, …).
21
21
  * Виняток: `*_test.rego` дозволені поруч з полісі — це конвенція conftest.
22
22
  *
23
- * Версія та CHANGELOG: перший заголовок `## [version]` у `npm/CHANGELOG.md` має збігатися з `version` у
24
- * `npm/package.json` (найсвіжіший реліз зверху). Якщо в git є незакомічені зміни під `npm/`, `version` у робочому
25
- * файлі має відрізнятися від `HEAD` інакше типовий пропуск bump після правок у пакеті.
23
+ * Версія та CHANGELOG тут НЕ перевіряються: єдиний артефакт зміни change-файл, а узгодженість
24
+ * `version`/`CHANGELOG.md` (включно з drift від ручного bump) валідує `changelog/js/consistency.mjs`
25
+ * за моделлю `n-changelog.mdc`. Інваріант «верхня секція CHANGELOG == package.json.version» істинний
26
+ * лише post-release і його гарантує `n-cursor release` у CI — локально його не підтримують руками.
26
27
  * @param {string} cwd корінь репозиторію
27
28
  */
28
- import { execFile } from 'node:child_process'
29
29
  import { existsSync } from 'node:fs'
30
30
  import { readFile, stat } from 'node:fs/promises'
31
31
  import { join, sep } from 'node:path'
32
- import { promisify } from 'node:util'
33
32
 
34
33
  import { parseSync } from 'oxc-parser'
35
34
 
@@ -43,14 +42,6 @@ import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
43
42
  import { loadCursorIgnorePaths } from '../../../scripts/lib/load-cursor-config.mjs'
44
43
  import { walkDir } from '../../../scripts/utils/walkDir.mjs'
45
44
 
46
- const execFileAsync = promisify(execFile)
47
-
48
- /** Перший заголовок релізу у Keep a Changelog (`## [1.2.3]`). */
49
- const CHANGELOG_FIRST_VERSION_RE = /^## \[([^\]]+)\]/m
50
-
51
- /** Поле `version` у текстовому зрізі `package.json` (для `git show HEAD:npm/package.json`). */
52
- const PACKAGE_JSON_VERSION_RE = /"version":\s*"([^"]+)"/u
53
-
54
45
  /** Файл проєкту TypeScript для emit без каталогу `src` (див. npm-module.mdc) */
55
46
  const EMIT_TYPES_CONFIG = 'npm/tsconfig.emit-types.json'
56
47
 
@@ -91,9 +82,9 @@ const GLOBSTAR_TRAILING_RE = /\/__GLOBSTAR__$/u
91
82
 
92
83
  /**
93
84
  * Чи є під `npm/src` хоча б один `.js` (рекурсивно).
85
+ * @param {string} cwd корінь репозиторію
94
86
  * @param {string[]} [ignorePaths] абсолютні шляхи каталогів, повністю виключених з обходу
95
87
  * @returns {Promise<boolean>} `true`, якщо знайдено хоча б один `.js`
96
- * @param {string} cwd корінь репозиторію
97
88
  */
98
89
  async function npmSrcTreeHasJsFile(cwd, ignorePaths = []) {
99
90
  const root = join(cwd, 'npm/src')
@@ -215,136 +206,6 @@ function checkEmitTypesConfig(passFn, failFn, cwd) {
215
206
  passFn(`${EMIT_TYPES_CONFIG} є (структуру перевіряє npx @nitra/cursor fix → npm_module.emit_types_config)`)
216
207
  }
217
208
 
218
- /**
219
- * Перевіряє npm-publish.yml workflow.
220
- * @param {(msg: string) => void} passFn callback при успішній перевірці
221
- * @param {(msg: string) => void} failFn callback при помилці
222
- * @param {string} cwd корінь репозиторію
223
- */
224
- /**
225
- * Чи виконано `git` у корені робочого дерева.
226
- * @returns {Promise<boolean>} true, якщо процес запущено в межах git work tree
227
- * @param {string} cwd корінь репозиторію
228
- */
229
- async function gitInsideWorkTree(cwd) {
230
- try {
231
- const { stdout } = await execFileAsync('git', ['rev-parse', '--is-inside-work-tree'], { encoding: 'utf8', cwd })
232
- return stdout.trim() === 'true'
233
- } catch {
234
- return false
235
- }
236
- }
237
-
238
- /**
239
- * Список незакомічених шляхів під `npm/` відносно `HEAD`.
240
- * @param {string} cwd корінь репозиторію
241
- * @returns {Promise<string[] | null>} шляхи або `null`, якщо `git` недоступний
242
- */
243
- async function gitDiffNameOnlyNpm(cwd) {
244
- try {
245
- const { stdout } = await execFileAsync('git', ['diff', '--name-only', 'HEAD', '--', 'npm'], {
246
- encoding: 'utf8',
247
- cwd
248
- })
249
- return stdout.trim().split('\n').filter(Boolean)
250
- } catch {
251
- return null
252
- }
253
- }
254
-
255
- /**
256
- * Поле `version` з `npm/package.json` на заданому git-ref (`HEAD:npm/package.json`).
257
- * @param {string} refPath на кшталт `HEAD:npm/package.json`
258
- * @param {string} cwd корінь репозиторію
259
- * @returns {Promise<string | null>} значення поля `version` або `null`, якщо ref недоступний
260
- */
261
- async function gitShowNpmPackageVersionAt(refPath, cwd) {
262
- try {
263
- const { stdout } = await execFileAsync('git', ['show', refPath], { encoding: 'utf8', cwd })
264
- const m = stdout.match(PACKAGE_JSON_VERSION_RE)
265
- return m ? m[1] : null
266
- } catch {
267
- return null
268
- }
269
- }
270
-
271
- /**
272
- * Версія з першого заголовка `## […]` у тексті CHANGELOG.
273
- * @param {string} changelogText вміст файлу CHANGELOG.md
274
- * @returns {string | null} версія з першої секції або `null`, якщо заголовка немає
275
- */
276
- function firstChangelogSectionVersion(changelogText) {
277
- const m = changelogText.match(CHANGELOG_FIRST_VERSION_RE)
278
- return m ? m[1] : null
279
- }
280
-
281
- /**
282
- * Перший реліз у CHANGELOG має збігатися з `version` у `npm/package.json`.
283
- * @param {(msg: string) => void} passFn callback при успішній перевірці
284
- * @param {(msg: string) => void} failFn callback при виявленому порушенні
285
- * @returns {Promise<void>}
286
- * @param {string} cwd корінь репозиторію
287
- */
288
- async function checkChangelogTopMatchesPackageVersion(passFn, failFn, cwd) {
289
- if (!existsSync(join(cwd, 'npm/CHANGELOG.md')) || !existsSync(join(cwd, 'npm/package.json'))) return
290
- const pkg = JSON.parse(await readFile(join(cwd, 'npm/package.json'), 'utf8'))
291
- const ver = typeof pkg.version === 'string' ? pkg.version : null
292
- if (!ver) {
293
- failFn('npm/package.json: відсутнє поле version')
294
- return
295
- }
296
- const cl = await readFile(join(cwd, 'npm/CHANGELOG.md'), 'utf8')
297
- const first = firstChangelogSectionVersion(cl)
298
- if (!first) {
299
- failFn('npm/CHANGELOG.md: не знайдено жодного заголовка ## [version]')
300
- return
301
- }
302
- if (first !== ver) {
303
- failFn(
304
- `npm/CHANGELOG.md: перша секція [${first}] не збігається з npm/package.json version "${ver}" ` +
305
- '(зверху має бути найсвіжіший реліз і той самий номер — npm-module.mdc).'
306
- )
307
- return
308
- }
309
- passFn(`npm/CHANGELOG.md: перша секція [${first}] збігається з npm/package.json`)
310
- }
311
-
312
- /**
313
- * Незакомічені зміни під `npm/` вимагають підвищення `version` відносно `HEAD`.
314
- * @param {(msg: string) => void} passFn callback при успішній перевірці
315
- * @param {(msg: string) => void} failFn callback при виявленому порушенні
316
- * @returns {Promise<void>}
317
- * @param {string} cwd корінь репозиторію
318
- */
319
- async function checkDirtyNpmRequiresVersionBump(passFn, failFn, cwd) {
320
- if (!(await gitInsideWorkTree(cwd))) {
321
- passFn('npm-module: git недоступний або поза work tree — перевірку незакоміченого bump пропущено')
322
- return
323
- }
324
- const changed = await gitDiffNameOnlyNpm(cwd)
325
- if (changed === null) {
326
- passFn('npm-module: git diff під npm/ недоступний — пропущено')
327
- return
328
- }
329
- if (changed.length === 0) return
330
-
331
- const headVer = await gitShowNpmPackageVersionAt('HEAD:npm/package.json', cwd)
332
- if (headVer === null) return
333
-
334
- const pkg = JSON.parse(await readFile(join(cwd, 'npm/package.json'), 'utf8'))
335
- const cur = typeof pkg.version === 'string' ? pkg.version : null
336
- if (!cur) return
337
-
338
- if (cur === headVer) {
339
- failFn(
340
- `Незакомічені зміни під npm/ (${changed.join(', ')}), але "version" у npm/package.json лишився ${cur} ` +
341
- '(як у HEAD). Підвищ version (+1) і додай секцію ## [нова версія] зверху CHANGELOG (npm-module.mdc).'
342
- )
343
- return
344
- }
345
- passFn(`npm/: незакомічені зміни під npm/ узгоджені з підвищенням version (${headVer} → ${cur})`)
346
- }
347
-
348
209
  /**
349
210
  * FS-existence для `npm-publish.yml` workflow. Поля workflow (`on.push.paths`,
350
211
  * `branches`, `id-token: write`, JS-DevTools/npm-publish step) валідує
@@ -614,8 +475,5 @@ export async function check(cwd = process.cwd()) {
614
475
 
615
476
  await checkPublishWorkflow(pass, fail, cwd)
616
477
 
617
- await checkChangelogTopMatchesPackageVersion(pass, fail, cwd)
618
- await checkDirtyNpmRequiresVersionBump(pass, fail, cwd)
619
-
620
478
  return reporter.getExitCode()
621
479
  }
@@ -2,7 +2,7 @@
2
2
  description: Оформлення репозиторію для npm модуля
3
3
  globs: "npm/**,**/package.json,**/hk.pkl,.github/workflows/npm-publish.yml,**/tsconfig*.json"
4
4
  alwaysApply: false
5
- version: '1.13'
5
+ version: '1.14'
6
6
  ---
7
7
 
8
8
  Bun monorepo: workspace **`npm/`**, кореневий **`package.json`**, **`.github/workflows/`**; опційно **`demo/`**.
@@ -55,21 +55,11 @@ bunx -p typescript tsc src/**/*.js --declaration --allowJs --emitDeclarationOnly
55
55
 
56
56
  Після додавання **`hk.pkl`**: **`hk install`**.
57
57
 
58
- ## Build версія
58
+ ## Версія та CHANGELOG
59
59
 
60
- Після змін у **`npm/`** обовʼязково підвищ **build**-версію в **`npm/package.json`**, але не роби зайвих підвищень: між номером у файлі й тим, що вже збережено в **git** (`HEAD`), має лишатися не більше одного кроку **+1**.
60
+ Версію (`version` у **`npm/package.json`**) і **`npm/CHANGELOG.md`** **не редагуй вручну** — навіть для hotfix. Єдиний артефакт зміни — **change-файл** (`npx @nitra/cursor change --bump <major|minor|patch> --section <Added|Changed|Fixed|Removed> --message "<…>"`); bump `version` і генерацію секції CHANGELOG робить `n-cursor release` у CI на `main`. Будь-який ручний bump `version` поза CI завалює `check changelog` навіть із change-файлом.
61
61
 
62
- У робочій копії не повинно бути більше одного незбереженого в **git** підвищення **build**-версії за раз.
63
-
64
- **Чеклист у тому ж наборі змін, що й правки під `npm/`:** `version` у **`npm/package.json`** → **+1**; зверху **`npm/CHANGELOG.md`** нова секція **`## [нова версія] - …`**; у секції лише те, що входить у цей реліз.
65
-
66
- **Антипатерн:** не дописувати нові bullet-и в уже існуючу секцію **`## [X.Y.Z]`**, якщо паралельно не піднімаєш **`version`** до нового номера й не створюєш **нову** секцію зверху. Інакше змішуються різні релізи в одному номері, а `check npm-module` / `check changelog` гірше ловлять порушення.
67
-
68
- **Підказка:** щоб не дублювати bump і бачити різницю зі збереженим деревом, перевір `git status npm/package.json` або `git diff HEAD -- npm/package.json` перед другим підвищенням у тій самій гілці / наборі змін.
69
-
70
- ## CHANGELOG
71
-
72
- Найновіша версія — **перша** секція **`## [version]`** у файлі (зверху після заголовка). Вона **має збігатися** з полем **`version`** у **`npm/package.json`**.
62
+ Повна модель (база порівняння, інверсія шляхів, формат CHANGELOG, post-release-інваріант «верхня секція CHANGELOG == `version`») — у **`n-changelog.mdc`** (джерело істини). Це правило їй підпорядковане й власних інструкцій bump/CHANGELOG не дублює.
73
63
 
74
64
  ## npm publish
75
65
 
@@ -16,6 +16,8 @@ import { hasCargoTomlInTree } from '../lib/has-cargo-toml.mjs'
16
16
  import { resolveCargoManifest } from '../../../scripts/utils/resolve-cargo-manifest.mjs'
17
17
 
18
18
  const IGNORED_DIR_NAMES = new Set(['node_modules', '.git', '.next', '.turbo', 'target'])
19
+ /** Rust-релевантні зміни: `.rs`-джерела або маніфести Cargo. */
20
+ const RUST_CHANGE = /(\.rs$)|((^|\/)Cargo\.(toml|lock)$)/
19
21
 
20
22
  /**
21
23
  * Чи провайдер застосовний у поточному cwd.
@@ -33,7 +35,7 @@ export function detect(cwd) {
33
35
  * на ≤2 ядрах = 1, на 4 = 2, на 8+ = 4. Стеля 4 — Rust linker bottleneck:
34
36
  * вище практичного приросту не дає навіть на 16+ ядрах.
35
37
  * @param {string | undefined} envValue значення `process.env.CARGO_MUTANTS_JOBS`
36
- * @returns {number}
38
+ * @returns {number} кількість паралельних воркерів (>= 1)
37
39
  */
38
40
  export function resolveJobs(envValue) {
39
41
  if (envValue !== undefined && envValue !== '') {
@@ -119,12 +121,21 @@ const defaultRunner = {
119
121
 
120
122
  /**
121
123
  * Збирає Rust-метрики покриття + мутаційного тестування.
124
+ *
125
+ * Changed-режим (`opts.changedFiles` задано): якщо серед змінених немає Rust-релевантних
126
+ * файлів (`.rs` / `Cargo.toml` / `Cargo.lock`) — повертає `[]` (skip), щоб JS-only крок
127
+ * турнікета не ганяв повний `cargo mutants`. Якщо Rust змінено — наразі прогін повний по
128
+ * crate (per-file scoping cargo-mutants — окремий крок).
122
129
  * @param {string} cwd корінь проєкту
123
- * @param {{runner?: typeof defaultRunner}} [opts] ін'єкція runner-а для тестів
130
+ * @param {{runner?: typeof defaultRunner, changedFiles?: string[]}} [opts] ін'єкція runner-а + changed-scope
124
131
  * @returns {Promise<Array<{area:string, coverage:object, mutation:{caught:number,total:number}}>>} рядки для COVERAGE.md
125
132
  */
126
133
  export async function collect(cwd, opts = {}) {
127
134
  const runner = opts.runner ?? defaultRunner
135
+ // Changed-режим без Rust-релевантних змін → не запускаємо повний crate-прогін.
136
+ if (Array.isArray(opts.changedFiles) && !opts.changedFiles.some(f => RUST_CHANGE.test(f))) {
137
+ return []
138
+ }
128
139
  const manifestPath = await resolveCargoManifest(cwd)
129
140
  if (manifestPath === null) {
130
141
  throw new Error('rust coverage: Cargo.toml не знайдено (cwd + workspaces)')