@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.
- package/.claude-template/npm-CLAUDE.md +4 -0
- package/CHANGELOG.md +21 -0
- package/bin/auto-rules.md +2 -0
- package/bin/n-cursor.js +25 -4
- package/mdc/ci4.mdc +51 -0
- package/mdc/tauri.mdc +20 -0
- package/package.json +1 -1
- package/policy/k8s/base_kustomization/base_kustomization.rego +40 -0
- package/policy/k8s/base_kustomization/base_kustomization_test.rego +36 -0
- package/policy/k8s/base_manifest/base_manifest.rego +154 -0
- package/policy/k8s/base_manifest/base_manifest_test.rego +94 -0
- package/policy/k8s/gateway/gateway.rego +151 -0
- package/policy/k8s/gateway/gateway_test.rego +122 -0
- package/policy/k8s/hasura_configmap/hasura_configmap.rego +69 -0
- package/policy/k8s/hasura_configmap/hasura_configmap_test.rego +49 -0
- package/policy/k8s/hasura_httproute/hasura_httproute.rego +298 -0
- package/policy/k8s/hasura_httproute/hasura_httproute_test.rego +148 -0
- package/policy/k8s/hpa_pdb/hpa_pdb.rego +139 -0
- package/policy/k8s/hpa_pdb/hpa_pdb_test.rego +101 -0
- package/policy/k8s/kustomization/kustomization.rego +220 -0
- package/policy/k8s/kustomization/kustomization_test.rego +128 -0
- package/policy/k8s/kustomize_managed/kustomize_managed.rego +31 -0
- package/policy/k8s/kustomize_managed/kustomize_managed_test.rego +30 -0
- package/policy/k8s/manifest/manifest.rego +151 -4
- package/policy/k8s/manifest/manifest_test.rego +309 -0
- package/policy/k8s/svc_hl_yaml/svc_hl_yaml.rego +51 -0
- package/policy/k8s/svc_hl_yaml/svc_hl_yaml_test.rego +42 -0
- package/policy/k8s/svc_yaml/svc_yaml.rego +31 -0
- package/policy/k8s/svc_yaml/svc_yaml_test.rego +41 -0
- package/scripts/auto-skills.mjs +8 -1
- package/scripts/check-bun.mjs +3 -3
- package/scripts/check-changelog.mjs +2 -3
- package/scripts/check-image-avif.mjs +14 -6
- package/scripts/check-image-compress.mjs +1 -1
- package/scripts/check-js-run.mjs +58 -47
- package/scripts/check-k8s.mjs +128 -51
- package/scripts/check-npm-module.mjs +1 -4
- package/scripts/check-php.mjs +5 -5
- package/scripts/claude-stop-hook.mjs +2 -2
- package/scripts/lint-conftest.mjs +88 -8
- package/scripts/lint-ga.mjs +1 -1
- package/scripts/lint-rego.mjs +19 -4
- package/scripts/run-shellcheck-text.mjs +94 -64
- package/scripts/sync-claude-config.mjs +1 -1
- package/scripts/utils/ast-scan-utils.mjs +28 -0
- package/scripts/utils/bun-sql-scan.mjs +53 -34
- package/scripts/utils/bunyan-imports.mjs +10 -61
- package/scripts/utils/conn-file-rules.mjs +76 -37
- package/scripts/utils/depcheck-workflow.mjs +27 -6
- package/scripts/utils/redis-imports.mjs +9 -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 =>
|
|
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 =>
|
|
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 =>
|
|
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 =>
|
|
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.
|
|
438
|
+
process.exitCode = runLintConftestCli()
|
|
359
439
|
}
|
package/scripts/lint-ga.mjs
CHANGED
|
@@ -214,7 +214,7 @@ export function runLintGaCli() {
|
|
|
214
214
|
* Поведінка fallback:
|
|
215
215
|
* - якщо `conftest` не знайдено в PATH — друкуємо `ℹ` повідомлення з підказкою встановлення й
|
|
216
216
|
* повертаємо 0 (тобто конфтест поки що **не** є обовʼязковою залежністю lint-ga; перевірки лежать
|
|
217
|
-
* паралельно в `check-ga.mjs`, і `npx
|
|
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.
|
package/scripts/lint-rego.mjs
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Лінт Rego-полісі (`conftest.mdc` + `rego.mdc`): preflight на `opa` і `regal`,
|
|
3
|
-
* далі послідовно `opa check --strict
|
|
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
|
-
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
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 (
|
|
80
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
input: diffResult.stdout ?? '',
|
|
145
|
-
encoding: 'utf8',
|
|
146
|
-
env: process.env
|
|
147
|
-
})
|
|
124
|
+
return runFinalShellcheck(shellcheck, files, root)
|
|
125
|
+
}
|
|
148
126
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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.
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
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
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
* @
|
|
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
|
|
291
|
-
if (!
|
|
292
|
-
|
|
293
|
-
if (
|
|
294
|
-
|
|
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
|
-
|
|
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
|
|
424
|
-
|
|
425
|
-
|
|
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,
|
|
440
|
-
snippet: normalizeSnippet(content.slice(
|
|
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 {
|
|
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
|
-
|
|
59
|
+
walkAstWithAncestors(result.program, [], node => {
|
|
111
60
|
const reqMod = requireCallModule(node)
|
|
112
61
|
if (reqMod && FORBIDDEN_MODULES.has(reqMod)) {
|
|
113
62
|
out.push({
|