@nitra/cursor 1.13.64 → 1.13.66

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.
@@ -334,7 +334,19 @@ jq -c '.operations[]' "$RESPONSE_CLEAN_FILE" | while IFS= read -r op_json; do
334
334
  continue
335
335
  ;;
336
336
  esac
337
- DEST_PATH=$(resolve_unique_slug_path "$SLUG")
337
+ # Keep the draft's `YYYYMMDD-HHMMSS-` prefix on the clean file: the name
338
+ # stays anchored to capture time, only the slug part changes between draft
339
+ # and clean, and docs/adr/ keeps sorting chronologically. Drafts without a
340
+ # timestamp prefix fall back to a bare `<slug>.md`.
341
+ case "$FILE" in
342
+ [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*)
343
+ DEST_SLUG="$(printf '%s' "$FILE" | cut -c1-15)-$SLUG"
344
+ ;;
345
+ *)
346
+ DEST_SLUG="$SLUG"
347
+ ;;
348
+ esac
349
+ DEST_PATH=$(resolve_unique_slug_path "$DEST_SLUG")
338
350
  printf '%s\n' "$CONTENT" > "$DEST_PATH"
339
351
  rm -- "$SRC_PATH"
340
352
  log "rewrite: $FILE → $(basename "$DEST_PATH")"
package/CHANGELOG.md CHANGED
@@ -4,6 +4,18 @@
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.66] - 2026-05-20
8
+
9
+ ### Changed
10
+
11
+ - `adr`: `normalize-decisions.sh` тепер зберігає `YYYYMMDD-HHMMSS-`-префікс чернетки в імені clean-файлу — операція `rewrite` пише результат у `<timestamp>-<slug>.md` замість bare `<slug>.md`. Причина: під час нормалізації LLM генерує `slug` заново, тож раніше чернетка `20260518-092807-foo.md` ставала clean-файлом з абсолютно іншим іменем `bar.md` — назва «стрибала» цілком. Тепер timestamp-префікс лишається стабільним якорем: між draft і clean змінюється лише slug-частина, а `docs/adr/` сортується хронологічно (capture-час). Чернетки без `YYYYMMDD-HHMMSS-`-префікса лишаються на fallback bare `<slug>.md`. Колізії resolve'яться як і раніше — детермінований суфікс `-2`, `-3`, тепер на повному імені `<timestamp>-<slug>-N.md`. Зачеплено: [normalize-decisions.sh](.claude-template/hooks/normalize-decisions.sh) (нова `case`-гілка у rewrite-операції обчислює `DEST_SLUG` з timestamp-префіксом перед `resolve_unique_slug_path`), [adr.mdc](rules/adr/adr.mdc) (опис clean-формату, рядок таблиці `rewrite`, дерево каталогу `docs/adr/` й абзац про `slug`), [SKILL.md](skills/adr-normalize/SKILL.md) (опис rewrite-результату й дублів імен). Bump `adr.mdc` `2.0` → `2.1`.
12
+
13
+ ## [1.13.65] - 2026-05-20
14
+
15
+ ### Changed
16
+
17
+ - Скіл **`n-abie-clean`**: розділ перекладів — апострофи та спецсимволи в англійських рядках (`tr`); після очистки — обовʼязкова локальна перевірка `bun vite build` поряд із `check abie`.
18
+
7
19
  ## [1.13.64] - 2026-05-20
8
20
 
9
21
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.13.64",
3
+ "version": "1.13.66",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
package/rules/adr/adr.mdc CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  description: Автоматичний збір ADR/Runbook/Knowledge-чернеток і батч-нормалізація у `docs/adr/` через Stop-хуки Claude Code та Cursor Agent
3
3
  alwaysApply: true
4
- version: '2.0'
4
+ version: '2.1'
5
5
  ---
6
6
 
7
7
  ## MADR v4 і дві фази
@@ -19,7 +19,7 @@ ADR живуть у єдиному каталозі **`docs/adr/`**. Clean ADR-
19
19
  Є два стани файлу, які відрізняються YAML frontmatter:
20
20
 
21
21
  - **Draft** — файл з frontmatter `session: …`, `captured: …`, `transcript: …` та timestamp-іменем `YYYYMMDD-HHMMSS-<sid>.md`. Пише `capture-decisions.sh` після кожної сесії.
22
- - **Clean** — файл без frontmatter, з kebab-case-іменем `<slug>.md` (наприклад `ланцюжок-запуску-abie.md`). Створює `normalize-decisions.sh` або людина руками.
22
+ - **Clean** — файл без frontmatter, з kebab-case-іменем. `normalize-decisions.sh` зберігає timestamp-префікс чернетки → `YYYYMMDD-HHMMSS-<slug>.md` (наприклад `20260518-092807-ланцюжок-запуску-abie.md`); створений руками clean-файл може мати просто `<slug>.md`.
23
23
 
24
24
  `normalize-decisions.sh` ніколи не чіпає clean-файли — крім випадку `merge-into`, коли дописує `## Update YYYY-MM-DD` в кінець наявного clean-файлу.
25
25
 
@@ -44,10 +44,10 @@ LLM повертає масив операцій:
44
44
  | `op` | Семантика | Поля |
45
45
  | --- | --- | --- |
46
46
  | `delete` | Чернетка тривіальна / повністю покрита іншим clean-ADR-ом. | `file`, `reason` |
47
- | `rewrite` | Чернетка стає окремим clean-файлом MADR v4 minimal: frontmatter знімається, ім'я → `<slug>.md`, додаються `**Status:** Accepted`, `**Date:**` з `captured` і canonical MADR headings. | `file`, `slug`, `content` |
47
+ | `rewrite` | Чернетка стає окремим clean-файлом MADR v4 minimal: frontmatter знімається, ім'я → `<timestamp>-<slug>.md` (timestamp-префікс чернетки збережено), додаються `**Status:** Accepted`, `**Date:**` з `captured` і canonical MADR headings. | `file`, `slug`, `content` |
48
48
  | `merge-into` | Чернетка повторює тему вже існуючого clean-файлу; дописуємо `## Update YYYY-MM-DD` у кінець `target`. | `file`, `target`, `additions` |
49
49
 
50
- `slug` — kebab-case українською (`ланцюжок-запуску-abie`, `npm-publish-flow`); англійські технічні терміни лишаються англійською без транслітерації. Колізія slug-ів обробляється детермінованим суфіксом `-2`, `-3`.
50
+ `slug` — kebab-case українською (`ланцюжок-запуску-abie`, `npm-publish-flow`); англійські технічні терміни лишаються англійською без транслітерації. До імені clean-файлу скрипт додає `YYYYMMDD-HHMMSS-` чернетки, тож запис лишається прив'язаним до часу capture, а `docs/adr/` сортується хронологічно. Колізія імен обробляється детермінованим суфіксом `-2`, `-3`.
51
51
 
52
52
  ### Жодних git-операцій
53
53
 
@@ -82,8 +82,8 @@ LLM повертає масив операцій:
82
82
 
83
83
  ```text
84
84
  docs/adr/
85
- ├── YYYYMMDD-HHMMSS-<sid>.md # drafts (frontmatter session:/captured:/transcript:)
86
- └── <slug>.md # clean ADR-и (без frontmatter)
85
+ ├── YYYYMMDD-HHMMSS-<sid>.md # drafts (frontmatter session:/captured:/transcript:)
86
+ └── YYYYMMDD-HHMMSS-<slug>.md # clean ADR-и (без frontmatter, timestamp-префікс чернетки збережено)
87
87
  .claude/hooks/
88
88
  ├── capture-decisions.sh # auto-synced з пакета
89
89
  ├── normalize-decisions.sh # auto-synced з пакета
@@ -22,6 +22,9 @@ import { readFile } from 'node:fs/promises'
22
22
 
23
23
  import { createCheckReporter } from '../../../../scripts/utils/check-reporter.mjs'
24
24
 
25
+ /** Розділювач токенів у `scripts.lint` (послідовність пробільних символів). */
26
+ const WHITESPACE_RE = /\s+/u
27
+
25
28
  // Перевірка `devDependencies` кореневого `package.json` (дозволено лише `@nitra/*`)
26
29
  // — у rego (`npm/policy/bun/package_json/`). JS-копії `isAllowedRootDevDependency`
27
30
  // видалено, щоб не було двох джерел істини.
@@ -53,8 +56,8 @@ async function loadNCursorRules() {
53
56
  */
54
57
  function lintChainHasScript(lintScript, target) {
55
58
  if (!lintScript) return false
56
- const escaped = target.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`)
57
- return new RegExp(String.raw`(?:^|\s)bun\s+run\s+${escaped}(?:$|\s)`, 'u').test(lintScript)
59
+ const tokens = lintScript.split(WHITESPACE_RE)
60
+ return tokens.some((tok, i) => tok === 'bun' && tokens[i + 1] === 'run' && tokens[i + 2] === target)
58
61
  }
59
62
 
60
63
  /**
@@ -287,7 +287,7 @@ async function checkVueAvifImports(ignorePaths, usedAvifAbs, stats, pass, fail)
287
287
  * переписати на AVIF-двійник (через `import x from '...png'` або `<img src="...png" />`).
288
288
  *
289
289
  * Якщо false — весь подальший етап `image-avif` пропускаємо: ні `npx --avif`, ні rewrite,
290
- * ні cleanup-сиріт не дали б ніяких змін. Сенс — не запускати дорогий `npx @nitra/minify-image`
290
+ * ні cleanup-сиріт не дали б ніяких змін. Сенс — не запускати дорогий `npx \@nitra/minify-image`
291
291
  * у проєктах, де AVIF не вживається (а опційно і не плануються).
292
292
  *
293
293
  * Скан робиться тими самими regexp-ами, що й основний rewrite-пасс (`VUE_RASTER_IMPORT_RE`
@@ -1319,15 +1319,11 @@ export async function collectResourceDescriptorsForKustomizationWalk(kustAbs, ro
1319
1319
  */
1320
1320
  const out = []
1321
1321
 
1322
- /*
1323
- * @param {string} ref шлях з resources/bases/…
1324
-
1325
- * @returns {Promise<void>} результат
1326
- */
1327
1322
  /**
1328
- *
1329
- * @param {*} ref параметр
1330
- */ async function handleResourceDescriptorPathRef(ref) {
1323
+ * @param {string} ref шлях з resources/bases/…
1324
+ * @returns {Promise<void>} результат
1325
+ */
1326
+ async function handleResourceDescriptorPathRef(ref) {
1331
1327
  if (typeof ref !== 'string' || ref.includes('://')) {
1332
1328
  return
1333
1329
  }
@@ -2903,15 +2899,11 @@ export function collectGatewayApiRouteBackendServiceNames(spec) {
2903
2899
  */
2904
2900
  const out = []
2905
2901
 
2906
- /*
2907
- * @param {unknown} node вузол для обходу
2908
-
2909
- * @returns {void} результат
2910
- */
2911
2902
  /**
2912
- *
2913
- * @param {*} node параметр
2914
- */ function walk(node) {
2903
+ * @param {unknown} node вузол для обходу
2904
+ * @returns {void} результат
2905
+ */
2906
+ function walk(node) {
2915
2907
  if (node === null || node === undefined) return
2916
2908
  if (Array.isArray(node)) {
2917
2909
  for (const x of node) {
@@ -2947,15 +2939,11 @@ export function collectGatewayApiRouteBackendRefsWithRedundantNamespace(spec, ro
2947
2939
  */
2948
2940
  const out = []
2949
2941
 
2950
- /*
2951
- * @param {unknown} node вузол для обходу
2952
-
2953
- * @returns {void} результат
2954
- */
2955
2942
  /**
2956
- *
2957
- * @param {*} node параметр
2958
- */ function walk(node) {
2943
+ * @param {unknown} node вузол для обходу
2944
+ * @returns {void} результат
2945
+ */
2946
+ function walk(node) {
2959
2947
  if (node === null || node === undefined) return
2960
2948
  if (Array.isArray(node)) {
2961
2949
  for (const x of node) {
@@ -6385,6 +6373,21 @@ function networkPolicyHasLegacyCatchAllEgress(doc) {
6385
6373
  return false
6386
6374
  }
6387
6375
 
6376
+ /**
6377
+ * Витягує `spec.podSelector.matchLabels.app` як рядок (порожній — якщо ланцюжок неповний).
6378
+ * @param {unknown} spec значення `spec` NetworkPolicy-документа
6379
+ * @returns {string} app-label або '' (коли структура не відповідає очікуваній)
6380
+ */
6381
+ function networkPolicyPodSelectorAppLabel(spec) {
6382
+ if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) return ''
6383
+ const podSelector = /** @type {Record<string, unknown>} */ (spec).podSelector
6384
+ if (podSelector === null || typeof podSelector !== 'object' || Array.isArray(podSelector)) return ''
6385
+ const matchLabels = /** @type {Record<string, unknown>} */ (podSelector).matchLabels
6386
+ if (matchLabels === null || typeof matchLabels !== 'object' || Array.isArray(matchLabels)) return ''
6387
+ const app = /** @type {Record<string, unknown>} */ (matchLabels).app
6388
+ return typeof app === 'string' ? app : ''
6389
+ }
6390
+
6388
6391
  /**
6389
6392
  * Migrate legacy `networkpolicy.yaml`: якщо хоч один документ має catch-all in-cluster egress —
6390
6393
  * перезаписати **всі** документи у файлі через `buildNetworkPolicyYaml(name, appLabel)`. Деталі — k8s.mdc.
@@ -6404,17 +6407,7 @@ export async function regenerateLegacyNetworkPolicyDocsInFile(npAbs) {
6404
6407
  for (const doc of docs) {
6405
6408
  const name = manifestMetadataName(doc)
6406
6409
  const spec = /** @type {Record<string, unknown>} */ (doc).spec
6407
- let appLabel = ''
6408
- if (spec !== null && typeof spec === 'object' && !Array.isArray(spec)) {
6409
- const podSelector = /** @type {Record<string, unknown>} */ (spec).podSelector
6410
- if (podSelector !== null && typeof podSelector === 'object' && !Array.isArray(podSelector)) {
6411
- const matchLabels = /** @type {Record<string, unknown>} */ (podSelector).matchLabels
6412
- if (matchLabels !== null && typeof matchLabels === 'object' && !Array.isArray(matchLabels)) {
6413
- const a = /** @type {Record<string, unknown>} */ (matchLabels).app
6414
- if (typeof a === 'string') appLabel = a
6415
- }
6416
- }
6417
- }
6410
+ const appLabel = networkPolicyPodSelectorAppLabel(spec)
6418
6411
  if (typeof name === 'string' && name !== '' && appLabel !== '') specs.push({ name, appLabel })
6419
6412
  }
6420
6413
  if (specs.length === 0) return false
@@ -31,6 +31,9 @@ const KUBESCAPE_EXCEPTIONS_FILE = '.kubescape-exceptions.json'
31
31
  /** Назва kustomization-файлу (під `k8s` дозволено лише `.yaml`, див. k8s.mdc). */
32
32
  const KUSTOMIZATION_FILE = 'kustomization.yaml'
33
33
 
34
+ /** Підказка встановлення kubescape (PATH miss / ENOENT). */
35
+ const KUBESCAPE_MISSING_HINT = 'kubescape не знайдено в PATH. Встанови з https://github.com/kubescape/kubescape#readme'
36
+
34
37
  const PATH_SEPARATOR_RE = /[/\\]/u
35
38
  const YAML_EXT_RE = /\.yaml$/iu
36
39
 
@@ -223,6 +226,50 @@ function runKubescapeManifest(kubescapePath, manifest, exceptionsArgs) {
223
226
  }
224
227
  }
225
228
 
229
+ /**
230
+ * Сирий dir-скан kubescape для `…/k8s`-кореня без білдабельного `kustomization.yaml`.
231
+ * @param {string} kubescapePath абсолютний шлях до бінарника kubescape
232
+ * @param {string} dir абсолютний шлях до `…/k8s`
233
+ * @param {string[]} exceptionsArgs `['--exceptions', '<file>']` або `[]`
234
+ * @returns {number} 0 при успіху, 127 якщо kubescape зник з PATH, інакше код процесу
235
+ */
236
+ function scanRawK8sDir(kubescapePath, dir, exceptionsArgs) {
237
+ console.log(`run-k8s: kubescape scan ${dir} (без kustomization — сирий dir-скан)`)
238
+ const r = spawnSync(kubescapePath, ['scan', dir, '--severity-threshold', 'high', ...exceptionsArgs], {
239
+ stdio: 'inherit',
240
+ shell: false
241
+ })
242
+ if (r.error && 'code' in r.error && r.error.code === 'ENOENT') {
243
+ console.error(KUBESCAPE_MISSING_HINT)
244
+ return 127
245
+ }
246
+ return r.status ?? 1
247
+ }
248
+
249
+ /**
250
+ * Скан kubescape'ом зібраних kustomize-маніфестів: для кожного `kustomization.yaml`-каталогу
251
+ * `kubectl kustomize <dir>` → `kubescape scan <tmp>`.
252
+ * @param {string} kubectlPath абсолютний шлях до бінарника kubectl
253
+ * @param {string} kubescapePath абсолютний шлях до бінарника kubescape
254
+ * @param {string[]} kdirs абсолютні шляхи каталогів з білдабельним `kustomization.yaml`
255
+ * @param {string[]} exceptionsArgs `['--exceptions', '<file>']` або `[]`
256
+ * @returns {number} 0 при успіху, 127 якщо kubescape зник з PATH, інакше код невдалого процесу
257
+ */
258
+ function scanKustomizeK8sDirs(kubectlPath, kubescapePath, kdirs, exceptionsArgs) {
259
+ for (const kdir of kdirs) {
260
+ console.log(`run-k8s: kubectl kustomize ${kdir} | kubescape scan <tmp>`)
261
+ const build = runKustomizeBuild(kubectlPath, kdir)
262
+ if (build.status !== 0) return build.status
263
+ const ks = runKubescapeManifest(kubescapePath, build.stdout, exceptionsArgs)
264
+ if (ks.enoent) {
265
+ console.error(KUBESCAPE_MISSING_HINT)
266
+ return 127
267
+ }
268
+ if (ks.status !== 0) return ks.status
269
+ }
270
+ return 0
271
+ }
272
+
226
273
  /**
227
274
  * Запускає kubescape по зібраному kustomize-маніфесту для кожного `…/k8s`-кореня. Для кожного
228
275
  * dir-у з `kustomization.yaml` (крім `kind: Component`) робимо `kubectl kustomize <dir>` і
@@ -248,23 +295,15 @@ async function runKubescape(dirs, root) {
248
295
  }
249
296
  const kubescapePath = resolveCmd('kubescape')
250
297
  if (!kubescapePath) {
251
- console.error('kubescape не знайдено в PATH. Встанови з https://github.com/kubescape/kubescape#readme')
298
+ console.error(KUBESCAPE_MISSING_HINT)
252
299
  return 127
253
300
  }
254
301
  let kubectlPath = null
255
302
  for (const d of dirs) {
256
303
  const kdirs = await findKustomizationDirs(d)
257
304
  if (kdirs.length === 0) {
258
- console.log(`run-k8s: kubescape scan ${d} (без kustomization — сирий dir-скан)`)
259
- const r = spawnSync(kubescapePath, ['scan', d, '--severity-threshold', 'high', ...exceptionsArgs], {
260
- stdio: 'inherit',
261
- shell: false
262
- })
263
- if (r.error && 'code' in r.error && r.error.code === 'ENOENT') {
264
- console.error('kubescape не знайдено в PATH. Встанови з https://github.com/kubescape/kubescape#readme')
265
- return 127
266
- }
267
- if (r.status !== 0) return r.status ?? 1
305
+ const rawStatus = scanRawK8sDir(kubescapePath, d, exceptionsArgs)
306
+ if (rawStatus !== 0) return rawStatus
268
307
  continue
269
308
  }
270
309
  if (kubectlPath === null) {
@@ -274,17 +313,8 @@ async function runKubescape(dirs, root) {
274
313
  return 127
275
314
  }
276
315
  }
277
- for (const kdir of kdirs) {
278
- console.log(`run-k8s: kubectl kustomize ${kdir} | kubescape scan <tmp>`)
279
- const build = runKustomizeBuild(kubectlPath, kdir)
280
- if (build.status !== 0) return build.status
281
- const ks = runKubescapeManifest(kubescapePath, build.stdout, exceptionsArgs)
282
- if (ks.enoent) {
283
- console.error('kubescape не знайдено в PATH. Встанови з https://github.com/kubescape/kubescape#readme')
284
- return 127
285
- }
286
- if (ks.status !== 0) return ks.status
287
- }
316
+ const buildStatus = scanKustomizeK8sDirs(kubectlPath, kubescapePath, kdirs, exceptionsArgs)
317
+ if (buildStatus !== 0) return buildStatus
288
318
  }
289
319
  return 0
290
320
  }
@@ -4,7 +4,8 @@ import { basename, extname, join } from 'node:path'
4
4
 
5
5
  const MD_LINK_RE = /\[([^\]]{1,200})\]\((\.\/[^)]{1,500})\)/g
6
6
  const TEMPLATE_SEGMENT_RE = /\/template\//
7
- const SLOTS = ['snippet', 'deny', 'contains']
7
+ /** Статичні regexp-літерали `^(.+)\.<slot>\.<ext>$` без `RegExp(variable)`. */
8
+ const SLOT_SUFFIX_RES = [/^(.+)\.snippet\.[^.]+$/, /^(.+)\.deny\.[^.]+$/, /^(.+)\.contains\.[^.]+$/]
8
9
 
9
10
  /**
10
11
  * @param {string} filePath шлях до файлу
@@ -25,8 +26,8 @@ function langFromExt(filePath) {
25
26
  * @returns {string} ім'я реального target-файлу
26
27
  */
27
28
  function normalizeTargetName(fileBasename) {
28
- for (const slot of SLOTS) {
29
- const m = fileBasename.match(new RegExp(String.raw`^(.+)\.${slot}\.[^.]+$`))
29
+ for (const re of SLOT_SUFFIX_RES) {
30
+ const m = fileBasename.match(re)
30
31
  if (m) return m[1]
31
32
  }
32
33
  return fileBasename
@@ -11,23 +11,17 @@ import { parse as parseToml } from 'smol-toml'
11
11
  import { getMonorepoPackageRootDirs } from './workspaces.mjs'
12
12
 
13
13
  /**
14
- @typedef {'npm' | 'python'} PackageKind
14
+ * @typedef {'npm' | 'python'} PackageKind
15
15
  */
16
16
 
17
- /*
17
+ /**
18
18
  * @typedef {object} PackageManifest
19
-
20
- * @property {PackageKind} kind поле
19
+ * @property {PackageKind} kind тип маніфесту
21
20
  * @property {string} ws відносний шлях воркспейсу (`'.'` для кореня)
22
-
23
21
  * @property {string} manifestRel `package.json` | `pyproject.toml`
24
-
25
22
  * @property {string | null} name ім'я пакета (npm / PyPI)
26
-
27
23
  * @property {string | null} version semver-рядок
28
-
29
24
  * @property {boolean} registryPublishable чи застосовується режим порівняння з реєстром
30
-
31
25
  * @property {string[] | null} [npmFiles] лише npm: `files` з package.json
32
26
  */
33
27
 
@@ -47,27 +47,19 @@ function failConftestMissing() {
47
47
  )
48
48
  }
49
49
 
50
- /*
50
+ /**
51
51
  * @typedef {object} ConftestViolation
52
-
53
52
  * @property {string} filename абсолютний шлях до файла, що дав порушення (з output conftest)
54
-
55
53
  * @property {string} message текст порушення (як у `deny` rego-пакета)
56
-
57
54
  * @property {string} namespace namespace rego-пакета (наприклад `abie.base_deployment_preem`)
58
55
  */
59
56
 
60
- /*
57
+ /**
61
58
  * @typedef {object} ConftestBatchOptions
62
-
63
59
  * @property {string} policyDirRel шлях до підкаталогу `npm/policy/...` (наприклад `abie/base_deployment_preem`)
64
-
65
60
  * @property {string} namespace повне імʼя rego-пакета (наприклад `abie.base_deployment_preem`)
66
-
67
61
  * @property {string[]} files список абсолютних шляхів файлів для перевірки (порожній — повертаємо порожньо)
68
-
69
62
  * @property {string[]} [extraArgs] додаткові аргументи для conftest (наприклад `--combine` для крос-документних правил)
70
-
71
63
  * @property {object} [templateData] опціональне merged-дерево; серіалізується у JSON `{ "template": <data> }` і передається як `--data <tmpfile>` (cleanup після завершення)
72
64
  */
73
65
 
@@ -3,7 +3,7 @@
3
3
  * by target basename. For each <target>, returns whichever of snippet/deny/contains
4
4
  * exist (parsed in native format by extension).
5
5
  * @param {string} concernDir absolute path to fix/<concern>/ or policy/<concern>/
6
- * @returns {Promise<Record<string, { snippet?: any, deny?: any, contains?: any }>>}
6
+ * @returns {Promise<Record<string, { snippet?: unknown, deny?: unknown, contains?: unknown }>>}
7
7
  */
8
8
  import { existsSync } from 'node:fs'
9
9
  import { readdir, readFile, stat } from 'node:fs/promises'
@@ -11,7 +11,12 @@ import { basename as _basename, extname, join, relative } from 'node:path'
11
11
 
12
12
  import { parse as parseToml } from 'smol-toml'
13
13
 
14
- const SLOTS = ['snippet', 'deny', 'contains']
14
+ /** `<target>.<slot>.<ext>` класифікатори статичні regexp-літерали (без `RegExp(variable)`). */
15
+ const SLOT_CLASSIFIERS = [
16
+ { slot: 'snippet', re: /^(?<target>.+)\.snippet\.[^.]+$/ },
17
+ { slot: 'deny', re: /^(?<target>.+)\.deny\.[^.]+$/ },
18
+ { slot: 'contains', re: /^(?<target>.+)\.contains\.[^.]+$/ }
19
+ ]
15
20
  const IDENT_RE = /^[a-zA-Z_$][\w$]*$/
16
21
  const NEWLINE_RE = /\r?\n/
17
22
  const LEADING_BANG_RE = /^!/
@@ -66,8 +71,8 @@ async function walk(dir, base = dir) {
66
71
  */
67
72
  function classifyTemplateFile(relPath) {
68
73
  // Try ".<slot>." suffix detection
69
- for (const slot of SLOTS) {
70
- const m = relPath.match(new RegExp(String.raw`^(?<target>.+)\.${slot}\.[^.]+$`))
74
+ for (const { slot, re } of SLOT_CLASSIFIERS) {
75
+ const m = relPath.match(re)
71
76
  if (m?.groups?.target) return { target: m.groups.target, slot }
72
77
  }
73
78
  // No slot suffix → text-only canon for the literal target name
@@ -241,7 +246,7 @@ export function checkTextSubset(actual, template, opts) {
241
246
 
242
247
  /**
243
248
  * @param {string} concernDir абсолютний шлях до fix/<concern>/ або policy/<concern>/
244
- * @returns {Promise<Record<string, { snippet?: any, deny?: any, contains?: any }>>} merged template-дерево, індексоване за target
249
+ * @returns {Promise<Record<string, { snippet?: unknown, deny?: unknown, contains?: unknown }>>} merged template-дерево, індексоване за target
245
250
  */
246
251
  export async function loadTemplate(concernDir) {
247
252
  const tplDir = join(concernDir, 'template')
@@ -196,8 +196,21 @@ const tr = {
196
196
  Ні: { ru: 'No' }
197
197
  }
198
198
 
199
+ ### 5.1. Апострофи та спецсимволи в рядках перекладу
200
+
201
+ Український текст рідко містить апостроф у сенсі JS-рядка; у **англійських** перекладах часті скорочення з `'` (`today's`, `can't`, `don't`, `you're` тощо). Після кожної зміни `tr` переглянь результат на символи `'`, `"` та `\` і переконайся, що **роздільники рядка** парсяться коректно.
202
+
203
+ **Одинарні лапки.** Якщо англійський переклад містить апостроф і рядок обгорнуто в `'…'`:
204
+
205
+ - **Неправильно:** `'today\\'s route'` — подвійний backslash + кінець рядка, синтаксична помилка.
206
+ - **Правильно:** `'today\'s route'` — один backslash екранує апостроф.
207
+ - **Краще:** `"today's route"` — подвійні лапки, екранування не потрібне.
208
+
209
+ **Безпечне правило:** якщо в англійському перекладі є апостроф — переходь на **подвійні** лапки як роздільник рядка.
210
+
199
211
  ## 6. Після очистки
200
212
 
201
213
  - Переконайся, що `kustomization.yaml` у кожній директорії `k8s/` не посилається на видалені overlay або файли.
202
214
  - Пройдись `git grep` по репозиторію на залишки: `git grep -n -i -e '\bru\b' -e cr\.yandex -e country/ru -e prod-ru -e values-ru -e "'ya'"` — переглянь усі знахідки вручну, бо `ru` як слово може траплятися в легітимних контекстах (наприклад, `truncate`, `Aurum`, `cruft`). Видаляй лише ті входження, що належать ru-середовищу. `git grep` за замовчуванням пропускає невідстежувані шляхи, тож `node_modules/` у вихід не потрапить, поки воно у `.gitignore`.
203
215
  - Перевір CI локально: `npx @nitra/cursor check abie` (якщо правило **abie** ввімкнене у проєкті).
216
+ - Після змін у вихідних файлах (особливо рядках перекладу) переконайся, що збірка **компілюється**: `bun vite build` у корені відповідного пакета (або там, де в проєкті налаштований Vite). Синтаксична помилка в об’єкті `tr` ламає всі три CI-пайплайни (android, ios, site) — локальний build ловить її до коміту.
@@ -48,7 +48,7 @@ description: >-
48
48
  git diff docs/adr/
49
49
  ```
50
50
 
51
- Видалені файли — `delete`-операція. Нові файли `<slug>.md` — `rewrite`. Модифіковані clean-файли — `merge-into`.
51
+ Видалені файли — `delete`-операція. Нові файли `<timestamp>-<slug>.md` (timestamp-префікс чернетки збережено) — `rewrite`. Модифіковані clean-файли — `merge-into`.
52
52
 
53
53
  4. **Прийняти / відкотити:**
54
54
  - Прийняти все: `git add docs/adr/ && git commit -m "adr: normalize batch"`.
@@ -67,4 +67,4 @@ description: >-
67
67
 
68
68
  - LLM повернув криву JSON → у логу буде `invalid JSON response (first 200 chars): …`. Запусти ще раз — нерідко це разовий збій.
69
69
  - Скрипт виходить миттєво без логу → перевір `ADR_NORMALIZE_RUNNING` у env (recursion guard) і чи репо не у стані merge/rebase.
70
- - Перейменування зробило слаги-дублі (`<slug>-2.md`) → це нормально, скрипт детермінований; під час review можна обʼєднати руками й видалити `-2`.
70
+ - Перейменування зробило дублі імен (`<timestamp>-<slug>-2.md`) → це нормально, скрипт детермінований; під час review можна обʼєднати руками й видалити `-2`.