@nitra/cursor 1.8.104 → 1.8.105

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.
@@ -64,6 +64,28 @@ const ABIE_SHARED_CROSS_NS_BACKEND_SET = new Set(ABIE_SHARED_CROSS_NS_BACKEND_NA
64
64
  export const ABIE_HC_SCHEMA_URL = 'https://datreeio.github.io/CRDs-catalog/networking.gke.io/healthcheckpolicy_v1.json'
65
65
 
66
66
  const MODELINE_RE = /^#\s*yaml-language-server:\s*\$schema=(\S+)\s*$/
67
+ const LINE_SPLIT_RE = /\r?\n/u
68
+ const RU_KUSTOMIZATION_PATH_RE = /(^|\/)ru\/kustomization\.yaml$/u
69
+ const UA_KUSTOMIZATION_PATH_RE = /(^|\/)ua\/kustomization\.yaml$/u
70
+ const OVERLAY_PACKAGE_DIR_RE = /^(.+)\/k8s\/(?:ua|ru)\/kustomization\.yaml$/u
71
+ const BASE_SEGMENT_RE = /(^|\/)base\//u
72
+ const YAML_EXTENSION_RE = /\.ya?ml$/iu
73
+ const K8S_PACKAGE_DIR_RE = /^(.+)\/k8s\//u
74
+ const PATCH_PATH_TYPE_RE = /path:\s*\/spec\/type\b/u
75
+ const PATCH_VALUE_NODE_PORT_RE = /value:\s*['"]?NodePort['"]?(?:\s|$)/iu
76
+ const PATCH_NODE_SELECTOR_PATH_RE = /path:\s*\/spec\/template\/spec\/nodeSelector\b/u
77
+ const PATCH_PREEM_FALSE_RE = /\bpreem:\s*['"]?false['"]?\b/u
78
+ const PATCH_YANDEX_PREEMPTIBLE_FALSE_RE = /yandex\.cloud\/preemptible:\s*['"]?false['"]?/u
79
+ const TRAILING_SLASH_RE = /\/$/u
80
+ const PATCH_HOSTNAMES_PATH_RE = /path:\s*\/spec\/hostnames\b/mu
81
+ const PATCH_PARENT_REF_NS_UA_RE = /path:\s*\/spec\/parentRefs\/0\/namespace\b[\s\S]{0,200}?value:\s*['"]?ua['"]?(?:\s|$)/mu
82
+ const PATCH_PARENT_REF_NS_RU_RE = /path:\s*\/spec\/parentRefs\/0\/namespace\b[\s\S]{0,200}?value:\s*['"]?ru['"]?(?:\s|$)/mu
83
+ const WEBSOCKET_ANNOTATION_RE = /gwin\.yandex\.cloud\/rules\.http\.upgradeTypes:\s*['"]?websocket['"]?/mu
84
+ const LEADING_EMPTY_LINE_RE = /^\s*\n/u
85
+ const REMOVE_CLUSTER_IP_AFTER_OP_RE = /op:\s*remove\b[\s\S]{0,200}?path:\s*\/spec\/clusterIP\b/mu
86
+ const REMOVE_CLUSTER_IP_BEFORE_OP_RE = /path:\s*\/spec\/clusterIP\b[\s\S]{0,200}?op:\s*remove\b/mu
87
+ const REMOVE_CLUSTER_IPS_AFTER_OP_RE = /op:\s*remove\b[\s\S]{0,200}?path:\s*\/spec\/clusterIPs\b/mu
88
+ const REMOVE_CLUSTER_IPS_BEFORE_OP_RE = /path:\s*\/spec\/clusterIPs\b[\s\S]{0,200}?op:\s*remove\b/mu
67
89
 
68
90
  /** Гілки, які мають бути в **`ignore_branches`** за abie.mdc. */
69
91
  export const ABIE_REQUIRED_IGNORE_BRANCHES = ['dev', 'ua', 'ru']
@@ -75,7 +97,7 @@ export const ABIE_REQUIRED_IGNORE_BRANCHES = ['dev', 'ua', 'ru']
75
97
  */
76
98
  export function isRuKustomizationPath(rel) {
77
99
  const norm = rel.replaceAll('\\', '/')
78
- return /(^|\/)ru\/kustomization\.yaml$/u.test(norm)
100
+ return RU_KUSTOMIZATION_PATH_RE.test(norm)
79
101
  }
80
102
 
81
103
  /**
@@ -85,7 +107,7 @@ export function isRuKustomizationPath(rel) {
85
107
  */
86
108
  export function isUaKustomizationPath(rel) {
87
109
  const norm = rel.replaceAll('\\', '/')
88
- return /(^|\/)ua\/kustomization\.yaml$/u.test(norm)
110
+ return UA_KUSTOMIZATION_PATH_RE.test(norm)
89
111
  }
90
112
 
91
113
  /**
@@ -96,7 +118,7 @@ export function isUaKustomizationPath(rel) {
96
118
  */
97
119
  export function abiePackageDirFromK8sOverlay(root, kustomizationAbs) {
98
120
  const rel = relative(root, kustomizationAbs).replaceAll('\\', '/') || kustomizationAbs
99
- const m = rel.match(/^(.+)\/k8s\/(?:ua|ru)\/kustomization\.yaml$/u)
121
+ const m = rel.match(OVERLAY_PACKAGE_DIR_RE)
100
122
  return m ? join(root, m[1]) : null
101
123
  }
102
124
 
@@ -147,7 +169,7 @@ export function abieOverlayK8sTreeHasDeployment(deploymentDirs, root, kustomizat
147
169
  */
148
170
  export function isAbieK8sBaseYamlPath(rel) {
149
171
  const norm = rel.replaceAll('\\', '/')
150
- return /(^|\/)base\//u.test(norm)
172
+ return BASE_SEGMENT_RE.test(norm)
151
173
  }
152
174
 
153
175
  /**
@@ -276,7 +298,7 @@ async function findK8sYamlFiles(root) {
276
298
  if (!pathHasK8sSegment(p)) {
277
299
  return
278
300
  }
279
- if (!/\.ya?ml$/iu.test(p)) {
301
+ if (!YAML_EXTENSION_RE.test(p)) {
280
302
  return
281
303
  }
282
304
  out.push(p)
@@ -335,7 +357,7 @@ function k8sYamlRelOutsideUaRuOverlays(relFromRoot) {
335
357
  */
336
358
  function abiePackageDirFromK8sYamlRel(root, relFromRoot) {
337
359
  const norm = relFromRoot.replaceAll('\\', '/')
338
- const m = norm.match(/^(.+)\/k8s\//u)
360
+ const m = norm.match(K8S_PACKAGE_DIR_RE)
339
361
  return m ? join(root, m[1]) : null
340
362
  }
341
363
 
@@ -418,12 +440,10 @@ export function jsonPatchRemovesPath(patchText, posixPath) {
418
440
  if (posixPath !== '/spec/clusterIP' && posixPath !== '/spec/clusterIPs') {
419
441
  return false
420
442
  }
421
- const pathRe =
422
- posixPath === '/spec/clusterIP'
423
- ? String.raw`path:\s*\/spec\/clusterIP\b`
424
- : String.raw`path:\s*\/spec\/clusterIPs\b`
425
- const opRe = String.raw`op:\s*remove\b`
426
- return new RegExp(String.raw`${opRe}[\s\S]{0,200}?${pathRe}`, 'mu').test(patchText) || new RegExp(String.raw`${pathRe}[\s\S]{0,200}?${opRe}`, 'mu').test(patchText)
443
+ if (posixPath === '/spec/clusterIP') {
444
+ return REMOVE_CLUSTER_IP_AFTER_OP_RE.test(patchText) || REMOVE_CLUSTER_IP_BEFORE_OP_RE.test(patchText)
445
+ }
446
+ return REMOVE_CLUSTER_IPS_AFTER_OP_RE.test(patchText) || REMOVE_CLUSTER_IPS_BEFORE_OP_RE.test(patchText)
427
447
  }
428
448
 
429
449
  /**
@@ -444,10 +464,10 @@ export function jsonPatchTextSetsServiceTypeNodePort(patchText) {
444
464
  if (typeof patchText !== 'string' || patchText.trim() === '') {
445
465
  return false
446
466
  }
447
- if (!/path:\s*\/spec\/type\b/u.test(patchText)) {
467
+ if (!PATCH_PATH_TYPE_RE.test(patchText)) {
448
468
  return false
449
469
  }
450
- if (!/value:\s*['"]?NodePort['"]?(?:\s|$)/iu.test(patchText)) {
470
+ if (!PATCH_VALUE_NODE_PORT_RE.test(patchText)) {
451
471
  return false
452
472
  }
453
473
  return true
@@ -502,7 +522,7 @@ function collectAbieServicePatchTextsByNameFromKustomizationDoc(doc) {
502
522
  */
503
523
  function collectAbieRuServicePatchTextByTargetNameFromRaw(raw) {
504
524
  const body = stripBom(raw)
505
- const lines = body.split(/\r?\n/u)
525
+ const lines = body.split(LINE_SPLIT_RE)
506
526
  const first = lines[0] ?? ''
507
527
  const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
508
528
  /** @type {Map<string, string>} */
@@ -589,7 +609,7 @@ async function collectAbieRuNodePortServiceTargetsByPackage(root, yamlAbs, fail)
589
609
  }
590
610
  if (readOk) {
591
611
  const body = stripBom(raw)
592
- const lines = body.split(/\r?\n/u)
612
+ const lines = body.split(LINE_SPLIT_RE)
593
613
  const first = lines[0] ?? ''
594
614
  const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
595
615
  /** @type {import('yaml').Document[]} */
@@ -620,8 +640,8 @@ async function collectAbieRuNodePortServiceTargetsByPackage(root, yamlAbs, fail)
620
640
  const needClusterIPsRemove = serviceDocumentBaseDeclaresClusterIPsField(obj)
621
641
  const prev = inner.get(n)
622
642
  inner.set(n, {
623
- requiresClusterIPNoneClear: (prev?.requiresClusterIPNoneClear === true) || needClear,
624
- requiresClusterIPsRemove: (prev?.requiresClusterIPsRemove === true) || needClusterIPsRemove
643
+ requiresClusterIPNoneClear: prev?.requiresClusterIPNoneClear === true || needClear,
644
+ requiresClusterIPsRemove: prev?.requiresClusterIPsRemove === true || needClusterIPsRemove
625
645
  })
626
646
  }
627
647
  }
@@ -700,7 +720,7 @@ async function collectDeploymentDirs(root, yamlAbs, fail) {
700
720
  }
701
721
  if (readOk) {
702
722
  const body = stripBom(raw)
703
- const lines = body.split(/\r?\n/u)
723
+ const lines = body.split(LINE_SPLIT_RE)
704
724
  const first = lines[0] ?? ''
705
725
  const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
706
726
  /** @type {import('yaml').Document[]} */
@@ -753,7 +773,7 @@ async function ensureAbieBaseDeploymentPreemNodeSelector(root, yamlFilesAbs, fai
753
773
  return
754
774
  }
755
775
  const body = stripBom(raw)
756
- const lines = body.split(/\r?\n/u)
776
+ const lines = body.split(LINE_SPLIT_RE)
757
777
  const first = lines[0] ?? ''
758
778
  const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
759
779
  /** @type {import('yaml').Document[]} */
@@ -806,10 +826,10 @@ function jsonPatchTextHasUaDeploymentNodeSelector(patchText) {
806
826
  if (typeof patchText !== 'string' || patchText.trim() === '') {
807
827
  return false
808
828
  }
809
- if (!/path:\s*\/spec\/template\/spec\/nodeSelector\b/u.test(patchText)) {
829
+ if (!PATCH_NODE_SELECTOR_PATH_RE.test(patchText)) {
810
830
  return false
811
831
  }
812
- if (!/\bpreem:\s*['"]?false['"]?\b/u.test(patchText)) {
832
+ if (!PATCH_PREEM_FALSE_RE.test(patchText)) {
813
833
  return false
814
834
  }
815
835
  return true
@@ -825,10 +845,10 @@ function jsonPatchTextHasRuDeploymentNodeSelector(patchText) {
825
845
  if (typeof patchText !== 'string' || patchText.trim() === '') {
826
846
  return false
827
847
  }
828
- if (!/path:\s*\/spec\/template\/spec\/nodeSelector\b/u.test(patchText)) {
848
+ if (!PATCH_NODE_SELECTOR_PATH_RE.test(patchText)) {
829
849
  return false
830
850
  }
831
- if (!/yandex\.cloud\/preemptible:\s*['"]?false['"]?/u.test(patchText)) {
851
+ if (!PATCH_YANDEX_PREEMPTIBLE_FALSE_RE.test(patchText)) {
832
852
  return false
833
853
  }
834
854
  return true
@@ -904,7 +924,7 @@ function kustomizationDocumentHasAbieDeploymentNodeSelectorPatch(doc, mode) {
904
924
  */
905
925
  export function kustomizationHasAbieDeploymentNodeSelectorPatch(raw, mode) {
906
926
  const body = stripBom(raw)
907
- const lines = body.split(/\r?\n/u)
927
+ const lines = body.split(LINE_SPLIT_RE)
908
928
  const first = lines[0] ?? ''
909
929
  const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
910
930
  /** @type {import('yaml').Document[]} */
@@ -930,7 +950,7 @@ export function kustomizationHasAbieDeploymentNodeSelectorPatch(raw, mode) {
930
950
  */
931
951
  export function isK8sYamlInAbiePackageExcludingUaRuOverlays(relFromRoot, pkgRelFromRoot) {
932
952
  const normRel = relFromRoot.replaceAll('\\', '/')
933
- const pkg = pkgRelFromRoot.replaceAll('\\', '/').replace(/\/$/u, '')
953
+ const pkg = pkgRelFromRoot.replaceAll('\\', '/').replace(TRAILING_SLASH_RE, '')
934
954
  const prefix = `${pkg}/k8s/`
935
955
  if (!normRel.startsWith(prefix)) {
936
956
  return false
@@ -1010,7 +1030,7 @@ export async function analyzeAbieSharedBackendRefsInPackageK8s(root, pkgAbs, yam
1010
1030
  }
1011
1031
  if (raw !== undefined) {
1012
1032
  const body = stripBom(raw)
1013
- const lines = body.split(/\r?\n/u)
1033
+ const lines = body.split(LINE_SPLIT_RE)
1014
1034
  const first = lines[0] ?? ''
1015
1035
  const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
1016
1036
  /** @type {import('yaml').Document[] | undefined} */
@@ -1109,7 +1129,7 @@ function collectAbieHttpRoutePatchStringsFromKustomizationDoc(doc) {
1109
1129
  */
1110
1130
  export function getCombinedNginxRunPatchTextFromKustomization(raw) {
1111
1131
  const body = stripBom(raw)
1112
- const lines = body.split(/\r?\n/u)
1132
+ const lines = body.split(LINE_SPLIT_RE)
1113
1133
  const first = lines[0] ?? ''
1114
1134
  const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
1115
1135
  /** @type {import('yaml').Document[]} */
@@ -1144,7 +1164,7 @@ export function validateAbieNginxRunHttpRoutePatches(
1144
1164
  if (typeof combined !== 'string' || combined.trim() === '') {
1145
1165
  return `очікується patch target kind HTTPRoute з непорожнім target.name (hostnames, parentRefs namespace ${mode}; для ru — gwin… websocket лише за наявності HASURA_GRAPHQL_JWT_SECRET у файлі) — abie.mdc`
1146
1166
  }
1147
- if (!/path:\s*\/spec\/hostnames\b/m.test(combined)) {
1167
+ if (!PATCH_HOSTNAMES_PATH_RE.test(combined)) {
1148
1168
  return 'HTTPRoute: потрібен path /spec/hostnames у patch (abie.mdc)'
1149
1169
  }
1150
1170
  const markers = mode === 'ua' ? ABIE_UA_HTTPROUTE_HOST_MARKERS : ABIE_RU_HTTPROUTE_HOST_MARKERS
@@ -1152,9 +1172,7 @@ export function validateAbieNginxRunHttpRoutePatches(
1152
1172
  return `HTTPRoute: у value для /spec/hostnames має бути один із доменів abie (${markers.join(', ')}) — abie.mdc`
1153
1173
  }
1154
1174
  const namespaceOk =
1155
- mode === 'ua'
1156
- ? /path:\s*\/spec\/parentRefs\/0\/namespace\b[\s\S]{0,200}?value:\s*['"]?ua['"]?(?:\s|$)/mu.test(combined)
1157
- : /path:\s*\/spec\/parentRefs\/0\/namespace\b[\s\S]{0,200}?value:\s*['"]?ru['"]?(?:\s|$)/mu.test(combined)
1175
+ mode === 'ua' ? PATCH_PARENT_REF_NS_UA_RE.test(combined) : PATCH_PARENT_REF_NS_RU_RE.test(combined)
1158
1176
  if (!namespaceOk) {
1159
1177
  return `HTTPRoute: потрібен path /spec/parentRefs/0/namespace з value ${mode} (abie.mdc)`
1160
1178
  }
@@ -1162,7 +1180,7 @@ export function validateAbieNginxRunHttpRoutePatches(
1162
1180
  mode === 'ru' &&
1163
1181
  typeof fullKustomizationRaw === 'string' &&
1164
1182
  fullKustomizationRaw.includes(HASURA_JWT_SECRET_IN_KUSTOMIZATION)
1165
- if (ruNeedsWebsocket && !/gwin\.yandex\.cloud\/rules\.http\.upgradeTypes:\s*['"]?websocket['"]?/m.test(combined)) {
1183
+ if (ruNeedsWebsocket && !WEBSOCKET_ANNOTATION_RE.test(combined)) {
1166
1184
  return 'HTTPRoute (ru): за наявності HASURA_GRAPHQL_JWT_SECRET у kustomization потрібна анотація gwin.yandex.cloud/rules.http.upgradeTypes: websocket (abie.mdc)'
1167
1185
  }
1168
1186
  const sharedCount =
@@ -1197,7 +1215,7 @@ export function kustomizationHasAbieNginxRunHttpRoutePatch(raw, mode) {
1197
1215
  */
1198
1216
  export function validateAbieHcYaml(raw, relPath) {
1199
1217
  const body = stripBom(raw)
1200
- const lines = body.split(/\r?\n/u)
1218
+ const lines = body.split(LINE_SPLIT_RE)
1201
1219
  if (lines.length === 0 || lines[0].trim() === '') {
1202
1220
  return `${relPath}: перший рядок порожній — потрібен # yaml-language-server: $schema=… (abie.mdc)`
1203
1221
  }
@@ -1211,7 +1229,7 @@ export function validateAbieHcYaml(raw, relPath) {
1211
1229
  const yamlBody = lines
1212
1230
  .slice(1)
1213
1231
  .join('\n')
1214
- .replace(/^\s*\n/u, '')
1232
+ .replace(LEADING_EMPTY_LINE_RE, '')
1215
1233
  /** @type {import('yaml').Document[]} */
1216
1234
  let docs
1217
1235
  try {
@@ -1308,7 +1326,7 @@ async function collectHealthCheckPolicyRelPaths(root, yamlAbs) {
1308
1326
  }
1309
1327
  if (raw !== null) {
1310
1328
  const body = stripBom(raw)
1311
- const lines = body.split(/\r?\n/u)
1329
+ const lines = body.split(LINE_SPLIT_RE)
1312
1330
  const first = lines[0] ?? ''
1313
1331
  const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
1314
1332
  try {
@@ -106,9 +106,7 @@ export async function check() {
106
106
  } else {
107
107
  const bad = Object.keys(dev).filter(n => !isAllowedRootDevDependency(n))
108
108
  if (bad.length > 0) {
109
- fail(
110
- `Кореневі devDependencies: дозволені лише @nitra/* — прибери або перенеси: ${bad.join(', ')} (bun.mdc)`
111
- )
109
+ fail(`Кореневі devDependencies: дозволені лише @nitra/* — прибери або перенеси: ${bad.join(', ')} (bun.mdc)`)
112
110
  } else {
113
111
  const n = Object.keys(dev).length
114
112
  pass(
@@ -145,9 +143,7 @@ export async function check() {
145
143
  const missing = lintPrefixed.filter(name => !aggregate.includes(`bun run ${name}`))
146
144
  if (missing.length > 0) {
147
145
  const missingList = missing.map(s => `\`${s}\``).join(', ')
148
- fail(
149
- `Скрипт \`lint\` має викликати всі lint-* через bun run; відсутньо: ${missingList}`
150
- )
146
+ fail(`Скрипт \`lint\` має викликати всі lint-* через bun run; відсутньо: ${missingList}`)
151
147
  } else {
152
148
  pass('package.json: агрегований `lint` покриває всі `lint-*` скрипти')
153
149
  if (OXFMT_END_RE.test(aggregate.trim())) {
@@ -1,5 +1,7 @@
1
1
  /**
2
- * Перевіряє правило graphql.mdc: наявність **`.graphqlrc.yml`** і рекомендації **`graphql.vscode-graphql`**, якщо у дереві є **`gql\`…\``**.
2
+ * Перевіряє правило graphql.mdc: наявність **`.graphqlrc.yml`**, рекомендації
3
+ * **`graphql.vscode-graphql`** і скрипта **`dump-schema`** у кореневому
4
+ * **`package.json`**, якщо у дереві є **`gql\`…\``**.
3
5
  *
4
6
  * Обхід репозиторію — **`walkDir`** від **`process.cwd()`** (пропуски як у інших check). Кандидати — **`.vue`** та **`.js`/`.ts`/`.jsx`/`.tsx`** тощо; пропуск **`.d.ts`**, **auto-imports.d.ts** тощо — **`shouldSkipFileForGqlScan`**.
5
7
  *
@@ -22,16 +24,16 @@ export const GRAPHQL_RC_FILENAME = '.graphqlrc.yml'
22
24
 
23
25
  /** Розширення VS Code з graphql.mdc. */
24
26
  export const REQUIRED_GRAPHQL_VSCODE_EXTENSION = 'graphql.vscode-graphql'
27
+ /** Команда dump-schema з graphql.mdc. */
28
+ export const REQUIRED_DUMP_SCHEMA_SCRIPT =
29
+ "bunx graphqurl http://localhost:4040/v1/graphql -H 'X-Hasura-Admin-Secret: secret' --introspect > schema.graphql"
25
30
 
26
31
  /**
27
- * Перевіряє graphql.mdc: умовна вимога .graphqlrc.yml і graphql.vscode-graphql за наявності gql tagged templates.
28
- * @returns {Promise<number>} 0 OK, 1 — порушення
32
+ * Збирає абсолютні шляхи source-файлів, які підлягають скануванню на gql templates.
33
+ * @param {string} root абсолютний шлях кореня
34
+ * @returns {Promise<string[]>} список кандидатів
29
35
  */
30
- export async function check() {
31
- const reporter = createCheckReporter()
32
- const { pass, fail } = reporter
33
-
34
- const root = process.cwd()
36
+ async function collectScanCandidates(root) {
35
37
  /** @type {string[]} */
36
38
  const candidates = []
37
39
  await walkDir(root, absPath => {
@@ -41,7 +43,16 @@ export async function check() {
41
43
  }
42
44
  candidates.push(absPath)
43
45
  })
46
+ return candidates
47
+ }
44
48
 
49
+ /**
50
+ * Повертає відносні шляхи файлів, де знайдено gql tagged template.
51
+ * @param {string} root абсолютний шлях кореня
52
+ * @param {string[]} candidates абсолютні шляхи файлів-кандидатів
53
+ * @returns {Promise<string[]>} відносні шляхи файлів зі збігами
54
+ */
55
+ async function collectGqlHits(root, candidates) {
45
56
  /** @type {string[]} */
46
57
  const hits = []
47
58
  for (const absPath of candidates) {
@@ -51,9 +62,99 @@ export async function check() {
51
62
  hits.push(rel)
52
63
  }
53
64
  }
65
+ return hits
66
+ }
67
+
68
+ /**
69
+ * Перевіряє `.vscode/extensions.json` на рекомендацію GraphQL extension.
70
+ * @param {(msg: string) => void} pass success-репортер
71
+ * @param {(msg: string) => void} fail fail-репортер
72
+ * @returns {Promise<void>}
73
+ */
74
+ async function checkExtensionsRecommendation(pass, fail) {
75
+ if (!existsSync('.vscode/extensions.json')) {
76
+ fail(
77
+ '.vscode/extensions.json не існує — створи файл і додай у recommendations graphql.vscode-graphql (graphql.mdc)'
78
+ )
79
+ return
80
+ }
81
+
82
+ let ext
83
+ try {
84
+ ext = JSON.parse(await readFile('.vscode/extensions.json', 'utf8'))
85
+ } catch {
86
+ fail('.vscode/extensions.json не є валідним JSON')
87
+ return
88
+ }
89
+
90
+ const rec = ext.recommendations
91
+ if (!Array.isArray(rec)) {
92
+ fail('.vscode/extensions.json: поле recommendations має бути масивом')
93
+ return
94
+ }
95
+
96
+ if (rec.includes(REQUIRED_GRAPHQL_VSCODE_EXTENSION)) {
97
+ pass(`.vscode/extensions.json: є ${REQUIRED_GRAPHQL_VSCODE_EXTENSION}`)
98
+ } else {
99
+ fail(`.vscode/extensions.json: додай у recommendations "${REQUIRED_GRAPHQL_VSCODE_EXTENSION}" (graphql.mdc)`)
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Перевіряє `package.json` і значення scripts.dump-schema.
105
+ * @param {(msg: string) => void} pass success-репортер
106
+ * @param {(msg: string) => void} fail fail-репортер
107
+ * @returns {Promise<void>}
108
+ */
109
+ async function checkPackageDumpSchemaScript(pass, fail) {
110
+ if (!existsSync('package.json')) {
111
+ fail('Відсутній package.json у корені репозиторію')
112
+ return
113
+ }
114
+
115
+ let pkg
116
+ try {
117
+ pkg = JSON.parse(await readFile('package.json', 'utf8'))
118
+ } catch {
119
+ fail('package.json не є валідним JSON')
120
+ return
121
+ }
122
+
123
+ const scripts = pkg.scripts
124
+ if (!scripts || typeof scripts !== 'object' || Array.isArray(scripts)) {
125
+ fail('package.json: поле scripts має бути обʼєктом')
126
+ return
127
+ }
128
+
129
+ if (!Object.hasOwn(scripts, 'dump-schema')) {
130
+ fail('package.json: відсутній scripts.dump-schema (graphql.mdc)')
131
+ return
132
+ }
133
+
134
+ if (scripts['dump-schema'] === REQUIRED_DUMP_SCHEMA_SCRIPT) {
135
+ pass('package.json: scripts.dump-schema відповідає graphql.mdc')
136
+ } else {
137
+ fail(`package.json: scripts.dump-schema має бути "${REQUIRED_DUMP_SCHEMA_SCRIPT}" (graphql.mdc)`)
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Перевіряє graphql.mdc: умовна вимога .graphqlrc.yml, graphql.vscode-graphql
143
+ * і scripts.dump-schema за наявності gql tagged templates.
144
+ * @returns {Promise<number>} 0 — OK, 1 — порушення
145
+ */
146
+ export async function check() {
147
+ const reporter = createCheckReporter()
148
+ const { pass, fail } = reporter
149
+
150
+ const root = process.cwd()
151
+ const candidates = await collectScanCandidates(root)
152
+ const hits = await collectGqlHits(root, candidates)
54
153
 
55
154
  if (hits.length === 0) {
56
- pass(`Немає tagged template з тегом gql у .vue / JS / TS джерелах (переглянуто ${candidates.length} файлів) — .graphqlrc.yml не вимагається`)
155
+ pass(
156
+ `Немає tagged template з тегом gql у .vue / JS / TS джерелах (переглянуто ${candidates.length} файлів) — .graphqlrc.yml не вимагається`
157
+ )
57
158
  return reporter.getExitCode()
58
159
  }
59
160
 
@@ -67,31 +168,8 @@ export async function check() {
67
168
  )
68
169
  }
69
170
 
70
- if (existsSync('.vscode/extensions.json')) {
71
- let ext
72
- try {
73
- ext = JSON.parse(await readFile('.vscode/extensions.json', 'utf8'))
74
- } catch {
75
- fail('.vscode/extensions.json не є валідним JSON')
76
- ext = null
77
- }
78
- if (ext) {
79
- const rec = ext.recommendations
80
- if (!Array.isArray(rec)) {
81
- fail('.vscode/extensions.json: поле recommendations має бути масивом')
82
- } else if (rec.includes(REQUIRED_GRAPHQL_VSCODE_EXTENSION)) {
83
- pass(`.vscode/extensions.json: є ${REQUIRED_GRAPHQL_VSCODE_EXTENSION}`)
84
- } else {
85
- fail(
86
- `.vscode/extensions.json: додай у recommendations "${REQUIRED_GRAPHQL_VSCODE_EXTENSION}" (graphql.mdc)`
87
- )
88
- }
89
- }
90
- } else {
91
- fail(
92
- '.vscode/extensions.json не існує — створи файл і додай у recommendations graphql.vscode-graphql (graphql.mdc)'
93
- )
94
- }
171
+ await checkExtensionsRecommendation(pass, fail)
172
+ await checkPackageDumpSchemaScript(pass, fail)
95
173
 
96
174
  return reporter.getExitCode()
97
175
  }
@@ -19,13 +19,17 @@ export const CANONICAL_LINT_JS = 'bunx oxlint --fix && bunx eslint --fix . && bu
19
19
  /** Мінімальні рекомендації розширень редактора з js-lint.mdc (eslint, oxlint, GA). */
20
20
  export const REQUIRED_VSCODE_EXTENSIONS = ['dbaeumer.vscode-eslint', 'github.vscode-github-actions', 'oxc.oxc-vscode']
21
21
 
22
+ const WHITESPACE_RE = /\s+/gu
23
+ const NON_DIGITS_RE = /\D+/u
24
+ const OXLINT_FIX_RE = /bunx\s+oxlint[^\n]*--fix/u
25
+
22
26
  /**
23
27
  * Нормалізує рядок скрипта для порівняння (зайві пробіли).
24
28
  * @param {string} s вихідний рядок скрипта `lint-js`
25
29
  * @returns {string} рядок без зайвих пробілів на краях і з одиничними пробілами всередині
26
30
  */
27
31
  export function normalizeLintJsScript(s) {
28
- return String(s).trim().replaceAll(/\s+/gu, ' ')
32
+ return String(s).trim().replaceAll(WHITESPACE_RE, ' ')
29
33
  }
30
34
 
31
35
  /**
@@ -47,13 +51,14 @@ export function nitraEslintConfigDeclaresE18eTransitive(versionSpec) {
47
51
  if (s.startsWith('workspace:')) {
48
52
  return true
49
53
  }
50
- const m = s.match(/(\d+)\.(\d+)\.(\d+)/u)
51
- if (!m) {
54
+ const parts = s.split(NON_DIGITS_RE).filter(Boolean)
55
+ if (parts.length < 3) {
56
+ return false
57
+ }
58
+ const [major, minor, patch] = parts.slice(0, 3).map(Number)
59
+ if ([major, minor, patch].some(n => Number.isNaN(n))) {
52
60
  return false
53
61
  }
54
- const major = Number(m[1])
55
- const minor = Number(m[2])
56
- const patch = Number(m[3])
57
62
  return major > 3 || (major === 3 && minor > 5) || (major === 3 && minor === 5 && patch >= 0)
58
63
  }
59
64
 
@@ -167,8 +172,8 @@ export async function check() {
167
172
 
168
173
  const nodeEngine = pkg.engines?.node
169
174
  if (nodeEngine) {
170
- const match = nodeEngine.match(/(\d+)/u)
171
- if (match && Number(match[1]) >= 24) {
175
+ const firstNumeric = String(nodeEngine).split(NON_DIGITS_RE).find(Boolean)
176
+ if (firstNumeric && Number(firstNumeric) >= 24) {
172
177
  pass(`engines.node: "${nodeEngine}"`)
173
178
  } else {
174
179
  fail(`engines.node: "${nodeEngine}" — має бути >=24`)
@@ -255,7 +260,7 @@ export async function check() {
255
260
  fail(errMsg)
256
261
  }
257
262
  }
258
- if (content.includes('bunx oxlint') && /bunx\s+oxlint[^\n]*--fix/u.test(content)) {
263
+ if (content.includes('bunx oxlint') && OXLINT_FIX_RE.test(content)) {
259
264
  fail('lint-js.yml: у CI не використовуй bunx oxlint --fix (лише bunx oxlint)')
260
265
  }
261
266
  if (content.includes('eslint --fix')) {
@@ -268,8 +273,7 @@ export async function check() {
268
273
 
269
274
  if (existsSync('.github/workflows/lint.yml')) {
270
275
  const lintYml = await readFile('.github/workflows/lint.yml', 'utf8')
271
- const looksLikeJsLint =
272
- /\bbunx\s+oxlint\b/u.test(lintYml) && /\bbunx\s+eslint\b/u.test(lintYml) && /\bjscpd\b/u.test(lintYml)
276
+ const looksLikeJsLint = lintYml.includes('bunx oxlint') && lintYml.includes('bunx eslint') && lintYml.includes('jscpd')
273
277
  if (looksLikeJsLint) {
274
278
  fail('.github/workflows/lint.yml дублює кроки lint-js.yml — залиш один workflow на лінт JS (js-lint.mdc)')
275
279
  } else {