@nitra/cursor 1.9.23 → 1.11.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.
Files changed (84) hide show
  1. package/.claude-template/hooks/capture-decisions.sh +3 -3
  2. package/.claude-template/hooks/normalize-decisions.sh +370 -0
  3. package/CHANGELOG.md +52 -0
  4. package/bin/n-cursor.js +30 -29
  5. package/package.json +2 -1
  6. package/rules/abie/js/applies/check.mjs +24 -0
  7. package/rules/abie/js/env_dns/check.mjs +53 -0
  8. package/rules/abie/js/firebase_hosting/check.mjs +49 -0
  9. package/rules/abie/js/hc_pairing/check.mjs +58 -0
  10. package/rules/abie/js/ua_http_route/check.mjs +86 -0
  11. package/rules/abie/js/ua_node_selector/check.mjs +65 -0
  12. package/rules/abie/policy/base_deployment_preem/target.json +10 -0
  13. package/rules/abie/policy/clean_merged_ignore_branches/target.json +4 -0
  14. package/rules/abie/policy/health_check_policy/target.json +4 -0
  15. package/rules/abie/policy/http_route_base/target.json +4 -0
  16. package/rules/abie/utils/enabled.mjs +35 -0
  17. package/rules/abie/utils/env-dns.mjs +81 -0
  18. package/rules/abie/utils/hc-yaml.mjs +27 -0
  19. package/rules/abie/utils/http-route.mjs +93 -0
  20. package/rules/abie/utils/k8s-tree.mjs +102 -0
  21. package/rules/abie/utils/kustomization-patches.mjs +224 -0
  22. package/rules/abie/utils/overlay-paths.mjs +97 -0
  23. package/rules/abie/utils/yaml.mjs +72 -0
  24. package/rules/adr/adr.mdc +82 -18
  25. package/rules/adr/js/check.mjs +84 -40
  26. package/rules/adr/policy/settings_json/settings_json.rego +17 -11
  27. package/rules/adr/policy/settings_json/target.json +4 -0
  28. package/rules/adr/policy/settings_local_json/settings_local_json.rego +24 -12
  29. package/rules/adr/policy/settings_local_json/target.json +4 -0
  30. package/rules/bun/policy/bunfig/target.json +4 -0
  31. package/rules/bun/policy/package_json/target.json +4 -0
  32. package/rules/capacitor/policy/package_json/target.json +4 -0
  33. package/rules/docker/policy/lint_docker_yml/target.json +4 -0
  34. package/rules/docker/policy/package_json/target.json +4 -0
  35. package/rules/hasura/policy/svc_hl/target.json +4 -0
  36. package/rules/image-avif/policy/package_json/target.json +4 -0
  37. package/rules/image-compress/policy/package_json/target.json +4 -0
  38. package/rules/js-bun-db/policy/package_json/target.json +4 -0
  39. package/rules/js-bun-redis/policy/package_json/target.json +4 -0
  40. package/rules/js-lint/policy/lint_js_yml/target.json +4 -0
  41. package/rules/js-lint/policy/package_json/target.json +4 -0
  42. package/rules/js-mssql/policy/package_json/target.json +4 -0
  43. package/rules/js-run/policy/configmap/target.json +4 -0
  44. package/rules/js-run/policy/package_json/target.json +4 -0
  45. package/rules/k8s/policy/base_kustomization/target.json +4 -0
  46. package/rules/k8s/policy/base_manifest/target.json +10 -0
  47. package/rules/k8s/policy/gateway/target.json +4 -0
  48. package/rules/k8s/policy/hpa_pdb/target.json +4 -0
  49. package/rules/k8s/policy/kustomization/target.json +4 -0
  50. package/rules/k8s/policy/manifest/target.json +4 -0
  51. package/rules/k8s/policy/svc_hl_yaml/target.json +4 -0
  52. package/rules/k8s/policy/svc_yaml/target.json +4 -0
  53. package/rules/npm-module/policy/emit_types_config/target.json +4 -0
  54. package/rules/npm-module/policy/npm_package_json/target.json +4 -0
  55. package/rules/npm-module/policy/npm_publish_yml/target.json +4 -0
  56. package/rules/npm-module/policy/root_package_json/target.json +4 -0
  57. package/rules/php/policy/lint_php_yml/target.json +4 -0
  58. package/rules/php/policy/package_json/target.json +4 -0
  59. package/rules/rego/js/applies/check.mjs +54 -0
  60. package/rules/rego/policy/package_json/target.json +5 -0
  61. package/rules/rego/policy/vscode_extensions/target.json +5 -0
  62. package/rules/rego/policy/vscode_settings/target.json +5 -0
  63. package/rules/style-lint/policy/lint_style_yml/target.json +4 -0
  64. package/rules/style-lint/policy/package_json/target.json +4 -0
  65. package/rules/style-lint/policy/vscode_extensions/target.json +4 -0
  66. package/rules/style-lint/policy/vscode_settings/target.json +4 -0
  67. package/rules/text/policy/cspell/target.json +4 -0
  68. package/rules/text/policy/markdownlint/target.json +4 -0
  69. package/rules/text/policy/oxfmtrc/target.json +4 -0
  70. package/rules/text/policy/package_json/target.json +4 -0
  71. package/rules/text/policy/vscode_extensions/target.json +4 -0
  72. package/rules/text/policy/vscode_settings/target.json +4 -0
  73. package/rules/vue/policy/package_json/target.json +4 -0
  74. package/schemas/target.json +58 -0
  75. package/scripts/auto-skills.mjs +2 -0
  76. package/scripts/lint-conftest.mjs +65 -414
  77. package/scripts/sync-claude-config.mjs +70 -14
  78. package/scripts/utils/discover-checkable-rules.mjs +123 -0
  79. package/scripts/utils/resolve-target-files.mjs +109 -0
  80. package/scripts/utils/run-rule.mjs +131 -0
  81. package/skills/adr-normalize/SKILL.md +71 -0
  82. package/skills/adr-normalize/auto.md +1 -0
  83. package/rules/abie/js/check.mjs +0 -1152
  84. package/rules/rego/js/check.mjs +0 -106
@@ -10,10 +10,12 @@
10
10
  * - `npm/CLAUDE.md` — **fully owned**: завжди перезаписується; пропускається,
11
11
  * якщо в проєкті немає каталогу `npm/`.
12
12
  * - `.claude/commands/n-check.md` — fully owned slash-команда.
13
- * - `.claude/hooks/capture-decisions.sh` — fully owned bash-скрипт ADR Stop-hook;
13
+ * - `.claude/hooks/capture-decisions.sh` — fully owned bash-скрипт ADR capture Stop-hook;
14
14
  * копіюється з `.claude-template/hooks/`, лише коли в `.n-cursor.json` `rules`
15
15
  * присутнє `adr` (правило вмикається вручну). Якщо правила немає, керована
16
16
  * ADR-група в hooks так само автоматично прибирається з settings.json.
17
+ * - `.claude/hooks/normalize-decisions.sh` — fully owned bash-скрипт ADR normalize
18
+ * Stop-hook (батч-нормалізація чернеток); умови — ті самі, що для `capture`.
17
19
  *
18
20
  * Опт-аут — `claude-config: false` у `.n-cursor.json`.
19
21
  */
@@ -23,20 +25,27 @@ import { join } from 'node:path'
23
25
 
24
26
  /** Маркер lint Stop-hook'а (`npx --no \@nitra/cursor stop-hook`). */
25
27
  export const MANAGED_HOOK_COMMAND_MARKER = '@nitra/cursor stop-hook'
26
- /** Маркер ADR Stop-hook'а — підрядок шляху до bash-скрипта. */
28
+ /** Маркер ADR Stop-hook'а — підрядок шляху до bash-скрипта capture-decisions. */
27
29
  export const ADR_HOOK_COMMAND_MARKER = '.claude/hooks/capture-decisions.sh'
30
+ /** Маркер ADR Stop-hook'а — підрядок шляху до bash-скрипта normalize-decisions. */
31
+ export const ADR_NORMALIZE_HOOK_COMMAND_MARKER = '.claude/hooks/normalize-decisions.sh'
28
32
  /** Усі маркери managed-hook'ів пакета — за ними відрізняємо свої записи від користувацьких. */
29
- export const MANAGED_HOOK_COMMAND_MARKERS = Object.freeze([MANAGED_HOOK_COMMAND_MARKER, ADR_HOOK_COMMAND_MARKER])
33
+ export const MANAGED_HOOK_COMMAND_MARKERS = Object.freeze([
34
+ MANAGED_HOOK_COMMAND_MARKER,
35
+ ADR_HOOK_COMMAND_MARKER,
36
+ ADR_NORMALIZE_HOOK_COMMAND_MARKER
37
+ ])
30
38
 
31
39
  const CLAUDE_DIR = '.claude'
32
40
  const CLAUDE_SETTINGS_FILE = `${CLAUDE_DIR}/settings.json`
33
41
  const CLAUDE_COMMANDS_DIR = `${CLAUDE_DIR}/commands`
34
42
  const CLAUDE_HOOKS_DIR = `${CLAUDE_DIR}/hooks`
35
43
  const ADR_HOOK_SCRIPT_NAME = 'capture-decisions.sh'
44
+ const ADR_NORMALIZE_HOOK_SCRIPT_NAME = 'normalize-decisions.sh'
36
45
  const NPM_CLAUDE_MD_FILE = 'npm/CLAUDE.md'
37
46
  const TEMPLATE_DIR_NAME = '.claude-template'
38
47
 
39
- /** Канонічна група hooks для ADR Stop-hook'а — додається в settings, коли `adr` у `rules`. */
48
+ /** Канонічна група hooks для ADR capture Stop-hook'а — додається в settings, коли `adr` у `rules`. */
40
49
  const ADR_STOP_HOOK_GROUP = Object.freeze({
41
50
  matcher: '',
42
51
  hooks: Object.freeze([
@@ -49,6 +58,19 @@ const ADR_STOP_HOOK_GROUP = Object.freeze({
49
58
  ])
50
59
  })
51
60
 
61
+ /** Канонічна група hooks для ADR normalize Stop-hook'а — батч-нормалізація чернеток у `docs/adr/`. */
62
+ const ADR_NORMALIZE_STOP_HOOK_GROUP = Object.freeze({
63
+ matcher: '',
64
+ hooks: Object.freeze([
65
+ Object.freeze({
66
+ type: 'command',
67
+ command: `bash "$CLAUDE_PROJECT_DIR/${ADR_NORMALIZE_HOOK_COMMAND_MARKER}"`,
68
+ async: true,
69
+ timeout: 600
70
+ })
71
+ ])
72
+ })
73
+
52
74
  /**
53
75
  * @typedef {object} HookEntry
54
76
  * @property {string} type тип hook'а у форматі Claude Code (зазвичай `'command'`)
@@ -140,7 +162,11 @@ function templateWithAdrHook(template) {
140
162
  for (const [event, groups] of Object.entries(template.hooks ?? {})) {
141
163
  hooks[event] = Array.isArray(groups) ? [...groups] : []
142
164
  }
143
- hooks.Stop = [...(hooks.Stop ?? []), /** @type {HookGroup} */ (ADR_STOP_HOOK_GROUP)]
165
+ hooks.Stop = [
166
+ ...(hooks.Stop ?? []),
167
+ /** @type {HookGroup} */ (ADR_STOP_HOOK_GROUP),
168
+ /** @type {HookGroup} */ (ADR_NORMALIZE_STOP_HOOK_GROUP)
169
+ ]
144
170
  return { ...template, hooks }
145
171
  }
146
172
 
@@ -209,24 +235,45 @@ export async function syncClaudeSettings(projectRoot, templateDir, options = {})
209
235
  }
210
236
 
211
237
  /**
212
- * Копіює канонічний `.claude/hooks/capture-decisions.sh` з темплейту пакета.
238
+ * Копіює один канонічний bash-скрипт hook'а з темплейту пакета у `.claude/hooks/`.
213
239
  * Файл повністю керується пакетом — на кожен sync перезаписується (як setup-bun-deps).
214
240
  * @param {string} projectRoot корінь проєкту, куди писати
215
241
  * @param {string} templateDir каталог `.claude-template/` усередині пакету
242
+ * @param {string} scriptName базове ім'я скрипта (наприклад `capture-decisions.sh`)
216
243
  * @returns {Promise<{ written: boolean, path: string }>} результат: чи писали файл, та його відносний шлях
217
244
  */
218
- export async function syncAdrHookScript(projectRoot, templateDir) {
219
- const templatePath = join(templateDir, 'hooks', ADR_HOOK_SCRIPT_NAME)
245
+ async function syncHookScript(projectRoot, templateDir, scriptName) {
246
+ const templatePath = join(templateDir, 'hooks', scriptName)
220
247
  if (!existsSync(templatePath)) {
221
248
  return { written: false, path: '' }
222
249
  }
223
250
  const content = await readFile(templatePath, 'utf8')
224
251
  const hooksDir = join(projectRoot, CLAUDE_HOOKS_DIR)
225
252
  await mkdir(hooksDir, { recursive: true })
226
- const destPath = join(hooksDir, ADR_HOOK_SCRIPT_NAME)
253
+ const destPath = join(hooksDir, scriptName)
227
254
  await writeFile(destPath, content, 'utf8')
228
255
  await chmod(destPath, 0o755)
229
- return { written: true, path: `${CLAUDE_HOOKS_DIR}/${ADR_HOOK_SCRIPT_NAME}` }
256
+ return { written: true, path: `${CLAUDE_HOOKS_DIR}/${scriptName}` }
257
+ }
258
+
259
+ /**
260
+ * Копіює канонічний `.claude/hooks/capture-decisions.sh` з темплейту пакета.
261
+ * @param {string} projectRoot корінь проєкту, куди писати
262
+ * @param {string} templateDir каталог `.claude-template/` усередині пакету
263
+ * @returns {Promise<{ written: boolean, path: string }>} результат: чи писали файл, та його відносний шлях
264
+ */
265
+ export function syncAdrHookScript(projectRoot, templateDir) {
266
+ return syncHookScript(projectRoot, templateDir, ADR_HOOK_SCRIPT_NAME)
267
+ }
268
+
269
+ /**
270
+ * Копіює канонічний `.claude/hooks/normalize-decisions.sh` з темплейту пакета.
271
+ * @param {string} projectRoot корінь проєкту, куди писати
272
+ * @param {string} templateDir каталог `.claude-template/` усередині пакету
273
+ * @returns {Promise<{ written: boolean, path: string }>} результат: чи писали файл, та його відносний шлях
274
+ */
275
+ export function syncAdrNormalizeHookScript(projectRoot, templateDir) {
276
+ return syncHookScript(projectRoot, templateDir, ADR_NORMALIZE_HOOK_SCRIPT_NAME)
230
277
  }
231
278
 
232
279
  /**
@@ -283,20 +330,29 @@ export async function syncClaudeCommands(projectRoot, templateDir) {
283
330
  * @param {string} options.bundledPackageRoot корінь установленого `@nitra/cursor`
284
331
  * @param {boolean} options.enabled чи увімкнено sync (з `.n-cursor.json` `claude-config`)
285
332
  * @param {string[]} [options.rules] список увімкнених правил із `.n-cursor.json` — впливає на ADR Stop-hook (`adr`)
286
- * @returns {Promise<{ settings: boolean, npmClaudeMd: boolean, commands: string[], adrHook: boolean }>} прапорці записів settings/CLAUDE.md/ADR-hook та список записаних slash-команд
333
+ * @returns {Promise<{ settings: boolean, npmClaudeMd: boolean, commands: string[], adrHook: boolean, adrNormalizeHook: boolean }>} прапорці записів settings/CLAUDE.md/ADR-hook(s) та список записаних slash-команд
287
334
  */
288
335
  export async function syncClaudeConfig({ projectRoot, bundledPackageRoot, enabled, rules = [] }) {
289
336
  if (!enabled) {
290
- return { settings: false, npmClaudeMd: false, commands: [], adrHook: false }
337
+ return { settings: false, npmClaudeMd: false, commands: [], adrHook: false, adrNormalizeHook: false }
291
338
  }
292
339
  const templateDir = join(bundledPackageRoot, TEMPLATE_DIR_NAME)
293
340
  if (!existsSync(templateDir)) {
294
- return { settings: false, npmClaudeMd: false, commands: [], adrHook: false }
341
+ return { settings: false, npmClaudeMd: false, commands: [], adrHook: false, adrNormalizeHook: false }
295
342
  }
296
343
  const includeAdrHook = Array.isArray(rules) && rules.includes('adr')
297
344
  const adrHook = includeAdrHook ? await syncAdrHookScript(projectRoot, templateDir) : { written: false, path: '' }
345
+ const adrNormalizeHook = includeAdrHook
346
+ ? await syncAdrNormalizeHookScript(projectRoot, templateDir)
347
+ : { written: false, path: '' }
298
348
  const settings = await syncClaudeSettings(projectRoot, templateDir, { includeAdrHook })
299
349
  const npmClaudeMd = await syncNpmClaudeMd(projectRoot, templateDir)
300
350
  const commands = await syncClaudeCommands(projectRoot, templateDir)
301
- return { settings: settings.written, npmClaudeMd: npmClaudeMd.written, commands, adrHook: adrHook.written }
351
+ return {
352
+ settings: settings.written,
353
+ npmClaudeMd: npmClaudeMd.written,
354
+ commands,
355
+ adrHook: adrHook.written,
356
+ adrNormalizeHook: adrNormalizeHook.written
357
+ }
302
358
  }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Discovery rules для CLI `check`. Шукає правила, для яких є щось «прогонне»:
3
+ * - JS concerns: `rules/<id>/js/<concern>/<check.mjs | check-*.mjs>` — кожен concern окремий вузол.
4
+ * - Policy concerns: `rules/<id>/policy/<concern>/target.json` — пара з `<concern>.rego`.
5
+ * - Legacy JS (на час міграції): `rules/<id>/js/check.mjs` (плаский) — мапиться у concern `legacy`,
6
+ * щоб не ламати ще не мігровані правила.
7
+ *
8
+ * Каталог `utils/` всередині `js/` свідомо пропускається — це хелпери, не концерни.
9
+ * Файли `*.test.mjs` фільтруються regex (`^check(?:-.+)?\.mjs$`).
10
+ *
11
+ * Намеренно НЕ парсимо `target.json` тут (це робить runner). Discovery — швидкий скан структури:
12
+ * шляхи + назви, без I/O вмісту.
13
+ */
14
+ import { existsSync } from 'node:fs'
15
+ import { readdir } from 'node:fs/promises'
16
+ import { join } from 'node:path'
17
+
18
+ const CHECK_FILENAME_RE = /^check(?:-.+)?\.mjs$/u
19
+ const TEST_SUFFIX = '.test.mjs'
20
+
21
+ /**
22
+ * @typedef {object} JsConcern
23
+ * @property {string} name імʼя концерну (`<name>` у `js/<name>/`); для legacy — `'legacy'`
24
+ * @property {string[]} files імена `check*.mjs` у концерні (відсортовані алфавітно)
25
+ * @property {boolean} legacy чи це fallback на плаский `js/check.mjs`
26
+ */
27
+
28
+ /**
29
+ * @typedef {object} PolicyConcern
30
+ * @property {string} name імʼя концерну (`<name>` у `policy/<name>/`)
31
+ */
32
+
33
+ /**
34
+ * @typedef {object} CheckableRule
35
+ * @property {string} id ідентифікатор правила (імʼя каталогу `rules/<id>/`)
36
+ * @property {JsConcern[]} jsConcerns
37
+ * @property {PolicyConcern[]} policyConcerns
38
+ */
39
+
40
+ /**
41
+ * Перелічує JS-концерни одного правила: підкаталоги `js/<name>/` з принаймні одним `check*.mjs`,
42
+ * плюс legacy-fallback на плаский `js/check.mjs` (без підкаталогу).
43
+ *
44
+ * `js/utils/` свідомо пропускається — це хелпери, а не концерни.
45
+ * @param {string} jsDir абсолютний шлях `rules/<id>/js/`
46
+ * @returns {Promise<JsConcern[]>} концерни в алфавітному порядку
47
+ */
48
+ async function listJsConcerns(jsDir) {
49
+ if (!existsSync(jsDir)) return []
50
+ const topLevel = await readdir(jsDir, { withFileTypes: true })
51
+
52
+ // Перевага — нова concern-структура (`js/<concern>/check*.mjs`).
53
+ /** @type {JsConcern[]} */
54
+ const concerns = []
55
+ for (const entry of topLevel) {
56
+ if (!entry.isDirectory() || entry.name === 'utils' || entry.name.startsWith('.')) continue
57
+ const concernDir = join(jsDir, entry.name)
58
+ const files = (await readdir(concernDir))
59
+ .filter(n => CHECK_FILENAME_RE.test(n) && !n.endsWith(TEST_SUFFIX))
60
+ .toSorted((a, b) => a.localeCompare(b))
61
+ if (files.length > 0) {
62
+ concerns.push({ name: entry.name, files, legacy: false })
63
+ }
64
+ }
65
+
66
+ // Legacy fallback — лише якщо subdir-концернів немає взагалі. Гібридні правила
67
+ // (одночасно legacy check.mjs + нові концерни) трактуються як уже мігровані:
68
+ // CLI запускає тільки субдиректорні концерни, flat-файл лишається для backward-compat
69
+ // тестів, які імпортують `check` напряму.
70
+ if (concerns.length === 0) {
71
+ const flatChecks = topLevel
72
+ .filter(e => e.isFile() && CHECK_FILENAME_RE.test(e.name) && !e.name.endsWith(TEST_SUFFIX))
73
+ .map(e => e.name)
74
+ .toSorted((a, b) => a.localeCompare(b))
75
+ if (flatChecks.length > 0) {
76
+ concerns.push({ name: 'legacy', files: flatChecks, legacy: true })
77
+ }
78
+ }
79
+
80
+ return concerns.toSorted((a, b) => a.name.localeCompare(b.name))
81
+ }
82
+
83
+ /**
84
+ * Перелічує policy-концерни: підкаталоги `policy/<name>/` з наявним `target.json`.
85
+ * @param {string} policyDir абсолютний шлях `rules/<id>/policy/`
86
+ * @returns {Promise<PolicyConcern[]>} концерни в алфавітному порядку
87
+ */
88
+ async function listPolicyConcerns(policyDir) {
89
+ if (!existsSync(policyDir)) return []
90
+ const entries = await readdir(policyDir, { withFileTypes: true })
91
+ /** @type {PolicyConcern[]} */
92
+ const out = []
93
+ for (const entry of entries) {
94
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue
95
+ if (existsSync(join(policyDir, entry.name, 'target.json'))) {
96
+ out.push({ name: entry.name })
97
+ }
98
+ }
99
+ return out.toSorted((a, b) => a.name.localeCompare(b.name))
100
+ }
101
+
102
+ /**
103
+ * Сканує `rules/` і повертає правила, для яких є JS-концерни або policy-концерни.
104
+ * Правила без жодної прогонної частини (тільки `.mdc` + `auto.md`) фільтруються.
105
+ * @param {string} bundledRulesDir абсолютний шлях до `npm/rules/`
106
+ * @returns {Promise<CheckableRule[]>} відсортовані за id
107
+ */
108
+ export async function discoverCheckableRules(bundledRulesDir) {
109
+ if (!existsSync(bundledRulesDir)) return []
110
+ const entries = await readdir(bundledRulesDir, { withFileTypes: true })
111
+ /** @type {CheckableRule[]} */
112
+ const out = []
113
+ for (const entry of entries) {
114
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue
115
+ const ruleDir = join(bundledRulesDir, entry.name)
116
+ const jsConcerns = await listJsConcerns(join(ruleDir, 'js'))
117
+ const policyConcerns = await listPolicyConcerns(join(ruleDir, 'policy'))
118
+ if (jsConcerns.length > 0 || policyConcerns.length > 0) {
119
+ out.push({ id: entry.name, jsConcerns, policyConcerns })
120
+ }
121
+ }
122
+ return out.toSorted((a, b) => a.id.localeCompare(b.id))
123
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Резолвер списку файлів для одного `policy/<name>/target.json` у новій структурі правил.
3
+ *
4
+ * Дві форми у `target.json:files`:
5
+ * - `{ "single": "<rel>" }` — конкретний відносний шлях. Якщо `existsSync(root/single)` → `[single]`;
6
+ * інакше `[]` (caller сам вирішує fail vs silent skip за `required`).
7
+ * - `{ "walkGlob": <glob | glob[]> }` — picomatch проти posix-відносних шляхів, отриманих обходом
8
+ * `walkDir` від `root` із загальними skip-ами та `.n-cursor.json:ignore`. Обхід кешований у
9
+ * `walkCache` (Map ключ — підпис ignorePaths) — повторні таргети з тим самим набором ignore
10
+ * перевикористовують список без нового readdir.
11
+ *
12
+ * Path-traversal у `single` — кидаємо помилку при resolve. Реалізує інваріант контракту: полісі
13
+ * читають лише файли в репо.
14
+ */
15
+ import { existsSync } from 'node:fs'
16
+ import { isAbsolute, join, normalize, relative, sep } from 'node:path'
17
+
18
+ import picomatch from 'picomatch'
19
+
20
+ import { loadCursorIgnorePaths } from './load-cursor-config.mjs'
21
+ import { walkDir } from './walkDir.mjs'
22
+
23
+ /** Узгоджений regex для path-traversal: `..` як сегмент або абсолютний шлях. */
24
+ const PARENT_SEGMENT_RE = /(^|[\\/])\.\.([\\/]|$)/u
25
+
26
+ /**
27
+ * Перевіряє, що `single`-шлях у `target.json:files` лежить у межах репозиторію.
28
+ * Кидає помилку, якщо шлях абсолютний або містить сегмент `..`.
29
+ * @param {string} singlePath значення `files.single`
30
+ * @returns {void}
31
+ */
32
+ function assertSafeSinglePath(singlePath) {
33
+ if (isAbsolute(singlePath)) {
34
+ throw new Error(`target.json: files.single має бути відносним шляхом (отримано: ${singlePath})`)
35
+ }
36
+ if (PARENT_SEGMENT_RE.test(singlePath)) {
37
+ throw new Error(`target.json: files.single не може містити '..' (отримано: ${singlePath})`)
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Збирає всі файли (posix-відносні шляхи від `root`) одним обходом дерева.
43
+ * Скіпи: загальні з `walkDir` + `.n-cursor.json:ignore`.
44
+ * @param {string} root абсолютний корінь репозиторію
45
+ * @param {string[]} ignorePaths абсолютні posix-шляхи виключених каталогів
46
+ * @returns {Promise<string[]>} відсортовані posix-відносні шляхи
47
+ */
48
+ async function walkAllRelative(root, ignorePaths) {
49
+ /** @type {string[]} */
50
+ const out = []
51
+ await walkDir(
52
+ root,
53
+ abs => {
54
+ const rel = relative(root, abs).split(sep).join('/')
55
+ if (rel.length > 0) out.push(rel)
56
+ },
57
+ ignorePaths
58
+ )
59
+ return out.toSorted((a, b) => a.localeCompare(b))
60
+ }
61
+
62
+ /**
63
+ * Витягує (або обчислює і кешує) список усіх файлів у дереві для заданого набору ignore-шляхів.
64
+ * Кеш — мapа `signature → Promise<string[]>`, тож паралельні виклики одного й того ж набору
65
+ * чекають один обхід.
66
+ * @param {string} root абсолютний корінь репозиторію
67
+ * @param {string[]} ignorePaths абсолютні posix-шляхи виключених каталогів
68
+ * @param {Map<string, Promise<string[]>>} walkCache мутабельний кеш від caller-а
69
+ * @returns {Promise<string[]>} відсортовані posix-відносні шляхи
70
+ */
71
+ function getAllFilesCached(root, ignorePaths, walkCache) {
72
+ const signature = `${root}|${ignorePaths.join('|')}`
73
+ let p = walkCache.get(signature)
74
+ if (!p) {
75
+ p = walkAllRelative(root, ignorePaths)
76
+ walkCache.set(signature, p)
77
+ }
78
+ return p
79
+ }
80
+
81
+ /**
82
+ * Резолвить список файлів для одного `target.json:files`.
83
+ * @param {object} filesSpec поле `files` з `target.json` (вже після schema-валідації)
84
+ * @param {string} root абсолютний корінь репозиторію
85
+ * @param {Map<string, Promise<string[]>>} walkCache кеш обходів дерева (cross-target у межах одного check-прогону)
86
+ * @returns {Promise<string[]>} абсолютні шляхи знайдених файлів (порожній — нічого не знайдено)
87
+ */
88
+ export async function resolveTargetFiles(filesSpec, root, walkCache) {
89
+ if (typeof filesSpec?.single === 'string') {
90
+ assertSafeSinglePath(filesSpec.single)
91
+ const normalized = normalize(filesSpec.single).split(sep).join('/')
92
+ const abs = join(root, normalized)
93
+ return existsSync(abs) ? [abs] : []
94
+ }
95
+ if (filesSpec?.walkGlob !== undefined) {
96
+ const ignorePaths = await loadCursorIgnorePaths(root)
97
+ const all = await getAllFilesCached(root, ignorePaths, walkCache)
98
+ const globs = Array.isArray(filesSpec.walkGlob) ? filesSpec.walkGlob : [filesSpec.walkGlob]
99
+ // picomatch у масиві трактує `!neg` як ОКРЕМИЙ позитивний матчер «не-neg» (some-OR логіка),
100
+ // тож наївне `picomatch(['pos','!neg'])` повертає true майже на всьому. Розділяємо вручну:
101
+ // позитиви join-имо через picomatch(...), негативні фільтруємо окремим isExcluded.
102
+ const positives = globs.filter(g => !g.startsWith('!'))
103
+ const negatives = globs.filter(g => g.startsWith('!')).map(g => g.slice(1))
104
+ const isMatch = positives.length > 0 ? picomatch(positives, { dot: false }) : () => false
105
+ const isExcluded = negatives.length > 0 ? picomatch(negatives, { dot: false }) : () => false
106
+ return all.filter(rel => isMatch(rel) && !isExcluded(rel)).map(rel => join(root, rel))
107
+ }
108
+ throw new Error(`target.json: files має містити single або walkGlob (отримано: ${JSON.stringify(filesSpec)})`)
109
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Оркестратор одного правила під CLI `check`.
3
+ *
4
+ * Послідовність (concerns у межах правила — алфавітно):
5
+ * 1. **applies-гейт** з `js/applies/check.mjs`. Якщо модуль експортує `applies()` і вона повертає
6
+ * false — друкуємо `✅ правило не застосовне` і завершуємо без подальших викликів.
7
+ * 2. **JS-концерни** — кожен `check*.mjs` у `js/<concern>/`. Concern `applies` теж може мати
8
+ * `check()` для друку контексту (його `applies()` уже відпрацював на кроці 1, він не повторюється).
9
+ * Legacy-fallback: плаский `js/check.mjs` лежить як concern `legacy` — імпортується з кореня `js/`,
10
+ * а не з підкаталога.
11
+ * 3. **Policy-концерни** — кожен `policy/<concern>/target.json` через `runConftestBatch`.
12
+ * Реcолвер `resolveTargetFiles` ділить cache (`walkCache`) між концернами.
13
+ *
14
+ * Кожен concern має власний `createCheckReporter` — їхні exit-коди OR-яться в один на рівні правила.
15
+ * Це дає той самий 0/1 контракт, що й попередня модель «один check.mjs на правило».
16
+ */
17
+ import { readFile } from 'node:fs/promises'
18
+ import { join } from 'node:path'
19
+
20
+ import { createCheckReporter } from './check-reporter.mjs'
21
+ import { resolveTargetFiles } from './resolve-target-files.mjs'
22
+ import { runConftestBatch } from './run-conftest-batch.mjs'
23
+
24
+ const APPLIES_CONCERN_NAME = 'applies'
25
+
26
+ /**
27
+ * Обчислює абсолютний шлях до файла-check у JS-концерні.
28
+ * @param {string} bundledRulesDir абсолютний `rules/`
29
+ * @param {string} ruleId id правила
30
+ * @param {import('./discover-checkable-rules.mjs').JsConcern} concern опис концерну
31
+ * @param {string} fileName імʼя файла з `concern.files`
32
+ * @returns {string} абсолютний шлях
33
+ */
34
+ function resolveJsCheckPath(bundledRulesDir, ruleId, concern, fileName) {
35
+ return concern.legacy
36
+ ? join(bundledRulesDir, ruleId, 'js', fileName)
37
+ : join(bundledRulesDir, ruleId, 'js', concern.name, fileName)
38
+ }
39
+
40
+ /**
41
+ * Спробувати викликати applies() гейт з `js/applies/check.mjs` правила.
42
+ * Гейт активний лише за наявності концерну з імʼям `applies` і експортом-функцією `applies` у його
43
+ * першому check-файлі (алфавіт).
44
+ * @param {string} bundledRulesDir абсолютний `rules/`
45
+ * @param {import('./discover-checkable-rules.mjs').CheckableRule} rule опис правила
46
+ * @returns {Promise<boolean>} `true` — правило застосовне (або гейту немає); `false` — пропустити
47
+ */
48
+ async function evaluateAppliesGate(bundledRulesDir, rule) {
49
+ const concern = rule.jsConcerns.find(c => c.name === APPLIES_CONCERN_NAME)
50
+ if (!concern || concern.files.length === 0) return true
51
+ const path = resolveJsCheckPath(bundledRulesDir, rule.id, concern, concern.files[0])
52
+ const mod = await import(path)
53
+ if (typeof mod.applies !== 'function') return true
54
+ return Boolean(await mod.applies())
55
+ }
56
+
57
+ /**
58
+ * Запускає одну policy-полісі через `runConftestBatch`. Створює локальний репортер,
59
+ * читає `target.json`, резолвить файли, фіксує fail/pass — і повертає exit-код.
60
+ * @param {string} bundledRulesDir абсолютний `rules/`
61
+ * @param {string} ruleId id правила
62
+ * @param {string} concernName імʼя полісі (= підкаталог у `policy/`)
63
+ * @param {Map<string, Promise<string[]>>} walkCache shared cache між концернами одного check-прогону
64
+ * @returns {Promise<number>} 0 — OK, 1 — є порушення
65
+ */
66
+ async function runPolicyConcern(bundledRulesDir, ruleId, concernName, walkCache) {
67
+ const reporter = createCheckReporter()
68
+ const targetPath = join(bundledRulesDir, ruleId, 'policy', concernName, 'target.json')
69
+ /** @type {{ files: { single?: string, walkGlob?: string|string[], required?: boolean }, missingMessage?: string }} */
70
+ const target = JSON.parse(await readFile(targetPath, 'utf8'))
71
+ const files = await resolveTargetFiles(target.files, process.cwd(), walkCache)
72
+ if (files.length === 0) {
73
+ if (target.files.required && target.files.single) {
74
+ const msg =
75
+ target.missingMessage ?? `${target.files.single} не існує — створи згідно ${ruleId}.mdc (${ruleId}.${concernName})`
76
+ reporter.fail(msg)
77
+ }
78
+ return reporter.getExitCode()
79
+ }
80
+ // Rego не дозволяє '-' в імені пакета, тому kebab-id у `.n-cursor.json:rules`
81
+ // мапиться на snake у namespace. Файлова структура `rules/<id>/policy/` лишається kebab.
82
+ const regoNamespace = `${ruleId.replaceAll('-', '_')}.${concernName}`
83
+ const violations = runConftestBatch({
84
+ policyDirRel: `${ruleId}/${concernName}`,
85
+ namespace: regoNamespace,
86
+ files
87
+ })
88
+ if (violations.length === 0) {
89
+ reporter.pass(`${concernName}: ${files.length} файл(ів) OK (rego)`)
90
+ } else {
91
+ for (const v of violations) reporter.fail(v.message)
92
+ }
93
+ return reporter.getExitCode()
94
+ }
95
+
96
+ /**
97
+ * Запускає одне правило: applies-гейт → JS-концерни → policy-концерни.
98
+ * @param {import('./discover-checkable-rules.mjs').CheckableRule} rule
99
+ * @param {string} bundledRulesDir абсолютний шлях до `rules/`
100
+ * @param {Map<string, Promise<string[]>>} walkCache shared cache (один на check-прогон)
101
+ * @returns {Promise<number>} 0 — OK, 1 — є порушення в одному чи більше концернів
102
+ */
103
+ export async function runRule(rule, bundledRulesDir, walkCache) {
104
+ console.log(`📋 ${rule.id}:`)
105
+
106
+ if (!(await evaluateAppliesGate(bundledRulesDir, rule))) {
107
+ console.log(` ✅ Правило ${rule.id} не застосовне до цього репо — пропущено`)
108
+ return 0
109
+ }
110
+
111
+ let totalCode = 0
112
+
113
+ for (const concern of rule.jsConcerns) {
114
+ for (const fileName of concern.files) {
115
+ const path = resolveJsCheckPath(bundledRulesDir, rule.id, concern, fileName)
116
+ // eslint-disable-next-line no-unsanitized/method -- path будується з discovered concern/file, які пройшли regex CHECK_FILENAME_RE
117
+ const mod = await import(path)
118
+ if (typeof mod.check === 'function') {
119
+ const code = await mod.check()
120
+ if (code !== 0) totalCode = 1
121
+ }
122
+ }
123
+ }
124
+
125
+ for (const policyConcern of rule.policyConcerns) {
126
+ const code = await runPolicyConcern(bundledRulesDir, rule.id, policyConcern.name, walkCache)
127
+ if (code !== 0) totalCode = 1
128
+ }
129
+
130
+ return totalCode
131
+ }
@@ -0,0 +1,71 @@
1
+ ---
2
+ name: n-adr-normalize
3
+ description: >-
4
+ Ручний запуск ADR-нормалізації — обхід порогу й min-interval, прогон одного
5
+ батчу чернеток через LLM, перегляд результату через git diff
6
+ ---
7
+
8
+ # n-adr-normalize — ручна нормалізація ADR-чернеток
9
+
10
+ Скіл запускає `.claude/hooks/normalize-decisions.sh` поза звичайним Stop-hook-тригером. Корисно, коли:
11
+
12
+ - Поріг `ADR_NORMALIZE_THRESHOLD` ще не досягнуто, але хочеш почистити inbox.
13
+ - Минулого разу LLM відмовився, тепер минув ще не весь `ADR_NORMALIZE_MIN_INTERVAL_HOURS` — хочеш повторити одразу.
14
+ - Спочатку треба побачити, що саме LLM зробить (`ADR_NORMALIZE_DRY=1`).
15
+
16
+ ## Передумови
17
+
18
+ - Правило `adr` увімкнене у `.n-cursor.json` (`"adr"` у `rules`).
19
+ - `.claude/hooks/normalize-decisions.sh` існує (`npx @nitra/cursor` поклав його сюди).
20
+ - У `PATH` доступний `claude` або `cursor-agent` (інакше скрипт мовчки вийде).
21
+ - У `docs/adr/` є чернетки — файли з `session: …` у YAML frontmatter.
22
+
23
+ ## Кроки
24
+
25
+ 1. **Dry-run** (не міняє файли, лише пише план у `.claude/hooks/normalize-decisions.log`):
26
+
27
+ ```bash
28
+ ADR_NORMALIZE_THRESHOLD=0 \
29
+ ADR_NORMALIZE_MIN_INTERVAL_HOURS=0 \
30
+ ADR_NORMALIZE_DRY=1 \
31
+ bash .claude/hooks/normalize-decisions.sh
32
+ ```
33
+
34
+ Потім переглянь план: `tail -100 .claude/hooks/normalize-decisions.log`.
35
+
36
+ 2. **Реальний прогон одного батчу** (за замовчуванням до 30 чернеток):
37
+
38
+ ```bash
39
+ ADR_NORMALIZE_THRESHOLD=0 \
40
+ ADR_NORMALIZE_MIN_INTERVAL_HOURS=0 \
41
+ bash .claude/hooks/normalize-decisions.sh
42
+ ```
43
+
44
+ 3. **Перегляд результату** — скрипт нічого не комітить:
45
+
46
+ ```bash
47
+ git status docs/adr/
48
+ git diff docs/adr/
49
+ ```
50
+
51
+ Видалені файли — `delete`-операція. Нові файли `<slug>.md` — `rewrite`. Модифіковані clean-файли — `merge-into`.
52
+
53
+ 4. **Прийняти / відкотити:**
54
+
55
+ - Прийняти все: `git add docs/adr/ && git commit -m "adr: normalize batch"`.
56
+ - Відкотити конкретний файл: `git checkout -- docs/adr/<file>` (для `rewrite` цього мало — треба ще `git restore --staged` і `rm` нового).
57
+ - Відкотити весь батч: `git checkout -- docs/adr/ && git clean -f docs/adr/` (видалить і untracked rewrite-результати).
58
+
59
+ 5. **Повторити для наступного батчу**, якщо чернеток ще багато. Кожен запуск обробляє до `ADR_NORMALIZE_BATCH` файлів (default 30, найстарші за часовою позначкою у назві).
60
+
61
+ ## Tuning через ENV
62
+
63
+ - `ADR_NORMALIZE_BATCH=10` — менший батч (менше токенів, частіші коміти).
64
+ - `ADR_NORMALIZE_MODEL=opus` — інша модель `claude -p`.
65
+ - `ADR_NORMALIZE_CURSOR_MODEL=…` — інша модель для cursor-agent fallback.
66
+
67
+ ## Якщо щось пішло не так
68
+
69
+ - LLM повернув криву JSON → у логу буде `invalid JSON response (first 200 chars): …`. Запусти ще раз — нерідко це разовий збій.
70
+ - Скрипт виходить миттєво без логу → перевір `ADR_NORMALIZE_RUNNING` у env (recursion guard) і чи репо не у стані merge/rebase.
71
+ - Перейменування зробило слаги-дублі (`<slug>-2.md`) → це нормально, скрипт детермінований; під час review можна обʼєднати руками й видалити `-2`.
@@ -0,0 +1 @@
1
+ [adr]