@nitra/cursor 5.2.0 → 5.3.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.
@@ -2,7 +2,7 @@
2
2
  description: Перевірка JavaScript коду
3
3
  globs: "**/{.oxlintrc.json,eslint.config.js,.jscpd.json,knip.json,package.json},**/*.{js,mjs,cjs,jsx,ts,tsx}"
4
4
  alwaysApply: false
5
- version: '1.29'
5
+ version: '1.30'
6
6
  ---
7
7
 
8
8
  **oxlint**, **ESLint**, **jscpd**, **knip**. У скрипті **`lint-js`** і в CI — **`bunx oxlint`**, **`bunx eslint`**, **`bunx jscpd`**, **`bunx knip`** (у CI без **`--fix`** для oxlint/eslint — див. приклад workflow нижче). Без **prettier** і **@nitra/prettier-config**. У **`devDependencies`** має бути **`@nitra/eslint-config`** — версія не нижче канонічного мінімуму зі snippet нижче (semver-поріг, єдине джерело істини) (з **3.8.0** правило `no-restricted-syntax` забороняє `for...in`; з **3.9.2** у `getConfig` вбудовано ignore для **`**/adr/**`** — ADR-документи не валідуються ESLint, локально цей glob додавати не потрібно; також транзитивно йде **`@e18e/eslint-plugin`** для oxlint). Dependency-політику CI-етапу (`@e18e/eslint-plugin` і oxlint/eslint/jscpd/knip окремо не додавати) винесено в `js-lint-ci`.
@@ -23,6 +23,19 @@ version: '1.29'
23
23
 
24
24
  Канон `type` + `scripts.lint-js` (substring requirement) і мінімальна `@nitra/eslint-config` (semver-поріг `devDependencies`): [package.json.snippet.json](./policy/package_json/template/package.json.snippet.json)
25
25
 
26
+ ## Розширення нових файлів — `.mjs` / `.cjs`, не `.js`
27
+
28
+ **Нові** JS-файли створюй з явним розширенням модуля:
29
+
30
+ - **`.mjs`** — для ESM (типовий випадок);
31
+ - **`.cjs`** — для CommonJS, де він справді потрібен.
32
+
33
+ Голий **`.js`** для нового файлу **заборонено**. Розширення `.js` інтерпретується як ESM чи CJS лише за полем `package.json#type`, тож той самий файл читається по-різному залежно від пакета. Явне `.mjs`/`.cjs` робить тип модуля однозначним **без читання `package.json`** — навіть якщо `type` зміниться або файл перемістять в інший пакет. Це доповнює вимогу `"type": "module"` вище: `type` лишається каноном для всього дерева, а розширення нового файлу прибирає залежність від нього.
34
+
35
+ Стосується **backend і frontend** — будь-який новий вихідний файл: `src/`, тести `*.test.*`, `scripts/`, `src/conn/` тощо.
36
+
37
+ **Існуючі `.js` лишаються як є** — масово перейменовувати не треба; це конвенція для нового коду. Автоматичної перевірки тут немає: stateless-скан не відрізнить новий файл від існуючого, тож `.js` нікого не фейлить.
38
+
26
39
  У `.vscode/extensions.json` `recommendations` мають містити `dbaeumer.vscode-eslint`, `github.vscode-github-actions`, `oxc.oxc-vscode`: [extensions.json.snippet.json](./policy/vscode_extensions/template/extensions.json.snippet.json)
27
40
 
28
41
  У корені має бути **`.oxlintrc.json`**, який **збігається з каноном** oxlint з пакета **`@nitra/cursor`**: файл **`npm/rules/js-lint/js/data/tooling/oxlint-canonical.json`** (plugins, jsPlugins з **`@e18e/eslint-plugin`**, categories, повний набір **rules** із канону — додаткові записи в **`rules`** дозволені; також **`settings`**, **`env`**, **`globals`**). Поле **`ignorePatterns`** працює як **`rules`**: канонічні патерни з **`oxlint-canonical.json`** (наразі **`**/schema.graphql`**, **`**/auto-imports.d.ts`**) мають бути присутні, додаткові локальні glob-и дозволені. Канон **`oxlint-canonical.json`** — source-of-truth, редагується напряму; у споживачі оновлюється копіюванням файлу з репозиторію пакета. Модуль **`@e18e/eslint-plugin`** не оголошуй окремо в **`package.json`** — він уже в залежностях **`@nitra/eslint-config`** (з **3.8.0**), oxlint підвантажує його з **`node_modules`**.
@@ -2,7 +2,7 @@
2
2
  description: Це правила для backend проектів на JavaScript/Node.js, сюди входять і job і WEB сервери.
3
3
  globs: "**/package.json,**/jsconfig.json,**/src/**/*.{js,mjs,cjs,ts,tsx}"
4
4
  alwaysApply: false
5
- version: '1.11'
5
+ version: '1.12'
6
6
  ---
7
7
 
8
8
  ## Область застосування
@@ -97,7 +97,7 @@ import sql from 'mssql'
97
97
  import { GraphQLClient } from '@nitra/graphql-request'
98
98
  ```
99
99
 
100
- то ці підключення повинні бути винесені в окремий файл, наприклад `/src/conn/pg.js`, в package.json повинні бути додано аліас:
100
+ то ці підключення повинні бути винесені в окремий файл, наприклад `/src/conn/pg.mjs`, в package.json повинні бути додано аліас:
101
101
 
102
102
  ```json
103
103
  {
@@ -110,7 +110,7 @@ import { GraphQLClient } from '@nitra/graphql-request'
110
110
 
111
111
  так виглядатиме підключення до PostgreSQL в коді:
112
112
 
113
- ```javascript title="Приклад підключення до PostgreSQL в /src/conn/pg.js"
113
+ ```javascript title="Приклад підключення до PostgreSQL в /src/conn/pg.mjs"
114
114
  import { checkEnv, env } from '@nitra/check-env'
115
115
  import { SQL } from 'bun'
116
116
 
@@ -140,7 +140,7 @@ export const graphQLClientSmart = new GraphQLClient(env.QL, {
140
140
  а в коді повинно бути використано:
141
141
 
142
142
  ```js
143
- import { pool } from '#conn/pg.js'
143
+ import { pool } from '#conn/pg.mjs'
144
144
 
145
145
  // або
146
146
 
@@ -152,24 +152,24 @@ import { gql, graphQLClient } from '@nitra/graphql-request'
152
152
  Назва файла в `src/conn/` має одразу повідомляти, **до чого** підключаємось і **в якому режимі**:
153
153
 
154
154
  - **GraphQL** — префікс `ql-`, далі ідентифікатор endpoint:
155
- - `src/conn/ql-contract.js`
156
- - `src/conn/ql-smart.js`
155
+ - `src/conn/ql-contract.mjs`
156
+ - `src/conn/ql-smart.mjs`
157
157
  - **PostgreSQL** — префікс `pg-`, далі тип підключення (репліка vs мастер): `read` або `write`:
158
- - `src/conn/pg-read.js`
159
- - `src/conn/pg-write.js`
158
+ - `src/conn/pg-read.mjs`
159
+ - `src/conn/pg-write.mjs`
160
160
  - **PostgreSQL до кількох БД** — додатково ідентифікатор підключення після типу:
161
- - `src/conn/pg-read-smart.js`
162
- - `src/conn/pg-write-contract.js`
163
- - **MySQL** — префікс `mysql-` за тією ж схемою (`mysql-read.js`, `mysql-write-<id>.js` тощо).
164
- - **MSSQL** — префікс `mssql-` за тією ж схемою (`mssql-read.js`, `mssql-write-<id>.js` тощо). Хоча npm-пакет один (`mssql`), а драйвер MS SQL Server під капотом T-SQL — у файловій назві відрізняємо MS SQL Server від MySQL, бо це різні СУБД, різні діалекти, різні рантаймні залежності. Якщо проєкт історично використовує `mysql-…` для MSSQL-підключень — він валідний і далі (для backward-compat), але новий код пишемо з префіксом `mssql-`.
161
+ - `src/conn/pg-read-smart.mjs`
162
+ - `src/conn/pg-write-contract.mjs`
163
+ - **MySQL** — префікс `mysql-` за тією ж схемою (`mysql-read.mjs`, `mysql-write-<id>.mjs` тощо).
164
+ - **MSSQL** — префікс `mssql-` за тією ж схемою (`mssql-read.mjs`, `mssql-write-<id>.mjs` тощо). Хоча npm-пакет один (`mssql`), а драйвер MS SQL Server під капотом T-SQL — у файловій назві відрізняємо MS SQL Server від MySQL, бо це різні СУБД, різні діалекти, різні рантаймні залежності. Якщо проєкт історично використовує `mysql-…` для MSSQL-підключень — він валідний і далі (для backward-compat), але новий код пишемо з префіксом `mssql-`.
165
165
 
166
- Підключення до БД **обов'язково** має бути ідентифіковано як `read` (репліка) або `write` (мастер). Якщо з імені змінної оточення (наприклад, `env.PG_CONN`) це не очевидно — визнач режим за операціями в коді: якщо немає операцій зміни даних (`INSERT`/`UPDATE`/`DELETE`/DDL) — це `pg-read.js`, інакше `pg-write.js`.
166
+ Підключення до БД **обов'язково** має бути ідентифіковано як `read` (репліка) або `write` (мастер). Якщо з імені змінної оточення (наприклад, `env.PG_CONN`) це не очевидно — визнач режим за операціями в коді: якщо немає операцій зміни даних (`INSERT`/`UPDATE`/`DELETE`/DDL) — це `pg-read.mjs`, інакше `pg-write.mjs`.
167
167
 
168
168
  ### Експорти у файлах `src/conn/`
169
169
 
170
170
  У файлах підключень **заборонений** `export default`. Експорт має бути **іменований** і збігатися з назвою файла в camelCase.
171
171
 
172
- Приклад — `src/conn/ql-smart.js`:
172
+ Приклад — `src/conn/ql-smart.mjs`:
173
173
 
174
174
  ```javascript title="❌ Так не можна"
175
175
  export default new GraphQLClient(env.SMART_QL, {
@@ -187,13 +187,13 @@ export const qlSmart = new GraphQLClient(env.SMART_QL, {
187
187
  })
188
188
  ```
189
189
 
190
- Відповідно: `pg-read.js` → `export const pgRead = …`, `pg-write-contract.js` → `export const pgWriteContract = …`, `ql-contract.js` → `export const qlContract = …`.
190
+ Відповідно: `pg-read.mjs` → `export const pgRead = …`, `pg-write-contract.mjs` → `export const pgWriteContract = …`, `ql-contract.mjs` → `export const qlContract = …`.
191
191
 
192
192
  ## CheckEnv
193
193
 
194
194
  Усі змінні оточення, які використовуються в коді, повинні бути перевірені за допомогою `checkEnv` з пакету `@nitra/check-env`. Це гарантує, що всі необхідні змінні оточення встановлені перед запуском програми.
195
195
 
196
- ```javascript title="Приклад підключення до PostgreSQL в /src/conn/pg.js"
196
+ ```javascript title="Приклад підключення до PostgreSQL в /src/conn/pg.mjs"
197
197
  import { checkEnv, env } from '@nitra/check-env'
198
198
  import { SQL } from 'bun'
199
199
 
@@ -5,6 +5,18 @@ import { join } from 'node:path'
5
5
 
6
6
  import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
7
7
 
8
+ // Зовнішні файли конфігу stylelint, які підхоплює cosmiconfig. Канон нових
9
+ // JS-конфігів — `.mjs`/`.cjs` (js-lint.mdc), legacy `.js` лишається валідним.
10
+ const STYLELINT_CONFIG_FILES = [
11
+ '.stylelintrc.json',
12
+ '.stylelintrc.js',
13
+ '.stylelintrc.cjs',
14
+ '.stylelintrc.mjs',
15
+ 'stylelint.config.js',
16
+ 'stylelint.config.cjs',
17
+ 'stylelint.config.mjs'
18
+ ]
19
+
8
20
  /**
9
21
  * Альтернатива полю `stylelint` у `package.json` — зовнішній файл конфігу. Якщо
10
22
  * поля немає і файлу немає, фейлимося; якщо є хоч щось — пропускаємо. Поле
@@ -18,10 +30,7 @@ async function checkStylelintConfigPresence(reporter, cwd) {
18
30
  if (!existsSync(pkgPath)) return
19
31
  const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
20
32
  const hasField = pkg.stylelint && typeof pkg.stylelint === 'object'
21
- const hasExternalCfg =
22
- existsSync(join(cwd, '.stylelintrc.json')) ||
23
- existsSync(join(cwd, '.stylelintrc.js')) ||
24
- existsSync(join(cwd, 'stylelint.config.js'))
33
+ const hasExternalCfg = STYLELINT_CONFIG_FILES.some(name => existsSync(join(cwd, name)))
25
34
  if (hasField || hasExternalCfg) {
26
35
  pass('Конфіг stylelint є — у package.json або окремим файлом')
27
36
  } else {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  description: Правила стилів CSS та SCSS
3
- version: '1.4'
3
+ version: '1.5'
4
4
  globs: "**/*.{css,scss,vue}"
5
5
  alwaysApply: false
6
6
  ---
@@ -1,7 +1,7 @@
1
1
  /** @type {import('@stryker-mutator/core').PartialStrykerOptions} */
2
2
  export default {
3
3
  testRunner: 'vitest',
4
- vitest: { configFile: 'vitest.config.js' },
4
+ vitest: { configFile: 'vitest.config.mjs' },
5
5
  // perTest: Stryker запускає лише тести, що покривають мутовану лінію — головний приріст
6
6
  // швидкості проти command runner (де треба було б ганяти ввесь test-suite на кожен мутант).
7
7
  coverageAnalysis: 'perTest',
@@ -1,7 +1,7 @@
1
1
  /** @type {import('@stryker-mutator/core').PartialStrykerOptions} */
2
2
  export default {
3
3
  testRunner: 'vitest',
4
- vitest: { configFile: 'vitest.config.js' },
4
+ vitest: { configFile: 'vitest.config.mjs' },
5
5
  // perTest: Stryker запускає лише тести, що покривають мутовану лінію — головний приріст
6
6
  // швидкості проти command runner (де треба було б ганяти ввесь test-suite на кожен мутант).
7
7
  coverageAnalysis: 'perTest',
@@ -18,6 +18,24 @@ const STRYKER_VUE_PLUGIN_PATH = join(HERE, 'data', 'stryker_config', 'stryker-vu
18
18
  const STRYKER_VUE_PLUGIN_FILENAME = 'stryker-vue-macros-ignorer.mjs'
19
19
  const VITEST_BASELINE_PATH = join(HERE, 'data', 'vitest_config', 'vitest.config.baseline.js')
20
20
 
21
+ // Канонічна назва vitest-конфіга — `.mjs` (нові файли, js-lint.mdc); legacy
22
+ // `.js` лишається валідним. Перший знайдений виграє (.mjs пріоритетніший).
23
+ const VITEST_CONFIG_NAMES = ['vitest.config.mjs', 'vitest.config.js']
24
+ // Заміна literal `configFile` у скопійованому stryker-baseline на фактичне
25
+ // ім'я vitest-конфіга jsRoot-а (узгодження Stryker ↔ vitest).
26
+ const STRYKER_CONFIG_FILE_RE = /configFile: 'vitest\.config\.[cm]?js'/u
27
+
28
+ /**
29
+ * Визначає ім'я vitest-конфіга для jsRoot: існуючий `.mjs`/`.js` (якщо є),
30
+ * інакше дефолт `vitest.config.mjs` (нові файли — `.mjs`). Існуючий
31
+ * `vitest.config.js` лишається валідним (backward-compat), новий не плодиться.
32
+ * @param {string} jsRoot абсолютний шлях до workspace-каталогу
33
+ * @returns {string} ім'я vitest-конфіга
34
+ */
35
+ function resolveVitestConfigName(jsRoot) {
36
+ return VITEST_CONFIG_NAMES.find(name => existsSync(join(jsRoot, name))) ?? 'vitest.config.mjs'
37
+ }
38
+
21
39
  // Канонічні entries, які vue-варіант baseline тримає у `plugins`/`ignorers`.
22
40
  // Augment-крок (augmentVueStrykerConfig) дбає, щоб саме вони були присутні в
23
41
  // уже-існуючому `stryker.config.mjs` Vue-root-а. Нову property пишемо у
@@ -64,15 +82,20 @@ async function hasVueFiles(jsRoot) {
64
82
  * @param {string} cwd корінь проєкту (для relative-шляхів у логах)
65
83
  * @param {string} baselinePath абсолютний шлях до canonical baseline
66
84
  * @param {string} target абсолютний шлях, куди копіювати
67
- * @param {string} label зрозуміла для людини мітка ("stryker.config.mjs" / "vitest.config.js")
85
+ * @param {string} label зрозуміла для людини мітка ("stryker.config.mjs" / "vitest.config.mjs")
86
+ * @param {(content: string) => string} [transform] опційне перетворення тексту baseline перед записом
68
87
  * @returns {Promise<void>}
69
88
  */
70
- async function ensureBaselineFile(reporter, cwd, baselinePath, target, label) {
89
+ async function ensureBaselineFile(reporter, cwd, baselinePath, target, label, transform) {
71
90
  if (existsSync(target)) {
72
91
  reporter.pass(`${label} існує (${relative(cwd, target)})`)
73
92
  return
74
93
  }
75
- await copyFile(baselinePath, target)
94
+ if (transform) {
95
+ await writeFile(target, transform(await readFile(baselinePath, 'utf8')), 'utf8')
96
+ } else {
97
+ await copyFile(baselinePath, target)
98
+ }
76
99
  reporter.pass(`${label} створено з canonical baseline (${relative(cwd, target)}) (test.mdc)`)
77
100
  }
78
101
 
@@ -341,7 +364,12 @@ export async function check(cwd = process.cwd()) {
341
364
  // і саме тут augment закриває drift-hole.
342
365
  const wasMissing = !existsSync(strykerTarget)
343
366
  const strykerBaseline = isVueRoot ? STRYKER_VUE_BASELINE_PATH : STRYKER_BASELINE_PATH
344
- await ensureBaselineFile(reporter, cwd, strykerBaseline, strykerTarget, 'stryker.config.mjs')
367
+ // configFile у новоствореному baseline має вказувати на фактичний vitest-конфіг
368
+ // jsRoot-а (existing `.js`/`.mjs` або дефолтний `.mjs`).
369
+ const vitestName = resolveVitestConfigName(jsRoot)
370
+ await ensureBaselineFile(reporter, cwd, strykerBaseline, strykerTarget, 'stryker.config.mjs', content =>
371
+ content.replace(STRYKER_CONFIG_FILE_RE, `configFile: '${vitestName}'`)
372
+ )
345
373
  if (isVueRoot) {
346
374
  if (!wasMissing) {
347
375
  await augmentVueStrykerConfig(reporter, cwd, jsRoot)
@@ -354,7 +382,7 @@ export async function check(cwd = process.cwd()) {
354
382
  STRYKER_VUE_PLUGIN_FILENAME
355
383
  )
356
384
  }
357
- await ensureBaselineFile(reporter, cwd, VITEST_BASELINE_PATH, join(jsRoot, 'vitest.config.js'), 'vitest.config.js')
385
+ await ensureBaselineFile(reporter, cwd, VITEST_BASELINE_PATH, join(jsRoot, vitestName), vitestName)
358
386
  }
359
387
 
360
388
  // Гарантуємо що тест-артефакти (Stryker output, lcov HTML-звіт) ніколи не
@@ -8,8 +8,12 @@ import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
8
8
  /** Subтring-pattern: `pool: 'forks'` або `pool: "forks"` (з опційним whitespace). */
9
9
  const POOL_FORKS_RE = /pool\s*:\s*['"]forks['"]/u
10
10
 
11
+ // Канонічна назва — `.mjs` (нові файли, js-lint.mdc), але legacy `.js` лишається
12
+ // валідним. Перший знайдений виграє: `.mjs` пріоритетніший.
13
+ const VITEST_CONFIG_NAMES = ['vitest.config.mjs', 'vitest.config.js']
14
+
11
15
  /**
12
- * Перевіряє, що `vitest.config.js` (якщо існує) містить `pool: 'forks'`.
16
+ * Перевіряє, що `vitest.config.{mjs,js}` (якщо існує) містить `pool: 'forks'`.
13
17
  * @param {string} [cwdParam] корінь репозиторію
14
18
  * @returns {Promise<number>} 0 — OK або skip, 1 — config без `pool: 'forks'`
15
19
  */
@@ -17,18 +21,18 @@ export async function check(cwdParam = process.cwd()) {
17
21
  const reporter = createCheckReporter()
18
22
  const { pass, fail } = reporter
19
23
 
20
- const configPath = join(cwdParam, 'vitest.config.js')
21
- if (!existsSync(configPath)) {
22
- pass('vitest.config.js відсутній — pool-перевірку пропущено')
24
+ const configName = VITEST_CONFIG_NAMES.find(name => existsSync(join(cwdParam, name)))
25
+ if (!configName) {
26
+ pass('vitest.config.mjs/.js відсутній — pool-перевірку пропущено')
23
27
  return reporter.getExitCode()
24
28
  }
25
29
 
26
- const body = await readFile(configPath, 'utf8')
30
+ const body = await readFile(join(cwdParam, configName), 'utf8')
27
31
  if (POOL_FORKS_RE.test(body)) {
28
- pass("vitest.config.js містить pool: 'forks' (test.mdc)")
32
+ pass(`${configName} містить pool: 'forks' (test.mdc)`)
29
33
  } else {
30
34
  fail(
31
- "vitest.config.js має містити pool: 'forks' — defense-in-depth для race у process.cwd() між паралельними test files (test.mdc)"
35
+ `${configName} має містити pool: 'forks' — defense-in-depth для race у process.cwd() між паралельними test files (test.mdc)`
32
36
  )
33
37
  }
34
38
 
@@ -1,7 +1,7 @@
1
1
  ---
2
- description: JS-тести (*.test.mjs) живуть у tests/. Правило `test` керує stryker.config.mjs + vitest.config.js (якщо js-lint enabled) і .cargo/mutants.toml (якщо rust enabled).
3
- version: '2.7'
4
- globs: "**/{.n-cursor.json,package.json,Cargo.toml,stryker.config.mjs,vitest.config.js,.cargo/mutants.toml},**/*.test.mjs"
2
+ description: JS-тести (*.test.mjs) живуть у tests/. Правило `test` керує stryker.config.mjs + vitest.config.mjs (якщо js-lint enabled) і .cargo/mutants.toml (якщо rust enabled).
3
+ version: '2.8'
4
+ globs: "**/{.n-cursor.json,package.json,Cargo.toml,stryker.config.mjs,vitest.config.mjs,vitest.config.js,.cargo/mutants.toml},**/*.test.mjs"
5
5
  alwaysApply: false
6
6
  ---
7
7
 
@@ -71,15 +71,15 @@ Recursive globs ловлять файли всередині `tests/` так с
71
71
  - Усі FS-операції у тесті — через `join(dir, …)` і `writeJson(join(dir, …), …)` / `ensureDir(join(dir, …))` (хелпери валідують `isAbsolute`).
72
72
  - Усі child-процеси — `execFile(bin, args, { cwd: dir })`, `spawnSync(bin, args, { cwd: dir })`.
73
73
  - Concern-функції правил — `await check(dir)`, `await applies(dir)`, `await fix(dir)`; усі production функції приймають перший параметр `cwd = process.cwd()` (default зберігає CLI-сумісність).
74
- - `vitest.config.js` додатково ставить `pool: 'forks'` як defense-in-depth: навіть якщо хтось пропустить правило вище, fork-ізоляція не дасть race у production tree.
74
+ - `vitest.config.mjs` (або legacy `vitest.config.js`) додатково ставить `pool: 'forks'` як defense-in-depth: навіть якщо хтось пропустить правило вище, fork-ізоляція не дасть race у production tree.
75
75
 
76
76
  Це **обов'язково** і для тестів пакета `@nitra/cursor`, і для кожного проєкту-споживача. Триплет перевірок:
77
77
 
78
78
  - **`no-process-chdir`** (`rules/test/js/no-process-chdir.mjs`) — сканує `**/*.test.{js,mjs}` і падає з ❌ на будь-яке вживання `process.chdir(`.
79
79
  - **`no-relative-fs-path`** (`rules/test/js/no-relative-fs-path.mjs`) — AST-сканер (`oxc-parser`): знаходить виклики FS-функцій із `node:fs`/`node:fs/promises` (`writeFile`, `copyFile`, `mkdir`, `readFile`, `existsSync`, `rename`, `symlink`, `cp`, … включно з `*Sync`-варіантами та `writeJson`/`ensureDir`-хелперами), де path-аргумент — це **string literal** без префікса `/`, `\`, `file:`, `http(s):`, `data:`, чи Windows-disk-letter `C:\`. Виклики `copyFile`/`rename`/`symlink`/`link`/`cp` перевіряють обидва path-аргументи. Виклики з обчисленим path (`join(dir, …)`, змінна, template-literal з виразом) пропускаються. Виловив би інцидент v1.28.0 у `tests/check-rule-fixtures.test.mjs` (`copyFile(src, 'default.conf.template')` → файл у production tree).
80
- - **`vitest-config-pool-forks`** (`rules/test/js/vitest-config-pool-forks.mjs`) — substring-перевірка `pool: 'forks'` у `vitest.config.js`. Defense-in-depth.
80
+ - **`vitest-config-pool-forks`** (`rules/test/js/vitest-config-pool-forks.mjs`) — substring-перевірка `pool: 'forks'` у `vitest.config.mjs` (або legacy `vitest.config.js`; `.mjs` пріоритетніший). Defense-in-depth.
81
81
 
82
- Canonical `vitest.config.js` (для довідки — `pool: 'forks'` + `include` + `coverage`) — у `rules/test/js/data/vitest_config/vitest.config.baseline.js` (концерн `stryker_config` копіює його у кожен JS-root).
82
+ Canonical `vitest.config.mjs` (для довідки — `pool: 'forks'` + `include` + `coverage`) — у `rules/test/js/data/vitest_config/vitest.config.baseline.js` (концерн `stryker_config` копіює його у кожен JS-root; нові файли — `.mjs`, наявний `vitest.config.js` лишається валідним і не дублюється).
83
83
 
84
84
  ## Console mocking у тестах
85
85
 
@@ -144,7 +144,7 @@ test.skipIf(env.STRYKER_MUTATOR_WORKER)('узгоджені з поточним
144
144
 
145
145
  ## Налаштування mutation-testing
146
146
 
147
- Якщо у `.n-cursor.json#rules` присутнє правило `js-lint` — правило `test` створює canonical baseline `stryker.config.mjs` + `vitest.config.js` у **кожному** JS-root проєкту: у кожному workspace з власним `package.json` (або в корені для single-package). У monorepo з `workspaces: ['app', 'scripts']` отримаєте `app/stryker.config.mjs` + `app/vitest.config.js` і `scripts/stryker.config.mjs` + `scripts/vitest.config.js`.
147
+ Якщо у `.n-cursor.json#rules` присутнє правило `js-lint` — правило `test` створює canonical baseline `stryker.config.mjs` + `vitest.config.mjs` у **кожному** JS-root проєкту: у кожному workspace з власним `package.json` (або в корені для single-package). У monorepo з `workspaces: ['app', 'scripts']` отримаєте `app/stryker.config.mjs` + `app/vitest.config.mjs` і `scripts/stryker.config.mjs` + `scripts/vitest.config.mjs`. Якщо у JS-root уже лежить legacy `vitest.config.js` — він лишається валідним, новий `.mjs` поряд не створюється, а `vitest.configFile` у скопійованому `stryker.config.mjs` приводиться до фактичного імені.
148
148
 
149
149
  Канон Stryker config (Vitest runner + perTest): [stryker.config.baseline.mjs](./js/data/stryker_config/stryker.config.baseline.mjs)
150
150
 
@@ -160,7 +160,7 @@ JS-root без `.vue` отримує дефолтний baseline без `plugins
160
160
 
161
161
  ### Vitest baseline та `package.json#scripts`
162
162
 
163
- Поряд зі Stryker концерн `stryker_config` без дублювання копіює `vitest.config.js` (теж тільки якщо файлу немає). Canonical: [vitest.config.baseline.js](./js/data/vitest_config/vitest.config.baseline.js) — `environment: 'node'`, `coverage.provider: 'v8'` з lcov+text-summary репортами, `include: ['**/*.test.{js,mjs}', 'tests/**/*.test.{js,mjs}']` (підхоплює обидві розкладки — тести у `tests/`-піддиректоріях і top-level integration suites у `<root>/tests/`).
163
+ Поряд зі Stryker концерн `stryker_config` без дублювання копіює `vitest.config.mjs` (тільки якщо немає ні `.mjs`, ні legacy `.js`). Canonical: [vitest.config.baseline.js](./js/data/vitest_config/vitest.config.baseline.js) — `environment: 'node'`, `coverage.provider: 'v8'` з lcov+text-summary репортами, `include: ['**/*.test.{js,mjs}', 'tests/**/*.test.{js,mjs}']` (підхоплює обидві розкладки — тести у `tests/`-піддиректоріях і top-level integration suites у `<root>/tests/`).
164
164
 
165
165
  У `package.json#scripts` має бути `"test": "vitest run"` (canonical contains-substring `vitest` — допустимо `vitest run` та інші локальні розширення); опційно — `"test:watch": "vitest"`.
166
166
 
@@ -168,7 +168,7 @@ JS-root без `.vue` отримує дефолтний baseline без `plugins
168
168
 
169
169
  ### Frontend-варіант (Vue/Vite + happy-dom)
170
170
 
171
- Для проєктів зі своїм `vite.config.js` `vitest.config.js` має повторно використовувати vite-плагіни та aliases і перемкнути `environment` на `'happy-dom'` (або `'jsdom'`):
171
+ Для проєктів зі своїм `vite.config.js` `vitest.config.mjs` має повторно використовувати vite-плагіни та aliases і перемкнути `environment` на `'happy-dom'` (або `'jsdom'`):
172
172
 
173
173
  ```js
174
174
  import { defineConfig, mergeConfig } from 'vitest/config'
package/rules/vue/vue.mdc CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  description: Vue
3
- version: '2.1'
3
+ version: '2.2'
4
4
  globs: "**/*.vue"
5
5
  alwaysApply: false
6
6
  ---
@@ -44,14 +44,14 @@ const folderStructure = `
44
44
  assets/
45
45
  public/
46
46
  App.vue
47
- main.js
47
+ main.mjs
48
48
  `
49
49
  ```
50
50
 
51
51
  ### Найменування файлів
52
52
 
53
53
  - **SFC:** імена файлів компонентів у **PascalCase** починаючи з букви N(`NMyWidget.vue`).
54
- - **Інші JS-модулі:** узгоджено **kebab-case** (`date-utils.js`).
54
+ - **Інші JS-модулі:** узгоджено **kebab-case** (`date-utils.mjs`).
55
55
 
56
56
  ### Модулі та архітектура
57
57
 
@@ -116,9 +116,9 @@ const additionalInstructions = `
116
116
 
117
117
  ### Тестування
118
118
 
119
- - **Unit + Component / DOM:** **Vitest** (`vitest`) + **Vue Test Utils** з **happy-dom** як DOM-середовищем. Це канон, узгоджений з `test.mdc` (Stryker з vitest-runner + `perTest`-аналіз покриття). `vitest.config.js` повторно використовує `vite.config.js` через `mergeConfig` і перемикає `environment` на `'happy-dom'`:
119
+ - **Unit + Component / DOM:** **Vitest** (`vitest`) + **Vue Test Utils** з **happy-dom** як DOM-середовищем. Це канон, узгоджений з `test.mdc` (Stryker з vitest-runner + `perTest`-аналіз покриття). `vitest.config.mjs` повторно використовує `vite.config.js` через `mergeConfig` і перемикає `environment` на `'happy-dom'`:
120
120
 
121
- ```js title="vitest.config.js"
121
+ ```js title="vitest.config.mjs"
122
122
  import { defineConfig, mergeConfig } from 'vitest/config'
123
123
  import viteConfig from './vite.config.js'
124
124
 
@@ -590,7 +590,7 @@ function getTr() {
590
590
  Називай store за назвою сторінки або компонента — `customerPageStore`, `routePageStore` тощо. На сторінці звертайся до нього через змінну `pageStore`.
591
591
 
592
592
  ```javascript
593
- // store/customerPage.js
593
+ // store/customerPage.mjs
594
594
  export const useCustomerPageStore = defineStore('customerPage', {
595
595
  state: () => ({
596
596
  filterName: '',
@@ -11,11 +11,10 @@
11
11
  * решта → pi CLI. Якщо omlx-Tier 1 недоступний, помилка падає в той самий catch
12
12
  * і класифікація відкочується на хмарний Tier 2 через pi.
13
13
  */
14
- import { spawnSync } from 'node:child_process'
15
14
  import { join } from 'node:path'
16
15
 
17
16
  import { CLOUD_MIN, resolveModel } from '../../lib/models.mjs'
18
- import { callOmlx, isOmlxModel } from '../../lib/omlx.mjs'
17
+ import { callLlm } from '../../lib/llm.mjs'
19
18
  import { deriveCacheKey, readCache, writeCache } from './cache.mjs'
20
19
  import { buildUserPrompt, SYSTEM_PROMPT } from './prompt.mjs'
21
20
  import { parseVerdict } from './verdict-schema.mjs'
@@ -27,25 +26,14 @@ const FALLBACK_VERDICT = {
27
26
  }
28
27
 
29
28
  /**
30
- * Викликає LLM за model-id і повертає raw текст відповіді.
31
- * `omlx/...` → прямий HTTP до omlx (text-only); решта → pi CLI.
29
+ * Викликає LLM через спільний `callLlm` (маршрут за префіксом model-id; wire-trace).
32
30
  * @param {string} prompt текст промпта
33
31
  * @param {string} model provider/model-id, `omlx/...` або '' для pi-дефолту
34
32
  * @returns {string} текст відповіді моделі
35
33
  * @throws якщо backend недоступний або повертає помилку
36
34
  */
37
35
  function callModel(prompt, model) {
38
- if (isOmlxModel(model)) {
39
- return callOmlx([{ role: 'user', content: prompt }], model, { timeoutMs: 60_000 })
40
- }
41
- const modelArgs = model ? ['--model', model] : []
42
- const r = spawnSync('pi', ['-p', prompt, ...modelArgs, '--no-session', '--mode', 'text', '--no-tools'], {
43
- encoding: 'utf8',
44
- timeout: 60_000
45
- })
46
- if (r.error) throw new Error(`pi error: ${r.error.message}`)
47
- if (r.status !== 0) throw new Error(`pi exit ${r.status}: ${r.stderr?.slice(0, 200) ?? ''}`)
48
- return r.stdout?.trim() ?? ''
36
+ return callLlm([{ role: 'user', content: prompt }], model, { timeoutMs: 60_000, caller: 'coverage' })
49
37
  }
50
38
 
51
39
  /**
@@ -15,9 +15,15 @@
15
15
  */
16
16
 
17
17
  const URL_RE = /https?:\/\/[^\s'"`)<>]+/g
18
+ // Після обрізання template-частини URL має лишитися host (R10).
19
+ const STATIC_URL_RE = /^https?:\/\/[^/${]+/
18
20
  const EXPORT_CONST_RE = /export\s+const\s+([A-Z][A-Z0-9_]+)\s*=\s*(['"`])([^'"`]+)\2/g
19
21
  const ERROR_MARKER_RE = /\(([a-z][\w-]*\.mdc)\)/g
20
- const CONFIG_REF_RE = /\b(\.[a-z][\w.-]*\.json)\b/gi
22
+ // Повне ім'я json-конфіга (з опційним провідним дотом). Lookbehind `(?<![\w.])`
23
+ // не дає почати матч усередині складеного імені — інакше `settings.local.json`
24
+ // дало б хибний анкор `.local.json`, а `capacitor.config.json` → `.config.json`,
25
+ // і модель, маючи їх у «обов'язкових анкорах», писала б неіснуючий файл як факт.
26
+ const CONFIG_REF_RE = /(?<![\w.])(\.?[a-z][\w.-]*\.json)\b/gi
21
27
  const FILE_HEADER_RE = /^\s*\/\*\*([\s\S]*?)\*\//
22
28
  const CODE_BLOCK_RE = /```[a-z]{0,12}\n([\s\S]*?)\n[ \t]{0,8}\*?[ \t]{0,8}```/g
23
29
 
@@ -50,7 +56,16 @@ function uniq(arr) {
50
56
  * }} категоризовані анкори файлу
51
57
  */
52
58
  export function extractAnchors(src) {
53
- const urls = uniq(Array.from(src.matchAll(URL_RE), m => m[0]))
59
+ // R10: template-literal URL (`https://h/${expr}/x`) обрізаємо на `${`, лишаючи
60
+ // статичний префікс. Інакше анкор тягне у доку сміття типу `…/${encodeURIComponent(name`.
61
+ const urls = uniq(
62
+ Array.from(src.matchAll(URL_RE), m => m[0])
63
+ .map(u => {
64
+ const i = u.indexOf('${')
65
+ return i === -1 ? u : u.slice(0, i)
66
+ })
67
+ .filter(u => STATIC_URL_RE.test(u))
68
+ )
54
69
 
55
70
  const magicStrings = []
56
71
  const seenNames = new Set()
@@ -73,6 +88,17 @@ export function extractAnchors(src) {
73
88
  return { urls, magicStrings, errorMarkers, configRefs, examples }
74
89
  }
75
90
 
91
+ /**
92
+ * Плоский список анкор-токенів, які мають дослівно зʼявитися в документі (R5):
93
+ * URLs, імена констант-рядків, маркери `(rule.mdc)`, конфіги. Приклади й
94
+ * code-блоки опускаються — їх багаторядковість не звіряється підрядком.
95
+ * @param {ReturnType<typeof extractAnchors>} a анкори файлу
96
+ * @returns {string[]} токени для перевірки покриття/валідності
97
+ */
98
+ export function anchorTokens(a) {
99
+ return [...a.urls, ...a.magicStrings.map(s => s.name), ...a.errorMarkers.map(m => `(${m})`), ...a.configRefs]
100
+ }
101
+
76
102
  /**
77
103
  * Форматує анкори у компактний текст для system-промпта.
78
104
  * Якщо анкорів немає взагалі — повертає порожній рядок (системний блок про
@@ -30,6 +30,9 @@ const RETURNS_LINE_RE = /^@returns?[ \t]{1,8}(?:\{[^}]{0,200}\}[ \t]{1,8})?(.{0,
30
30
  const FILE_HEADER_RE = /^\s*\/\*\*([\s\S]*?)\*\//
31
31
  const PRECEDING_JSDOC_RE = /\/\*\*(?:(?!\*\/)[\s\S])*\*\/\s*$/
32
32
  const EXPORT_DECL_RE = /export\s+(?:async\s+)?(function|const|class)\s+(\w+)/g
33
+ // Top-level function/class декларації (колонка 0) — для R6: службові функції,
34
+ // які не експортуються, не мають протікати у Поведінку/API як «публічні».
35
+ const TOP_FN_DECL_RE = /^(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function\*?|class)\s+(\w+)/gm
33
36
  const IMPORT_FROM_RE = /^import[ \t]{1,8}[\s\S]{0,300}?from\s{1,8}['"]([^'"]+)['"]/gm
34
37
  const NODE_PREFIX_RE = /^node:/
35
38
  const INTERNAL_IMPORT_RE = /import[ \t]{1,8}([^'"]{0,300}?)from[ \t]{1,8}['"]\.[^'"]{1,300}['"]/g
@@ -41,7 +44,10 @@ const CATCH_RE = /catch\s*\(/
41
44
  const TRY_RE = /\btry\s*\{/
42
45
  const FALSY_RETURN_RE = /return\s+(false|null|''|"")/
43
46
  const NETWORK_RE = /\bfetch\(|https?\.|axios|got\(/
44
- const CACHE_RE = /new Map\(\)|Cache|cache/
47
+ // Кеш лише за ІМЕНОВАНИМ маркером (`cache`/`Cache`/`memoize`), не за будь-яким
48
+ // `new Map()`: акумулятор (напр. `byPath = new Map()`) — не кеш, а хибна гарантія
49
+ // «Кешує результати» гірша за пропуск (фабрикація > мовчання).
50
+ const CACHE_RE = /cache|memoi[sz]e/i
45
51
 
46
52
  /**
47
53
  * Прибирає `/** *​/`-обрамлення й `*`-префікси, повертає чистий текст рядками.
@@ -168,6 +174,22 @@ function extractInternalSymbols(src) {
168
174
  return [...out]
169
175
  }
170
176
 
177
+ /**
178
+ * Імена top-level функцій/класів, які НЕ експортуються (службові помічники).
179
+ * Модель не має подавати їх як «публічні функції» у Поведінці/API (R6).
180
+ * Const-стрілки свідомо не ловимо — менше false-positive на змістовних константах.
181
+ * @param {string} src вміст файлу
182
+ * @returns {Array<string>} список імен неекспортованих функцій/класів
183
+ */
184
+ function extractLocalSymbols(src) {
185
+ const exported = new Set(Array.from(src.matchAll(EXPORT_DECL_RE), m => m[2]))
186
+ const out = new Set()
187
+ for (const m of src.matchAll(TOP_FN_DECL_RE)) {
188
+ if (!exported.has(m[1])) out.add(m[1])
189
+ }
190
+ return [...out]
191
+ }
192
+
171
193
  /**
172
194
  * Поведінкові маркери — евристики регулярками.
173
195
  * @param {string} src вміст файлу
@@ -207,6 +229,7 @@ export function extractFacts(src, relPath) {
207
229
  exports: extractExports(src),
208
230
  imports: extractImports(src),
209
231
  internalSymbols: extractInternalSymbols(src),
232
+ localSymbols: extractLocalSymbols(src),
210
233
  markers: extractMarkers(src)
211
234
  }
212
235
  }