@nitra/cursor 1.8.156 → 1.8.157

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.
@@ -9,6 +9,9 @@
9
9
  * Якщо таких файлів немає — layout через `npm/tsconfig.emit-types.json`: поле `types` має вказувати на існуючий
10
10
  * файл під `./types/…`, у hk — `tsc -p tsconfig.emit-types.json`, у JSON-конфігу — потрібні compilerOptions для emit.
11
11
  *
12
+ * Окремо перевіряється `npm/CHANGELOG.md`: файл існує, є в `files` у `npm/package.json` і містить запис
13
+ * для поточної версії (формат `## [X.Y.Z] - YYYY-MM-DD`, Keep a Changelog).
14
+ *
12
15
  * Поля workflow перевіряються після **YAML parse**, щоб не плутати з коментарями.
13
16
  */
14
17
  import { existsSync } from 'node:fs'
@@ -33,6 +36,9 @@ const TYPES_INDEX = './types/index.d.ts'
33
36
  /** Файл проєкту TypeScript для emit без каталогу `src` (див. npm-module.mdc) */
34
37
  const EMIT_TYPES_CONFIG = 'npm/tsconfig.emit-types.json'
35
38
 
39
+ /** Шлях до `CHANGELOG.md` в каталозі npm-модуля */
40
+ const CHANGELOG_PATH = 'npm/CHANGELOG.md'
41
+
36
42
  /**
37
43
  * Чи є під `npm/src` хоча б один `.js` (рекурсивно).
38
44
  * @returns {Promise<boolean>} `true`, якщо знайдено хоча б один `.js`
@@ -194,6 +200,53 @@ async function checkNpmPackageJson(useSrcJsLayout, passFn, failFn) {
194
200
  }
195
201
  }
196
202
 
203
+ /**
204
+ * Чи містить текст `CHANGELOG.md` запис заголовка для конкретної версії
205
+ * у форматі Keep a Changelog: `## [X.Y.Z]` (з опційним `- YYYY-MM-DD`).
206
+ * @param {string} text вміст `CHANGELOG.md`
207
+ * @param {string} version номер версії з `npm/package.json`
208
+ * @returns {boolean} `true`, якщо знайдено заголовок версії
209
+ */
210
+ function changelogHasVersionEntry(text, version) {
211
+ const escaped = version.replaceAll(/[.+*?^$()[\]{}|\\]/g, String.raw`\$&`)
212
+ const re = new RegExp(String.raw`^##\s+\[${escaped}\]`, 'm')
213
+ return re.test(text)
214
+ }
215
+
216
+ /**
217
+ * Перевіряє наявність і вміст `npm/CHANGELOG.md`, а також що він є в `files` у `npm/package.json`.
218
+ * @param {(msg: string) => void} passFn callback при успішній перевірці
219
+ * @param {(msg: string) => void} failFn callback при помилці
220
+ */
221
+ async function checkChangelog(passFn, failFn) {
222
+ if (!existsSync(CHANGELOG_PATH)) {
223
+ failFn(`Відсутній ${CHANGELOG_PATH} (npm-module.mdc: CHANGELOG)`)
224
+ return
225
+ }
226
+ passFn(`${CHANGELOG_PATH} існує`)
227
+
228
+ if (existsSync('npm/package.json')) {
229
+ const npmPkg = JSON.parse(await readFile('npm/package.json', 'utf8'))
230
+ if (Array.isArray(npmPkg.files) && npmPkg.files.includes('CHANGELOG.md')) {
231
+ passFn('npm/package.json: files містить "CHANGELOG.md"')
232
+ } else {
233
+ failFn('npm/package.json: масив files має містити "CHANGELOG.md"')
234
+ }
235
+
236
+ const version = typeof npmPkg.version === 'string' ? npmPkg.version : null
237
+ if (version) {
238
+ const text = await readFile(CHANGELOG_PATH, 'utf8')
239
+ if (changelogHasVersionEntry(text, version)) {
240
+ passFn(`${CHANGELOG_PATH}: знайдено запис для версії ${version}`)
241
+ } else {
242
+ failFn(
243
+ `${CHANGELOG_PATH}: відсутній запис для поточної версії ${version} (формат Keep a Changelog: "## [${version}] - YYYY-MM-DD")`
244
+ )
245
+ }
246
+ }
247
+ }
248
+ }
249
+
197
250
  /**
198
251
  * Перевіряє npm/tsconfig.emit-types.json.
199
252
  * @param {(msg: string) => void} passFn callback при успішній перевірці
@@ -338,6 +391,8 @@ export async function check() {
338
391
 
339
392
  await checkNpmPackageJson(useSrcJsLayout, pass, fail)
340
393
 
394
+ await checkChangelog(pass, fail)
395
+
341
396
  if (!useSrcJsLayout) {
342
397
  await checkEmitTypesConfig(pass, fail)
343
398
  }
@@ -116,7 +116,8 @@ function isEmptyListTest(test, name) {
116
116
  if (!['===', '==', '<=', '<'].includes(operator)) return false
117
117
  if (isLengthMember(left, name) && isZeroNumberLiteral(right)) return true
118
118
  // допускаємо `0 === ids.length` теж
119
- if (isZeroNumberLiteral(left) && isLengthMember(right, name) && (operator === '===' || operator === '==')) return true
119
+ if (isZeroNumberLiteral(left) && isLengthMember(right, name) && (operator === '===' || operator === '=='))
120
+ return true
120
121
  }
121
122
 
122
123
  return false
@@ -307,7 +308,9 @@ function collectInListGuardViolationsFromTemplate(template, ancestors, content,
307
308
  for (const [i, expr] of expressions.entries()) {
308
309
  const q = quasis[i]
309
310
  const raw =
310
- q && typeof q === 'object' && q.value && typeof q.value === 'object' && typeof q.value.raw === 'string' ? q.value.raw : ''
311
+ q && typeof q === 'object' && q.value && typeof q.value === 'object' && typeof q.value.raw === 'string'
312
+ ? q.value.raw
313
+ : ''
311
314
  if (!IN_PLACEHOLDER_END_RE.test(raw)) continue
312
315
 
313
316
  const extracted = extractInListVarNameFromExpr(expr)
@@ -11,7 +11,7 @@
11
11
  * (порядок не важливий, кілька викликів зливаються в один список).
12
12
  *
13
13
  * Обидва контракти можна точково «приглушити» коментарем-маркером
14
- * `// @nitra/cursor ignore-next-line checkEnv` на рядку безпосередньо перед
14
+ * `// \@nitra/cursor ignore-next-line checkEnv` на рядку безпосередньо перед
15
15
  * порушенням — це залишається сумісним escape-hatch для legacy-коду.
16
16
  *
17
17
  * Семантика береться з **oxc-parser** через `parseProgramOrNull`: regex по тілу
@@ -129,7 +129,7 @@ function hasCheckEnvImport(programNode) {
129
129
  }
130
130
 
131
131
  /**
132
- * Чи закритий рядок ignore-коментарем `// @nitra/cursor ignore-next-line checkEnv`.
132
+ * Чи закритий рядок ignore-коментарем `// \@nitra/cursor ignore-next-line checkEnv`.
133
133
  * @param {string[]} lines рядки файлу (split за \n, без CR)
134
134
  * @param {number} oneBasedLine 1-based номер рядка з порушенням
135
135
  * @returns {boolean} true, якщо попередній рядок містить маркер
@@ -161,6 +161,51 @@ function isEnvIdentifierMember(node) {
161
161
  * }} EnvViolation
162
162
  */
163
163
 
164
+ /**
165
+ * Чи parent — це MemberExpression, у якому `node` (process.env) є об'єктом доступу.
166
+ * @param {unknown} parent ancestor вузла
167
+ * @param {unknown} node перевіряємий вузол `process.env`
168
+ * @returns {boolean} true для `process.env.X` / `process.env['X']`
169
+ */
170
+ function isParentEnvMember(parent, node) {
171
+ return !!parent && typeof parent === 'object' && parent.type === 'MemberExpression' && parent.object === node
172
+ }
173
+
174
+ /**
175
+ * Чи parent — це VariableDeclarator виду `const { ... } = <node>`.
176
+ * @param {unknown} parent ancestor вузла
177
+ * @param {unknown} node перевіряємий вузол (process.env або Identifier `env`)
178
+ * @returns {boolean} true для `const { ... } = node`
179
+ */
180
+ function isParentObjectPatternDeclarator(parent, node) {
181
+ return (
182
+ !!parent &&
183
+ typeof parent === 'object' &&
184
+ parent.type === 'VariableDeclarator' &&
185
+ parent.init === node &&
186
+ !!parent.id &&
187
+ parent.id.type === 'ObjectPattern' &&
188
+ Array.isArray(parent.id.properties)
189
+ )
190
+ }
191
+
192
+ /**
193
+ * Чи node — це VariableDeclarator виду `const { ... } = env`, де `env` — Identifier.
194
+ * @param {Record<string, unknown>} node AST-вузол
195
+ * @returns {boolean} true для `const { ... } = env`
196
+ */
197
+ function isEnvObjectPatternDeclarator(node) {
198
+ return (
199
+ node.type === 'VariableDeclarator' &&
200
+ !!node.init &&
201
+ node.init.type === 'Identifier' &&
202
+ node.init.name === 'env' &&
203
+ !!node.id &&
204
+ node.id.type === 'ObjectPattern' &&
205
+ Array.isArray(node.id.properties)
206
+ )
207
+ }
208
+
164
209
  /**
165
210
  * Перебирає AST і для кожного знайденого доступу до `process.env` чи `env`
166
211
  * (де `env` — імпорт з `@nitra/check-env`) реєструє порушення відповідного типу.
@@ -191,34 +236,42 @@ function collectViolations(program, content, lines, checkedNames, envFromCheckEn
191
236
  out.push({ kind, name, line })
192
237
  }
193
238
 
194
- walkAstWithAncestors(program, [], (node, ancestors) => {
195
- // 1. process.env.X завжди порушення (рекомендуємо замінити на env)
196
- if (isProcessEnvAccess(node)) {
197
- const parent = ancestors[ancestors.length - 1]
198
- if (parent && typeof parent === 'object' && parent.type === 'MemberExpression' && parent.object === node) {
199
- const envName = envNameFromMember(parent)
200
- if (envName) report('process-env', envName, offsetToLine(content, parent.start))
201
- }
202
- if (
203
- parent &&
204
- typeof parent === 'object' &&
205
- parent.type === 'VariableDeclarator' &&
206
- parent.init === node &&
207
- parent.id &&
208
- parent.id.type === 'ObjectPattern' &&
209
- Array.isArray(parent.id.properties)
210
- ) {
211
- for (const p of parent.id.properties) {
212
- const name = staticPropertyName(p)
213
- if (name) report('process-env', name, offsetToLine(content, p.start ?? parent.start))
214
- }
215
- }
216
- return
239
+ /**
240
+ * Реєструє порушення для всіх статичних ключів ObjectPattern (`const { A, B } = …`).
241
+ * @param {Record<string, unknown>} declarator VariableDeclarator з ObjectPattern у `id`
242
+ * @param {'process-env' | 'check-env-missing-checkEnv'} kind тип порушення
243
+ * @param {(name: string) => boolean} skipName предикат «пропустити це ім'я» (наприклад, вже у checkEnv)
244
+ */
245
+ function reportObjectPatternKeys(declarator, kind, skipName) {
246
+ const fallbackOffset = declarator.start
247
+ for (const p of declarator.id.properties) {
248
+ const name = staticPropertyName(p)
249
+ if (!name || skipName(name)) continue
250
+ report(kind, name, offsetToLine(content, p.start ?? fallbackOffset))
217
251
  }
252
+ }
218
253
 
219
- // 2. env.X — порушення лише якщо env імпортовано з @nitra/check-env і немає checkEnv
220
- if (!envFromCheckEnv) return
254
+ /**
255
+ * Обробка `process.env`-доступу: і `parent.X`, і деструктуризація.
256
+ * @param {unknown} node AST-вузол `process.env`
257
+ * @param {unknown[]} ancestors стек предків з walkAstWithAncestors
258
+ */
259
+ function handleProcessEnv(node, ancestors) {
260
+ const parent = ancestors.at(-1)
261
+ if (isParentEnvMember(parent, node)) {
262
+ const envName = envNameFromMember(parent)
263
+ if (envName) report('process-env', envName, offsetToLine(content, parent.start))
264
+ }
265
+ if (isParentObjectPatternDeclarator(parent, node)) {
266
+ reportObjectPatternKeys(parent, 'process-env', () => false)
267
+ }
268
+ }
221
269
 
270
+ /**
271
+ * Обробка вузлів, що стосуються `env` з `@nitra/check-env`.
272
+ * @param {Record<string, unknown>} node AST-вузол
273
+ */
274
+ function handleCheckEnvAccess(node) {
222
275
  if (isEnvIdentifierMember(node)) {
223
276
  const envName = envNameFromMember(node)
224
277
  if (envName && !checkedNames.has(envName)) {
@@ -226,24 +279,17 @@ function collectViolations(program, content, lines, checkedNames, envFromCheckEn
226
279
  }
227
280
  return
228
281
  }
282
+ if (isEnvObjectPatternDeclarator(node)) {
283
+ reportObjectPatternKeys(node, 'check-env-missing-checkEnv', name => checkedNames.has(name))
284
+ }
285
+ }
229
286
 
230
- // const { X, Y } = env — теж потребує checkEnv для кожного імені
231
- if (
232
- node.type === 'VariableDeclarator' &&
233
- node.init &&
234
- node.init.type === 'Identifier' &&
235
- node.init.name === 'env' &&
236
- node.id &&
237
- node.id.type === 'ObjectPattern' &&
238
- Array.isArray(node.id.properties)
239
- ) {
240
- for (const p of node.id.properties) {
241
- const name = staticPropertyName(p)
242
- if (name && !checkedNames.has(name)) {
243
- report('check-env-missing-checkEnv', name, offsetToLine(content, p.start ?? node.start))
244
- }
245
- }
287
+ walkAstWithAncestors(program, [], (node, ancestors) => {
288
+ if (isProcessEnvAccess(node)) {
289
+ handleProcessEnv(node, ancestors)
290
+ return
246
291
  }
292
+ if (envFromCheckEnv) handleCheckEnvAccess(node)
247
293
  })
248
294
 
249
295
  return out
@@ -266,7 +312,6 @@ function staticPropertyName(property) {
266
312
 
267
313
  /**
268
314
  * Знаходить порушення правила «process.env / CheckEnv» у файлі.
269
- *
270
315
  * @param {string} content вихідний код
271
316
  * @param {string} [virtualPath] шлях для вибору `lang` парсера
272
317
  * @returns {EnvViolation[]} список порушень із типом, іменем змінної та рядком
@@ -20,7 +20,17 @@ import { langFromPath, normalizeSnippet, offsetToLine } from './ast-scan-utils.m
20
20
  import { parseSync } from 'oxc-parser'
21
21
 
22
22
  const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/u
23
- const TRAILING_SLASH_RE = /\/+$/u
23
+
24
+ /**
25
+ * Прибирає хвостові `/` зі шляху без використання regex (щоб не тригерити slow-regex попередження).
26
+ * @param {string} s рядок зі шляхом
27
+ * @returns {string} `s` без хвостових `/`
28
+ */
29
+ function stripTrailingSlashes(s) {
30
+ let end = s.length
31
+ while (end > 0 && s.codePointAt(end - 1) === 47) end--
32
+ return end === s.length ? s : s.slice(0, end)
33
+ }
24
34
 
25
35
  /**
26
36
  * Нормалізує шлях до posix без хвостових слешів.
@@ -30,7 +40,7 @@ const TRAILING_SLASH_RE = /\/+$/u
30
40
  function toPosixDir(p) {
31
41
  let s = String(p).replaceAll('\\', '/').trim()
32
42
  if (s.startsWith('./')) s = s.slice(2)
33
- return s.replace(TRAILING_SLASH_RE, '')
43
+ return stripTrailingSlashes(s)
34
44
  }
35
45
 
36
46
  /**
@@ -57,7 +67,7 @@ export function resolveConnDirFromPackageJson(pkgJson) {
57
67
  // Прибираємо хвіст `*`, потім слеші
58
68
  let s = toPosixDir(raw)
59
69
  if (s.endsWith('/*')) s = s.slice(0, -2)
60
- return s.replace(TRAILING_SLASH_RE, '') || fallback
70
+ return stripTrailingSlashes(s) || fallback
61
71
  }
62
72
 
63
73
  /**
@@ -36,60 +36,81 @@ const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/
36
36
  const IN_PLACEHOLDER_END_RE = /\bin\s*\(\s*$/iu
37
37
  const NUMERIC_PARSE_FN_NAMES = new Set(['parseInt', 'parseFloat', 'Number', 'BigInt'])
38
38
 
39
+ /**
40
+ * Чи є вузол літералом числа `0` (як NumericLiteral, так і generic Literal).
41
+ * @param {unknown} node AST-вузол
42
+ * @returns {boolean} true для `0`
43
+ */
44
+ function isZeroLiteral(node) {
45
+ return (
46
+ !!node &&
47
+ typeof node === 'object' &&
48
+ ((node.type === 'NumericLiteral' && node.value === 0) || (node.type === 'Literal' && node.value === 0))
49
+ )
50
+ }
51
+
52
+ /**
53
+ * Чи `node` — це MemberExpression `name.length` (некомп'ютерований Identifier.Identifier).
54
+ * @param {unknown} node AST-вузол
55
+ * @param {string} name очікуване імʼя змінної-власника
56
+ * @returns {boolean} true для `<name>.length`
57
+ */
58
+ function isLengthMemberOf(node, name) {
59
+ return (
60
+ !!node &&
61
+ typeof node === 'object' &&
62
+ node.type === 'MemberExpression' &&
63
+ !node.computed &&
64
+ !!node.object &&
65
+ node.object.type === 'Identifier' &&
66
+ node.object.name === name &&
67
+ !!node.property &&
68
+ node.property.type === 'Identifier' &&
69
+ node.property.name === 'length'
70
+ )
71
+ }
72
+
73
+ const EMPTY_LIST_BINARY_OPERATORS = new Set(['===', '==', '<=', '<'])
74
+ const EMPTY_LIST_REVERSED_OPERATORS = new Set(['===', '=='])
75
+
76
+ /**
77
+ * Чи `BinaryExpression`-тест з оператором з {@link EMPTY_LIST_BINARY_OPERATORS}
78
+ * порівнює `<name>.length` з літералом `0` (у будь-якому порядку для `===`/`==`).
79
+ * @param {Record<string, unknown>} test BinaryExpression вузол
80
+ * @param {string} name імʼя змінної списку
81
+ * @returns {boolean} true для умов виду `name.length === 0`, `name.length <= 0`, `0 === name.length` тощо
82
+ */
83
+ function isEmptyListBinaryTest(test, name) {
84
+ const { left, right, operator } = test
85
+ if (!EMPTY_LIST_BINARY_OPERATORS.has(operator)) return false
86
+ if (isLengthMemberOf(left, name) && isZeroLiteral(right)) return true
87
+ return EMPTY_LIST_REVERSED_OPERATORS.has(operator) && isZeroLiteral(left) && isLengthMemberOf(right, name)
88
+ }
89
+
39
90
  /**
40
91
  * Чи містить тест if-умови перевірку “список порожній”.
41
92
  * Підтримує базові форми:
42
93
  * - `if (!ids.length) ...`
43
94
  * - `if (ids.length === 0) ...` / `<= 0` / `< 1`
44
- *
45
95
  * @param {unknown} test IfStatement.test
46
96
  * @param {string} name імʼя змінної списку
47
- * @returns {boolean}
97
+ * @returns {boolean} true, якщо тест перевіряє, що `name` — порожній масив
48
98
  */
49
99
  function isEmptyListTest(test, name) {
50
100
  if (!test || typeof test !== 'object') return false
51
-
52
101
  if (test.type === 'UnaryExpression' && test.operator === '!') {
53
- const arg = test.argument
54
- if (!arg || typeof arg !== 'object') return false
55
- if (arg.type === 'MemberExpression' && !arg.computed) {
56
- const obj = arg.object
57
- const prop = arg.property
58
- return !!obj && obj.type === 'Identifier' && obj.name === name && !!prop && prop.type === 'Identifier' && prop.name === 'length'
59
- }
102
+ return isLengthMemberOf(test.argument, name)
60
103
  }
61
-
62
104
  if (test.type === 'BinaryExpression') {
63
- const { left, right, operator } = test
64
- const isLen = node =>
65
- !!node &&
66
- typeof node === 'object' &&
67
- node.type === 'MemberExpression' &&
68
- !node.computed &&
69
- node.object &&
70
- node.object.type === 'Identifier' &&
71
- node.object.name === name &&
72
- node.property &&
73
- node.property.type === 'Identifier' &&
74
- node.property.name === 'length'
75
- const isZero = node =>
76
- !!node &&
77
- typeof node === 'object' &&
78
- ((node.type === 'NumericLiteral' && node.value === 0) || (node.type === 'Literal' && node.value === 0))
79
-
80
- if (!['===', '==', '<=', '<'].includes(operator)) return false
81
- if (isLen(left) && isZero(right)) return true
82
- // допускаємо `0 === ids.length` теж
83
- if (isZero(left) && isLen(right) && (operator === '===' || operator === '==')) return true
105
+ return isEmptyListBinaryTest(test, name)
84
106
  }
85
-
86
107
  return false
87
108
  }
88
109
 
89
110
  /**
90
111
  * Чи є в consequent (або в його BlockStatement) ThrowStatement.
91
112
  * @param {unknown} consequent IfStatement.consequent
92
- * @returns {boolean}
113
+ * @returns {boolean} true, якщо гілка consequent містить `throw`
93
114
  */
94
115
  function consequentHasThrow(consequent) {
95
116
  if (!consequent || typeof consequent !== 'object') return false
@@ -105,7 +126,7 @@ function consequentHasThrow(consequent) {
105
126
  * @param {unknown} block BlockStatement
106
127
  * @param {number} statementIndex індекс statement, перед яким шукаємо guard
107
128
  * @param {string} name імʼя змінної списку
108
- * @returns {boolean}
129
+ * @returns {boolean} true, якщо знайдено guard `if (empty(name)) throw` до statementIndex
109
130
  */
110
131
  function hasEmptyGuardBefore(block, statementIndex, name) {
111
132
  if (!block || typeof block !== 'object' || block.type !== 'BlockStatement') return false
@@ -125,7 +146,7 @@ function hasEmptyGuardBefore(block, statementIndex, name) {
125
146
  /**
126
147
  * Знаходить найближчий enclosing BlockStatement і statement всередині нього.
127
148
  * @param {unknown[]} ancestors ancestors масив з walkAstWithAncestors
128
- * @returns {{ block: unknown, index: number } | null}
149
+ * @returns {{ block: unknown, index: number } | null} пара (BlockStatement, індекс statement) або null
129
150
  */
130
151
  function findEnclosingBlockAndStatementIndex(ancestors) {
131
152
  if (!Array.isArray(ancestors) || ancestors.length === 0) return null
@@ -492,7 +513,6 @@ function collectInListUnparsedFromTemplate(node, content, declarators, out) {
492
513
  * Збирає порушення для одного TemplateLiteral: якщо у `IN (${...})`:
493
514
  * - `${...}` не є Identifier (значення не винесені у змінну);
494
515
  * - або це Identifier, але перед запитом немає guard `if (empty) throw`.
495
- *
496
516
  * @param {Record<string, unknown>} node TemplateLiteral
497
517
  * @param {unknown[]} ancestors ancestors від walkAstWithAncestors
498
518
  * @param {string} content вихідний код
@@ -562,7 +582,6 @@ export function findUnsafeMssqlInListUnparsedInText(content, virtualPath = 'scan
562
582
  * Знаходить підстановки списків у `IN (${...})`, які:
563
583
  * - не винесені в окрему змінну (в `${...}` стоїть не Identifier);
564
584
  * - або винесені, але перед запитом немає перевірки на пустоту з `throw`.
565
- *
566
585
  * @param {string} content вихідний код
567
586
  * @param {string} [virtualPath] шлях для вибору мови парсера (lang)
568
587
  * @returns {{ line: number, snippet: string, reason: 'not_var' | 'missing_guard', name?: string }[]} список порушень