@nitra/cursor 1.8.220 → 1.8.222

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 (51) hide show
  1. package/.claude-template/npm-CLAUDE.md +4 -0
  2. package/CHANGELOG.md +21 -0
  3. package/bin/auto-rules.md +2 -0
  4. package/bin/n-cursor.js +25 -4
  5. package/mdc/ci4.mdc +51 -0
  6. package/mdc/tauri.mdc +20 -0
  7. package/package.json +1 -1
  8. package/policy/k8s/base_kustomization/base_kustomization.rego +40 -0
  9. package/policy/k8s/base_kustomization/base_kustomization_test.rego +36 -0
  10. package/policy/k8s/base_manifest/base_manifest.rego +154 -0
  11. package/policy/k8s/base_manifest/base_manifest_test.rego +94 -0
  12. package/policy/k8s/gateway/gateway.rego +151 -0
  13. package/policy/k8s/gateway/gateway_test.rego +122 -0
  14. package/policy/k8s/hasura_configmap/hasura_configmap.rego +69 -0
  15. package/policy/k8s/hasura_configmap/hasura_configmap_test.rego +49 -0
  16. package/policy/k8s/hasura_httproute/hasura_httproute.rego +298 -0
  17. package/policy/k8s/hasura_httproute/hasura_httproute_test.rego +148 -0
  18. package/policy/k8s/hpa_pdb/hpa_pdb.rego +139 -0
  19. package/policy/k8s/hpa_pdb/hpa_pdb_test.rego +101 -0
  20. package/policy/k8s/kustomization/kustomization.rego +220 -0
  21. package/policy/k8s/kustomization/kustomization_test.rego +128 -0
  22. package/policy/k8s/kustomize_managed/kustomize_managed.rego +31 -0
  23. package/policy/k8s/kustomize_managed/kustomize_managed_test.rego +30 -0
  24. package/policy/k8s/manifest/manifest.rego +151 -4
  25. package/policy/k8s/manifest/manifest_test.rego +309 -0
  26. package/policy/k8s/svc_hl_yaml/svc_hl_yaml.rego +51 -0
  27. package/policy/k8s/svc_hl_yaml/svc_hl_yaml_test.rego +42 -0
  28. package/policy/k8s/svc_yaml/svc_yaml.rego +31 -0
  29. package/policy/k8s/svc_yaml/svc_yaml_test.rego +41 -0
  30. package/scripts/auto-skills.mjs +8 -1
  31. package/scripts/check-bun.mjs +3 -3
  32. package/scripts/check-changelog.mjs +2 -3
  33. package/scripts/check-image-avif.mjs +14 -6
  34. package/scripts/check-image-compress.mjs +1 -1
  35. package/scripts/check-js-run.mjs +58 -47
  36. package/scripts/check-k8s.mjs +128 -51
  37. package/scripts/check-npm-module.mjs +1 -4
  38. package/scripts/check-php.mjs +5 -5
  39. package/scripts/claude-stop-hook.mjs +2 -2
  40. package/scripts/lint-conftest.mjs +88 -8
  41. package/scripts/lint-ga.mjs +1 -1
  42. package/scripts/lint-rego.mjs +19 -4
  43. package/scripts/run-shellcheck-text.mjs +94 -64
  44. package/scripts/sync-claude-config.mjs +1 -1
  45. package/scripts/utils/ast-scan-utils.mjs +28 -0
  46. package/scripts/utils/bun-sql-scan.mjs +53 -34
  47. package/scripts/utils/bunyan-imports.mjs +10 -61
  48. package/scripts/utils/conn-file-rules.mjs +76 -37
  49. package/scripts/utils/depcheck-workflow.mjs +27 -6
  50. package/scripts/utils/redis-imports.mjs +9 -51
  51. package/skills/llm-patch/SKILL.md +16 -5
@@ -42,7 +42,6 @@ const POLICY_DIR = join(PACKAGE_ROOT, 'policy')
42
42
  * в інших скриптах: `node_modules`, `.git`, `dist`, `coverage`, `build`,
43
43
  * `.turbo`, `.next`. Не використовуємо bun Glob, щоб не плодити залежності
44
44
  * за межами `node:fs`.
45
- *
46
45
  * @typedef {{
47
46
  * namespace: string,
48
47
  * policyDir: string,
@@ -72,6 +71,25 @@ function loadActiveCursorRules(cwd) {
72
71
 
73
72
  const SKIP_DIR_NAMES = new Set(['node_modules', '.git', 'dist', 'coverage', 'build', '.turbo', '.next'])
74
73
 
74
+ /** `…/k8s/<env>/configmap.yaml` (configmap безпосередньо у directory `k8s/<…>`). */
75
+ const K8S_CONFIGMAP_PATH_RE = /(^|\/)k8s\/[^/]+\/configmap\.yaml$/u
76
+ /** Будь-який шлях під сегментом `k8s/`. */
77
+ const K8S_DIR_PATH_RE = /(^|\/)k8s\//u
78
+ /** `…/k8s/<…>/hc.yaml` (HealthCheckPolicy будь-де під k8s). */
79
+ const K8S_HC_YAML_PATH_RE = /(^|\/)k8s\/.+\/hc\.yaml$/u
80
+ /** `…/k8s/…/base/…/hr.yaml` (HTTPRoute у base-шарі). */
81
+ const K8S_BASE_HR_YAML_PATH_RE = /(^|\/)k8s\/.*base\/.*hr\.yaml$/u
82
+ /** `kustomization.yaml` будь-де під сегментом `k8s/`. */
83
+ const K8S_KUSTOMIZATION_PATH_RE = /(^|\/)k8s\/.*\/kustomization\.yaml$/u
84
+ /** `…/k8s/.../base/.../kustomization.yaml`. */
85
+ const K8S_BASE_KUSTOMIZATION_PATH_RE = /(^|\/)k8s\/.*base\/(?:.*\/)?kustomization\.yaml$/u
86
+ /** Будь-який ресурсний `*.yaml` під сегментом `…/k8s/.../base/...`, окрім `kustomization.yaml`. */
87
+ const K8S_BASE_MANIFEST_PATH_RE = /(^|\/)k8s\/.*base\//u
88
+ /** `…/k8s/.../svc.yaml` (cluster-IP Service). */
89
+ const K8S_SVC_YAML_PATH_RE = /(^|\/)k8s\/.+\/svc\.yaml$/u
90
+ /** `…/k8s/.../svc-hl.yaml` (headless Service). */
91
+ const K8S_SVC_HL_YAML_PATH_RE = /(^|\/)k8s\/.+\/svc-hl\.yaml$/u
92
+
75
93
  /** @type {ConftestTarget[]} */
76
94
  const TARGETS = [
77
95
  // ── bun ─────────────────────────────────────────────────────────────────
@@ -207,15 +225,77 @@ const TARGETS = [
207
225
  namespace: 'js_run.configmap',
208
226
  policyDir: 'js_run',
209
227
  rule: 'js-run',
210
- walk: { match: rel => /(^|\/)k8s\/[^/]+\/configmap\.yaml$/.test(rel) }
228
+ walk: { match: rel => K8S_CONFIGMAP_PATH_RE.test(rel) }
211
229
  },
212
230
 
213
231
  // Усі YAML у дереві з сегментом `k8s` — пер-документні структурні правила.
214
232
  {
215
233
  namespace: 'k8s.manifest',
216
- policyDir: 'k8s',
234
+ policyDir: 'k8s/manifest',
235
+ rule: 'k8s',
236
+ walk: { match: rel => K8S_DIR_PATH_RE.test(rel) && (rel.endsWith('.yaml') || rel.endsWith('.yml')) }
237
+ },
238
+
239
+ // Gateway API + HealthCheckPolicy — застосовується до будь-якого YAML під k8s
240
+ // (правила перевіряють лише відповідні kind / apiVersion).
241
+ {
242
+ namespace: 'k8s.gateway',
243
+ policyDir: 'k8s/gateway',
244
+ rule: 'k8s',
245
+ walk: { match: rel => K8S_DIR_PATH_RE.test(rel) && (rel.endsWith('.yaml') || rel.endsWith('.yml')) }
246
+ },
247
+
248
+ // Структурні перевірки HPA / PDB (apiVersion / behavior / metrics / selector).
249
+ {
250
+ namespace: 'k8s.hpa_pdb',
251
+ policyDir: 'k8s/hpa_pdb',
252
+ rule: 'k8s',
253
+ walk: { match: rel => K8S_DIR_PATH_RE.test(rel) && (rel.endsWith('.yaml') || rel.endsWith('.yml')) }
254
+ },
255
+
256
+ // Kustomization-файли: resources sort, patches sort, JSON6902 conflicts.
257
+ {
258
+ namespace: 'k8s.kustomization',
259
+ policyDir: 'k8s/kustomization',
217
260
  rule: 'k8s',
218
- walk: { match: rel => /(^|\/)k8s\//.test(rel) && (rel.endsWith('.yaml') || rel.endsWith('.yml')) }
261
+ walk: { match: rel => K8S_KUSTOMIZATION_PATH_RE.test(rel) }
262
+ },
263
+
264
+ // svc.yaml — cluster-IP Service.
265
+ {
266
+ namespace: 'k8s.svc_yaml',
267
+ policyDir: 'k8s/svc_yaml',
268
+ rule: 'k8s',
269
+ walk: { match: rel => K8S_SVC_YAML_PATH_RE.test(rel) }
270
+ },
271
+
272
+ // svc-hl.yaml — headless Service з суфіксом `-hl`.
273
+ {
274
+ namespace: 'k8s.svc_hl_yaml',
275
+ policyDir: 'k8s/svc_hl_yaml',
276
+ rule: 'k8s',
277
+ walk: { match: rel => K8S_SVC_HL_YAML_PATH_RE.test(rel) }
278
+ },
279
+
280
+ // base/kustomization.yaml — обов'язкове непорожнє поле `namespace:`.
281
+ {
282
+ namespace: 'k8s.base_kustomization',
283
+ policyDir: 'k8s/base_kustomization',
284
+ rule: 'k8s',
285
+ walk: { match: rel => K8S_BASE_KUSTOMIZATION_PATH_RE.test(rel) }
286
+ },
287
+
288
+ // Ресурсні маніфести під `…/k8s/.../base/...` (окрім kustomization.yaml).
289
+ {
290
+ namespace: 'k8s.base_manifest',
291
+ policyDir: 'k8s/base_manifest',
292
+ rule: 'k8s',
293
+ walk: {
294
+ match: rel =>
295
+ K8S_BASE_MANIFEST_PATH_RE.test(rel) &&
296
+ !K8S_BASE_KUSTOMIZATION_PATH_RE.test(rel) &&
297
+ (rel.endsWith('.yaml') || rel.endsWith('.yml'))
298
+ }
219
299
  },
220
300
 
221
301
  // abie HealthCheckPolicy: `hc.yaml` у дереві k8s.
@@ -223,7 +303,7 @@ const TARGETS = [
223
303
  namespace: 'abie.health_check_policy',
224
304
  policyDir: 'abie',
225
305
  rule: 'abie',
226
- walk: { match: rel => /(^|\/)k8s\/.+\/hc\.yaml$/.test(rel) }
306
+ walk: { match: rel => K8S_HC_YAML_PATH_RE.test(rel) }
227
307
  },
228
308
 
229
309
  // abie HTTPRoute у `base/`.
@@ -231,7 +311,7 @@ const TARGETS = [
231
311
  namespace: 'abie.http_route_base',
232
312
  policyDir: 'abie',
233
313
  rule: 'abie',
234
- walk: { match: rel => /(^|\/)k8s\/.*base\/.*hr\.yaml$/.test(rel) }
314
+ walk: { match: rel => K8S_BASE_HR_YAML_PATH_RE.test(rel) }
235
315
  }
236
316
  ]
237
317
 
@@ -245,7 +325,7 @@ const TARGETS = [
245
325
  function collectFiles(root, match) {
246
326
  /** @type {string[]} */
247
327
  const out = []
248
- /** @param {string} dirAbs */
328
+ /** @param {string} dirAbs абсолютний шлях каталогу для рекурсивного обходу */
249
329
  function visit(dirAbs) {
250
330
  /** @type {import('node:fs').Dirent[]} */
251
331
  let entries
@@ -355,5 +435,5 @@ export function runLintConftestCli() {
355
435
  }
356
436
 
357
437
  if (import.meta.url === `file://${process.argv[1]}`) {
358
- process.exit(runLintConftestCli())
438
+ process.exitCode = runLintConftestCli()
359
439
  }
@@ -214,7 +214,7 @@ export function runLintGaCli() {
214
214
  * Поведінка fallback:
215
215
  * - якщо `conftest` не знайдено в PATH — друкуємо `ℹ` повідомлення з підказкою встановлення й
216
216
  * повертаємо 0 (тобто конфтест поки що **не** є обовʼязковою залежністю lint-ga; перевірки лежать
217
- * паралельно в `check-ga.mjs`, і `npx @nitra/cursor check ga` все одно їх запустить);
217
+ * паралельно в `check-ga.mjs`, і `npx \@nitra/cursor check ga` все одно їх запустить);
218
218
  * - якщо `conftest` є й полісі-каталог відсутній (нетипова інсталяція) — також `ℹ` skip;
219
219
  * - якщо є цільовий workflow і conftest — запускаємо `conftest test <workflow> -p <policy-dir>` і
220
220
  * повертаємо його exit-код, щоб порушення зупиняли lint-ga, як це робить actionlint/zizmor.
@@ -1,13 +1,17 @@
1
1
  /**
2
2
  * Лінт Rego-полісі (`conftest.mdc` + `rego.mdc`): preflight на `opa` і `regal`,
3
- * далі послідовно `opa check --strict` і `regal lint`.
3
+ * далі послідовно `opa check --strict`, `regal lint` і опційний
4
+ * `conftest verify` (для `*_test.rego`-файлів) якщо conftest у PATH.
4
5
  *
5
- * Чому два інструменти:
6
+ * Чому два-три інструменти:
6
7
  * - `opa check --strict` — компіляція з типами і строгим режимом (мертвий код, неоднозначні
7
8
  * правила, незадекларовані змінні). Ловить помилки, які `regal` навмисно лишає поза скоупом
8
9
  * (він — про стиль і ідіоматичність, а не про компіляцію).
9
10
  * - `regal lint` (https://docs.styra.com/regal) — статичний лінтер Rego: ловить v0-синтаксис,
10
11
  * неявні set-rules та інші відхилення від `rego.v1`, плюс bugs/idiomatic/performance-правила.
12
+ * - `conftest verify` (опційно) — виконує `test_*` правила у `*_test.rego` (юніт-тести політик).
13
+ * Якщо conftest відсутній у PATH — пропускаємо без помилки (тести опційні в локальному середовищі;
14
+ * у CI потрібно встановити conftest).
11
15
  *
12
16
  * Без preflight-у на бінарники лінт мовчки злетить з невиразним повідомленням від shell —
13
17
  * друкуємо явні install-hints (як це робить `lint-ga.mjs` для shellcheck/uv). `opa` додатково
@@ -16,7 +20,7 @@
16
20
  *
17
21
  * Цілі лінту: `npm/policy/` (місце, де поки що живуть Rego-полісі пакета `@nitra/cursor`).
18
22
  * Якщо в репозиторії з’являться інші *.rego поза цим деревом, додай шлях у `LINT_TARGETS` —
19
- * обидва інструменти приймають кілька шляхів і самі рекурсивно обходять директорії.
23
+ * усі три інструменти приймають кілька шляхів і самі рекурсивно обходять директорії.
20
24
  */
21
25
  import { spawnSync } from 'node:child_process'
22
26
  import { existsSync } from 'node:fs'
@@ -110,7 +114,18 @@ export function runLintRego(cwd = process.cwd()) {
110
114
  const opaCode = runStep(opa, ['check', '--strict', ...targets], root)
111
115
  if (opaCode !== 0) return opaCode
112
116
 
113
- return runStep(regal, ['lint', ...targets], root)
117
+ const regalCode = runStep(regal, ['lint', ...targets], root)
118
+ if (regalCode !== 0) return regalCode
119
+
120
+ const conftest = resolveCmd('conftest')
121
+ if (!conftest) {
122
+ console.log(
123
+ 'ℹ conftest не знайдено в PATH — пропускаю `conftest verify` (юніт-тести *_test.rego).\n' +
124
+ ' Встанови, щоб запустити локально: brew install conftest (macOS) або https://www.conftest.dev/install/'
125
+ )
126
+ return 0
127
+ }
128
+ return runStep(conftest, ['verify', ...targets.flatMap(t => ['-p', t])], root)
114
129
  }
115
130
 
116
131
  process.exitCode = runLintRego()
@@ -65,22 +65,25 @@ function printPatchInstallHints() {
65
65
  * @returns {string[]} відсортований масив шляхів відносно cwd
66
66
  */
67
67
  export function listShellScriptPaths(cwd) {
68
- const gitOk = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], {
69
- cwd,
70
- encoding: 'utf8',
71
- env: process.env
72
- })
73
- if (gitOk.status === 0 && gitOk.stdout.trim() === 'true') {
74
- const ls = spawnSync('git', ['ls-files', '-z', '--', ':(glob)**/*.sh'], {
68
+ const gitPath = resolveCmd('git')
69
+ if (gitPath) {
70
+ const gitOk = spawnSync(gitPath, ['rev-parse', '--is-inside-work-tree'], {
75
71
  cwd,
76
72
  encoding: 'utf8',
77
73
  env: process.env
78
74
  })
79
- if (ls.status !== 0) {
80
- return []
75
+ if (gitOk.status === 0 && gitOk.stdout.trim() === 'true') {
76
+ const ls = spawnSync(gitPath, ['ls-files', '-z', '--', ':(glob)**/*.sh'], {
77
+ cwd,
78
+ encoding: 'utf8',
79
+ env: process.env
80
+ })
81
+ if (ls.status !== 0) {
82
+ return []
83
+ }
84
+ const files = ls.stdout.split('\0').filter(Boolean)
85
+ return new Set(files).toSorted()
81
86
  }
82
- const files = ls.stdout.split('\0').filter(Boolean)
83
- return new Set(files).toSorted()
84
87
  }
85
88
 
86
89
  const fromGlob = globSync('**/*.sh', {
@@ -114,51 +117,87 @@ export function runShellcheckText(cwd = process.cwd()) {
114
117
  }
115
118
 
116
119
  for (const rel of files) {
117
- for (let round = 0; round < MAX_FIX_ROUNDS_PER_FILE; round++) {
118
- const diffResult = spawnSync(shellcheck, ['-f', 'diff', rel], {
119
- cwd: root,
120
- encoding: 'utf8',
121
- env: process.env,
122
- maxBuffer: 10 * 1024 * 1024
123
- })
124
-
125
- if (diffResult.error) {
126
- process.stderr.write(`${diffResult.error.message}\n`)
127
- return 1
128
- }
129
-
130
- const code = diffResult.status ?? 1
131
- const out = (diffResult.stdout ?? '').trim()
132
- const err = (diffResult.stderr ?? '').trim()
133
-
134
- if (code === 0) {
135
- break
136
- }
137
-
138
- if (err.includes(NON_AUTOFIXABLE_HINT) || !out) {
139
- break
140
- }
120
+ const fixCode = autofixOneFile(shellcheck, patchBin, root, rel)
121
+ if (fixCode !== 0) return fixCode
122
+ }
141
123
 
142
- const patchRun = spawnSync(patchBin, ['-p1'], {
143
- cwd: root,
144
- input: diffResult.stdout ?? '',
145
- encoding: 'utf8',
146
- env: process.env
147
- })
124
+ return runFinalShellcheck(shellcheck, files, root)
125
+ }
148
126
 
149
- if (patchRun.status !== 0) {
150
- if (patchRun.stderr?.length) {
151
- process.stderr.write(patchRun.stderr)
152
- }
153
- if (patchRun.stdout?.length) {
154
- process.stderr.write(patchRun.stdout)
155
- }
156
- process.stderr.write(`run-shellcheck-text: patch не застосував diff для ${rel}\n`)
157
- return 1
158
- }
127
+ /**
128
+ * Запускає до `MAX_FIX_ROUNDS_PER_FILE` ітерацій `shellcheck -f diff` + `patch` для одного файла.
129
+ * Виходить з 0 у випадках: shellcheck повернув 0, нема autofixable, або порожній diff.
130
+ * @param {string} shellcheck абсолютний шлях до shellcheck
131
+ * @param {string} patchBin абсолютний шлях до patch
132
+ * @param {string} root абсолютний робочий каталог (cwd для spawn)
133
+ * @param {string} rel відносний шлях файла від `root`
134
+ * @returns {number} 0 OK; 1 — помилка spawn або patch
135
+ */
136
+ function autofixOneFile(shellcheck, patchBin, root, rel) {
137
+ for (let round = 0; round < MAX_FIX_ROUNDS_PER_FILE; round++) {
138
+ const diffResult = spawnSync(shellcheck, ['-f', 'diff', rel], {
139
+ cwd: root,
140
+ encoding: 'utf8',
141
+ env: process.env,
142
+ maxBuffer: 10 * 1024 * 1024
143
+ })
144
+ if (diffResult.error) {
145
+ process.stderr.write(`${diffResult.error.message}\n`)
146
+ return 1
159
147
  }
148
+ if (shouldStopAutofixLoop(diffResult)) return 0
149
+ const patchCode = applyShellcheckDiff(patchBin, root, rel, diffResult.stdout ?? '')
150
+ if (patchCode !== 0) return patchCode
160
151
  }
152
+ return 0
153
+ }
154
+
155
+ /**
156
+ * Чи треба зупинити цикл авто-фіксів: shellcheck повернув 0, або у stderr є пометка
157
+ * `none were auto-fixable`, або stdout порожній (нема дифу для застосування).
158
+ * @param {{ status: number | null, stdout?: string | null, stderr?: string | null }} diffResult результат spawnSync
159
+ * @returns {boolean} true — більше нічого фіксити
160
+ */
161
+ function shouldStopAutofixLoop(diffResult) {
162
+ const code = diffResult.status ?? 1
163
+ if (code === 0) return true
164
+ const out = (diffResult.stdout ?? '').trim()
165
+ const err = (diffResult.stderr ?? '').trim()
166
+ return err.includes(NON_AUTOFIXABLE_HINT) || !out
167
+ }
161
168
 
169
+ /**
170
+ * Застосовує `shellcheck -f diff`-вивід через `patch -p1`. На помилку виливає stdout/stderr від patch
171
+ * у `process.stderr` (щоб користувач бачив, чому не застосувалося) і повертає 1.
172
+ * @param {string} patchBin абсолютний шлях до patch
173
+ * @param {string} root cwd для spawn
174
+ * @param {string} rel відносний шлях для повідомлення про помилку
175
+ * @param {string} diffStdout вміст unified-diff від shellcheck (input для patch)
176
+ * @returns {number} 0 — застосовано; 1 — помилка
177
+ */
178
+ function applyShellcheckDiff(patchBin, root, rel, diffStdout) {
179
+ const patchRun = spawnSync(patchBin, ['-p1'], {
180
+ cwd: root,
181
+ input: diffStdout,
182
+ encoding: 'utf8',
183
+ env: process.env
184
+ })
185
+ if (patchRun.status === 0) return 0
186
+ if (patchRun.stderr?.length) process.stderr.write(patchRun.stderr)
187
+ if (patchRun.stdout?.length) process.stderr.write(patchRun.stdout)
188
+ process.stderr.write(`run-shellcheck-text: patch не застосував diff для ${rel}\n`)
189
+ return 1
190
+ }
191
+
192
+ /**
193
+ * Фінальний прогон `shellcheck` по всіх файлах — без `-f diff`, щоб отримати звичайний звіт.
194
+ * Будь-який ненульовий код shellcheck-а пробрасує як 1 (з виводом stdout/stderr на користувацькі stream-и).
195
+ * @param {string} shellcheck абсолютний шлях до shellcheck
196
+ * @param {string[]} files відносні шляхи файлів для перевірки
197
+ * @param {string} root cwd для spawn
198
+ * @returns {number} 0 — чисто; 1 — помилка spawn або зауваження shellcheck
199
+ */
200
+ function runFinalShellcheck(shellcheck, files, root) {
162
201
  const finalRun = spawnSync(shellcheck, files, {
163
202
  cwd: root,
164
203
  encoding: 'utf8',
@@ -166,23 +205,14 @@ export function runShellcheckText(cwd = process.cwd()) {
166
205
  maxBuffer: 10 * 1024 * 1024,
167
206
  stdio: ['ignore', 'pipe', 'pipe']
168
207
  })
169
-
170
208
  if (finalRun.error) {
171
209
  process.stderr.write(`${finalRun.error.message}\n`)
172
210
  return 1
173
211
  }
174
-
175
- if (finalRun.status !== 0) {
176
- if (finalRun.stdout?.length) {
177
- process.stdout.write(finalRun.stdout)
178
- }
179
- if (finalRun.stderr?.length) {
180
- process.stderr.write(finalRun.stderr)
181
- }
182
- return 1
183
- }
184
-
185
- return 0
212
+ if (finalRun.status === 0) return 0
213
+ if (finalRun.stdout?.length) process.stdout.write(finalRun.stdout)
214
+ if (finalRun.stderr?.length) process.stderr.write(finalRun.stderr)
215
+ return 1
186
216
  }
187
217
 
188
218
  if (isRunAsCli()) {
@@ -21,7 +21,7 @@ import { existsSync } from 'node:fs'
21
21
  import { chmod, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
22
22
  import { join } from 'node:path'
23
23
 
24
- /** Маркер lint Stop-hook'а (`npx --no @nitra/cursor stop-hook`). */
24
+ /** Маркер lint Stop-hook'а (`npx --no \@nitra/cursor stop-hook`). */
25
25
  export const MANAGED_HOOK_COMMAND_MARKER = '@nitra/cursor stop-hook'
26
26
  /** Маркер ADR Stop-hook'а — підрядок шляху до bash-скрипта. */
27
27
  export const ADR_HOOK_COMMAND_MARKER = '.claude/hooks/capture-decisions.sh'
@@ -175,3 +175,31 @@ export function templateQuasisText(template) {
175
175
  export function isSqlListContextTemplate(template) {
176
176
  return SQL_LIST_CONTEXT_RE.test(templateQuasisText(template))
177
177
  }
178
+
179
+ /**
180
+ * Перевіряє, чи це виклик `require('<module>')` з рядковим аргументом.
181
+ * Спільне для сканерів імпортів (`bunyan-imports`, `redis-imports`, ...).
182
+ * @param {Record<string, unknown> | null | undefined} node вузол AST
183
+ * @returns {string | null} ім'я модуля з аргументу, інакше `null`
184
+ */
185
+ export function requireCallModule(node) {
186
+ if (!node || node.type !== 'CallExpression') return null
187
+ const callee = node.callee
188
+ if (!callee || callee.type !== 'Identifier' || callee.name !== 'require') return null
189
+ const arg = node.arguments?.[0]
190
+ if (!arg || arg.type !== 'Literal' || typeof arg.value !== 'string') return null
191
+ return arg.value
192
+ }
193
+
194
+ /**
195
+ * Перевіряє, чи це динамічний `import('<module>')` з рядковим аргументом.
196
+ * Спільне для сканерів імпортів.
197
+ * @param {Record<string, unknown> | null | undefined} node вузол AST
198
+ * @returns {string | null} ім'я модуля, інакше `null`
199
+ */
200
+ export function dynamicImportModule(node) {
201
+ if (!node || node.type !== 'ImportExpression') return null
202
+ const src = node.source
203
+ if (!src || src.type !== 'Literal' || typeof src.value !== 'string') return null
204
+ return src.value
205
+ }
@@ -56,6 +56,9 @@ const PG_FORMAT_SHIM_FUNC_NAMES = new Set(['format', 'pgFormat', 'sqlFormat', 'p
56
56
  // як named export з модуля-обгортки.
57
57
  const QUOTE_HELPER_NAMES = new Set(['quoteLiteral', 'quoteIdent', 'escapeLiteral', 'escapeIdent'])
58
58
 
59
+ // Імена першого параметра pg-style query-обгортки (`function query(text, params)` тощо).
60
+ const PG_QUERY_FIRST_PARAM_RE = /^(text|sql|query)$/u
61
+
59
62
  /**
60
63
  * @param {unknown} node AST node
61
64
  * @param {string} name імʼя змінної
@@ -280,19 +283,21 @@ function asPgLeftoverCall(node) {
280
283
  return { name: /** @type {'connect' | 'end'} */ (prop.name) }
281
284
  }
282
285
 
286
+ // Локальний alias на `isUnsafeCall` — щоб у nodeContainsUnsafeCall (під query-шимом)
287
+ // був семантично-говорящий call-site, але без дубля логіки з основним сканом.
288
+ const isUnsafeCallNode = isUnsafeCall
289
+
283
290
  /**
284
- * Чи це CallExpression `<obj>.unsafe(...)` (для пошуку в тілі query-шиму).
285
- * Дублює `isUnsafeCall` з основного скану, але локально щоб не залежати
286
- * від порядку оголошень у файлі.
287
- * @param {unknown} node AST node
288
- * @returns {boolean} true для `<obj>.unsafe(...)`
291
+ * Витягує ім'я ключа з AST `Property.key`. Підтримує `Identifier` (`{ foo: }`)
292
+ * та `Literal` (`{ 'foo': }` / `{ 5: }`); інші форми (computed expression тощо) — `null`.
293
+ * @param {unknown} key AST `Property.key`
294
+ * @returns {string | number | null} ім'я ключа або null
289
295
  */
290
- function isUnsafeCallNode(node) {
291
- if (!node || node.type !== 'CallExpression') return false
292
- const callee = node.callee
293
- if (!callee || callee.type !== 'MemberExpression' || callee.computed) return false
294
- const prop = callee.property
295
- return !!prop && prop.type === 'Identifier' && prop.name === 'unsafe'
296
+ function propertyKeyName(key) {
297
+ if (!key || typeof key !== 'object') return null
298
+ if (key.type === 'Identifier' && typeof key.name === 'string') return key.name
299
+ if (key.type === 'Literal' && (typeof key.value === 'string' || typeof key.value === 'number')) return key.value
300
+ return null
296
301
  }
297
302
 
298
303
  /**
@@ -325,10 +330,8 @@ function nodeContainsPgFormatPlaceholder(root) {
325
330
  found = true
326
331
  return
327
332
  }
328
- if (t === 'TemplateLiteral') {
329
- if (PG_FORMAT_PLACEHOLDER_RE.test(templateQuasisText(n))) {
330
- found = true
331
- }
333
+ if (t === 'TemplateLiteral' && PG_FORMAT_PLACEHOLDER_RE.test(templateQuasisText(n))) {
334
+ found = true
332
335
  }
333
336
  })
334
337
  return found
@@ -406,7 +409,6 @@ export function findPgFormatShimDefinitionInText(content, virtualPath = 'scan.ts
406
409
  * - значення — функція з 1–2 параметрами, перший — Identifier з типовим
407
410
  * pg-іменем (`text` / `sql` / `query`);
408
411
  * - у тілі функції є виклик `<obj>.unsafe(...)`.
409
- *
410
412
  * @param {string} content вихідний код
411
413
  * @param {string} [virtualPath] шлях для вибору `lang`
412
414
  * @returns {{ line: number, snippet: string }[]} список порушень
@@ -419,31 +421,48 @@ export function findPgFormatLikeQueryWrapperInText(content, virtualPath = 'scan.
419
421
  /** @type {{ line: number, snippet: string }[]} */
420
422
  const out = []
421
423
  walkAstWithAncestors(program, [], node => {
422
- if (node.type !== 'ObjectExpression') return
423
- const properties = node.properties
424
- if (!Array.isArray(properties)) return
425
- for (const prop of properties) {
426
- if (!prop || prop.type !== 'Property') continue
427
- const key = prop.key
428
- const keyName = key && key.type === 'Identifier' ? key.name : key && key.type === 'Literal' ? key.value : null
429
- if (keyName !== 'query') continue
430
- const value = prop.value
431
- if (!value || (value.type !== 'FunctionExpression' && value.type !== 'ArrowFunctionExpression')) continue
432
- const params = value.params
433
- const firstName = Array.isArray(params) && params[0]?.type === 'Identifier' ? params[0].name : null
434
- const looksLikePgQuery =
435
- Array.isArray(params) && params.length >= 1 && params.length <= 2 && /^(text|sql|query)$/u.test(firstName || '')
436
- if (!looksLikePgQuery) continue
437
- if (!nodeContainsUnsafeCall(value.body)) continue
424
+ if (node.type !== 'ObjectExpression' || !Array.isArray(node.properties)) return
425
+ for (const prop of node.properties) {
426
+ const queryProp = asPgFormatLikeQueryProp(prop)
427
+ if (!queryProp) continue
438
428
  out.push({
439
- line: offsetToLine(content, prop.start),
440
- snippet: normalizeSnippet(content.slice(prop.start, prop.end))
429
+ line: offsetToLine(content, queryProp.start),
430
+ snippet: normalizeSnippet(content.slice(queryProp.start, queryProp.end))
441
431
  })
442
432
  }
443
433
  })
444
434
  return out
445
435
  }
446
436
 
437
+ /**
438
+ * Чи є цей вузол `Property` тим самим pg-сумісним `{ query(text, params) { … unsafe … } }`?
439
+ * Повертає сам `prop` (для зручного `start`/`end`) або `null`.
440
+ * @param {unknown} prop AST вузол `Property`
441
+ * @returns {{ start: number, end: number } | null} `prop` як власний рекорд або `null`
442
+ */
443
+ function asPgFormatLikeQueryProp(prop) {
444
+ if (!prop || typeof prop !== 'object' || prop.type !== 'Property') return null
445
+ if (propertyKeyName(prop.key) !== 'query') return null
446
+ const value = prop.value
447
+ if (!value || (value.type !== 'FunctionExpression' && value.type !== 'ArrowFunctionExpression')) return null
448
+ if (!hasPgQuerySignature(value.params)) return null
449
+ if (!nodeContainsUnsafeCall(value.body)) return null
450
+ return { start: prop.start, end: prop.end }
451
+ }
452
+
453
+ /**
454
+ * Чи виглядає сигнатура функції як pg-style `query(text, params?)`: 1–2 параметри,
455
+ * перший — Identifier з типовим pg-іменем (`text` / `sql` / `query`).
456
+ * @param {unknown} params AST `params` (масив)
457
+ * @returns {boolean} true, якщо схоже на pg-обгортку
458
+ */
459
+ function hasPgQuerySignature(params) {
460
+ if (!Array.isArray(params) || params.length < 1 || params.length > 2) return false
461
+ const first = params[0]
462
+ if (!first || first.type !== 'Identifier' || typeof first.name !== 'string') return false
463
+ return PG_QUERY_FIRST_PARAM_RE.test(first.name)
464
+ }
465
+
447
466
  /**
448
467
  * Чи є у піддереві виклик `<obj>.unsafe(...)`.
449
468
  * @param {unknown} root корінь піддерева
@@ -11,69 +11,18 @@
11
11
  */
12
12
  import { parseSync } from 'oxc-parser'
13
13
 
14
- import { langFromPath, offsetToLine } from './ast-scan-utils.mjs'
14
+ import {
15
+ dynamicImportModule,
16
+ langFromPath,
17
+ normalizeSnippet,
18
+ offsetToLine,
19
+ requireCallModule,
20
+ walkAstWithAncestors
21
+ } from './ast-scan-utils.mjs'
15
22
 
16
- const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/
23
+ const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/u
17
24
  const FORBIDDEN_MODULES = new Set(['@nitra/bunyan', 'bunyan'])
18
25
 
19
- /**
20
- * Стискає пробіли для повідомлення про порушення.
21
- * @param {string} s фрагмент коду
22
- * @returns {string} скорочений однорядковий рядок
23
- */
24
- function normalizeSnippet(s) {
25
- return s.replaceAll(/\s+/g, ' ').trim().slice(0, 160)
26
- }
27
-
28
- /**
29
- * Перевіряє, чи це виклик `require('<module>')` з рядковим аргументом.
30
- * @param {Record<string, unknown> | null | undefined} node вузол AST
31
- * @returns {string | null} ім'я модуля з аргументу, інакше `null`
32
- */
33
- function requireCallModule(node) {
34
- if (!node || node.type !== 'CallExpression') return null
35
- const callee = node.callee
36
- if (!callee || callee.type !== 'Identifier' || callee.name !== 'require') return null
37
- const arg = node.arguments?.[0]
38
- if (!arg || arg.type !== 'Literal' || typeof arg.value !== 'string') return null
39
- return arg.value
40
- }
41
-
42
- /**
43
- * Перевіряє, чи це динамічний `import('<module>')` з рядковим аргументом.
44
- * @param {Record<string, unknown> | null | undefined} node вузол AST
45
- * @returns {string | null} ім'я модуля, інакше `null`
46
- */
47
- function dynamicImportModule(node) {
48
- if (!node || node.type !== 'ImportExpression') return null
49
- const src = node.source
50
- if (!src || src.type !== 'Literal' || typeof src.value !== 'string') return null
51
- return src.value
52
- }
53
-
54
- /**
55
- * Простий рекурсивний обхід AST: заходимо в усі об'єкти/масиви, щоб знайти require/import-вузли.
56
- * @param {unknown} node корінь або під-вузол AST
57
- * @param {(n: unknown) => void} visit виклик для кожного об'єкта-вузла
58
- * @returns {void}
59
- */
60
- function walkAst(node, visit) {
61
- if (!node || typeof node !== 'object') return
62
- if (Array.isArray(node)) {
63
- for (const item of node) walkAst(item, visit)
64
- return
65
- }
66
- if (typeof node.type === 'string') {
67
- visit(node)
68
- }
69
- for (const key of Object.keys(node)) {
70
- if (key !== 'parent') {
71
- const v = node[key]
72
- if (v && typeof v === 'object') walkAst(v, visit)
73
- }
74
- }
75
- }
76
-
77
26
  /**
78
27
  * Знаходить заборонені імпорти/require з `@nitra/bunyan` у тексті.
79
28
  * @param {string} content вихідний код
@@ -107,7 +56,7 @@ export function findBunyanImportsInText(content, virtualPath = 'scan.ts') {
107
56
  }
108
57
  }
109
58
 
110
- walkAst(result.program, node => {
59
+ walkAstWithAncestors(result.program, [], node => {
111
60
  const reqMod = requireCallModule(node)
112
61
  if (reqMod && FORBIDDEN_MODULES.has(reqMod)) {
113
62
  out.push({