@nitra/cursor 1.13.26 → 1.13.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,41 @@
4
4
 
5
5
  Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
6
6
 
7
+ ## [1.13.31] - 2026-05-18
8
+
9
+ ### Changed
10
+
11
+ - `changelog` rule (`n-changelog.mdc` `2.3` → `2.4`): підтримка Python — `pyproject.toml` (`[project]` / Poetry) поряд із `package.json`; discovery воркспейсів через `getMonorepoProjectRootDirs`; PyPI-порівняння для registry-published Python-пакетів. `package-manifest.mjs` — уніфікований маніфест npm/python.
12
+
13
+ ## [1.13.30] - 2026-05-18
14
+
15
+ ### Changed
16
+
17
+ - `changelog` rule (`n-changelog.mdc` `2.2` → `2.3`): `alwaysApply: true` без `globs`; інверсія — не вимагати bump для `docs/`/`doc/` і шляхів з `.gitignore`. `check changelog`: інтеграційна база `dev` **або** `main` (перша наявна); на `dev`/`main` local-only пропускається; релевантні зміни фільтруються в git. Тести.
18
+
19
+ ## [1.13.29] - 2026-05-18
20
+
21
+ ### Changed
22
+
23
+ - `changelog` rule (`n-changelog.mdc` `2.1` → `2.2`): розширені `globs` (типові шляхи пакета + `*.rego` / `*.mdc`) — правило потрапляє в контекст агента при правках коду, не лише `package.json` / `CHANGELOG.md`; секція «Чеклист агента» для будь-якого репозиторію з правилом.
24
+ - `changelog/fix/consistency/check.mjs`: npm-published режим — якщо `version` збігається з реєстром, але в git є зміни workspace без bump (feature vs `dev` або незакомічене на `dev`) → fail. Регресійні тести.
25
+
26
+ ## [1.13.28] - 2026-05-18
27
+
28
+ ### Fixed
29
+
30
+ - `scripts/utils/template.mjs` (`stripJsonComments`): враховує контекст рядкових літералів. Раніше regex `\/\*[\s\S]*?\*\/` без розрізнення string-літералів агресивно вирізав блоки між `/*` і `*/`, які зустрічаються в glob-патернах JSON-значень (напр. `**/node_modules/**`, `**/k8s/**/*.yaml`), і канонічний `.cspell.json.snippet.json` чи `.oxfmtrc.json.snippet.json` після стрипу стягувався в один склеєний рядок замість 7-елементного масиву. Новий стриппер пропускає вміст `"..."` (з підтримкою backslash-escape) без змін і вирізає лише реальні JSONC-коментарі.
31
+
32
+ ### Changed
33
+
34
+ - `hasura` rule (`hasura.svc_hl`): іменування Service узгоджено з `k8s.svc_hl_yaml` — headless (`spec.clusterIP: None`) має суфікс `-h-hl` (напр. `db-h` → `db-h-hl`), clusterIP у `svc.yaml` — `-h`. Target розширено на `hasura/k8s/base/svc.yaml` і `svc-hl.yaml`; додано `svc_hl_test.rego`. `hasura.mdc` і `fix/internal_urls` оновлено під headless DNS (`contract-h-hl`). Bump `hasura.mdc` `1.1` → `1.2`.
35
+
36
+ ## [1.13.27] - 2026-05-18
37
+
38
+ ### Fixed
39
+
40
+ - `text`, `js-lint`, `js-run` rules: додано markdown-посилання на template-файли у канонічні `<id>.mdc` — `findMissingMdcRefs` (викликається з `run-rule.mjs`) раніше падав, бо канонічні `.mdc` не містили `[name](./policy/<concern>/template/<file>)` для власних шаблонів. Bump rule versions: `text.mdc` `1.27` → `1.28`, `js-lint.mdc` `1.22` → `1.23`, `js-run.mdc` `1.8` → `1.9`.
41
+
7
42
  ## [1.13.26] - 2026-05-17
8
43
 
9
44
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.13.26",
3
+ "version": "1.13.31",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -1,36 +1,61 @@
1
1
  ---
2
- description: CHANGELOG.md в кожному workspace, з двома моделями бази порівняння
3
- version: '2.1'
4
- globs: "**/{CHANGELOG.md,package.json}"
5
- alwaysApply: false
2
+ description: CHANGELOG.md в кожному workspace, з двома моделями бази порівняння (npm і Python)
3
+ version: '2.4'
4
+ alwaysApply: true
6
5
  ---
7
6
 
8
- Bun monorepo: у кожному workspace із кореневого `package.json.workspaces` (плюс кореневий пакет, плюс `npm/`) має бути власний **`CHANGELOG.md`**. Спільного на репозиторій змісту змін **не існує** — кожен пакет веде свій.
7
+ У кожному **пакетному** workspace (каталог із `package.json` або `pyproject.toml`) має бути власний **`CHANGELOG.md`**. Спільного на репозиторій змісту змін **не існує** — кожен пакет веде свій.
8
+
9
+ **Маніфест версії:**
10
+
11
+ - **JS / Bun / npm** — `<ws>/package.json` (`version`, для монорепо ще `workspaces` у кореневому `package.json`).
12
+ - **Python** — `<ws>/pyproject.toml`: `[project].name` / `[project].version` (PEP 621) або `[tool.poetry].name` / `[tool.poetry].version`.
13
+
14
+ Каталоги лише з `pyproject.toml` (без `package.json`) теж враховуються; `node_modules/`, `.venv/`, `venv/` при пошуку ігноруються.
15
+
16
+ ## Чеклист агента (будь-який репозиторій з цим правилом)
17
+
18
+ **Інверсія (за замовчуванням не вимагають bump/CHANGELOG):**
19
+
20
+ - зміни **лише** під `docs/` або `doc/`;
21
+ - файли під **`.gitignore`**;
22
+ - правки **лише** `CHANGELOG.md` або поля `version` у маніфесті як сам релізний крок.
23
+
24
+ **Вимагають bump + нову секцію CHANGELOG** — усі інші зміни в каталозі workspace (код, rego, правила, конфіги, тести тощо).
25
+
26
+ Перед завершенням задачі:
27
+
28
+ 1. **`version`** у `<ws>/package.json` або `[project].version` у `<ws>/pyproject.toml` підвищено (build/patch +1 на PR), якщо зміни входять у реліз.
29
+ 2. **`CHANGELOG.md`** — **нова** секція `## [версія] - YYYY-MM-DD` зверху (не дописуй bullet-и в стару версію).
30
+ 3. **`npx @nitra/cursor check changelog`** — exit `0`.
31
+
32
+ Перевірка програмна (`changelog/fix/consistency/check.mjs`).
9
33
 
10
34
  ## Дві моделі бази порівняння
11
35
 
12
- Правило за **`<ws>/package.json`** автоматично визначає режим перевірки:
36
+ Режим визначається автоматично з маніфесту.
13
37
 
14
- ### npm-published воркспейс
38
+ ### registry-published (npm / PyPI)
15
39
 
16
- Якщо в `<ws>/package.json` є непорожнє `name`, **не** `private: true` і оголошено масив `files` — workspace публікується в npm. База — **опублікована версія в реєстрі** (`npm view <name> version`):
40
+ **npm:** непорожнє `name`, не `private: true`, масив `files`.
17
41
 
18
- 1. Якщо локальна `version` дорівнює опублікованій ще нічого не зрелізнуто, перевірка мовчить.
19
- 2. Якщо локальна `version` відрізняється від опублікованої — потрібен запис у `<ws>/CHANGELOG.md` для локальної версії (формат `## [версія] - YYYY-MM-DD`) і `"CHANGELOG.md"` у `files`.
20
- 3. Якщо реєстр недосяжний (офлайн / пакет ще не публікувався) — fail-safe pass із поясненням, щоб локальна розробка не блокувалася.
42
+ **Python:** статичні `project.name` і `project.version` у `pyproject.toml` (або Poetry-секція).
21
43
 
22
- Git у цьому режимі не використовується порівнюється поточний стан робочої копії з тим, що насправді в npm. Це покриває кейс «прямі коміти в `main` поза PR-flow» автоматично, бо неопубліковані зміни одразу видно.
44
+ 1. **Локальна `version` опублікованій** (npm / PyPI): запис у `<ws>/CHANGELOG.md`; для npm також `"CHANGELOG.md"` у `files`.
45
+ 2. **Версії збігаються**, але в git є **релевантні** зміни без bump → fail.
46
+ 3. **Реєстр недосяжний** — fail-safe pass.
47
+ 4. **Немає релевантних змін** — pass.
23
48
 
24
- ### local-only воркспейс
49
+ ### local-only
25
50
 
26
- Якщо workspace приватний (`private: true`) або без `files` (apps, services, internal-only пакети) база = **гілка `dev`**, точніше `git merge-base <dev> HEAD`:
51
+ **npm:** `private: true` або без `files`. **Python:** без пари name+version для реєстру. База = **`dev` або `main`** (перша наявна), `git merge-base`:
27
52
 
28
- 1. На самій гілці `dev` правило не активне.
29
- 2. На feature-гілці merge-base = точка розгалуження від `dev` → видно лише унікальні коміти цієї гілки. Bump + запис у `CHANGELOG.md` потрібні **раз на весь PR — як сума по гілці**, без bump-шуму в проміжних комітах.
30
- 3. На `main` після merge `dev main` merge-base = поточний `dev` → diff порожній → правило мовчить.
31
- 4. Direct-commit на `main` поза PR-flow ловиться як зміна, що потребує bump + запис у `CHANGELOG.md`.
53
+ 1. На **`dev`** local-only не активний (крім незакомічених registry-published).
54
+ 2. На feature-гілці bump + CHANGELOG **раз на PR**.
55
+ 3. Після merge на інтеграційну гілку diff порожній → pass.
56
+ 4. Direct-commit на `main` ловиться так само.
32
57
 
33
- Якщо не git-репо, або `dev`/`origin/dev` не існує — local-only перевірка пропускається.
58
+ Якщо немає git або немає `dev`/`main` — local-only пропускається.
34
59
 
35
60
  ## Формат CHANGELOG.md
36
61
 
@@ -1,25 +1,19 @@
1
1
  /**
2
- * Перевіряє, що в кожному workspace із незакомічаними/незрелізнутими змінами підвищена `version` у
3
- * `<ws>/package.json` і в `<ws>/CHANGELOG.md` присутній запис `## [version] - YYYY-MM-DD`
4
- * (формат Keep a Changelog).
2
+ * Перевіряє, що в кожному workspace із релізно-релевантними змінами підвищена `version`
3
+ * у маніфесті (`package.json` або `pyproject.toml`) і в `<ws>/CHANGELOG.md` є запис
4
+ * `## [version] - YYYY-MM-DD` (формат Keep a Changelog).
5
5
  *
6
- * Дві моделі визначення «бази для порівняння» — на рівні воркспейсу:
6
+ * Дві моделі бази — на рівні воркспейсу (див. n-changelog.mdc):
7
7
  *
8
- * 1) **npm-published mode** (`<ws>/package.json` має непорожнє `name`, не `private: true`,
9
- * і має масив `files`): база = опублікована версія в npm-реєстрі (`npm view <name> version`).
10
- * Git не задіяний. Якщо локальна версія відрізняється від опублікованої — потрібен запис
11
- * у CHANGELOG для локальної версії й `"CHANGELOG.md"` у `files`. Якщо `npm view` недосяжний
12
- * (немає мережі / пакет ще не публікувався) — fail-safe pass із поясненням, щоб локальна
13
- * розробка офлайн не блокувалась.
8
+ * 1) **registry-published** (npm: `name` + `files`, не `private`; Python: `project.name` +
9
+ * статична `project.version` у `pyproject.toml`): база = опублікована версія в npm / PyPI.
10
+ * Якщо локальна версія відрізняється — потрібен CHANGELOG; для npm також `"CHANGELOG.md"`
11
+ * у `files`. Якщо версії збігаються, але в git є релевантні зміни без bump — fail.
14
12
  *
15
- * 2) **local-only mode** (приватні / без `files` воркспейси): PR-scoped перевірка проти `dev`.
16
- * База = `git merge-base <dev> HEAD` (точка розгалуження поточної гілки від `dev`), щоб:
17
- * - на feature-гілці бачити лише унікальні коміти цієї гілки;
18
- * - на `main` після merge `dev → main` diff був порожній (нічого не вимагати);
19
- * - direct-commit на `main` поза PR-flow ловився як зміна, що потребує bump + CHANGELOG.
20
- * Якщо не git-репо, поточна гілка = `dev`, або `dev`/`origin/dev` не існує — пропуск.
13
+ * 2) **local-only** (приватні npm, без `files`, Python без імені/версії для реєстру): PR-scoped
14
+ * перевірка проти `dev` / `main` через `git merge-base`.
21
15
  *
22
- * Усі `git` і `npm` виклики — через `execFile`, без shell-інтерполяції.
16
+ * Усі `git` і зовнішні виклики — через `execFile` / `fetch`, без shell-інтерполяції.
23
17
  */
24
18
  import { execFile } from 'node:child_process'
25
19
  import { existsSync } from 'node:fs'
@@ -28,20 +22,34 @@ import { join } from 'node:path'
28
22
  import { promisify } from 'node:util'
29
23
 
30
24
  import { createCheckReporter } from '../../../../scripts/utils/check-reporter.mjs'
31
- import { getMonorepoPackageRootDirs } from '../../../../scripts/utils/workspaces.mjs'
25
+ import {
26
+ getMonorepoProjectRootDirs,
27
+ manifestFilePath,
28
+ parsePyprojectFields,
29
+ readPackageManifest,
30
+ } from '../../../../scripts/utils/package-manifest.mjs'
32
31
 
33
32
  const execFileAsync = promisify(execFile)
34
33
 
35
- /** Базова гілка PR фіксована, без конфіга (див. n-changelog.mdc) */
36
- const BASE_BRANCH = 'dev'
34
+ /** Кандидати інтеграційної гілки (перша наявна в репо; див. n-changelog.mdc) */
35
+ const BASE_BRANCH_CANDIDATES = Object.freeze(['dev', 'main'])
37
36
 
38
- /** Таймаут на `npm view <name> version` (мс), щоб не блокуватись на офлайні */
39
- const NPM_VIEW_TIMEOUT_MS = 10_000
37
+ /** Гілки, на яких local-only перевірку пропускаємо (крім незакомічених registry-published). */
38
+ const INTEGRATION_BRANCHES = Object.freeze(['dev', 'main'])
39
+
40
+ /** Префікси шляхів (posix), які не вважаються релізними змінами — інверсія glob (n-changelog.mdc). */
41
+ const CHANGELOG_IGNORE_PATH_PREFIXES = Object.freeze(['docs/', 'doc/'])
42
+
43
+ /** Точні шляхи каталогів документації (posix), без bump. */
44
+ const CHANGELOG_IGNORE_PATH_EXACT = Object.freeze(['docs', 'doc'])
45
+
46
+ /** Таймаут на `npm view` / PyPI (мс) */
47
+ const REGISTRY_TIMEOUT_MS = 10_000
40
48
 
41
49
  /**
42
50
  * Тихо запускає `git` і повертає stdout або `null` при будь-якій помилці.
43
51
  * @param {string[]} args аргументи `git`
44
- * @returns {Promise<string | null>} stdout процесу або `null` при будь-якій помилці виконання
52
+ * @returns {Promise<string | null>}
45
53
  */
46
54
  async function gitOrNull(args) {
47
55
  try {
@@ -53,8 +61,7 @@ async function gitOrNull(args) {
53
61
  }
54
62
 
55
63
  /**
56
- * Чи робочий каталог — git-репозиторій.
57
- * @returns {Promise<boolean>} `true`, якщо `git rev-parse --is-inside-work-tree` повернув `true`
64
+ * @returns {Promise<boolean>}
58
65
  */
59
66
  async function isInsideGitRepo() {
60
67
  const out = await gitOrNull(['rev-parse', '--is-inside-work-tree'])
@@ -62,8 +69,7 @@ async function isInsideGitRepo() {
62
69
  }
63
70
 
64
71
  /**
65
- * Назва поточної гілки (або `HEAD` для detached state).
66
- * @returns {Promise<string | null>} назва гілки чи `'HEAD'`, або `null` (поза git / помилка)
72
+ * @returns {Promise<string | null>}
67
73
  */
68
74
  async function currentBranchName() {
69
75
  const out = await gitOrNull(['rev-parse', '--abbrev-ref', 'HEAD'])
@@ -71,25 +77,64 @@ async function currentBranchName() {
71
77
  }
72
78
 
73
79
  /**
74
- * Знаходить ref для базової гілки. Перевага локальному `dev`, далі `origin/dev`. Повертає `null`,
75
- * якщо жоден не існує.
76
- * @returns {Promise<string | null>} назва ref-а (`dev` чи `origin/dev`) або `null`, якщо жоден не знайдено
80
+ * @param {string | null} branch
81
+ * @returns {boolean}
82
+ */
83
+ function isIntegrationBranch(branch) {
84
+ return branch !== null && INTEGRATION_BRANCHES.includes(branch)
85
+ }
86
+
87
+ /**
88
+ * @param {string} ref
89
+ * @returns {string}
90
+ */
91
+ function baseRefLabel(ref) {
92
+ return ref.startsWith('origin/') ? ref.slice('origin/'.length) : ref
93
+ }
94
+
95
+ /**
96
+ * @param {string} relPath
97
+ * @returns {boolean}
98
+ */
99
+ function isChangelogIgnoredPath(relPath) {
100
+ const p = relPath.replace(/\\/g, '/').replace(/^\.\//, '')
101
+ if (CHANGELOG_IGNORE_PATH_EXACT.includes(p)) {
102
+ return true
103
+ }
104
+ return CHANGELOG_IGNORE_PATH_PREFIXES.some(prefix => p.startsWith(prefix))
105
+ }
106
+
107
+ /**
108
+ * @param {string} relPath
109
+ * @returns {Promise<boolean>}
110
+ */
111
+ async function isPathGitIgnored(relPath) {
112
+ try {
113
+ await execFileAsync('git', ['check-ignore', '-q', '--', relPath])
114
+ return true
115
+ } catch {
116
+ return false
117
+ }
118
+ }
119
+
120
+ /**
121
+ * @returns {Promise<string | null>}
77
122
  */
78
123
  async function resolveBaseRef() {
79
- for (const ref of [BASE_BRANCH, `origin/${BASE_BRANCH}`]) {
80
- const out = await gitOrNull(['rev-parse', '--verify', '--quiet', ref])
81
- if (typeof out === 'string' && out.trim().length > 0) {
82
- return ref
124
+ for (const name of BASE_BRANCH_CANDIDATES) {
125
+ for (const ref of [name, `origin/${name}`]) {
126
+ const out = await gitOrNull(['rev-parse', '--verify', '--quiet', ref])
127
+ if (typeof out === 'string' && out.trim().length > 0) {
128
+ return ref
129
+ }
83
130
  }
84
131
  }
85
132
  return null
86
133
  }
87
134
 
88
135
  /**
89
- * Точка розгалуження поточної гілки від `baseRef`. На feature-гілці = коли вона відгалузилась;
90
- * на `main` після merge `dev → main` = поточний `dev`. Повертає `null`, якщо merge-base нема.
91
- * @param {string} baseRef SHA або ref-name бази (зазвичай `dev` / `origin/dev`)
92
- * @returns {Promise<string | null>} SHA точки розгалуження або `null`, якщо merge-base нема
136
+ * @param {string} baseRef
137
+ * @returns {Promise<string | null>}
93
138
  */
94
139
  async function resolveMergeBase(baseRef) {
95
140
  const out = await gitOrNull(['merge-base', baseRef, 'HEAD'])
@@ -99,14 +144,9 @@ async function resolveMergeBase(baseRef) {
99
144
  }
100
145
 
101
146
  /**
102
- * Будує pathspec для `git diff` / `ls-files` для воркспейсу.
103
- *
104
- * Для кореня `.` — це точка плюс magic-виключення кожного підворкспейсу через `:(exclude)<sub>/`,
105
- * щоб зміни всередині sub-workspace не вважалися змінами кореня.
106
- * Для звичайного воркспейсу — просто `<ws>/`.
107
- * @param {string} ws шлях воркспейсу (`'.'` для кореня, інакше — відносний шлях, як у `workspaces`)
108
- * @param {string[]} subWorkspaces усі під-воркспейси (зокрема для `'.'` потрібно виключити їх)
109
- * @returns {string[]} pathspec для git: масив, що передається після `--`
147
+ * @param {string} ws
148
+ * @param {string[]} subWorkspaces
149
+ * @returns {string[]}
110
150
  */
111
151
  function pathspecForWorkspace(ws, subWorkspaces) {
112
152
  if (ws !== '.') return [`${ws}/`]
@@ -114,50 +154,74 @@ function pathspecForWorkspace(ws, subWorkspaces) {
114
154
  }
115
155
 
116
156
  /**
117
- * Чи є зміни (committed або в робочому дереві) у каталозі `<ws>` відносно `baseRef`.
118
- *
119
- * `git diff --quiet <baseRef> -- <pathspec>` ловить committed-зміни на цій гілці й незбережені
120
- * правки tracked-файлів. Untracked-файли — `git ls-files --others --exclude-standard`.
121
- * @param {string} baseRef SHA або ref-name (зокрема merge-base)
122
- * @param {string} ws шлях воркспейсу (`'.'` для кореня)
123
- * @param {string[]} subWorkspaces усі під-воркспейси для коректного формування pathspec кореня
124
- * @returns {Promise<boolean>} `true`, якщо в межах воркспейсу є будь-які зміни (committed або untracked)
157
+ * @param {string} baseRef
158
+ * @param {string[]} pathspec
159
+ * @returns {Promise<string[]>}
125
160
  */
126
- async function workspaceHasChangesAgainstBase(baseRef, ws, subWorkspaces) {
161
+ async function listChangedPathsAgainstBase(baseRef, pathspec) {
162
+ /** @type {string[]} */
163
+ const out = []
164
+ const diffArgs =
165
+ baseRef === 'HEAD'
166
+ ? ['diff', '--name-only', 'HEAD', '--', ...pathspec]
167
+ : ['diff', '--name-only', baseRef, '--', ...pathspec]
168
+ const diffOut = await gitOrNull(diffArgs)
169
+ if (typeof diffOut === 'string' && diffOut.trim().length > 0) {
170
+ out.push(...diffOut.trim().split('\n'))
171
+ }
172
+ const untrackedOut = await gitOrNull(['ls-files', '--others', '--exclude-standard', '--', ...pathspec])
173
+ if (typeof untrackedOut === 'string' && untrackedOut.trim().length > 0) {
174
+ out.push(...untrackedOut.trim().split('\n'))
175
+ }
176
+ return [...new Set(out)]
177
+ }
178
+
179
+ /**
180
+ * @param {string} baseRef
181
+ * @param {string} ws
182
+ * @param {string[]} subWorkspaces
183
+ * @returns {Promise<boolean>}
184
+ */
185
+ async function workspaceHasRelevantChangesAgainstBase(baseRef, ws, subWorkspaces) {
127
186
  const pathspec = pathspecForWorkspace(ws, subWorkspaces)
128
- try {
129
- await execFileAsync('git', ['diff', '--quiet', baseRef, '--', ...pathspec])
130
- } catch (error) {
131
- const code = /** @type {{ code?: number }} */ (error).code
132
- return code === 1
187
+ const paths = await listChangedPathsAgainstBase(baseRef, pathspec)
188
+ for (const p of paths) {
189
+ if (isChangelogIgnoredPath(p)) {
190
+ continue
191
+ }
192
+ if (await isPathGitIgnored(p)) {
193
+ continue
194
+ }
195
+ return true
133
196
  }
134
- const untracked = await gitOrNull(['ls-files', '--others', '--exclude-standard', '--', ...pathspec])
135
- return typeof untracked === 'string' && untracked.trim().length > 0
197
+ return false
136
198
  }
137
199
 
138
200
  /**
139
- * Версія з `<ws>/package.json` на `baseRef` або `null`.
140
- * @param {string} baseRef SHA або ref-name (зазвичай merge-base) для `git show`
141
- * @param {string} ws шлях воркспейсу (`'.'` для кореня)
142
- * @returns {Promise<string | null>} значення поля `version` або `null`, якщо файла нема / JSON некоректний
201
+ * Версія з маніфесту на `baseRef`.
202
+ * @param {string} baseRef
203
+ * @param {import('../../../../scripts/utils/package-manifest.mjs').PackageManifest} manifest
204
+ * @returns {Promise<string | null>}
143
205
  */
144
- async function readBaseVersion(baseRef, ws) {
145
- const wsPath = ws === '.' ? 'package.json' : `${ws}/package.json`
206
+ async function readBaseVersion(baseRef, manifest) {
207
+ const wsPath = manifest.ws === '.' ? manifest.manifestRel : `${manifest.ws}/${manifest.manifestRel}`
146
208
  const out = await gitOrNull(['show', `${baseRef}:${wsPath}`])
147
209
  if (out === null) return null
148
- try {
149
- const parsed = JSON.parse(out)
150
- return typeof parsed?.version === 'string' ? parsed.version : null
151
- } catch {
152
- return null
210
+ if (manifest.kind === 'npm') {
211
+ try {
212
+ const parsed = JSON.parse(out)
213
+ return typeof parsed?.version === 'string' ? parsed.version : null
214
+ } catch {
215
+ return null
216
+ }
153
217
  }
218
+ return parsePyprojectFields(out).version
154
219
  }
155
220
 
156
221
  /**
157
- * Чи містить текст `CHANGELOG.md` запис `## [version]` (з опційним `- YYYY-MM-DD`).
158
- * @param {string} text вміст CHANGELOG.md
159
- * @param {string} version версія, яку шукаємо у форматі Keep a Changelog
160
- * @returns {boolean} `true`, якщо запис для `version` знайдено
222
+ * @param {string} text
223
+ * @param {string} version
224
+ * @returns {boolean}
161
225
  */
162
226
  function changelogHasVersionEntry(text, version) {
163
227
  const needle = `## [${version}]`
@@ -165,62 +229,68 @@ function changelogHasVersionEntry(text, version) {
165
229
  }
166
230
 
167
231
  /**
168
- * Зчитує `<ws>/package.json`. `null`, якщо файл відсутній або JSON некоректний.
169
- * @param {string} ws шлях воркспейсу (`'.'` для кореня)
170
- * @returns {Promise<Record<string, unknown> | null>} розпарсений `package.json` або `null`
232
+ * @param {string} name
233
+ * @returns {Promise<string | null>}
171
234
  */
172
- async function readPackageJsonOrNull(ws) {
173
- const path = join(ws, 'package.json')
174
- if (!existsSync(path)) return null
235
+ async function defaultGetPublishedNpmVersion(name) {
175
236
  try {
176
- const parsed = JSON.parse(await readFile(path, 'utf8'))
177
- return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
178
- ? /** @type {Record<string, unknown>} */ (parsed)
179
- : null
237
+ const { stdout } = await execFileAsync('npm', ['view', name, 'version'], { timeout: REGISTRY_TIMEOUT_MS })
238
+ const v = stdout.trim()
239
+ return v.length > 0 ? v : null
180
240
  } catch {
181
241
  return null
182
242
  }
183
243
  }
184
244
 
185
245
  /**
186
- * Воркспейс публікується в npm: має непорожній `name`, не `private: true`, і має масив `files`.
187
- * @param {Record<string, unknown> | null} pkg розпарсений `package.json` (або `null`)
188
- * @returns {boolean} `true`, якщо пакет придатний для публікації в npm
246
+ * @param {string} name
247
+ * @returns {Promise<string | null>}
189
248
  */
190
- function isNpmPublishable(pkg) {
191
- if (!pkg) return false
192
- if (typeof pkg.name !== 'string' || pkg.name.length === 0) return false
193
- if (pkg.private === true) return false
194
- return Array.isArray(pkg.files)
249
+ async function defaultGetPublishedPyPiVersion(name) {
250
+ try {
251
+ const res = await fetch(`https://pypi.org/pypi/${encodeURIComponent(name)}/json`, {
252
+ signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS),
253
+ })
254
+ if (!res.ok) return null
255
+ const data = await res.json()
256
+ const v = data?.info?.version
257
+ return typeof v === 'string' && v.length > 0 ? v : null
258
+ } catch {
259
+ return null
260
+ }
195
261
  }
196
262
 
197
263
  /**
198
- * Опублікована версія пакета в npm-реєстрі. `null` — пакет не знайдено / нема мережі / помилка.
199
- * Дефолтна імплементація `npm view <name> version` із таймаутом, щоб не блокуватись офлайн.
200
- * @param {string} name повна назва пакета (включно зі скоупом)
201
- * @returns {Promise<string | null>} опублікована версія або `null` (нема пакета / офлайн)
264
+ * @param {import('../../../../scripts/utils/package-manifest.mjs').PackageManifest} manifest
265
+ * @param {(name: string, kind?: import('../../../../scripts/utils/package-manifest.mjs').PackageKind) => Promise<string | null>} getPublishedVersion
266
+ * @returns {Promise<string | null>}
202
267
  */
203
- async function defaultGetPublishedVersion(name) {
204
- try {
205
- const { stdout } = await execFileAsync('npm', ['view', name, 'version'], { timeout: NPM_VIEW_TIMEOUT_MS })
206
- const v = stdout.trim()
207
- return v.length > 0 ? v : null
208
- } catch {
209
- return null
268
+ async function resolvePublishedVersion(manifest, getPublishedVersion) {
269
+ if (!manifest.name) return null
270
+ return getPublishedVersion(manifest.name, manifest.kind)
271
+ }
272
+
273
+ /**
274
+ * @returns {(name: string, kind?: import('../../../../scripts/utils/package-manifest.mjs').PackageKind) => Promise<string | null>}
275
+ */
276
+ function createDefaultGetPublishedVersion() {
277
+ return async (name, kind = 'npm') => {
278
+ if (kind === 'python') {
279
+ return defaultGetPublishedPyPiVersion(name)
280
+ }
281
+ return defaultGetPublishedNpmVersion(name)
210
282
  }
211
283
  }
212
284
 
213
285
  /**
214
- * Перевіряє масив `files` у `<ws>/package.json`: якщо оголошено — має містити `"CHANGELOG.md"`.
215
- * @param {Record<string, unknown> | null} pkg розпарсений `package.json` воркспейсу
216
- * @param {string} ws шлях воркспейсу (`'.'` для кореня)
217
- * @param {(msg: string) => void} pass callback при успішній перевірці
218
- * @param {(msg: string) => void} fail callback при помилці
286
+ * @param {import('../../../../scripts/utils/package-manifest.mjs').PackageManifest} manifest
287
+ * @param {(msg: string) => void} pass
288
+ * @param {(msg: string) => void} fail
219
289
  */
220
- function checkFilesArrayContainsChangelog(pkg, ws, pass, fail) {
221
- if (!pkg || !Array.isArray(pkg.files)) return
222
- const pkgPath = join(ws, 'package.json')
223
- if (pkg.files.includes('CHANGELOG.md')) {
290
+ function checkNpmFilesArrayContainsChangelog(manifest, pass, fail) {
291
+ if (manifest.kind !== 'npm' || !manifest.npmFiles) return
292
+ const pkgPath = manifestFilePath(manifest.ws, manifest)
293
+ if (manifest.npmFiles.includes('CHANGELOG.md')) {
224
294
  pass(`${pkgPath}: files містить "CHANGELOG.md"`)
225
295
  } else {
226
296
  fail(`${pkgPath}: масив files має містити "CHANGELOG.md", щоб публікувати changelog із пакетом`)
@@ -228,12 +298,11 @@ function checkFilesArrayContainsChangelog(pkg, ws, pass, fail) {
228
298
  }
229
299
 
230
300
  /**
231
- * Перевіряє наявність запису у `<ws>/CHANGELOG.md` для версії `version`.
232
- * @param {string} ws шлях воркспейсу (`'.'` для кореня)
233
- * @param {string} version версія, для якої очікується запис
234
- * @param {(msg: string) => void} pass callback при успішній перевірці
235
- * @param {(msg: string) => void} fail callback при помилці
236
- * @returns {Promise<boolean>} `false`, якщо файл відсутній або немає запису
301
+ * @param {string} ws
302
+ * @param {string} version
303
+ * @param {(msg: string) => void} pass
304
+ * @param {(msg: string) => void} fail
305
+ * @returns {Promise<boolean>}
237
306
  */
238
307
  async function verifyChangelogEntry(ws, version, pass, fail) {
239
308
  const label = ws === '.' ? '<root>' : ws
@@ -252,89 +321,147 @@ async function verifyChangelogEntry(ws, version, pass, fail) {
252
321
  }
253
322
 
254
323
  /**
255
- * npm-published режим: порівнює локальну `version` з опублікованою в реєстрі. Якщо вони
256
- * відрізняються — вимагає запис у CHANGELOG і `"CHANGELOG.md"` у `files`. Якщо реєстр недосяжний,
257
- * правило fail-safe пасує (щоб офлайн-розробка не блокувалась).
258
- * @param {string} ws шлях воркспейсу (`'.'` для кореня)
259
- * @param {Record<string, unknown>} pkg розпарсений `package.json` воркспейсу
260
- * @param {(name: string) => Promise<string | null>} getPublishedVersion стаб/реальна функція отримання опублікованої версії
261
- * @param {(msg: string) => void} pass callback при успішній перевірці
262
- * @param {(msg: string) => void} fail callback при помилці
324
+ * @param {import('../../../../scripts/utils/package-manifest.mjs').PackageManifest} manifest
325
+ * @returns {string}
263
326
  */
264
- async function checkPublishedWorkspace(ws, pkg, getPublishedVersion, pass, fail) {
265
- const label = ws === '.' ? '<root>' : ws
266
- const Vcurrent = typeof pkg.version === 'string' ? pkg.version : null
327
+ function workspaceLabel(manifest) {
328
+ return manifest.ws === '.' ? '<root>' : manifest.ws
329
+ }
330
+
331
+ /**
332
+ * @param {import('../../../../scripts/utils/package-manifest.mjs').PackageManifest} manifest
333
+ * @param {string} Vcurrent
334
+ * @param {string[]} subWorkspaces
335
+ * @param {(msg: string) => void} pass
336
+ * @param {(msg: string) => void} fail
337
+ * @returns {Promise<void>}
338
+ */
339
+ async function checkPublishedWorkspacePendingGitChanges(manifest, Vcurrent, subWorkspaces, pass, fail) {
340
+ const label = workspaceLabel(manifest)
341
+ const mf = manifestFilePath(manifest.ws, manifest)
342
+ if (!(await isInsideGitRepo())) {
343
+ return
344
+ }
345
+
346
+ const branch = await currentBranchName()
347
+ if (isIntegrationBranch(branch)) {
348
+ if (await workspaceHasRelevantChangesAgainstBase('HEAD', manifest.ws, subWorkspaces)) {
349
+ fail(
350
+ `${label}: у registry-published пакеті є незакомічені зміни при version ${Vcurrent}, що вже в реєстрі. ` +
351
+ `Підвищ version у ${mf} і додай запис у CHANGELOG.md (n-changelog.mdc)`
352
+ )
353
+ }
354
+ return
355
+ }
356
+
357
+ const baseRef = await resolveBaseRef()
358
+ if (!baseRef) {
359
+ return
360
+ }
361
+ const mergeBase = await resolveMergeBase(baseRef)
362
+ if (!mergeBase) {
363
+ return
364
+ }
365
+ if (!(await workspaceHasRelevantChangesAgainstBase(mergeBase, manifest.ws, subWorkspaces))) {
366
+ return
367
+ }
368
+
369
+ const Vbase = await readBaseVersion(mergeBase, manifest)
370
+ const baseLabel = baseRefLabel(baseRef)
371
+ if (Vbase === null || Vbase === Vcurrent) {
372
+ fail(
373
+ `${label}: у цій гілці є зміни в registry-published пакеті, але version у ${mf} ` +
374
+ `не підвищено (на ${baseLabel} — ${Vbase ?? '∅'}). Bump + запис у CHANGELOG.md обов'язкові на PR (n-changelog.mdc)`
375
+ )
376
+ return
377
+ }
378
+ pass(`${label}: version змінено (${Vbase} → ${Vcurrent}) — очікується запис CHANGELOG після bump`)
379
+ }
380
+
381
+ /**
382
+ * @param {import('../../../../scripts/utils/package-manifest.mjs').PackageManifest} manifest
383
+ * @param {string[]} subWorkspaces
384
+ * @param {(name: string, kind?: import('../../../../scripts/utils/package-manifest.mjs').PackageKind) => Promise<string | null>} getPublishedVersion
385
+ * @param {(msg: string) => void} pass
386
+ * @param {(msg: string) => void} fail
387
+ * @returns {Promise<void>}
388
+ */
389
+ async function checkPublishedWorkspace(manifest, subWorkspaces, getPublishedVersion, pass, fail) {
390
+ const label = workspaceLabel(manifest)
391
+ const mf = manifestFilePath(manifest.ws, manifest)
392
+ const Vcurrent = manifest.version
267
393
  if (!Vcurrent) {
268
- fail(`${label}: у package.json відсутнє поле version (npm-published воркспейс)`)
394
+ fail(`${label}: у ${mf} відсутнє поле version (registry-published воркспейс)`)
395
+ return
396
+ }
397
+ const name = manifest.name
398
+ if (!name) {
399
+ fail(`${label}: у ${mf} відсутнє ім'я пакета (registry-published воркспейс)`)
269
400
  return
270
401
  }
271
- const name = /** @type {string} */ (pkg.name)
272
- const Vpublished = await getPublishedVersion(name)
402
+ const Vpublished = await resolvePublishedVersion(manifest, getPublishedVersion)
273
403
  if (Vpublished === null) {
274
404
  pass(`${label}: ${name} — опублікована версія недоступна (мережа/реєстр), перевірку пропущено`)
275
405
  return
276
406
  }
277
407
  if (Vpublished === Vcurrent) {
278
- pass(`${label}: ${name}@${Vcurrent} вже опублікованозмін до релізу немає`)
408
+ pass(`${label}: ${name}@${Vcurrent} збігається з реєстром перевіряємо git на незрелізні зміни`)
409
+ await checkPublishedWorkspacePendingGitChanges(manifest, Vcurrent, subWorkspaces, pass, fail)
279
410
  return
280
411
  }
281
412
  pass(`${label}: ${name} — нова локальна версія (${Vpublished} → ${Vcurrent})`)
282
- await verifyChangelogEntry(ws, Vcurrent, pass, fail)
283
- checkFilesArrayContainsChangelog(pkg, ws, pass, fail)
413
+ await verifyChangelogEntry(manifest.ws, Vcurrent, pass, fail)
414
+ checkNpmFilesArrayContainsChangelog(manifest, pass, fail)
284
415
  }
285
416
 
286
417
  /**
287
- * local-only режим: PR-scoped перевірка проти `dev` через `git merge-base`. Викликається лише
288
- * для воркспейсів, де є реальні зміни щодо merge-base.
289
- * @param {string} mergeBase SHA точки розгалуження
290
- * @param {string} ws шлях воркспейсу (`'.'` для кореня)
291
- * @param {Record<string, unknown> | null} pkg розпарсений `package.json` воркспейсу (або `null`)
292
- * @param {(msg: string) => void} pass callback при успішній перевірці
293
- * @param {(msg: string) => void} fail callback при помилці
418
+ * @param {string} mergeBase
419
+ * @param {import('../../../../scripts/utils/package-manifest.mjs').PackageManifest} manifest
420
+ * @param {string} baseLabel
421
+ * @param {(msg: string) => void} pass
422
+ * @param {(msg: string) => void} fail
294
423
  */
295
- async function checkLocalOnlyChangedWorkspace(mergeBase, ws, pkg, pass, fail) {
296
- const label = ws === '.' ? '<root>' : ws
297
- const Vcurrent = typeof pkg?.version === 'string' ? pkg.version : null
424
+ async function checkLocalOnlyChangedWorkspace(mergeBase, manifest, baseLabel, pass, fail) {
425
+ const label = workspaceLabel(manifest)
426
+ const mf = manifestFilePath(manifest.ws, manifest)
427
+ const Vcurrent = manifest.version
298
428
  if (!Vcurrent) {
299
- fail(`${label}: у package.json відсутнє поле version (потрібне для запису в CHANGELOG)`)
429
+ fail(`${label}: у ${mf} відсутнє поле version (потрібне для запису в CHANGELOG)`)
300
430
  return
301
431
  }
302
- const Vbase = await readBaseVersion(mergeBase, ws)
303
- if (Vbase !== null && Vbase === Vcurrent) {
432
+ const Vbase = await readBaseVersion(mergeBase, manifest)
433
+ if (Vbase === null || Vbase === Vcurrent) {
304
434
  fail(
305
- `${label}: у цій гілці є зміни, але version у ${join(ws, 'package.json')} не підвищено (на ${BASE_BRANCH} — ${Vbase}). Bump + запис у CHANGELOG.md обов'язкові на PR`
435
+ `${label}: у цій гілці є зміни, але version у ${mf} не підвищено (на ${baseLabel} — ${Vbase ?? '∅'}). Bump + запис у CHANGELOG.md обов'язкові на PR`
306
436
  )
307
437
  return
308
438
  }
309
- pass(`${label}: version підвищено (${Vbase ?? '∅'} → ${Vcurrent})`)
310
- if (!(await verifyChangelogEntry(ws, Vcurrent, pass, fail))) return
311
- checkFilesArrayContainsChangelog(pkg, ws, pass, fail)
439
+ pass(`${label}: version підвищено (${Vbase} → ${Vcurrent})`)
440
+ if (!(await verifyChangelogEntry(manifest.ws, Vcurrent, pass, fail))) return
441
+ checkNpmFilesArrayContainsChangelog(manifest, pass, fail)
312
442
  }
313
443
 
314
444
  /**
315
- * Виконує local-only перевірку для всіх workspace-ів, у яких немає npm-published режиму.
316
- * @param {string[]} localOnlyWorkspaces список шляхів local-only воркспейсів
317
- * @param {Map<string, Record<string, unknown> | null>} pkgByWs мапа: шлях воркспейсу → розпарсений `package.json` (або `null`)
318
- * @param {string[]} subWorkspaces усі під-воркспейси (для коректного pathspec кореня)
319
- * @param {(msg: string) => void} pass callback при успішній перевірці
320
- * @param {(msg: string) => void} fail callback при помилці
321
- * @returns {Promise<void>} резолвиться по завершенню перевірок усіх local-only воркспейсів
445
+ * @param {import('../../../../scripts/utils/package-manifest.mjs').PackageManifest[]} localOnly
446
+ * @param {string[]} subWorkspaces
447
+ * @param {(msg: string) => void} pass
448
+ * @param {(msg: string) => void} fail
322
449
  */
323
- async function runLocalOnlyChecks(localOnlyWorkspaces, pkgByWs, subWorkspaces, pass, fail) {
324
- if (localOnlyWorkspaces.length === 0) return
450
+ async function runLocalOnlyChecks(localOnly, subWorkspaces, pass, fail) {
451
+ if (localOnly.length === 0) return
325
452
 
326
453
  if (!(await isInsideGitRepo())) {
327
454
  pass('changelog: не git-репозиторій — local-only перевірку пропущено')
328
455
  return
329
456
  }
330
457
  const branch = await currentBranchName()
331
- if (branch === BASE_BRANCH) {
332
- pass(`changelog: поточна гілка = ${BASE_BRANCH} — local-only перевірку пропущено`)
458
+ if (branch === 'dev') {
459
+ pass('changelog: поточна гілка = dev — local-only перевірку пропущено')
333
460
  return
334
461
  }
335
462
  const baseRef = await resolveBaseRef()
336
463
  if (!baseRef) {
337
- pass(`changelog: ref ${BASE_BRANCH} (та origin/${BASE_BRANCH}) не знайдено — local-only перевірку пропущено`)
464
+ pass('changelog: ref dev/main (та origin/*) не знайдено — local-only перевірку пропущено')
338
465
  return
339
466
  }
340
467
  const mergeBase = await resolveMergeBase(baseRef)
@@ -343,11 +470,12 @@ async function runLocalOnlyChecks(localOnlyWorkspaces, pkgByWs, subWorkspaces, p
343
470
  return
344
471
  }
345
472
 
473
+ const baseLabel = baseRefLabel(baseRef)
346
474
  let checkedAny = false
347
- for (const ws of localOnlyWorkspaces) {
348
- if (!(await workspaceHasChangesAgainstBase(mergeBase, ws, subWorkspaces))) continue
475
+ for (const manifest of localOnly) {
476
+ if (!(await workspaceHasRelevantChangesAgainstBase(mergeBase, manifest.ws, subWorkspaces))) continue
349
477
  checkedAny = true
350
- await checkLocalOnlyChangedWorkspace(mergeBase, ws, pkgByWs.get(ws) ?? null, pass, fail)
478
+ await checkLocalOnlyChangedWorkspace(mergeBase, manifest, baseLabel, pass, fail)
351
479
  }
352
480
  if (!checkedAny) {
353
481
  pass(`changelog: local-only воркспейси без змін відносно merge-base(${baseRef})`)
@@ -355,46 +483,40 @@ async function runLocalOnlyChecks(localOnlyWorkspaces, pkgByWs, subWorkspaces, p
355
483
  }
356
484
 
357
485
  /**
358
- * Перевіряє відповідність проєкту правилу changelog.mdc.
359
- * @param {object} [opts] опції перевірки
360
- * @param {(name: string) => Promise<string | null>} [opts.getPublishedVersion] перевизначення для тестів
361
- * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
486
+ * @param {object} [opts]
487
+ * @param {(name: string, kind?: import('../../../../scripts/utils/package-manifest.mjs').PackageKind) => Promise<string | null>} [opts.getPublishedVersion] перевизначення npm/PyPI у тестах
488
+ * @returns {Promise<number>}
362
489
  */
363
490
  export async function check(opts = {}) {
364
491
  const reporter = createCheckReporter()
365
492
  const { pass, fail } = reporter
366
- const getPublishedVersion = opts.getPublishedVersion ?? defaultGetPublishedVersion
493
+ const getPublishedVersion = opts.getPublishedVersion ?? createDefaultGetPublishedVersion()
367
494
 
368
- const workspaces = await getMonorepoPackageRootDirs(process.cwd())
495
+ const workspaces = await getMonorepoProjectRootDirs(process.cwd())
369
496
  const subWorkspaces = workspaces.filter(w => w !== '.')
370
497
 
371
- /** @type {Map<string, Record<string, unknown> | null>} */
372
- const pkgByWs = new Map()
373
- /** @type {string[]} */
374
- const publishedWorkspaces = []
375
- /** @type {string[]} */
376
- const localOnlyWorkspaces = []
498
+ /** @type {import('../../../../scripts/utils/package-manifest.mjs').PackageManifest[]} */
499
+ const published = []
500
+ /** @type {import('../../../../scripts/utils/package-manifest.mjs').PackageManifest[]} */
501
+ const localOnly = []
502
+
377
503
  for (const ws of workspaces) {
378
- const pkg = await readPackageJsonOrNull(ws)
379
- pkgByWs.set(ws, pkg)
380
- if (isNpmPublishable(pkg)) {
381
- publishedWorkspaces.push(ws)
504
+ const manifest = await readPackageManifest(ws)
505
+ if (!manifest) {
506
+ continue
507
+ }
508
+ if (manifest.registryPublishable) {
509
+ published.push(manifest)
382
510
  } else {
383
- localOnlyWorkspaces.push(ws)
511
+ localOnly.push(manifest)
384
512
  }
385
513
  }
386
514
 
387
- for (const ws of publishedWorkspaces) {
388
- await checkPublishedWorkspace(
389
- ws,
390
- /** @type {Record<string, unknown>} */ (pkgByWs.get(ws)),
391
- getPublishedVersion,
392
- pass,
393
- fail
394
- )
515
+ for (const manifest of published) {
516
+ await checkPublishedWorkspace(manifest, subWorkspaces, getPublishedVersion, pass, fail)
395
517
  }
396
518
 
397
- await runLocalOnlyChecks(localOnlyWorkspaces, pkgByWs, subWorkspaces, pass, fail)
519
+ await runLocalOnlyChecks(localOnly, subWorkspaces, pass, fail)
398
520
 
399
521
  return reporter.getExitCode()
400
522
  }
@@ -9,10 +9,11 @@
9
9
  *
10
10
  * Очікуваний формат URL — кластерний DNS-суфікс `<cluster>.internal`:
11
11
  * - GKE / GCP: `http://<service>.<namespace>.svc.<cluster>.internal:<port>`
12
- * приклад: `http://contract-h.ua-contract.svc.abie-ua.internal:8080`
12
+ * приклад: `http://contract-h-hl.ua-contract.svc.abie-ua.internal:8080`
13
13
  *
14
14
  * Сегменти беруться з `hasura/k8s/base/svc-hl.yaml` (`metadata.name` —
15
- * має закінчуватись на `-h`, headless-сервіс) і `hasura/k8s/base/namespace.yaml`
15
+ * headless, має закінчуватись на `-h-hl`; див. `hasura.svc_hl` / k8s.svc_hl_yaml) і
16
+ * `hasura/k8s/base/namespace.yaml`
16
17
  * (`metadata.name` — namespace). Якщо ці YAML є в репозиторії, у URL додатково
17
18
  * звіряються конкретні `<service>` і `<namespace>` з ними.
18
19
  *
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  description: Правила для директорії з hasura graphql-engine
3
- version: '1.1'
3
+ version: '1.2'
4
4
  globs: "**/hasura/**,**/*.env"
5
5
  alwaysApply: false
6
6
  ---
@@ -18,10 +18,10 @@ HASURA_GRAPHQL_ENDPOINT=https://vybeerai.com.ua/contract/ql
18
18
  Правильне значення:
19
19
 
20
20
  ```env
21
- HASURA_GRAPHQL_ENDPOINT=http://contract-h.ua-contract.svc.abie-ua.internal:8080
21
+ HASURA_GRAPHQL_ENDPOINT=http://contract-h-hl.ua-contract.svc.abie-ua.internal:8080
22
22
  ```
23
23
 
24
- де `contract-h` — це `metadata.name` сервісу з `hasura/k8s/base/svc-hl.yaml`, а `ua-contract` — `metadata.name` namespace з `hasura/k8s/base/namespace.yaml`.
24
+ де `contract-h-hl` — це `metadata.name` headless Service з `hasura/k8s/base/svc-hl.yaml` (пара з clusterIP `contract-h` у `svc.yaml`, якщо є; узгоджено з k8s: суфікс `-hl` на базі `-h`), а `ua-contract` — `metadata.name` namespace з `hasura/k8s/base/namespace.yaml`.
25
25
 
26
26
  Правило застосовується для проєктів **nitra** (у кореневому `package.json` `"repository": "https://github.com/nitra/*"`) і **abie** (`"repository": "https://github.com/abinbevefes/*"`); для інших репозиторіїв перевірка пропускається.
27
27
 
@@ -1,28 +1,50 @@
1
- # Порт мінімальної структурної перевірки `hasura/k8s/base/svc-hl.yaml` з
2
- # `npm/scripts/check-hasura.mjs` (hasura.mdc): для кожного Service у файлі
3
- # `metadata.name` має закінчуватись на `-h` (headless-сервіс Hasura).
1
+ # Іменування Service у `hasura/k8s/base/svc.yaml` та `svc-hl.yaml`, узгоджене з
2
+ # `k8s.svc_hl_yaml` / `k8s.svc_yaml` (пара clusterIP + headless під `k8s/**/`).
3
+ #
4
+ # Hasura-конвенція: базовий сегмент закінчується на `-h`; headless додає `-hl`
5
+ # → повне ім'я `*-h-hl` (також задовольняє k8s-вимогу суфікса `-hl`).
4
6
  #
5
7
  # Запуск (локально):
6
- # conftest test hasura/k8s/base/svc-hl.yaml -p npm/policy/hasura \
8
+ # conftest test hasura/k8s/base/svc-hl.yaml -p npm/rules/hasura/policy/svc_hl \
7
9
  # --namespace hasura.svc_hl
8
10
  #
9
- # Решта логіки `check-hasura.mjs` (звірення `HASURA_GRAPHQL_ENDPOINT` в `.env`-файлах
10
- # з `<service>.<namespace>.svc.<cluster>` через regex по всьому дереву репо, gating
11
- # на `repository` у кореневому `package.json`) — у JS: вона потребує текстового
12
- # парсингу `.env`-файлів, обходу дерева й cross-file resolution. JS authoritative;
13
- # ця Rego — додатковий gate (JS неявно перевіряє суфікс через звірку URL).
14
- #
15
- # Структура каталогу збігається зі шляхом пакету (regal: directory-package-mismatch).
16
- # Конвенція проєкту — `import rego.v1` + multi-value `deny contains msg if { … }`
17
- # (.cursor/rules/conftest.mdc). Лінт — `bun run lint-rego` (regal).
11
+ # Cross-file (`HASURA_GRAPHQL_ENDPOINT` YAML) — `fix/internal_urls/check.mjs`.
18
12
  package hasura.svc_hl
19
13
 
20
14
  import rego.v1
21
15
 
16
+ # Суфікс clusterIP Service у hasura/k8s/base (і база для пари з svc-hl.yaml).
17
+ hasura_cluster_suffix := "-h"
18
+
19
+ # Headless: `<cluster-name>-hl`, напр. `db-h` → `db-h-hl`.
20
+ hasura_headless_suffix := "-h-hl"
21
+
22
+ service_is_headless if {
23
+ input.kind == "Service"
24
+ spec := object.get(input, "spec", {})
25
+ is_object(spec)
26
+ spec.clusterIP == "None"
27
+ }
28
+
29
+ deny contains msg if {
30
+ service_is_headless
31
+ name := object.get(object.get(input, "metadata", {}), "name", "")
32
+ name != ""
33
+ not endswith(name, hasura_headless_suffix)
34
+ msg := sprintf(
35
+ "hasura svc-hl.yaml: headless Service %q має закінчуватись на `%s` (узгоджено з k8s.svc_hl_yaml `-hl`; hasura.mdc)",
36
+ [name, hasura_headless_suffix],
37
+ )
38
+ }
39
+
22
40
  deny contains msg if {
23
41
  input.kind == "Service"
42
+ not service_is_headless
24
43
  name := object.get(object.get(input, "metadata", {}), "name", "")
25
44
  name != ""
26
- not endswith(name, "-h")
27
- msg := sprintf("hasura svc-hl.yaml: Service %q має закінчуватись на `-h` (hasura.mdc / k8s.mdc)", [name])
45
+ not endswith(name, hasura_cluster_suffix)
46
+ msg := sprintf(
47
+ "hasura svc.yaml: clusterIP Service %q має закінчуватись на `%s` (hasura.mdc / k8s.mdc)",
48
+ [name, hasura_cluster_suffix],
49
+ )
28
50
  }
@@ -1,4 +1,6 @@
1
1
  {
2
2
  "$schema": "https://unpkg.com/@nitra/cursor/schemas/target.json",
3
- "files": { "single": "hasura/k8s/base/svc-hl.yaml" }
3
+ "files": {
4
+ "walkGlob": ["hasura/k8s/base/svc.yaml", "hasura/k8s/base/svc-hl.yaml"]
5
+ }
4
6
  }
@@ -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.22'
5
+ version: '1.23'
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` мінімум `^3.9.2`** (з **3.8.0** правило `no-restricted-syntax` забороняє `for...in`; з **3.9.2** у `getConfig` вбудовано ignore для **`**/adr/**`** — ADR-документи не валідуються ESLint, локально цей glob додавати не потрібно; також транзитивно йде **`@e18e/eslint-plugin`** для oxlint); пакет **`@e18e/eslint-plugin`** окремо не додавай. Пакети oxlint/eslint/jscpd/knip не додавай без потреби монорепо.
@@ -21,6 +21,10 @@ version: '1.22'
21
21
  }
22
22
  ```
23
23
 
24
+ Канон `type` + `scripts.lint-js` (substring requirement): [package.json.snippet.json](./policy/package_json/template/package.json.snippet.json)
25
+
26
+ У `.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
+
24
28
  У корені має бути **`.oxlintrc.json`**, який **збігається з каноном** oxlint з пакета **`@nitra/cursor`**: файл **`npm/scripts/utils/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-и дозволені. Оновити канон можна з репозиторію пакета або скопіювавши файл після **`bun ./scripts/utils/rebuild-oxlint-canonical.mjs`** (джерело правил — **`oxlint-rules.tsv`** + скелет **`oxlint-canonical-skeleton.json`**). Модуль **`@e18e/eslint-plugin`** не оголошуй окремо в **`package.json`** — він уже в залежностях **`@nitra/eslint-config`** (з **3.8.0**), oxlint підвантажує його з **`node_modules`**.
25
29
 
26
30
  Мінімум для розуміння структури (реальний корінь конфігу має збігатися з каноном повністю):
@@ -48,6 +52,8 @@ version: '1.22'
48
52
  }
49
53
  ```
50
54
 
55
+ Канон базових ключів `.jscpd.json` (`gitignore`, `exitCode`, `reporters`, `minLines`): [.jscpd.json.snippet.json](./policy/jscpd/template/.jscpd.json.snippet.json)
56
+
51
57
  ```text title=".gitignore (фрагмент)"
52
58
  .claude/worktrees/
53
59
  ```
@@ -122,6 +128,8 @@ jobs:
122
128
  bunx knip --no-config-hints
123
129
  ```
124
130
 
131
+ Канон workflow `.github/workflows/lint-js.yml`: [lint-js.yml.snippet.yml](./policy/lint_js_yml/template/lint-js.yml.snippet.yml)
132
+
125
133
  Перед **`./.github/actions/setup-bun-deps`** — **`actions/checkout@v6`** (див. **ga.mdc**). Composite: Node 24, Bun, кеш, `bun install --frozen-lockfile`.
126
134
 
127
135
  Один workflow на лінт JS; зайвий `lint.yml` з тими самими кроками — прибери.
@@ -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.8'
5
+ version: '1.9'
6
6
  ---
7
7
 
8
8
  ## Область застосування
@@ -46,16 +46,20 @@ readme.md
46
46
  }
47
47
  ```
48
48
 
49
+ Канон: [jsconfig.json.snippet.json](./policy/jsconfig/template/jsconfig.json.snippet.json)
50
+
49
51
  Якщо пакет не слідує структурі з `src/` (наприклад, лише `scripts/` у корені) — ця вимога не застосовується; для типових сервісів із `src/` файл обов’язковий і має збігатися з каноном.
50
52
 
51
53
  ## Використання @nitra/pino
52
54
 
53
55
  Проект використовує @nitra/pino для логування.
54
- Якщо в проекті присутній @nitra/bunyan, то він повинен бути замінений на @nitra/pino — як у `package.json`, так і в коді: усі `import` / `require` / динамічні `import()` з `@nitra/bunyan` (і застарілого `bunyan`) треба замінити на `@nitra/pino` і за потреби адаптувати виклики під його API.
56
+ Якщо в проекті присутній @nitra/bunyan, то він повинен бути замінений на @nitra/pino — як у `package.json`, так і в коді: усі `import` / `require` / динамічні `import()` з `@nitra/bunyan` (і застарілого `bunyan`) треба замінити на `@nitra/pino` і за потреби адаптувати виклики під його API. Канон заборонених `dependencies` / `devDependencies` (`bunyan`, `@nitra/bunyan`): [package.json.deny.json](./policy/package_json/template/package.json.deny.json)
55
57
 
56
58
  В **/k8s/base/configmap.yaml повинен бути заданий OTEL_RESOURCE_ATTRIBUTES: 'service.name=<project_name>,service.namespace=<project_namespace>'
57
59
  а в директоріях з kustomize повинні бути перевизначені значення OTEL_RESOURCE_ATTRIBUTES і в них service.namespace повинен відповідати namespace, в якому знаходиться дана директорія.
58
60
 
61
+ Канон обовʼязкових substring у `data.OTEL_RESOURCE_ATTRIBUTES` (`service.name=`, `service.namespace=`): [configmap.yaml.contains.yml](./policy/configmap/template/configmap.yaml.contains.yml)
62
+
59
63
  ## Внутрішні аліаси
60
64
 
61
65
  Якщо в проекті є підключення до баз даних, зовнішніх graphql на кшталт:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  description: Обробка та перевірка текстових файлів, oxfmt, cspell, shellcheck (sh), dotenv-linter (.env*), markdownlint-cli2, v8r, CI
3
3
  alwaysApply: true
4
- version: '1.27'
4
+ version: '1.28'
5
5
  ---
6
6
 
7
7
  **oxfmt** (`.oxfmtrc.json`, редактор), **cspell**, **shellcheck** (tracked `*.sh` у `lint-text`), **[dotenv-linter](https://dotenv-linter.github.io/)** (`.env*` у `lint-text`), **markdownlint-cli2**, **[v8r](https://chris48s.github.io/v8r/)** ([Schema Store](https://www.schemastore.org/)), розширення **DavidAnson.vscode-markdownlint** / **timonwong.shellcheck**, workflow **`lint-text`**.
@@ -20,6 +20,8 @@ version: '1.27'
20
20
  }
21
21
  ```
22
22
 
23
+ Канон `recommendations` (substring requirement): [extensions.json.snippet.json](./policy/vscode_extensions/template/extensions.json.snippet.json)
24
+
23
25
  ```json title=".vscode/settings.json"
24
26
  {
25
27
  "files.associations": {
@@ -79,6 +81,8 @@ version: '1.27'
79
81
  }
80
82
  ```
81
83
 
84
+ Канон `editor.formatOnSave` + `editor.defaultFormatter` для основних мов: [settings.json.snippet.json](./policy/vscode_settings/template/settings.json.snippet.json)
85
+
82
86
  У корені проєкту має бути файл з правилами форматування для **oxfmt**:
83
87
 
84
88
  ```json title=".oxfmtrc.json"
@@ -105,9 +109,11 @@ version: '1.27'
105
109
  }
106
110
  ```
107
111
 
112
+ Канон мінімального набору ключів і `ignorePatterns`: [.oxfmtrc.json.snippet.json](./policy/oxfmtrc/template/.oxfmtrc.json.snippet.json)
113
+
108
114
  Поле **`ignorePatterns`** обовʼязкове: у масиві мають бути **`**/hasura/metadata/**`**, **`**/schema.graphql`** і **`**/auto-imports.d.ts`**; інші glob-и додавай за потреби (згенеровані каталоги тощо) — канон задає мінімум, локальні розширення дозволені.
109
115
 
110
- Також потрібно прибрати, якщо є в проєкті, модуль **`@nitra/prettier-config`**, **prettier** та всі виклики prettier і налаштування для нього.
116
+ Також потрібно прибрати, якщо є в проєкті, модуль **`@nitra/prettier-config`**, **prettier** та всі виклики prettier і налаштування для нього. Канон заборонених top-level/`dependencies`/`devDependencies` (prettier, `@nitra/prettier-config`, `markdownlint-cli2`): [package.json.deny.json](./policy/package_json/template/package.json.deny.json)
111
117
 
112
118
  Завжди пиши **JSDoc** до функцій та методів.
113
119
 
@@ -156,6 +162,8 @@ version: '1.27'
156
162
  }
157
163
  ```
158
164
 
165
+ Канон: [.markdownlint-cli2.jsonc.snippet.jsonc](./policy/markdownlint/template/.markdownlint-cli2.jsonc.snippet.jsonc)
166
+
159
167
  **MD041** off навмисно (`.mdc` з frontmatter). Деталі — [markdownlint-cli2](https://github.com/DavidAnson/markdownlint-cli2).
160
168
 
161
169
  ## Cspell
@@ -238,6 +246,8 @@ jobs:
238
246
  }
239
247
  ```
240
248
 
249
+ Канон базових ключів `.cspell.json` (`version`, `ignorePaths`): [.cspell.json.snippet.json](./policy/cspell/template/.cspell.json.snippet.json). Обовʼязковий запис у `import`: [.cspell.json.contains.json](./policy/cspell/template/.cspell.json.contains.json). Заборонено імпортувати окремі `@cspell/dict-*` у `.cspell.json`: [.cspell.json.deny.json](./policy/cspell/template/.cspell.json.deny.json).
250
+
241
251
  ```json title="package.json"
242
252
  {
243
253
  "scripts": {
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Уніфікований маніфест пакета для перевірок changelog: `package.json` (npm/JS)
3
+ * або `pyproject.toml` (Python / PEP 621, Poetry).
4
+ */
5
+ import { existsSync } from 'node:fs'
6
+ import { glob, readFile } from 'node:fs/promises'
7
+ import { dirname, join, relative } from 'node:path'
8
+
9
+ import { parse as parseToml } from 'smol-toml'
10
+
11
+ import { getMonorepoPackageRootDirs } from './workspaces.mjs'
12
+
13
+ /** @typedef {'npm' | 'python'} PackageKind */
14
+
15
+ /**
16
+ * @typedef {object} PackageManifest
17
+ * @property {PackageKind} kind
18
+ * @property {string} ws відносний шлях воркспейсу (`'.'` для кореня)
19
+ * @property {string} manifestRel `package.json` | `pyproject.toml`
20
+ * @property {string | null} name ім'я пакета (npm / PyPI)
21
+ * @property {string | null} version semver-рядок
22
+ * @property {boolean} registryPublishable чи застосовується режим порівняння з реєстром
23
+ * @property {string[] | null} [npmFiles] лише npm: `files` з package.json
24
+ */
25
+
26
+ const PYPROJECT_GLOB_IGNORE = ['**/node_modules/**', '**/.git/**', '**/.venv/**', '**/venv/**']
27
+
28
+ /**
29
+ * @param {unknown} doc розпарсений pyproject.toml
30
+ * @returns {{ name: string | null, version: string | null }}
31
+ */
32
+ function projectFieldsFromPyprojectDoc(doc) {
33
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
34
+ return { name: null, version: null }
35
+ }
36
+ const root = /** @type {Record<string, unknown>} */ (doc)
37
+ const project = root.project
38
+ if (project && typeof project === 'object' && !Array.isArray(project)) {
39
+ const p = /** @type {Record<string, unknown>} */ (project)
40
+ return {
41
+ name: typeof p.name === 'string' ? p.name : null,
42
+ version: typeof p.version === 'string' ? p.version : null,
43
+ }
44
+ }
45
+ const tool = root.tool
46
+ if (tool && typeof tool === 'object' && !Array.isArray(tool)) {
47
+ const poetry = /** @type {Record<string, unknown>} */ (tool).poetry
48
+ if (poetry && typeof poetry === 'object' && !Array.isArray(poetry)) {
49
+ const po = /** @type {Record<string, unknown>} */ (poetry)
50
+ return {
51
+ name: typeof po.name === 'string' ? po.name : null,
52
+ version: typeof po.version === 'string' ? po.version : null,
53
+ }
54
+ }
55
+ }
56
+ return { name: null, version: null }
57
+ }
58
+
59
+ /**
60
+ * @param {string} text вміст pyproject.toml
61
+ * @returns {{ name: string | null, version: string | null }}
62
+ */
63
+ export function parsePyprojectFields(text) {
64
+ try {
65
+ return projectFieldsFromPyprojectDoc(parseToml(text))
66
+ } catch {
67
+ return { name: null, version: null }
68
+ }
69
+ }
70
+
71
+ /**
72
+ * @param {string} ws шлях воркспейсу
73
+ * @returns {Promise<PackageManifest | null>}
74
+ */
75
+ export async function readPackageManifest(ws) {
76
+ const pkgPath = join(ws, 'package.json')
77
+ if (existsSync(pkgPath)) {
78
+ try {
79
+ const parsed = JSON.parse(await readFile(pkgPath, 'utf8'))
80
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
81
+ return null
82
+ }
83
+ const pkg = /** @type {Record<string, unknown>} */ (parsed)
84
+ const registryPublishable =
85
+ typeof pkg.name === 'string' &&
86
+ pkg.name.length > 0 &&
87
+ pkg.private !== true &&
88
+ Array.isArray(pkg.files)
89
+ return {
90
+ kind: 'npm',
91
+ ws,
92
+ manifestRel: 'package.json',
93
+ name: typeof pkg.name === 'string' ? pkg.name : null,
94
+ version: typeof pkg.version === 'string' ? pkg.version : null,
95
+ registryPublishable,
96
+ npmFiles: Array.isArray(pkg.files) ? pkg.files : null,
97
+ }
98
+ } catch {
99
+ return null
100
+ }
101
+ }
102
+
103
+ const pyPath = join(ws, 'pyproject.toml')
104
+ if (!existsSync(pyPath)) {
105
+ return null
106
+ }
107
+ const fields = parsePyprojectFields(await readFile(pyPath, 'utf8'))
108
+ const registryPublishable = Boolean(fields.name && fields.version)
109
+ return {
110
+ kind: 'python',
111
+ ws,
112
+ manifestRel: 'pyproject.toml',
113
+ name: fields.name,
114
+ version: fields.version,
115
+ registryPublishable,
116
+ npmFiles: null,
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Каталоги пакетів: npm (`package.json` / workspaces) + Python (`pyproject.toml` без package.json).
122
+ * @param {string} [repoRoot]
123
+ * @returns {Promise<string[]>}
124
+ */
125
+ export async function getMonorepoProjectRootDirs(repoRoot = '.') {
126
+ const roots = new Set(await getMonorepoPackageRootDirs(repoRoot))
127
+
128
+ if (existsSync(join(repoRoot, 'pyproject.toml')) && !existsSync(join(repoRoot, 'package.json'))) {
129
+ roots.add('.')
130
+ }
131
+
132
+ for await (const relPy of glob('**/pyproject.toml', { cwd: repoRoot, ignore: PYPROJECT_GLOB_IGNORE })) {
133
+ const absDir = dirname(join(repoRoot, relPy))
134
+ const relRoot = relative(repoRoot, absDir)
135
+ const ws = relRoot === '' ? '.' : relRoot
136
+ if (!existsSync(join(repoRoot, ws, 'package.json'))) {
137
+ roots.add(ws)
138
+ }
139
+ }
140
+
141
+ const list = [...roots]
142
+ list.sort((a, b) => {
143
+ if (a === '.') return -1
144
+ if (b === '.') return 1
145
+ return a.localeCompare(b)
146
+ })
147
+ return list
148
+ }
149
+
150
+ /**
151
+ * Шлях до файлу маніфесту воркспейсу.
152
+ * @param {string} ws
153
+ * @param {PackageManifest} manifest
154
+ * @returns {string}
155
+ */
156
+ export function manifestFilePath(ws, manifest) {
157
+ return join(ws, manifest.manifestRel)
158
+ }
@@ -28,8 +28,9 @@ async function parseByExt(path) {
28
28
  }
29
29
 
30
30
  function stripJsonComments(s) {
31
- // Minimal: strip // line comments and /* */ block comments. JSON-with-comments format.
32
- return s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
31
+ // Match string literals OR comments. Strings are returned unchanged so we never
32
+ // strip `/*` / `//` / `*/` that appear inside values (e.g. glob `**/node_modules/**`).
33
+ return s.replace(/"(?:\\.|[^"\\])*"|\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, m => (m.startsWith('"') ? m : ''))
33
34
  }
34
35
 
35
36
  async function walk(dir, base = dir) {