@nitra/cursor 1.8.155 → 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)
@@ -1,23 +1,32 @@
1
1
  /**
2
- * AST-сканер для правила CheckEnv (js-run.mdc).
2
+ * AST-сканер правила «process.env / CheckEnv» (js-run.mdc).
3
3
  *
4
- * Кожне використання `process.env.X` у JS/TS-коді має бути «закрите» одним з двох способів:
5
- * - перед використанням у тому ж файлі викликано `checkEnv(['X', ...])` з пакету `@nitra/check-env`;
6
- * - на рядку безпосередньо перед `process.env.X` стоїть коментар-маркер
7
- * `// @nitra/cursor ignore-next-line checkEnv` (роздільники пробілів довільні; саме слово
8
- * `checkEnv` чутливе до регістру, як в усіх прикладах документа).
4
+ * Правило в .mdc формулює два контракти:
5
+ * 1. Прямий доступ до `process.env.X` має бути замінено на `env` з пакета
6
+ * `@nitra/check-env` (для обов'язкових змінних, із викликом `checkEnv([...])`)
7
+ * або з `node:process` (для опційних). Тому будь-яке `process.env.X` сканер
8
+ * завжди реєструє як порушення з порадою про конкретну заміну.
9
+ * 2. Якщо у файл імпортовано `env` саме з `@nitra/check-env`, то кожне `env.X`
10
+ * має бути закрите літеральним викликом `checkEnv(['X', ...])` у тому ж файлі
11
+ * (порядок не важливий, кілька викликів зливаються в один список).
9
12
  *
10
- * Семантика береться з **oxc-parser** через `parseProgramOrNull`: regex по тілу файлу не
11
- * використовується, лише сирий текст рядка з коментарем перевіряється на маркер. Якщо
12
- * файл не парситься повертаємо порожній результат, спочатку треба полагодити синтаксис.
13
+ * Обидва контракти можна точково «приглушити» коментарем-маркером
14
+ * `// \@nitra/cursor ignore-next-line checkEnv` на рядку безпосередньо перед
15
+ * порушеннямце залишається сумісним escape-hatch для legacy-коду.
13
16
  *
14
- * Покриті форми доступу до `process.env`:
15
- * - `process.env.X` (звичайний MemberExpression);
16
- * - `process.env['X']` (computed з рядковим літералом);
17
- * - `const { X, Y } = process.env` (ObjectPattern; ім'я з ключа);
18
- * - `const { X: alias } = process.env` (ім'я з ключа, не з alias).
17
+ * Семантика береться з **oxc-parser** через `parseProgramOrNull`: regex по тілу
18
+ * файлу не використовується, лише сирий текст рядка з коментарем перевіряється
19
+ * на маркер. Якщо файл не парситься — повертаємо порожній результат, спочатку
20
+ * треба полагодити синтаксис.
19
21
  *
20
- * Якщо ключ обчислюваний (наприклад, `process.env[varName]`) — пропускаємо без помилки,
22
+ * Покриті форми доступу:
23
+ * - `process.env.X` / `process.env['X']` (як MemberExpression);
24
+ * - `const { X, Y } = process.env` (ObjectPattern; ім'я з ключа, не з alias);
25
+ * - аналогічно для `env.X` / `env['X']` / `const { X } = env`,
26
+ * де `env` має бути імпортований з `@nitra/check-env` (інакше ігноруємо —
27
+ * це може бути локальна змінна чи `env` з `node:process`).
28
+ *
29
+ * Якщо ключ обчислюваний (`process.env[varName]`) — пропускаємо без помилки,
21
30
  * бо за статичним AST неможливо встановити, яка саме змінна оточення використовується.
22
31
  */
23
32
  import { offsetToLine, parseProgramOrNull, walkAstWithAncestors } from './ast-scan-utils.mjs'
@@ -25,6 +34,8 @@ import { offsetToLine, parseProgramOrNull, walkAstWithAncestors } from './ast-sc
25
34
  const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/u
26
35
  const IGNORE_DIRECTIVE_RE = /\/\/\s*@nitra\/cursor\s+ignore-next-line\s+checkEnv\b/u
27
36
 
37
+ const CHECK_ENV_PACKAGE = '@nitra/check-env'
38
+
28
39
  /**
29
40
  * Чи є цей вузол виразом `process.env`.
30
41
  * @param {unknown} node AST вузол
@@ -46,8 +57,8 @@ function isProcessEnvAccess(node) {
46
57
  }
47
58
 
48
59
  /**
49
- * Витягує ім'я ENV з MemberExpression-вузла `process.env.X` або `process.env['X']`.
50
- * @param {Record<string, unknown>} node MemberExpression, чий object — `process.env`
60
+ * Витягує ім'я ENV з MemberExpression `obj.X` або `obj['X']`.
61
+ * @param {Record<string, unknown>} node MemberExpression, чий object — `process.env` або `env`
51
62
  * @returns {string | null} ім'я змінної оточення або null, якщо ключ не статичний
52
63
  */
53
64
  function envNameFromMember(node) {
@@ -88,9 +99,39 @@ function collectCheckedEnvNames(programNode) {
88
99
  }
89
100
 
90
101
  /**
91
- * Чи закритий рядок ignore-коментарем `// @nitra/cursor ignore-next-line checkEnv`.
102
+ * Чи імпортовано локальний ідентифікатор `env` саме з `@nitra/check-env`.
103
+ * Перевіряє ImportDeclaration на specifier {imported.name === 'env', local.name === 'env'}.
104
+ * Aliased-варіанти (`{ env as x }`) свідомо не підтримуються — у наших правилах
105
+ * приклади завжди використовують канонічне ім'я `env`.
106
+ * @param {unknown} programNode корінь AST
107
+ * @returns {boolean} true, якщо у файлі є `import { env } from '@nitra/check-env'`
108
+ */
109
+ function hasCheckEnvImport(programNode) {
110
+ let found = false
111
+ walkAstWithAncestors(programNode, [], node => {
112
+ if (found) return
113
+ if (node.type !== 'ImportDeclaration') return
114
+ const source = node.source
115
+ if (!source || typeof source !== 'object' || source.value !== CHECK_ENV_PACKAGE) return
116
+ const specifiers = node.specifiers
117
+ if (!Array.isArray(specifiers)) return
118
+ for (const s of specifiers) {
119
+ if (!s || typeof s !== 'object' || s.type !== 'ImportSpecifier') continue
120
+ const imported = s.imported
121
+ const local = s.local
122
+ if (!imported || imported.name !== 'env') continue
123
+ if (!local || local.name !== 'env') continue
124
+ found = true
125
+ return
126
+ }
127
+ })
128
+ return found
129
+ }
130
+
131
+ /**
132
+ * Чи закритий рядок ignore-коментарем `// \@nitra/cursor ignore-next-line checkEnv`.
92
133
  * @param {string[]} lines рядки файлу (split за \n, без CR)
93
- * @param {number} oneBasedLine 1-based номер рядка з `process.env.X`
134
+ * @param {number} oneBasedLine 1-based номер рядка з порушенням
94
135
  * @returns {boolean} true, якщо попередній рядок містить маркер
95
136
  */
96
137
  function hasIgnoreDirective(lines, oneBasedLine) {
@@ -100,75 +141,192 @@ function hasIgnoreDirective(lines, oneBasedLine) {
100
141
  }
101
142
 
102
143
  /**
103
- * Знаходить всі доступи до `process.env.<NAME>`, які не покриті ні літеральним
104
- * `checkEnv([...])` у тому ж файлі, ні коментарем-маркером безпосередньо перед.
105
- *
106
- * @param {string} content вихідний код
107
- * @param {string} [virtualPath] шлях для вибору `lang` парсера
108
- * @returns {{ line: number, name: string }[]} список порушень
144
+ * Чи є вузол MemberExpression виду `env.X` / `env['X']`, де `env` Identifier
145
+ * (в AST oxc-parser globals і локальні імпорти не розрізняються — фільтр джерела
146
+ * робиться на рівні `hasCheckEnvImport`).
147
+ * @param {unknown} node AST вузол
148
+ * @returns {boolean} true, якщо це `env.<...>`
109
149
  */
110
- export function findUncheckedProcessEnvInText(content, virtualPath = 'scan.ts') {
111
- const program = parseProgramOrNull(content, virtualPath)
112
- if (!program) return []
150
+ function isEnvIdentifierMember(node) {
151
+ if (!node || typeof node !== 'object' || node.type !== 'MemberExpression') return false
152
+ const obj = node.object
153
+ return !!obj && obj.type === 'Identifier' && obj.name === 'env'
154
+ }
113
155
 
114
- const checked = collectCheckedEnvNames(program)
115
- const lines = content.split('\n').map(s => (s.endsWith('\r') ? s.slice(0, -1) : s))
156
+ /**
157
+ * @typedef {{
158
+ * line: number,
159
+ * name: string,
160
+ * kind: 'process-env' | 'check-env-missing-checkEnv'
161
+ * }} EnvViolation
162
+ */
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
+ }
116
208
 
117
- /** @type {{ line: number, name: string }[]} */
209
+ /**
210
+ * Перебирає AST і для кожного знайденого доступу до `process.env` чи `env`
211
+ * (де `env` — імпорт з `@nitra/check-env`) реєструє порушення відповідного типу.
212
+ * @param {unknown} program корінь AST
213
+ * @param {string} content вихідний код (для offset → line)
214
+ * @param {string[]} lines split-рядки content (для ignore-маркера)
215
+ * @param {Set<string>} checkedNames імена, закриті літеральним `checkEnv([...])`
216
+ * @param {boolean} envFromCheckEnv чи імпортовано `env` саме з `@nitra/check-env`
217
+ * @returns {EnvViolation[]} список порушень (відсортований за порядком зустрічі в AST)
218
+ */
219
+ function collectViolations(program, content, lines, checkedNames, envFromCheckEnv) {
220
+ /** @type {EnvViolation[]} */
118
221
  const out = []
119
222
  /** @type {Set<string>} */
120
223
  const reported = new Set()
121
224
 
122
225
  /**
123
- * Реєструє порушення з дедуплікацією за «name@line».
226
+ * Реєструє порушення з дедуплікацією за «kind|name|line» і урахуванням ignore-маркера.
227
+ * @param {'process-env' | 'check-env-missing-checkEnv'} kind тип порушення
124
228
  * @param {string} name ім'я ENV
125
229
  * @param {number} line 1-based рядок
126
230
  */
127
- function report(name, line) {
128
- if (checked.has(name)) return
231
+ function report(kind, name, line) {
129
232
  if (hasIgnoreDirective(lines, line)) return
130
- const key = `${name}@${line}`
233
+ const key = `${kind}|${name}|${line}`
131
234
  if (reported.has(key)) return
132
235
  reported.add(key)
133
- out.push({ name, line })
236
+ out.push({ kind, name, line })
237
+ }
238
+
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))
251
+ }
252
+ }
253
+
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
+ }
269
+
270
+ /**
271
+ * Обробка вузлів, що стосуються `env` з `@nitra/check-env`.
272
+ * @param {Record<string, unknown>} node AST-вузол
273
+ */
274
+ function handleCheckEnvAccess(node) {
275
+ if (isEnvIdentifierMember(node)) {
276
+ const envName = envNameFromMember(node)
277
+ if (envName && !checkedNames.has(envName)) {
278
+ report('check-env-missing-checkEnv', envName, offsetToLine(content, node.start))
279
+ }
280
+ return
281
+ }
282
+ if (isEnvObjectPatternDeclarator(node)) {
283
+ reportObjectPatternKeys(node, 'check-env-missing-checkEnv', name => checkedNames.has(name))
284
+ }
134
285
  }
135
286
 
136
287
  walkAstWithAncestors(program, [], (node, ancestors) => {
137
288
  if (isProcessEnvAccess(node)) {
138
- const parent = ancestors[ancestors.length - 1]
139
- // process.env.X / process.env['X']
140
- if (parent && typeof parent === 'object' && parent.type === 'MemberExpression' && parent.object === node) {
141
- const envName = envNameFromMember(parent)
142
- if (envName) report(envName, offsetToLine(content, parent.start))
143
- }
144
- // const { X, Y } = process.env → беремо імена з ObjectPattern
145
- if (
146
- parent &&
147
- typeof parent === 'object' &&
148
- parent.type === 'VariableDeclarator' &&
149
- parent.init === node &&
150
- parent.id &&
151
- parent.id.type === 'ObjectPattern' &&
152
- Array.isArray(parent.id.properties)
153
- ) {
154
- for (const p of parent.id.properties) {
155
- if (!p || typeof p !== 'object' || p.type !== 'Property') continue
156
- if (p.computed) continue
157
- const key = p.key
158
- if (!key || typeof key !== 'object') continue
159
- /** @type {string | null} */
160
- let name = null
161
- if (key.type === 'Identifier' && typeof key.name === 'string') name = key.name
162
- else if (key.type === 'Literal' && typeof key.value === 'string') name = key.value
163
- if (name) report(name, offsetToLine(content, p.start ?? parent.start))
164
- }
165
- }
289
+ handleProcessEnv(node, ancestors)
290
+ return
166
291
  }
292
+ if (envFromCheckEnv) handleCheckEnvAccess(node)
167
293
  })
168
294
 
169
295
  return out
170
296
  }
171
297
 
298
+ /**
299
+ * Витягує статичне ім'я з вузла Property у ObjectPattern.
300
+ * @param {unknown} property AST-вузол ObjectPattern.properties[i]
301
+ * @returns {string | null} ім'я ключа або null
302
+ */
303
+ function staticPropertyName(property) {
304
+ if (!property || typeof property !== 'object' || property.type !== 'Property') return null
305
+ if (property.computed) return null
306
+ const key = property.key
307
+ if (!key || typeof key !== 'object') return null
308
+ if (key.type === 'Identifier' && typeof key.name === 'string') return key.name
309
+ if (key.type === 'Literal' && typeof key.value === 'string') return key.value
310
+ return null
311
+ }
312
+
313
+ /**
314
+ * Знаходить порушення правила «process.env / CheckEnv» у файлі.
315
+ * @param {string} content вихідний код
316
+ * @param {string} [virtualPath] шлях для вибору `lang` парсера
317
+ * @returns {EnvViolation[]} список порушень із типом, іменем змінної та рядком
318
+ */
319
+ export function findUncheckedProcessEnvInText(content, virtualPath = 'scan.ts') {
320
+ const program = parseProgramOrNull(content, virtualPath)
321
+ if (!program) return []
322
+
323
+ const checked = collectCheckedEnvNames(program)
324
+ const envFromCheckEnv = hasCheckEnvImport(program)
325
+ const lines = content.split('\n').map(s => (s.endsWith('\r') ? s.slice(0, -1) : s))
326
+
327
+ return collectViolations(program, content, lines, checked, envFromCheckEnv)
328
+ }
329
+
172
330
  /**
173
331
  * Чи сканувати цей файл за розширенням (JS/TS-сім'я, без `.d.ts`).
174
332
  * @param {string} relativePathPosix відносний шлях (posix)
@@ -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 }[]} список порушень