@nitra/cursor 1.8.104 → 1.8.106

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.
@@ -21,6 +21,16 @@ import { findDockerfilePaths } from './check-docker.mjs'
21
21
  import { createCheckReporter } from './utils/check-reporter.mjs'
22
22
  import { walkDir } from './utils/walkDir.mjs'
23
23
 
24
+ const LINE_SPLIT_RE = /\r?\n/u
25
+ const INI_KEY_RE = /^([A-Za-z_]\w*)\s*=/u
26
+ const RETURN_200_RE = /return\s+200/u
27
+ const GZIP_STATIC_ON_RE = /gzip_static\s+on/gu
28
+ const PROXY_LIKE_RE =
29
+ /\b(proxy_pass|proxy_redirect|proxy_set_header|proxy_http_version|fastcgi_pass|grpc_pass|uwsgi_pass)\b/u
30
+ const FIND_CMD_RE = /\bfind\b/u
31
+ const GZIP_CMD_RE = /\bgzip\b/u
32
+ const GZIP_EXTENSION_RE = /\*\.(?:js|css)/u
33
+
24
34
  /**
25
35
  * Збирає абсолютні шляхи до **default.conf.template** у репозиторії; шлях `tests/fixtures` не обходиться як проєктний шаблон.
26
36
  * @param {string} root корінь cwd
@@ -82,10 +92,10 @@ export async function migrateDefaultTplConfFiles(root) {
82
92
  export function parseIniVariableNames(iniText) {
83
93
  /** @type {string[]} */
84
94
  const keys = []
85
- for (const line of iniText.split(/\r?\n/u)) {
95
+ for (const line of iniText.split(LINE_SPLIT_RE)) {
86
96
  const t = line.trim()
87
97
  if (t !== '' && !t.startsWith('#') && !t.startsWith(';')) {
88
- const m = t.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=/u)
98
+ const m = t.match(INI_KEY_RE)
89
99
  if (m) keys.push(m[1])
90
100
  }
91
101
  }
@@ -111,7 +121,7 @@ export function nginxTemplateViolations(content) {
111
121
  { msg: 'відсутнє root /usr/share/nginx/html', ok: c => c.includes('root /usr/share/nginx/html') },
112
122
  {
113
123
  msg: 'location /healthz має повертати healthy (див. nginx-default-tpl.mdc)',
114
- ok: c => c.includes('/healthz') && (c.includes('healthy') || /return\s+200/u.test(c))
124
+ ok: c => c.includes('/healthz') && (c.includes('healthy') || RETURN_200_RE.test(c))
115
125
  },
116
126
  {
117
127
  msg: 'відсутній location для статики без gzip (gif|jpeg|png|ico|woff2|xlsx) з Cache-Control 31536000',
@@ -126,7 +136,7 @@ export function nginxTemplateViolations(content) {
126
136
  },
127
137
  {
128
138
  msg: 'gzip_static on має бути принаймні двічі (два location зі стисненням)',
129
- ok: c => (c.match(/gzip_static\s+on/gu) ?? []).length >= 2
139
+ ok: c => (c.match(GZIP_STATIC_ON_RE) ?? []).length >= 2
130
140
  },
131
141
  { msg: 'відсутнє використання $PUBLIC_PATH у location', ok: c => c.includes('$PUBLIC_PATH') },
132
142
  {
@@ -144,9 +154,7 @@ export function nginxTemplateViolations(content) {
144
154
  }
145
155
 
146
156
  // cspell:ignore fastcgi uwsgi
147
- const proxyLike =
148
- /\b(proxy_pass|proxy_redirect|proxy_set_header|proxy_http_version|fastcgi_pass|grpc_pass|uwsgi_pass)\b/u
149
- if (proxyLike.test(content)) {
157
+ if (PROXY_LIKE_RE.test(content)) {
150
158
  return 'знайдено proxy, gRPC або інший *_pass до бекенду — прибери з шаблону, логіку винеси в HTTPRoute (k8s) (див. nginx-default-tpl.mdc)'
151
159
  }
152
160
 
@@ -242,11 +250,11 @@ export function iniKeysMissingInTemplate(keys, template) {
242
250
  function dockerfileHasGzipStaticPipeline(dockerfileContent) {
243
251
  const c = dockerfileContent
244
252
  return (
245
- /\bfind\b/u.test(c) &&
253
+ FIND_CMD_RE.test(c) &&
246
254
  c.includes('/usr/share/nginx/html') &&
247
- /\bgzip\b/u.test(c) &&
255
+ GZIP_CMD_RE.test(c) &&
248
256
  c.includes('-k') &&
249
- /\*\.(?:js|css)/u.test(c)
257
+ GZIP_EXTENSION_RE.test(c)
250
258
  )
251
259
  }
252
260
 
@@ -259,6 +267,110 @@ function dockerfileHasEnvsSubstTemplate(dockerfileContent) {
259
267
  return dockerfileContent.includes('envsubst') && dockerfileContent.includes('default.conf.template')
260
268
  }
261
269
 
270
+ /**
271
+ * Перевіряє один template-файл і поруч *.ini.
272
+ * @param {string} abs абсолютний шлях до template
273
+ * @param {string} root cwd
274
+ * @param {(msg: string) => void} passFn callback при успішній перевірці
275
+ * @param {(msg: string) => void} failFn callback при помилці
276
+ */
277
+ async function checkTemplateFile(abs, root, passFn, failFn) {
278
+ const rel = relative(root, abs) || abs
279
+ const content = await readFile(abs, 'utf8')
280
+ const v = nginxTemplateViolations(content)
281
+ if (v) {
282
+ failFn(`${rel}: ${v}`)
283
+ } else {
284
+ passFn(`${rel}: структура шаблону узгоджена з nginx-default-tpl.mdc`)
285
+ }
286
+
287
+ const dir = dirname(abs)
288
+ let iniNames = []
289
+ try {
290
+ const dirEntries = await readdir(dir)
291
+ iniNames = dirEntries.filter(n => n.endsWith('.ini'))
292
+ } catch {
293
+ iniNames = []
294
+ }
295
+ if (iniNames.length === 0) {
296
+ failFn(`${rel}: поруч немає жодного *.ini — додай values-*.ini для середовищ (див. nginx-default-tpl.mdc)`)
297
+ return
298
+ }
299
+ passFn(`${rel}: поруч є *.ini (${iniNames.length})`)
300
+ for (const iniName of iniNames) {
301
+ const iniPath = `${dir}/${iniName}`
302
+ const iniRel = relative(root, iniPath) || iniPath
303
+ try {
304
+ const iniRaw = await readFile(iniPath, 'utf8')
305
+ const miss = iniKeysMissingInTemplate(parseIniVariableNames(iniRaw), content)
306
+ if (miss) failFn(`${iniRel}: ${miss}`)
307
+ } catch (error) {
308
+ failFn(`${iniRel}: не вдалося прочитати (${error instanceof Error ? error.message : String(error)})`)
309
+ }
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Перевіряє Dockerfile на наявність gzip та envsubst.
315
+ * @param {string} root корінь репозиторію
316
+ * @param {(msg: string) => void} passFn callback при успішній перевірці
317
+ * @param {(msg: string) => void} failFn callback при помилці
318
+ */
319
+ async function checkDockerfiles(root, passFn, failFn) {
320
+ const dockerPaths = await findDockerfilePaths(root)
321
+ if (dockerPaths.length === 0) {
322
+ failFn(
323
+ 'Є default.conf.template, але немає Dockerfile / Containerfile — додай gzip для статики та envsubst (див. nginx-default-tpl.mdc)'
324
+ )
325
+ return
326
+ }
327
+ const bodies = await Promise.all(dockerPaths.map(p => readFile(p, 'utf8')))
328
+ if (bodies.some(body => dockerfileHasGzipStaticPipeline(body))) {
329
+ passFn('Dockerfile: знайдено крок стиснення статики (find + gzip -k)')
330
+ } else {
331
+ failFn('Dockerfile: потрібен RUN find … /usr/share/nginx/html … gzip -k (див. nginx-default-tpl.mdc)')
332
+ }
333
+ if (bodies.some(body => dockerfileHasEnvsSubstTemplate(body))) {
334
+ passFn('Dockerfile: знайдено envsubst для default.conf.template')
335
+ } else {
336
+ failFn('Dockerfile: потрібен envsubst з default.conf.template (див. nginx-default-tpl.mdc)')
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Перевіряє VSCode extensions.json та settings.json для nginx.
342
+ * @param {(msg: string) => void} passFn callback при успішній перевірці
343
+ * @param {(msg: string) => void} failFn callback при помилці
344
+ */
345
+ async function checkVscodeNginx(passFn, failFn) {
346
+ if (existsSync('.vscode/extensions.json')) {
347
+ const ext = JSON.parse(await readFile('.vscode/extensions.json', 'utf8'))
348
+ if (ext.recommendations?.includes('ahmadalli.vscode-nginx-conf')) {
349
+ passFn('extensions.json містить ahmadalli.vscode-nginx-conf')
350
+ } else {
351
+ failFn('extensions.json не містить ahmadalli.vscode-nginx-conf')
352
+ }
353
+ } else {
354
+ failFn('Очікується .vscode/extensions.json з ahmadalli.vscode-nginx-conf (див. nginx-default-tpl.mdc)')
355
+ }
356
+
357
+ if (!existsSync('.vscode/settings.json')) {
358
+ failFn('Очікується .vscode/settings.json з форматером nginx і formatOnSave (див. nginx-default-tpl.mdc)')
359
+ return
360
+ }
361
+ const s = JSON.parse(await readFile('.vscode/settings.json', 'utf8'))
362
+ if (s['editor.formatOnSave'] === true) {
363
+ passFn('settings.json: editor.formatOnSave увімкнено')
364
+ } else {
365
+ failFn('settings.json: увімкни editor.formatOnSave: true (див. nginx-default-tpl.mdc)')
366
+ }
367
+ if (s['[nginx]']?.['editor.defaultFormatter'] === 'ahmadalli.vscode-nginx-conf') {
368
+ passFn('settings.json: [nginx] defaultFormatter налаштовано')
369
+ } else {
370
+ failFn('settings.json: [nginx].editor.defaultFormatter має бути ahmadalli.vscode-nginx-conf')
371
+ }
372
+ }
373
+
262
374
  /**
263
375
  * Перевіряє відповідність проєкту правилам nginx-default-tpl.mdc
264
376
  * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
@@ -287,98 +399,11 @@ export async function check() {
287
399
  pass(`Знайдено default.conf.template: ${templates.length}`)
288
400
 
289
401
  for (const abs of templates) {
290
- const rel = relative(root, abs) || abs
291
- const content = await readFile(abs, 'utf8')
292
- const v = nginxTemplateViolations(content)
293
- if (v) {
294
- fail(`${rel}: ${v}`)
295
- } else {
296
- pass(`${rel}: структура шаблону узгоджена з nginx-default-tpl.mdc`)
297
- }
298
-
299
- const dir = dirname(abs)
300
- let iniNames = []
301
- try {
302
- const dirEntries = await readdir(dir)
303
- iniNames = dirEntries.filter(n => n.endsWith('.ini'))
304
- } catch {
305
- iniNames = []
306
- }
307
- if (iniNames.length === 0) {
308
- fail(`${rel}: поруч немає жодного *.ini — додай values-*.ini для середовищ (див. nginx-default-tpl.mdc)`)
309
- } else {
310
- pass(`${rel}: поруч є *.ini (${iniNames.length})`)
311
- }
312
-
313
- for (const iniName of iniNames) {
314
- const iniPath = `${dir}/${iniName}`
315
- const iniRel = relative(root, iniPath) || iniPath
316
- let iniRaw
317
- try {
318
- iniRaw = await readFile(iniPath, 'utf8')
319
- } catch (error) {
320
- fail(`${iniRel}: не вдалося прочитати (${error instanceof Error ? error.message : String(error)})`)
321
- iniRaw = null
322
- }
323
- if (iniRaw !== null) {
324
- const keys = parseIniVariableNames(iniRaw)
325
- const miss = iniKeysMissingInTemplate(keys, content)
326
- if (miss) {
327
- fail(`${iniRel}: ${miss}`)
328
- }
329
- }
330
- }
402
+ await checkTemplateFile(abs, root, pass, fail)
331
403
  }
332
404
 
333
- const dockerPaths = await findDockerfilePaths(root)
334
- if (dockerPaths.length === 0) {
335
- fail(
336
- 'Є default.conf.template, але немає Dockerfile / Containerfile — додай gzip для статики та envsubst (див. nginx-default-tpl.mdc)'
337
- )
338
- } else {
339
- const bodies = await Promise.all(dockerPaths.map(p => readFile(p, 'utf8')))
340
- const gzipOk = bodies.some(body => dockerfileHasGzipStaticPipeline(body))
341
- const envOk = bodies.some(body => dockerfileHasEnvsSubstTemplate(body))
342
- if (gzipOk) {
343
- pass('Dockerfile: знайдено крок стиснення статики (find + gzip -k)')
344
- } else {
345
- fail('Dockerfile: потрібен RUN find … /usr/share/nginx/html … gzip -k (див. nginx-default-tpl.mdc)')
346
- }
347
- if (envOk) {
348
- pass('Dockerfile: знайдено envsubst для default.conf.template')
349
- } else {
350
- fail('Dockerfile: потрібен envsubst з default.conf.template (див. nginx-default-tpl.mdc)')
351
- }
352
- }
353
-
354
- if (existsSync('.vscode/extensions.json')) {
355
- const extRaw = await readFile('.vscode/extensions.json', 'utf8')
356
- const ext = JSON.parse(extRaw)
357
- if (ext.recommendations?.includes('ahmadalli.vscode-nginx-conf')) {
358
- pass('extensions.json містить ahmadalli.vscode-nginx-conf')
359
- } else {
360
- fail('extensions.json не містить ahmadalli.vscode-nginx-conf')
361
- }
362
- } else {
363
- fail('Очікується .vscode/extensions.json з ahmadalli.vscode-nginx-conf (див. nginx-default-tpl.mdc)')
364
- }
365
-
366
- if (existsSync('.vscode/settings.json')) {
367
- const settingsRaw = await readFile('.vscode/settings.json', 'utf8')
368
- const s = JSON.parse(settingsRaw)
369
- if (s['editor.formatOnSave'] === true) {
370
- pass('settings.json: editor.formatOnSave увімкнено')
371
- } else {
372
- fail('settings.json: увімкни editor.formatOnSave: true (див. nginx-default-tpl.mdc)')
373
- }
374
- if (s['[nginx]']?.['editor.defaultFormatter'] === 'ahmadalli.vscode-nginx-conf') {
375
- pass('settings.json: [nginx] defaultFormatter налаштовано')
376
- } else {
377
- fail('settings.json: [nginx].editor.defaultFormatter має бути ahmadalli.vscode-nginx-conf')
378
- }
379
- } else {
380
- fail('Очікується .vscode/settings.json з форматером nginx і formatOnSave (див. nginx-default-tpl.mdc)')
381
- }
405
+ await checkDockerfiles(root, pass, fail)
406
+ await checkVscodeNginx(pass, fail)
382
407
 
383
408
  return reporter.getExitCode()
384
409
  }
@@ -16,8 +16,6 @@ import { readFile, stat } from 'node:fs/promises'
16
16
  import { join } from 'node:path'
17
17
 
18
18
  import { createCheckReporter } from './utils/check-reporter.mjs'
19
-
20
- const TYPES_FILE_RE = /^\.\/types\/.+\.d\.(ts|mts)$/
21
19
  import {
22
20
  hasIdTokenWritePermission,
23
21
  hasNpmPublishStepWithPackage,
@@ -27,6 +25,8 @@ import {
27
25
  } from './utils/gha-workflow.mjs'
28
26
  import { walkDir } from './utils/walkDir.mjs'
29
27
 
28
+ const TYPES_FILE_RE = /^\.\/types\/.+\.d\.(ts|mts)$/
29
+
30
30
  /** Канонічний entrypoint типів для пакетів із вихідним `.js` під каталогом `npm/src` */
31
31
  const TYPES_INDEX = './types/index.d.ts'
32
32
 
@@ -142,13 +142,155 @@ function npmTypesFileFromPackageField(typesField) {
142
142
  }
143
143
 
144
144
  /**
145
- * Перевіряє відповідність проєкту правилам npm-module.mdc
146
- * @returns {Promise<number>} 0 все OK, 1 — є проблеми
145
+ * Перевіряє поле types у npm/package.json.
146
+ * @param {unknown} typesField значення поля types
147
+ * @param {boolean} useSrcJsLayout чи використовується layout з npm/src
148
+ * @param {(msg: string) => void} passFn callback при успішній перевірці
149
+ * @param {(msg: string) => void} failFn callback при помилці
147
150
  */
148
- export async function check() {
149
- const reporter = createCheckReporter()
150
- const { pass, fail } = reporter
151
+ function checkNpmTypesField(typesField, useSrcJsLayout, passFn, failFn) {
152
+ if (useSrcJsLayout) {
153
+ if (typesField === TYPES_INDEX) {
154
+ passFn(`npm/package.json: "types": "${TYPES_INDEX}" (layout npm/src + .js)`)
155
+ } else {
156
+ failFn(`npm/package.json: при наявності .js під npm/src очікується "types": "${TYPES_INDEX}"`)
157
+ }
158
+ } else if (typeof typesField === 'string' && TYPES_FILE_RE.test(typesField)) {
159
+ passFn(`npm/package.json: "types" вказує на файл під ./types/… (${typesField})`)
160
+ } else {
161
+ failFn(
162
+ 'npm/package.json: без .js під npm/src поле types має бути рядком виду ./types/….d.ts або .d.mts (див. npm-module.mdc)'
163
+ )
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Перевіряє npm/package.json на типи та files.
169
+ * @param {boolean} useSrcJsLayout чи використовується layout з npm/src
170
+ * @param {(msg: string) => void} passFn callback при успішній перевірці
171
+ * @param {(msg: string) => void} failFn callback при помилці
172
+ */
173
+ async function checkNpmPackageJson(useSrcJsLayout, passFn, failFn) {
174
+ if (!existsSync('npm/package.json')) return
175
+ const npmPkg = JSON.parse(await readFile('npm/package.json', 'utf8'))
176
+ const typesField = npmPkg.types
177
+
178
+ checkNpmTypesField(typesField, useSrcJsLayout, passFn, failFn)
151
179
 
180
+ if (Array.isArray(npmPkg.files) && npmPkg.files.includes('types')) {
181
+ passFn('npm/package.json: files містить "types"')
182
+ } else {
183
+ failFn('npm/package.json: масив files має містити "types"')
184
+ }
185
+
186
+ const typesPath = useSrcJsLayout ? join('npm', 'types', 'index.d.ts') : npmTypesFileFromPackageField(typesField)
187
+ const missingTypesMsg = useSrcJsLayout
188
+ ? `Відсутній ${join('npm', 'types', 'index.d.ts')} (згенеруй tsc з npm-module.mdc)`
189
+ : `Файл для поля types не знайдено або шлях не під ./types/ — ${String(typesField)}`
190
+ if (typesPath && existsSync(typesPath)) {
191
+ passFn(`${typesPath} існує`)
192
+ } else {
193
+ failFn(missingTypesMsg)
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Перевіряє npm/tsconfig.emit-types.json.
199
+ * @param {(msg: string) => void} passFn callback при успішній перевірці
200
+ * @param {(msg: string) => void} failFn callback при помилці
201
+ */
202
+ async function checkEmitTypesConfig(passFn, failFn) {
203
+ if (!existsSync(EMIT_TYPES_CONFIG)) {
204
+ failFn(
205
+ `Без .js під npm/src потрібен ${EMIT_TYPES_CONFIG} (див. npm-module.mdc: emit через tsconfig, без штучного src/index.js)`
206
+ )
207
+ return
208
+ }
209
+ passFn(`${EMIT_TYPES_CONFIG} існує`)
210
+ let raw
211
+ try {
212
+ raw = JSON.parse(await readFile(EMIT_TYPES_CONFIG, 'utf8'))
213
+ } catch {
214
+ failFn(`${EMIT_TYPES_CONFIG}: некоректний JSON`)
215
+ return
216
+ }
217
+ const issues = emitTypesConfigIssues(raw)
218
+ if (issues.length === 0) {
219
+ passFn(`${EMIT_TYPES_CONFIG}: compilerOptions придатні для emitDeclarationOnly → types/`)
220
+ } else {
221
+ failFn(`${EMIT_TYPES_CONFIG}: ${issues.join('; ')}`)
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Перевіряє npm-publish.yml workflow.
227
+ * @param {(msg: string) => void} passFn callback при успішній перевірці
228
+ * @param {(msg: string) => void} failFn callback при помилці
229
+ */
230
+ async function checkPublishWorkflow(passFn, failFn) {
231
+ const publishWf = '.github/workflows/npm-publish.yml'
232
+ if (!existsSync(publishWf)) {
233
+ failFn(`Відсутній ${publishWf} (npm-module.mdc: npm publish)`)
234
+ return
235
+ }
236
+ passFn(`${publishWf} існує`)
237
+ const pub = await readFile(publishWf, 'utf8')
238
+ const root = parseWorkflowYaml(pub)
239
+ if (root) {
240
+ const checks = [
241
+ {
242
+ ok: pushPathsIncludeNpmGlob(root),
243
+ pass: `${publishWf}: on.push.paths містить npm/**`,
244
+ fail: `${publishWf}: у on.push.paths має бути npm/**`
245
+ },
246
+ {
247
+ ok: pushHasMainBranch(root),
248
+ pass: `${publishWf}: очікується branch main`,
249
+ fail: `${publishWf}: очікується branch main`
250
+ },
251
+ {
252
+ ok: hasIdTokenWritePermission(root),
253
+ pass: `${publishWf}: permissions містить id-token: write (OIDC)`,
254
+ fail: `${publishWf}: permissions має містити id-token: write (OIDC)`
255
+ },
256
+ {
257
+ ok: hasNpmPublishStepWithPackage(root),
258
+ pass: `${publishWf}: uses JS-DevTools/npm-publish та with.package npm/package.json`,
259
+ fail: `${publishWf}: очікується uses: JS-DevTools/npm-publish та with.package: npm/package.json`
260
+ }
261
+ ]
262
+ for (const c of checks) {
263
+ if (c.ok) {
264
+ passFn(c.pass)
265
+ } else {
266
+ failFn(c.fail)
267
+ }
268
+ }
269
+ return
270
+ }
271
+ const need = [
272
+ { sub: 'npm/**', msg: `${publishWf}: у on.push.paths має бути npm/**` },
273
+ { sub: 'branches:', msg: `${publishWf}: очікується on.push.branches` },
274
+ { sub: 'main', msg: `${publishWf}: очікується branch main` },
275
+ { sub: 'id-token: write', msg: `${publishWf}: permissions має містити id-token: write (OIDC)` },
276
+ { sub: 'JS-DevTools/npm-publish', msg: `${publishWf}: очікується uses: JS-DevTools/npm-publish` },
277
+ { sub: 'package: npm/package.json', msg: `${publishWf}: with.package має бути npm/package.json` }
278
+ ]
279
+ for (const { sub, msg } of need) {
280
+ if (pub.includes(sub)) {
281
+ passFn(`${publishWf} містить «${sub}»`)
282
+ } else {
283
+ failFn(msg)
284
+ }
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Перевіряє базову структуру монорепо (package.json, npm/, workspaces, npm/package.json).
290
+ * @param {(msg: string) => void} pass callback при успішній перевірці
291
+ * @param {(msg: string) => void} fail callback при помилці
292
+ */
293
+ async function checkNpmModuleBasicStructure(pass, fail) {
152
294
  if (existsSync('package.json')) {
153
295
  pass('package.json існує')
154
296
  } else {
@@ -168,8 +310,7 @@ export async function check() {
168
310
 
169
311
  if (existsSync('package.json')) {
170
312
  const pkg = JSON.parse(await readFile('package.json', 'utf8'))
171
- const ws = pkg.workspaces
172
- if (Array.isArray(ws) && ws.includes('npm')) {
313
+ if (Array.isArray(pkg.workspaces) && pkg.workspaces.includes('npm')) {
173
314
  pass('package.json workspaces містить "npm"')
174
315
  } else {
175
316
  fail('package.json workspaces має містити "npm"')
@@ -181,81 +322,33 @@ export async function check() {
181
322
  } else {
182
323
  fail('npm/package.json не існує — створи package.json для npm модуля')
183
324
  }
325
+ }
184
326
 
185
- const useSrcJsLayout = await npmSrcTreeHasJsFile()
186
-
187
- if (existsSync('npm/package.json')) {
188
- const npmPkg = JSON.parse(await readFile('npm/package.json', 'utf8'))
189
- const typesField = npmPkg.types
327
+ /**
328
+ * Перевіряє відповідність проєкту правилам npm-module.mdc
329
+ * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
330
+ */
331
+ export async function check() {
332
+ const reporter = createCheckReporter()
333
+ const { pass, fail } = reporter
190
334
 
191
- if (useSrcJsLayout) {
192
- if (typesField === TYPES_INDEX) {
193
- pass(`npm/package.json: "types": "${TYPES_INDEX}" (layout npm/src + .js)`)
194
- } else {
195
- fail(`npm/package.json: при наявності .js під npm/src очікується "types": "${TYPES_INDEX}"`)
196
- }
197
- } else {
198
- if (typeof typesField === 'string' && /^\.\/types\/.+\.d\.(ts|mts)$/.test(typesField)) {
199
- pass(`npm/package.json: "types" вказує на файл під ./types/… (${typesField})`)
200
- } else {
201
- fail(
202
- 'npm/package.json: без .js під npm/src поле types має бути рядком виду ./types/….d.ts або .d.mts (див. npm-module.mdc)'
203
- )
204
- }
205
- }
335
+ await checkNpmModuleBasicStructure(pass, fail)
206
336
 
207
- const files = npmPkg.files
208
- if (Array.isArray(files) && files.includes('types')) {
209
- pass('npm/package.json: files містить "types"')
210
- } else {
211
- fail('npm/package.json: масив files має містити "types"')
212
- }
337
+ const useSrcJsLayout = await npmSrcTreeHasJsFile()
213
338
 
214
- const typesPath = useSrcJsLayout ? join('npm', 'types', 'index.d.ts') : npmTypesFileFromPackageField(typesField)
215
- if (typesPath && existsSync(typesPath)) {
216
- pass(`${typesPath} існує`)
217
- } else {
218
- fail(
219
- useSrcJsLayout
220
- ? `Відсутній ${join('npm', 'types', 'index.d.ts')} (згенеруй tsc з npm-module.mdc)`
221
- : `Файл для поля types не знайдено або шлях не під ./types/ — ${String(typesField)}`
222
- )
223
- }
224
- }
339
+ await checkNpmPackageJson(useSrcJsLayout, pass, fail)
225
340
 
226
341
  if (!useSrcJsLayout) {
227
- if (existsSync(EMIT_TYPES_CONFIG)) {
228
- pass(`${EMIT_TYPES_CONFIG} існує`)
229
- let raw
230
- try {
231
- raw = JSON.parse(await readFile(EMIT_TYPES_CONFIG, 'utf8'))
232
- } catch {
233
- fail(`${EMIT_TYPES_CONFIG}: некоректний JSON`)
234
- raw = null
235
- }
236
- if (raw) {
237
- const issues = emitTypesConfigIssues(raw)
238
- if (issues.length === 0) {
239
- pass(`${EMIT_TYPES_CONFIG}: compilerOptions придатні для emitDeclarationOnly → types/`)
240
- } else {
241
- fail(`${EMIT_TYPES_CONFIG}: ${issues.join('; ')}`)
242
- }
243
- }
244
- } else {
245
- fail(
246
- `Без .js під npm/src потрібен ${EMIT_TYPES_CONFIG} (див. npm-module.mdc: emit через tsconfig, без штучного src/index.js)`
247
- )
248
- }
342
+ await checkEmitTypesConfig(pass, fail)
249
343
  }
250
344
 
345
+ const layoutLabel = useSrcJsLayout ? 'layout src' : 'tsconfig emit-types'
251
346
  const hk = await readHkConfig()
252
347
  if (hk) {
253
348
  pass(`${hk.path} існує`)
254
349
  const missing = useSrcJsLayout ? missingHkSrcLayoutFragments(hk.text) : missingHkEmitTypesConfigFragments(hk.text)
255
350
  if (missing.length === 0) {
256
- pass(
257
- `${hk.path}: pre-commit містить очікуваний виклик tsc (${useSrcJsLayout ? 'layout src' : 'tsconfig emit-types'})`
258
- )
351
+ pass(`${hk.path}: pre-commit містить очікуваний виклик tsc (${layoutLabel})`)
259
352
  } else {
260
353
  fail(`${hk.path}: онови pre-commit крок (npm-module.mdc); не знайдено: ${missing.join(', ')}`)
261
354
  }
@@ -269,53 +362,7 @@ export async function check() {
269
362
  fail('.github/workflows/ не існує')
270
363
  }
271
364
 
272
- const publishWf = '.github/workflows/npm-publish.yml'
273
- if (existsSync(publishWf)) {
274
- pass(`${publishWf} існує`)
275
- const pub = await readFile(publishWf, 'utf8')
276
- const root = parseWorkflowYaml(pub)
277
-
278
- if (root) {
279
- if (pushPathsIncludeNpmGlob(root)) {
280
- pass(`${publishWf}: on.push.paths містить npm/**`)
281
- } else {
282
- fail(`${publishWf}: у on.push.paths має бути npm/**`)
283
- }
284
- if (pushHasMainBranch(root)) {
285
- pass(`${publishWf}: очікується branch main`)
286
- } else {
287
- fail(`${publishWf}: очікується branch main`)
288
- }
289
- if (hasIdTokenWritePermission(root)) {
290
- pass(`${publishWf}: permissions містить id-token: write (OIDC)`)
291
- } else {
292
- fail(`${publishWf}: permissions має містити id-token: write (OIDC)`)
293
- }
294
- if (hasNpmPublishStepWithPackage(root)) {
295
- pass(`${publishWf}: uses JS-DevTools/npm-publish та with.package npm/package.json`)
296
- } else {
297
- fail(`${publishWf}: очікується uses: JS-DevTools/npm-publish та with.package: npm/package.json`)
298
- }
299
- } else {
300
- const need = [
301
- { sub: 'npm/**', msg: `${publishWf}: у on.push.paths має бути npm/**` },
302
- { sub: 'branches:', msg: `${publishWf}: очікується on.push.branches` },
303
- { sub: 'main', msg: `${publishWf}: очікується branch main` },
304
- { sub: 'id-token: write', msg: `${publishWf}: permissions має містити id-token: write (OIDC)` },
305
- { sub: 'JS-DevTools/npm-publish', msg: `${publishWf}: очікується uses: JS-DevTools/npm-publish` },
306
- { sub: 'package: npm/package.json', msg: `${publishWf}: with.package має бути npm/package.json` }
307
- ]
308
- for (const { sub, msg } of need) {
309
- if (pub.includes(sub)) {
310
- pass(`${publishWf} містить «${sub}»`)
311
- } else {
312
- fail(msg)
313
- }
314
- }
315
- }
316
- } else {
317
- fail(`Відсутній ${publishWf} (npm-module.mdc: npm publish)`)
318
- }
365
+ await checkPublishWorkflow(pass, fail)
319
366
 
320
367
  return reporter.getExitCode()
321
368
  }