@nitra/cursor 1.8.220 → 1.8.221
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/bin/n-cursor.js +25 -4
- package/mdc/ci4.mdc +51 -0
- package/package.json +1 -1
- 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 +15 -7
- package/scripts/lint-ga.mjs +1 -1
- 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
package/scripts/check-k8s.mjs
CHANGED
|
@@ -325,6 +325,18 @@ const K8S_BASE_SEGMENT_RE = /(^|\/)k8s\/base\//u
|
|
|
325
325
|
const OXLINT_SCHEMA_MODELINE_RE = /^\s*#\s*yaml-language-server:\s*\$schema=\S+/u
|
|
326
326
|
const HTTPS_SCHEMA_RE = /^https:/iu
|
|
327
327
|
const HASURA_GRAPHQL_ENGINE_RE = /(^|\/)hasura\/graphql-engine(?::|$)/u
|
|
328
|
+
const BASE_CANON_MEMORY_RE = /^128Mi$/iu
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Видаляє хвостові символи `\n` зі стрічки без regex (щоб не тригерити sonarjs/slow-regex).
|
|
332
|
+
* @param {string} s стрічка YAML/тексту
|
|
333
|
+
* @returns {string} стрічка без trailing newlines
|
|
334
|
+
*/
|
|
335
|
+
function stripTrailingNewlines(s) {
|
|
336
|
+
let end = s.length
|
|
337
|
+
while (end > 0 && s.codePointAt(end - 1) === 10) end--
|
|
338
|
+
return end === s.length ? s : s.slice(0, end)
|
|
339
|
+
}
|
|
328
340
|
const BATCH_V1BETA1_API_VERSION_LINE_RE = /^(\s*apiVersion:\s*)["']?batch\/v1beta1["']?(\s*)$/u
|
|
329
341
|
|
|
330
342
|
/**
|
|
@@ -665,15 +677,25 @@ async function validateKustomizationPatchesStructuralSort(root, yamlFilesAbs, fa
|
|
|
665
677
|
if (kust === null) continue
|
|
666
678
|
const outer = kustomizationPatchesSortedViolation(kust)
|
|
667
679
|
if (outer !== null) fail(`${rel}: ${outer}`)
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
680
|
+
if (!Array.isArray(kust.patches)) continue
|
|
681
|
+
validateInlinePatchesSorted(rel, kust.patches, fail)
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Перевіряє, що inline-`patch:` (рядок YAML/JSON) у кожному `patches[i]` має ops у канонічному порядку
|
|
687
|
+
* (`add`/`replace` за `path`). Чужі форми (без `patch`-стрічки, з `target` без inline-блока) пропускаються.
|
|
688
|
+
* @param {string} rel відносний шлях `kustomization.yaml` для повідомлень
|
|
689
|
+
* @param {unknown[]} patches масив `kust.patches` (рекордів)
|
|
690
|
+
* @param {(msg: string) => void} fail callback при порушенні
|
|
691
|
+
*/
|
|
692
|
+
function validateInlinePatchesSorted(rel, patches, fail) {
|
|
693
|
+
for (const [i, p] of patches.entries()) {
|
|
694
|
+
if (p === null || typeof p !== 'object' || Array.isArray(p)) continue
|
|
695
|
+
const rec = /** @type {Record<string, unknown>} */ (p)
|
|
696
|
+
if (typeof rec.patch !== 'string') continue
|
|
697
|
+
const v = kustomizationInlinePatchOpsSortedViolation(rec.patch)
|
|
698
|
+
if (v !== null) fail(`${rel}: patches[${i}] (${kustomizationPatchLabel(p, i)}): ${v}`)
|
|
677
699
|
}
|
|
678
700
|
}
|
|
679
701
|
|
|
@@ -2582,7 +2604,7 @@ function isBaseCanonCpuValue(cpu) {
|
|
|
2582
2604
|
*/
|
|
2583
2605
|
function isBaseCanonMemoryValue(mem) {
|
|
2584
2606
|
if (typeof mem !== 'string' || mem.trim() === '') return false
|
|
2585
|
-
return
|
|
2607
|
+
return BASE_CANON_MEMORY_RE.test(mem.trim())
|
|
2586
2608
|
}
|
|
2587
2609
|
|
|
2588
2610
|
/**
|
|
@@ -5212,13 +5234,11 @@ function checkProdOverridesInKustomization(kust, rel, fail, passFn, needs) {
|
|
|
5212
5234
|
ok = false
|
|
5213
5235
|
}
|
|
5214
5236
|
}
|
|
5215
|
-
if (needs.needsPdbMinAvailablePatch) {
|
|
5216
|
-
|
|
5217
|
-
|
|
5218
|
-
|
|
5219
|
-
|
|
5220
|
-
ok = false
|
|
5221
|
-
}
|
|
5237
|
+
if (needs.needsPdbMinAvailablePatch && !pdbPaths.has('/spec/minAvailable')) {
|
|
5238
|
+
fail(
|
|
5239
|
+
`${rel}: прод-оверлей має перевизначати spec.minAvailable для PodDisruptionBudget (мінімум 1 у проді) (k8s.mdc)`
|
|
5240
|
+
)
|
|
5241
|
+
ok = false
|
|
5222
5242
|
}
|
|
5223
5243
|
if (ok) {
|
|
5224
5244
|
passFn(`${rel}: прод-оверрайди HPA/PDB за потреби присутні (k8s.mdc)`)
|
|
@@ -5573,18 +5593,7 @@ async function validateDeploymentsInDir(deployments, dir, root, fail, passFn) {
|
|
|
5573
5593
|
const isK8sBaseLayer = isK8sYamlUnderBaseDirectory(`${relDir}/probe.yaml`)
|
|
5574
5594
|
const deployRel = relDir === '' ? '.' : relDir
|
|
5575
5595
|
if (isK8sBaseLayer && deployments.length > 0) {
|
|
5576
|
-
|
|
5577
|
-
if (existsSync(hpaAbs)) {
|
|
5578
|
-
fail(
|
|
5579
|
-
`${deployRel}/${HPA_FILENAME}: у шарі k8s/.../base не тримай локальний hpa.yaml — HPA живе у sibling components/ (k8s.mdc)`
|
|
5580
|
-
)
|
|
5581
|
-
}
|
|
5582
|
-
const pdbAbs = join(dir, PDB_FILENAME)
|
|
5583
|
-
if (existsSync(pdbAbs)) {
|
|
5584
|
-
fail(
|
|
5585
|
-
`${deployRel}/${PDB_FILENAME}: у шарі k8s/.../base не тримай локальний pdb.yaml — PDB живе у sibling components/ (k8s.mdc)`
|
|
5586
|
-
)
|
|
5587
|
-
}
|
|
5596
|
+
failIfBaseLayerHasLocalHpaOrPdb(dir, deployRel, fail)
|
|
5588
5597
|
}
|
|
5589
5598
|
const hpaDocs = isK8sBaseLayer ? [] : await readDocsByKindInDir(dir, 'HorizontalPodAutoscaler', HPA_FILENAME)
|
|
5590
5599
|
const pdbDocs = isK8sBaseLayer ? [] : await readDocsByKindInDir(dir, 'PodDisruptionBudget', PDB_FILENAME)
|
|
@@ -5600,15 +5609,47 @@ async function validateDeploymentsInDir(deployments, dir, root, fail, passFn) {
|
|
|
5600
5609
|
passFn
|
|
5601
5610
|
)
|
|
5602
5611
|
if (isK8sBaseLayer) {
|
|
5603
|
-
|
|
5604
|
-
const appLabel = deploymentAppLabel(deployment)
|
|
5605
|
-
if (deployName !== null && appLabel !== null) {
|
|
5606
|
-
await validateComponentsForBaseDeployment(dir, deployName, appLabel, root, fail, passFn)
|
|
5607
|
-
}
|
|
5612
|
+
await validateBaseLayerComponentsIfNamed(deployment, dir, root, fail, passFn)
|
|
5608
5613
|
}
|
|
5609
5614
|
}
|
|
5610
5615
|
}
|
|
5611
5616
|
|
|
5617
|
+
/**
|
|
5618
|
+
* У шарі `…/k8s/…/base/` забороняє локальні `hpa.yaml` / `pdb.yaml` (вони мають жити у sibling `components/`).
|
|
5619
|
+
* @param {string} dir абсолютний каталог Deployment-маніфесту
|
|
5620
|
+
* @param {string} deployRel відносний шлях для повідомлень (`.` якщо корінь репо)
|
|
5621
|
+
* @param {(msg: string) => void} fail callback при порушенні
|
|
5622
|
+
*/
|
|
5623
|
+
function failIfBaseLayerHasLocalHpaOrPdb(dir, deployRel, fail) {
|
|
5624
|
+
if (existsSync(join(dir, HPA_FILENAME))) {
|
|
5625
|
+
fail(
|
|
5626
|
+
`${deployRel}/${HPA_FILENAME}: у шарі k8s/.../base не тримай локальний hpa.yaml — HPA живе у sibling components/ (k8s.mdc)`
|
|
5627
|
+
)
|
|
5628
|
+
}
|
|
5629
|
+
if (existsSync(join(dir, PDB_FILENAME))) {
|
|
5630
|
+
fail(
|
|
5631
|
+
`${deployRel}/${PDB_FILENAME}: у шарі k8s/.../base не тримай локальний pdb.yaml — PDB живе у sibling components/ (k8s.mdc)`
|
|
5632
|
+
)
|
|
5633
|
+
}
|
|
5634
|
+
}
|
|
5635
|
+
|
|
5636
|
+
/**
|
|
5637
|
+
* Якщо у Deployment є `metadata.name` і `spec.selector.matchLabels.app` — викликає
|
|
5638
|
+
* `validateComponentsForBaseDeployment` для звірки sibling-`components/`. Без цих ключів
|
|
5639
|
+
* каталог `components/` неможливо звʼязати з конкретним Deployment, тож пропускаємо мовчки.
|
|
5640
|
+
* @param {Record<string, unknown>} deployment AST документа Deployment
|
|
5641
|
+
* @param {string} dir абсолютний каталог Deployment-маніфесту
|
|
5642
|
+
* @param {string} root абсолютний корінь репо
|
|
5643
|
+
* @param {(msg: string) => void} fail callback при порушенні
|
|
5644
|
+
* @param {(msg: string) => void} passFn callback при успіху
|
|
5645
|
+
*/
|
|
5646
|
+
async function validateBaseLayerComponentsIfNamed(deployment, dir, root, fail, passFn) {
|
|
5647
|
+
const deployName = manifestMetadataName(deployment)
|
|
5648
|
+
const appLabel = deploymentAppLabel(deployment)
|
|
5649
|
+
if (deployName === null || appLabel === null) return
|
|
5650
|
+
await validateComponentsForBaseDeployment(dir, deployName, appLabel, root, fail, passFn)
|
|
5651
|
+
}
|
|
5652
|
+
|
|
5612
5653
|
/**
|
|
5613
5654
|
* Витягує документи Deployment з YAML-файлу (повертає порожній масив, якщо файл недоступний або немає Deployment).
|
|
5614
5655
|
* @param {string} filePath абсолютний шлях до YAML-файлу
|
|
@@ -6302,6 +6343,20 @@ function applyConversionsToDoc(doc, conversions) {
|
|
|
6302
6343
|
const patchesNode = doc.get('patches', true)
|
|
6303
6344
|
if (!isSeq(patchesNode)) return false
|
|
6304
6345
|
|
|
6346
|
+
applyPatchConversionsToPatchesNode(patchesNode, groupConversionsByPatchIndex(conversions))
|
|
6347
|
+
if (patchesNode.items.length === 0) {
|
|
6348
|
+
doc.delete('patches')
|
|
6349
|
+
}
|
|
6350
|
+
appendConvertedImagesNode(doc, conversions)
|
|
6351
|
+
return true
|
|
6352
|
+
}
|
|
6353
|
+
|
|
6354
|
+
/**
|
|
6355
|
+
* Згруповує конвертації за індексом `patches[i]` і збирає `opIdx`-список ops, які треба видалити.
|
|
6356
|
+
* @param {Array<{ index: number, opIndex: number, totalOps: number }>} conversions конвертації
|
|
6357
|
+
* @returns {Map<number, { totalOps: number, opIdx: number[] }>} згруповане
|
|
6358
|
+
*/
|
|
6359
|
+
function groupConversionsByPatchIndex(conversions) {
|
|
6305
6360
|
/** @type {Map<number, { totalOps: number, opIdx: number[] }>} */
|
|
6306
6361
|
const byPatch = new Map()
|
|
6307
6362
|
for (const c of conversions) {
|
|
@@ -6309,39 +6364,61 @@ function applyConversionsToDoc(doc, conversions) {
|
|
|
6309
6364
|
slot.opIdx.push(c.opIndex)
|
|
6310
6365
|
byPatch.set(c.index, slot)
|
|
6311
6366
|
}
|
|
6367
|
+
return byPatch
|
|
6368
|
+
}
|
|
6312
6369
|
|
|
6370
|
+
/**
|
|
6371
|
+
* Застосовує згруповані конвертації до `patches:` Sequence: видаляє повністю-конвертовані
|
|
6372
|
+
* patches або переписує inline `patch:` без конвертованих ops. Іде в порядку спадання
|
|
6373
|
+
* індексів, щоб зберегти стабільність вилучень з масиву.
|
|
6374
|
+
* @param {import('yaml').YAMLSeq & { get(i: number, keep: true): unknown, delete(i: number): void, items: unknown[] }} patchesNode YAML Seq (звужено через `isSeq` у caller-і)
|
|
6375
|
+
* @param {Map<number, { totalOps: number, opIdx: number[] }>} byPatch згруповані конвертації
|
|
6376
|
+
*/
|
|
6377
|
+
function applyPatchConversionsToPatchesNode(patchesNode, byPatch) {
|
|
6313
6378
|
const sortedIdx = [...byPatch.keys()].toSorted((a, b) => b - a)
|
|
6314
6379
|
for (const i of sortedIdx) {
|
|
6315
6380
|
const slot = byPatch.get(i)
|
|
6316
6381
|
if (slot === undefined) continue
|
|
6317
|
-
|
|
6318
|
-
if (opIdx.length === totalOps) {
|
|
6382
|
+
if (slot.opIdx.length === slot.totalOps) {
|
|
6319
6383
|
patchesNode.delete(i)
|
|
6320
6384
|
continue
|
|
6321
6385
|
}
|
|
6322
|
-
|
|
6323
|
-
if (patchEntry === undefined || patchEntry === null) continue
|
|
6324
|
-
const patchScalar = patchEntry.get('patch', true)
|
|
6325
|
-
if (patchScalar === undefined || patchScalar === null || typeof patchScalar.value !== 'string') continue
|
|
6326
|
-
const rewritten = rewriteInlinePatchWithoutOps(patchScalar.value, opIdx)
|
|
6327
|
-
if (rewritten === null) continue
|
|
6328
|
-
patchScalar.value = rewritten
|
|
6386
|
+
rewriteInlinePatchAtIndex(patchesNode, i, slot.opIdx)
|
|
6329
6387
|
}
|
|
6388
|
+
}
|
|
6330
6389
|
|
|
6331
|
-
|
|
6332
|
-
|
|
6333
|
-
|
|
6390
|
+
/**
|
|
6391
|
+
* Переписує inline `patch:` у `patches[i]`, видаляючи ops зі списку. Якщо вузол не знайдено
|
|
6392
|
+
* або переписування не вдалося — залишає Document без змін.
|
|
6393
|
+
* @param {import('yaml').YAMLSeq & { get(i: number, keep: true): unknown, delete(i: number): void, items: unknown[] }} patchesNode YAML Seq (звужено через `isSeq` у caller-і)
|
|
6394
|
+
* @param {number} i індекс у `patches:`
|
|
6395
|
+
* @param {number[]} opIdx індекси ops для видалення
|
|
6396
|
+
*/
|
|
6397
|
+
function rewriteInlinePatchAtIndex(patchesNode, i, opIdx) {
|
|
6398
|
+
const patchEntry = patchesNode.get(i, true)
|
|
6399
|
+
if (patchEntry === undefined || patchEntry === null) return
|
|
6400
|
+
const patchScalar = patchEntry.get('patch', true)
|
|
6401
|
+
if (patchScalar === undefined || patchScalar === null || typeof patchScalar.value !== 'string') return
|
|
6402
|
+
const rewritten = rewriteInlinePatchWithoutOps(patchScalar.value, opIdx)
|
|
6403
|
+
if (rewritten === null) return
|
|
6404
|
+
patchScalar.value = rewritten
|
|
6405
|
+
}
|
|
6334
6406
|
|
|
6335
|
-
|
|
6336
|
-
|
|
6337
|
-
|
|
6407
|
+
/**
|
|
6408
|
+
* Дописує `images:` Seq у Document результатами конвертацій (створює, якщо немає).
|
|
6409
|
+
* @param {import('yaml').Document} doc YAML Document
|
|
6410
|
+
* @param {Array<{ name: string, newName: string, newTag: string | null }>} conversions конвертації
|
|
6411
|
+
*/
|
|
6412
|
+
function appendConvertedImagesNode(doc, conversions) {
|
|
6413
|
+
const existing = doc.get('images', true)
|
|
6414
|
+
const imagesNode = isSeq(existing) ? existing : doc.createNode([])
|
|
6415
|
+
if (existing !== imagesNode) {
|
|
6338
6416
|
doc.set('images', imagesNode)
|
|
6339
6417
|
}
|
|
6340
6418
|
for (const { name, newName, newTag } of conversions) {
|
|
6341
6419
|
const entry = newTag === null ? { name, newName } : { name, newName, newTag }
|
|
6342
6420
|
imagesNode.add(doc.createNode(entry))
|
|
6343
6421
|
}
|
|
6344
|
-
return true
|
|
6345
6422
|
}
|
|
6346
6423
|
|
|
6347
6424
|
/**
|
|
@@ -6370,7 +6447,7 @@ function rewriteInlinePatchWithoutOps(patchText, opIndices) {
|
|
|
6370
6447
|
}
|
|
6371
6448
|
if (seq.items.length === 0) return null
|
|
6372
6449
|
seq.flow = false
|
|
6373
|
-
return inner.toString()
|
|
6450
|
+
return stripTrailingNewlines(inner.toString())
|
|
6374
6451
|
}
|
|
6375
6452
|
|
|
6376
6453
|
/**
|
|
@@ -33,9 +33,6 @@ const CHANGELOG_FIRST_VERSION_RE = /^## \[([^\]]+)\]/m
|
|
|
33
33
|
/** Поле `version` у текстовому зрізі `package.json` (для `git show HEAD:npm/package.json`). */
|
|
34
34
|
const PACKAGE_JSON_VERSION_RE = /"version":\s*"([^"]+)"/u
|
|
35
35
|
|
|
36
|
-
/** Канонічний entrypoint типів для пакетів із вихідним `.js` під каталогом `npm/src` */
|
|
37
|
-
const TYPES_INDEX = './types/index.d.ts'
|
|
38
|
-
|
|
39
36
|
/** Файл проєкту TypeScript для emit без каталогу `src` (див. npm-module.mdc) */
|
|
40
37
|
const EMIT_TYPES_CONFIG = 'npm/tsconfig.emit-types.json'
|
|
41
38
|
|
|
@@ -198,7 +195,7 @@ async function gitDiffNameOnlyNpm() {
|
|
|
198
195
|
async function gitShowNpmPackageVersionAt(refPath) {
|
|
199
196
|
try {
|
|
200
197
|
const { stdout } = await execFileAsync('git', ['show', refPath], { encoding: 'utf8' })
|
|
201
|
-
const m = stdout.match(
|
|
198
|
+
const m = stdout.match(PACKAGE_JSON_VERSION_RE)
|
|
202
199
|
return m ? m[1] : null
|
|
203
200
|
} catch {
|
|
204
201
|
return null
|
package/scripts/check-php.mjs
CHANGED
|
@@ -15,9 +15,9 @@ import { createCheckReporter } from './utils/check-reporter.mjs'
|
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Перевіряє відповідність проєкту правилам php.mdc.
|
|
18
|
-
* @returns {
|
|
18
|
+
* @returns {number} 0 — все OK, 1 — є проблеми
|
|
19
19
|
*/
|
|
20
|
-
export
|
|
20
|
+
export function check() {
|
|
21
21
|
const reporter = createCheckReporter()
|
|
22
22
|
const { pass, fail } = reporter
|
|
23
23
|
|
|
@@ -27,10 +27,10 @@ export async function check() {
|
|
|
27
27
|
fail('composer.json не знайдено в корені — додай (php.mdc)')
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
if (
|
|
31
|
-
fail('package.json не знайдено в корені — додай (php.mdc)')
|
|
32
|
-
} else {
|
|
30
|
+
if (existsSync('package.json')) {
|
|
33
31
|
pass('package.json є (наявність lint-php перевіряє bun run lint-conftest → php.package_json)')
|
|
32
|
+
} else {
|
|
33
|
+
fail('package.json не знайдено в корені — додай (php.mdc)')
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
const wfPath = '.github/workflows/lint-php.yml'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Stop-hook для Claude Code: запускається hook'ом із `.claude/settings.json` після того,
|
|
3
|
-
* як агент сигналізує завершення ходу. Прозоро прокидає `npx
|
|
3
|
+
* як агент сигналізує завершення ходу. Прозоро прокидає `npx \@nitra/cursor check`
|
|
4
4
|
* і повертає його exit code, щоб помилки правил блокували завершення.
|
|
5
5
|
*
|
|
6
6
|
* Захист від нескінченної рекурсії: якщо stdin містить `"stop_hook_active": true`
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* виходимо з кодом 0 без повторного запуску перевірок.
|
|
9
9
|
*
|
|
10
10
|
* Виклик з `bin/n-cursor.js`:
|
|
11
|
-
* `npx --no
|
|
11
|
+
* `npx --no \@nitra/cursor stop-hook`
|
|
12
12
|
*/
|
|
13
13
|
import { spawn } from 'node:child_process'
|
|
14
14
|
import { once } from 'node:events'
|
|
@@ -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,15 @@ 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
|
+
|
|
75
83
|
/** @type {ConftestTarget[]} */
|
|
76
84
|
const TARGETS = [
|
|
77
85
|
// ── bun ─────────────────────────────────────────────────────────────────
|
|
@@ -207,7 +215,7 @@ const TARGETS = [
|
|
|
207
215
|
namespace: 'js_run.configmap',
|
|
208
216
|
policyDir: 'js_run',
|
|
209
217
|
rule: 'js-run',
|
|
210
|
-
walk: { match: rel =>
|
|
218
|
+
walk: { match: rel => K8S_CONFIGMAP_PATH_RE.test(rel) }
|
|
211
219
|
},
|
|
212
220
|
|
|
213
221
|
// Усі YAML у дереві з сегментом `k8s` — пер-документні структурні правила.
|
|
@@ -215,7 +223,7 @@ const TARGETS = [
|
|
|
215
223
|
namespace: 'k8s.manifest',
|
|
216
224
|
policyDir: 'k8s',
|
|
217
225
|
rule: 'k8s',
|
|
218
|
-
walk: { match: rel =>
|
|
226
|
+
walk: { match: rel => K8S_DIR_PATH_RE.test(rel) && (rel.endsWith('.yaml') || rel.endsWith('.yml')) }
|
|
219
227
|
},
|
|
220
228
|
|
|
221
229
|
// abie HealthCheckPolicy: `hc.yaml` у дереві k8s.
|
|
@@ -223,7 +231,7 @@ const TARGETS = [
|
|
|
223
231
|
namespace: 'abie.health_check_policy',
|
|
224
232
|
policyDir: 'abie',
|
|
225
233
|
rule: 'abie',
|
|
226
|
-
walk: { match: rel =>
|
|
234
|
+
walk: { match: rel => K8S_HC_YAML_PATH_RE.test(rel) }
|
|
227
235
|
},
|
|
228
236
|
|
|
229
237
|
// abie HTTPRoute у `base/`.
|
|
@@ -231,7 +239,7 @@ const TARGETS = [
|
|
|
231
239
|
namespace: 'abie.http_route_base',
|
|
232
240
|
policyDir: 'abie',
|
|
233
241
|
rule: 'abie',
|
|
234
|
-
walk: { match: rel =>
|
|
242
|
+
walk: { match: rel => K8S_BASE_HR_YAML_PATH_RE.test(rel) }
|
|
235
243
|
}
|
|
236
244
|
]
|
|
237
245
|
|
|
@@ -245,7 +253,7 @@ const TARGETS = [
|
|
|
245
253
|
function collectFiles(root, match) {
|
|
246
254
|
/** @type {string[]} */
|
|
247
255
|
const out = []
|
|
248
|
-
/** @param {string} dirAbs */
|
|
256
|
+
/** @param {string} dirAbs абсолютний шлях каталогу для рекурсивного обходу */
|
|
249
257
|
function visit(dirAbs) {
|
|
250
258
|
/** @type {import('node:fs').Dirent[]} */
|
|
251
259
|
let entries
|
|
@@ -355,5 +363,5 @@ export function runLintConftestCli() {
|
|
|
355
363
|
}
|
|
356
364
|
|
|
357
365
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
358
|
-
process.
|
|
366
|
+
process.exitCode = runLintConftestCli()
|
|
359
367
|
}
|
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.
|
|
@@ -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
|
+
}
|