@nitra/cursor 1.8.219 → 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 +12 -0
- package/bin/n-cursor.js +25 -4
- package/mdc/ci4.mdc +51 -0
- package/mdc/k8s.mdc +2 -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 +141 -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)`)
|
|
@@ -5233,6 +5253,10 @@ function checkProdOverridesInKustomization(kust, rel, fail, passFn, needs) {
|
|
|
5233
5253
|
* `hpa.yaml` / `pdb.yaml`. Тоді у `patches[]` обов'язкові JSON6902-патчі прод-значень: для **HPA** —
|
|
5234
5254
|
* `/spec/minReplicas` і `/spec/maxReplicas` (мінімум 2), для **PDB** — `/spec/minAvailable` (мінімум 1).
|
|
5235
5255
|
* Для dev-like (`base` / `dev` / `*-qa`) overrides не потрібні (k8s.mdc).
|
|
5256
|
+
*
|
|
5257
|
+
* **Виняток — Kustomize Component (`kind: Component`):** сам `…/k8s/…/components/kustomization.yaml`
|
|
5258
|
+
* не overlay, а **джерело** ресурсів для overlays. Прод-перезаписи живуть у `<env>/kustomization.yaml`,
|
|
5259
|
+
* що підключає Component через `components:`; у самому Component patches не потрібні (env-неутральний).
|
|
5236
5260
|
* @param {string} rootNorm нормалізований корінь репозиторію
|
|
5237
5261
|
* @param {string} kustAbs абсолютний шлях до kustomization.yaml
|
|
5238
5262
|
* @returns {Promise<ProdOverlayHpaPdbOverrideNeeds>} прапорці потрібних перевизначень
|
|
@@ -5244,6 +5268,15 @@ export async function prodOverlayHpaPdbOverrideNeeds(rootNorm, kustAbs) {
|
|
|
5244
5268
|
return { needsHpaReplicaPatches: false, needsPdbMinAvailablePatch: false }
|
|
5245
5269
|
}
|
|
5246
5270
|
|
|
5271
|
+
// Kustomize Component (kind: Component) — джерело канонічних HPA/PDB для overlays,
|
|
5272
|
+
// а не overlay сам по собі. Прод-перезаписи (/spec/minReplicas, /spec/maxReplicas,
|
|
5273
|
+
// /spec/minAvailable) живуть у <env>/kustomization.yaml, що підключає Component
|
|
5274
|
+
// через `components:`; у самому Component patches не потрібні (env-неутральний).
|
|
5275
|
+
const kustDoc = await readFirstYamlObject(kustAbs)
|
|
5276
|
+
if (kustDoc !== null && kustDoc.kind === 'Component') {
|
|
5277
|
+
return { needsHpaReplicaPatches: false, needsPdbMinAvailablePatch: false }
|
|
5278
|
+
}
|
|
5279
|
+
|
|
5247
5280
|
const flags = await kustomizeResourceTreeHpaPdbDeploymentFlags(kustAbs, rootNorm)
|
|
5248
5281
|
return {
|
|
5249
5282
|
needsHpaReplicaPatches: flags.hasHpa,
|
|
@@ -5560,18 +5593,7 @@ async function validateDeploymentsInDir(deployments, dir, root, fail, passFn) {
|
|
|
5560
5593
|
const isK8sBaseLayer = isK8sYamlUnderBaseDirectory(`${relDir}/probe.yaml`)
|
|
5561
5594
|
const deployRel = relDir === '' ? '.' : relDir
|
|
5562
5595
|
if (isK8sBaseLayer && deployments.length > 0) {
|
|
5563
|
-
|
|
5564
|
-
if (existsSync(hpaAbs)) {
|
|
5565
|
-
fail(
|
|
5566
|
-
`${deployRel}/${HPA_FILENAME}: у шарі k8s/.../base не тримай локальний hpa.yaml — HPA живе у sibling components/ (k8s.mdc)`
|
|
5567
|
-
)
|
|
5568
|
-
}
|
|
5569
|
-
const pdbAbs = join(dir, PDB_FILENAME)
|
|
5570
|
-
if (existsSync(pdbAbs)) {
|
|
5571
|
-
fail(
|
|
5572
|
-
`${deployRel}/${PDB_FILENAME}: у шарі k8s/.../base не тримай локальний pdb.yaml — PDB живе у sibling components/ (k8s.mdc)`
|
|
5573
|
-
)
|
|
5574
|
-
}
|
|
5596
|
+
failIfBaseLayerHasLocalHpaOrPdb(dir, deployRel, fail)
|
|
5575
5597
|
}
|
|
5576
5598
|
const hpaDocs = isK8sBaseLayer ? [] : await readDocsByKindInDir(dir, 'HorizontalPodAutoscaler', HPA_FILENAME)
|
|
5577
5599
|
const pdbDocs = isK8sBaseLayer ? [] : await readDocsByKindInDir(dir, 'PodDisruptionBudget', PDB_FILENAME)
|
|
@@ -5587,15 +5609,47 @@ async function validateDeploymentsInDir(deployments, dir, root, fail, passFn) {
|
|
|
5587
5609
|
passFn
|
|
5588
5610
|
)
|
|
5589
5611
|
if (isK8sBaseLayer) {
|
|
5590
|
-
|
|
5591
|
-
const appLabel = deploymentAppLabel(deployment)
|
|
5592
|
-
if (deployName !== null && appLabel !== null) {
|
|
5593
|
-
await validateComponentsForBaseDeployment(dir, deployName, appLabel, root, fail, passFn)
|
|
5594
|
-
}
|
|
5612
|
+
await validateBaseLayerComponentsIfNamed(deployment, dir, root, fail, passFn)
|
|
5595
5613
|
}
|
|
5596
5614
|
}
|
|
5597
5615
|
}
|
|
5598
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
|
+
|
|
5599
5653
|
/**
|
|
5600
5654
|
* Витягує документи Deployment з YAML-файлу (повертає порожній масив, якщо файл недоступний або немає Deployment).
|
|
5601
5655
|
* @param {string} filePath абсолютний шлях до YAML-файлу
|
|
@@ -6289,6 +6343,20 @@ function applyConversionsToDoc(doc, conversions) {
|
|
|
6289
6343
|
const patchesNode = doc.get('patches', true)
|
|
6290
6344
|
if (!isSeq(patchesNode)) return false
|
|
6291
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) {
|
|
6292
6360
|
/** @type {Map<number, { totalOps: number, opIdx: number[] }>} */
|
|
6293
6361
|
const byPatch = new Map()
|
|
6294
6362
|
for (const c of conversions) {
|
|
@@ -6296,39 +6364,61 @@ function applyConversionsToDoc(doc, conversions) {
|
|
|
6296
6364
|
slot.opIdx.push(c.opIndex)
|
|
6297
6365
|
byPatch.set(c.index, slot)
|
|
6298
6366
|
}
|
|
6367
|
+
return byPatch
|
|
6368
|
+
}
|
|
6299
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) {
|
|
6300
6378
|
const sortedIdx = [...byPatch.keys()].toSorted((a, b) => b - a)
|
|
6301
6379
|
for (const i of sortedIdx) {
|
|
6302
6380
|
const slot = byPatch.get(i)
|
|
6303
6381
|
if (slot === undefined) continue
|
|
6304
|
-
|
|
6305
|
-
if (opIdx.length === totalOps) {
|
|
6382
|
+
if (slot.opIdx.length === slot.totalOps) {
|
|
6306
6383
|
patchesNode.delete(i)
|
|
6307
6384
|
continue
|
|
6308
6385
|
}
|
|
6309
|
-
|
|
6310
|
-
if (patchEntry === undefined || patchEntry === null) continue
|
|
6311
|
-
const patchScalar = patchEntry.get('patch', true)
|
|
6312
|
-
if (patchScalar === undefined || patchScalar === null || typeof patchScalar.value !== 'string') continue
|
|
6313
|
-
const rewritten = rewriteInlinePatchWithoutOps(patchScalar.value, opIdx)
|
|
6314
|
-
if (rewritten === null) continue
|
|
6315
|
-
patchScalar.value = rewritten
|
|
6386
|
+
rewriteInlinePatchAtIndex(patchesNode, i, slot.opIdx)
|
|
6316
6387
|
}
|
|
6388
|
+
}
|
|
6317
6389
|
|
|
6318
|
-
|
|
6319
|
-
|
|
6320
|
-
|
|
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
|
+
}
|
|
6321
6406
|
|
|
6322
|
-
|
|
6323
|
-
|
|
6324
|
-
|
|
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) {
|
|
6325
6416
|
doc.set('images', imagesNode)
|
|
6326
6417
|
}
|
|
6327
6418
|
for (const { name, newName, newTag } of conversions) {
|
|
6328
6419
|
const entry = newTag === null ? { name, newName } : { name, newName, newTag }
|
|
6329
6420
|
imagesNode.add(doc.createNode(entry))
|
|
6330
6421
|
}
|
|
6331
|
-
return true
|
|
6332
6422
|
}
|
|
6333
6423
|
|
|
6334
6424
|
/**
|
|
@@ -6357,7 +6447,7 @@ function rewriteInlinePatchWithoutOps(patchText, opIndices) {
|
|
|
6357
6447
|
}
|
|
6358
6448
|
if (seq.items.length === 0) return null
|
|
6359
6449
|
seq.flow = false
|
|
6360
|
-
return inner.toString()
|
|
6450
|
+
return stripTrailingNewlines(inner.toString())
|
|
6361
6451
|
}
|
|
6362
6452
|
|
|
6363
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'
|