@nitra/cursor 1.8.219 → 1.8.221

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -175,3 +175,31 @@ export function templateQuasisText(template) {
175
175
  export function isSqlListContextTemplate(template) {
176
176
  return SQL_LIST_CONTEXT_RE.test(templateQuasisText(template))
177
177
  }
178
+
179
+ /**
180
+ * Перевіряє, чи це виклик `require('<module>')` з рядковим аргументом.
181
+ * Спільне для сканерів імпортів (`bunyan-imports`, `redis-imports`, ...).
182
+ * @param {Record<string, unknown> | null | undefined} node вузол AST
183
+ * @returns {string | null} ім'я модуля з аргументу, інакше `null`
184
+ */
185
+ export function requireCallModule(node) {
186
+ if (!node || node.type !== 'CallExpression') return null
187
+ const callee = node.callee
188
+ if (!callee || callee.type !== 'Identifier' || callee.name !== 'require') return null
189
+ const arg = node.arguments?.[0]
190
+ if (!arg || arg.type !== 'Literal' || typeof arg.value !== 'string') return null
191
+ return arg.value
192
+ }
193
+
194
+ /**
195
+ * Перевіряє, чи це динамічний `import('<module>')` з рядковим аргументом.
196
+ * Спільне для сканерів імпортів.
197
+ * @param {Record<string, unknown> | null | undefined} node вузол AST
198
+ * @returns {string | null} ім'я модуля, інакше `null`
199
+ */
200
+ export function dynamicImportModule(node) {
201
+ if (!node || node.type !== 'ImportExpression') return null
202
+ const src = node.source
203
+ if (!src || src.type !== 'Literal' || typeof src.value !== 'string') return null
204
+ return src.value
205
+ }
@@ -56,6 +56,9 @@ const PG_FORMAT_SHIM_FUNC_NAMES = new Set(['format', 'pgFormat', 'sqlFormat', 'p
56
56
  // як named export з модуля-обгортки.
57
57
  const QUOTE_HELPER_NAMES = new Set(['quoteLiteral', 'quoteIdent', 'escapeLiteral', 'escapeIdent'])
58
58
 
59
+ // Імена першого параметра pg-style query-обгортки (`function query(text, params)` тощо).
60
+ const PG_QUERY_FIRST_PARAM_RE = /^(text|sql|query)$/u
61
+
59
62
  /**
60
63
  * @param {unknown} node AST node
61
64
  * @param {string} name імʼя змінної
@@ -280,19 +283,21 @@ function asPgLeftoverCall(node) {
280
283
  return { name: /** @type {'connect' | 'end'} */ (prop.name) }
281
284
  }
282
285
 
286
+ // Локальний alias на `isUnsafeCall` — щоб у nodeContainsUnsafeCall (під query-шимом)
287
+ // був семантично-говорящий call-site, але без дубля логіки з основним сканом.
288
+ const isUnsafeCallNode = isUnsafeCall
289
+
283
290
  /**
284
- * Чи це CallExpression `<obj>.unsafe(...)` (для пошуку в тілі query-шиму).
285
- * Дублює `isUnsafeCall` з основного скану, але локально щоб не залежати
286
- * від порядку оголошень у файлі.
287
- * @param {unknown} node AST node
288
- * @returns {boolean} true для `<obj>.unsafe(...)`
291
+ * Витягує ім'я ключа з AST `Property.key`. Підтримує `Identifier` (`{ foo: }`)
292
+ * та `Literal` (`{ 'foo': }` / `{ 5: }`); інші форми (computed expression тощо) — `null`.
293
+ * @param {unknown} key AST `Property.key`
294
+ * @returns {string | number | null} ім'я ключа або null
289
295
  */
290
- function isUnsafeCallNode(node) {
291
- if (!node || node.type !== 'CallExpression') return false
292
- const callee = node.callee
293
- if (!callee || callee.type !== 'MemberExpression' || callee.computed) return false
294
- const prop = callee.property
295
- return !!prop && prop.type === 'Identifier' && prop.name === 'unsafe'
296
+ function propertyKeyName(key) {
297
+ if (!key || typeof key !== 'object') return null
298
+ if (key.type === 'Identifier' && typeof key.name === 'string') return key.name
299
+ if (key.type === 'Literal' && (typeof key.value === 'string' || typeof key.value === 'number')) return key.value
300
+ return null
296
301
  }
297
302
 
298
303
  /**
@@ -325,10 +330,8 @@ function nodeContainsPgFormatPlaceholder(root) {
325
330
  found = true
326
331
  return
327
332
  }
328
- if (t === 'TemplateLiteral') {
329
- if (PG_FORMAT_PLACEHOLDER_RE.test(templateQuasisText(n))) {
330
- found = true
331
- }
333
+ if (t === 'TemplateLiteral' && PG_FORMAT_PLACEHOLDER_RE.test(templateQuasisText(n))) {
334
+ found = true
332
335
  }
333
336
  })
334
337
  return found
@@ -406,7 +409,6 @@ export function findPgFormatShimDefinitionInText(content, virtualPath = 'scan.ts
406
409
  * - значення — функція з 1–2 параметрами, перший — Identifier з типовим
407
410
  * pg-іменем (`text` / `sql` / `query`);
408
411
  * - у тілі функції є виклик `<obj>.unsafe(...)`.
409
- *
410
412
  * @param {string} content вихідний код
411
413
  * @param {string} [virtualPath] шлях для вибору `lang`
412
414
  * @returns {{ line: number, snippet: string }[]} список порушень
@@ -419,31 +421,48 @@ export function findPgFormatLikeQueryWrapperInText(content, virtualPath = 'scan.
419
421
  /** @type {{ line: number, snippet: string }[]} */
420
422
  const out = []
421
423
  walkAstWithAncestors(program, [], node => {
422
- if (node.type !== 'ObjectExpression') return
423
- const properties = node.properties
424
- if (!Array.isArray(properties)) return
425
- for (const prop of properties) {
426
- if (!prop || prop.type !== 'Property') continue
427
- const key = prop.key
428
- const keyName = key && key.type === 'Identifier' ? key.name : key && key.type === 'Literal' ? key.value : null
429
- if (keyName !== 'query') continue
430
- const value = prop.value
431
- if (!value || (value.type !== 'FunctionExpression' && value.type !== 'ArrowFunctionExpression')) continue
432
- const params = value.params
433
- const firstName = Array.isArray(params) && params[0]?.type === 'Identifier' ? params[0].name : null
434
- const looksLikePgQuery =
435
- Array.isArray(params) && params.length >= 1 && params.length <= 2 && /^(text|sql|query)$/u.test(firstName || '')
436
- if (!looksLikePgQuery) continue
437
- if (!nodeContainsUnsafeCall(value.body)) continue
424
+ if (node.type !== 'ObjectExpression' || !Array.isArray(node.properties)) return
425
+ for (const prop of node.properties) {
426
+ const queryProp = asPgFormatLikeQueryProp(prop)
427
+ if (!queryProp) continue
438
428
  out.push({
439
- line: offsetToLine(content, prop.start),
440
- snippet: normalizeSnippet(content.slice(prop.start, prop.end))
429
+ line: offsetToLine(content, queryProp.start),
430
+ snippet: normalizeSnippet(content.slice(queryProp.start, queryProp.end))
441
431
  })
442
432
  }
443
433
  })
444
434
  return out
445
435
  }
446
436
 
437
+ /**
438
+ * Чи є цей вузол `Property` тим самим pg-сумісним `{ query(text, params) { … unsafe … } }`?
439
+ * Повертає сам `prop` (для зручного `start`/`end`) або `null`.
440
+ * @param {unknown} prop AST вузол `Property`
441
+ * @returns {{ start: number, end: number } | null} `prop` як власний рекорд або `null`
442
+ */
443
+ function asPgFormatLikeQueryProp(prop) {
444
+ if (!prop || typeof prop !== 'object' || prop.type !== 'Property') return null
445
+ if (propertyKeyName(prop.key) !== 'query') return null
446
+ const value = prop.value
447
+ if (!value || (value.type !== 'FunctionExpression' && value.type !== 'ArrowFunctionExpression')) return null
448
+ if (!hasPgQuerySignature(value.params)) return null
449
+ if (!nodeContainsUnsafeCall(value.body)) return null
450
+ return { start: prop.start, end: prop.end }
451
+ }
452
+
453
+ /**
454
+ * Чи виглядає сигнатура функції як pg-style `query(text, params?)`: 1–2 параметри,
455
+ * перший — Identifier з типовим pg-іменем (`text` / `sql` / `query`).
456
+ * @param {unknown} params AST `params` (масив)
457
+ * @returns {boolean} true, якщо схоже на pg-обгортку
458
+ */
459
+ function hasPgQuerySignature(params) {
460
+ if (!Array.isArray(params) || params.length < 1 || params.length > 2) return false
461
+ const first = params[0]
462
+ if (!first || first.type !== 'Identifier' || typeof first.name !== 'string') return false
463
+ return PG_QUERY_FIRST_PARAM_RE.test(first.name)
464
+ }
465
+
447
466
  /**
448
467
  * Чи є у піддереві виклик `<obj>.unsafe(...)`.
449
468
  * @param {unknown} root корінь піддерева
@@ -11,69 +11,18 @@
11
11
  */
12
12
  import { parseSync } from 'oxc-parser'
13
13
 
14
- import { langFromPath, offsetToLine } from './ast-scan-utils.mjs'
14
+ import {
15
+ dynamicImportModule,
16
+ langFromPath,
17
+ normalizeSnippet,
18
+ offsetToLine,
19
+ requireCallModule,
20
+ walkAstWithAncestors
21
+ } from './ast-scan-utils.mjs'
15
22
 
16
- const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/
23
+ const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/u
17
24
  const FORBIDDEN_MODULES = new Set(['@nitra/bunyan', 'bunyan'])
18
25
 
19
- /**
20
- * Стискає пробіли для повідомлення про порушення.
21
- * @param {string} s фрагмент коду
22
- * @returns {string} скорочений однорядковий рядок
23
- */
24
- function normalizeSnippet(s) {
25
- return s.replaceAll(/\s+/g, ' ').trim().slice(0, 160)
26
- }
27
-
28
- /**
29
- * Перевіряє, чи це виклик `require('<module>')` з рядковим аргументом.
30
- * @param {Record<string, unknown> | null | undefined} node вузол AST
31
- * @returns {string | null} ім'я модуля з аргументу, інакше `null`
32
- */
33
- function requireCallModule(node) {
34
- if (!node || node.type !== 'CallExpression') return null
35
- const callee = node.callee
36
- if (!callee || callee.type !== 'Identifier' || callee.name !== 'require') return null
37
- const arg = node.arguments?.[0]
38
- if (!arg || arg.type !== 'Literal' || typeof arg.value !== 'string') return null
39
- return arg.value
40
- }
41
-
42
- /**
43
- * Перевіряє, чи це динамічний `import('<module>')` з рядковим аргументом.
44
- * @param {Record<string, unknown> | null | undefined} node вузол AST
45
- * @returns {string | null} ім'я модуля, інакше `null`
46
- */
47
- function dynamicImportModule(node) {
48
- if (!node || node.type !== 'ImportExpression') return null
49
- const src = node.source
50
- if (!src || src.type !== 'Literal' || typeof src.value !== 'string') return null
51
- return src.value
52
- }
53
-
54
- /**
55
- * Простий рекурсивний обхід AST: заходимо в усі об'єкти/масиви, щоб знайти require/import-вузли.
56
- * @param {unknown} node корінь або під-вузол AST
57
- * @param {(n: unknown) => void} visit виклик для кожного об'єкта-вузла
58
- * @returns {void}
59
- */
60
- function walkAst(node, visit) {
61
- if (!node || typeof node !== 'object') return
62
- if (Array.isArray(node)) {
63
- for (const item of node) walkAst(item, visit)
64
- return
65
- }
66
- if (typeof node.type === 'string') {
67
- visit(node)
68
- }
69
- for (const key of Object.keys(node)) {
70
- if (key !== 'parent') {
71
- const v = node[key]
72
- if (v && typeof v === 'object') walkAst(v, visit)
73
- }
74
- }
75
- }
76
-
77
26
  /**
78
27
  * Знаходить заборонені імпорти/require з `@nitra/bunyan` у тексті.
79
28
  * @param {string} content вихідний код
@@ -107,7 +56,7 @@ export function findBunyanImportsInText(content, virtualPath = 'scan.ts') {
107
56
  }
108
57
  }
109
58
 
110
- walkAst(result.program, node => {
59
+ walkAstWithAncestors(result.program, [], node => {
111
60
  const reqMod = requireCallModule(node)
112
61
  if (reqMod && FORBIDDEN_MODULES.has(reqMod)) {
113
62
  out.push({
@@ -19,13 +19,16 @@ import { parseProgramOrNull } from './ast-scan-utils.mjs'
19
19
  const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/u
20
20
 
21
21
  /**
22
- * Канонічний шаблон імені файла в каталозі conn.
23
- * - `ql-<id>` для GraphQL;
24
- * - `(pg|mysql|mssql)-(read|write)(-<id>)?` для БД.
25
- * `<id>` — починається з [a-z0-9], далі [a-z0-9-]*.
22
+ * Канонічний шаблон імені GraphQL-файла: `ql-<id>.<ext>`.
23
+ * `<id>` kebab без leading/trailing-`-`, починається/закінчується на `[a-z0-9]`.
26
24
  */
27
- const CONN_FILENAME_RE =
28
- /^(?:ql-[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|(?:pg|mysql|mssql)-(?:read|write)(?:-[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)?)\.([cm]?[jt]sx?)$/u
25
+ const CONN_FILENAME_QL_RE = /^ql-[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.[cm]?[jt]sx?$/u
26
+ /**
27
+ * Канонічний шаблон імені файла БД-підключення: `(pg|mysql|mssql)-(read|write)(-<id>)?.<ext>`.
28
+ * `<id>` — за тими ж правилами, що й для `ql-`. Розділили з GraphQL-формою, щоб
29
+ * не множити комплексність regex (sonarjs/regex-complexity).
30
+ */
31
+ const CONN_FILENAME_DB_RE = /^(?:pg|mysql|mssql)-(?:read|write)(?:-[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)?\.[cm]?[jt]sx?$/u
29
32
 
30
33
  /**
31
34
  * Чи це файл, який сканується правилом «conn-file» (JS/TS-сімʼя, без `.d.ts`).
@@ -43,7 +46,7 @@ export function isConnFileRulesSourceFile(relativePathPosix) {
43
46
  */
44
47
  function basenameNoExt(relativePathPosix) {
45
48
  const last = relativePathPosix.lastIndexOf('/')
46
- const base = last >= 0 ? relativePathPosix.slice(last + 1) : relativePathPosix
49
+ const base = last === -1 ? relativePathPosix : relativePathPosix.slice(last + 1)
47
50
  const dot = base.lastIndexOf('.')
48
51
  return dot > 0 ? base.slice(0, dot) : base
49
52
  }
@@ -64,8 +67,71 @@ export function kebabToCamel(kebab) {
64
67
  */
65
68
  export function isConnFileNameValid(relativePathPosix) {
66
69
  const last = relativePathPosix.lastIndexOf('/')
67
- const base = last >= 0 ? relativePathPosix.slice(last + 1) : relativePathPosix
68
- return CONN_FILENAME_RE.test(base)
70
+ const base = last === -1 ? relativePathPosix : relativePathPosix.slice(last + 1)
71
+ return CONN_FILENAME_QL_RE.test(base) || CONN_FILENAME_DB_RE.test(base)
72
+ }
73
+
74
+ /**
75
+ * Витягує імена з `export const/let/var X = …` (включно з кількома declarators у одному `export const a, b`).
76
+ * @param {Record<string, unknown>} decl AST `VariableDeclaration`
77
+ * @returns {string[]} імена змінних
78
+ */
79
+ function namesFromVariableDeclaration(decl) {
80
+ if (!Array.isArray(decl.declarations)) return []
81
+ /** @type {string[]} */
82
+ const out = []
83
+ for (const d of decl.declarations) {
84
+ const id = /** @type {Record<string, unknown> | null} */ (d?.id ?? null)
85
+ if (id && id.type === 'Identifier' && typeof id.name === 'string') out.push(id.name)
86
+ }
87
+ return out
88
+ }
89
+
90
+ /**
91
+ * Витягує імʼя з `export function X` / `export class X`.
92
+ * @param {Record<string, unknown>} decl AST `FunctionDeclaration` або `ClassDeclaration`
93
+ * @returns {string | null} імʼя або `null`, якщо id-вузол анонімний
94
+ */
95
+ function nameFromFnOrClassDeclaration(decl) {
96
+ if (decl.type !== 'FunctionDeclaration' && decl.type !== 'ClassDeclaration') return null
97
+ const id = /** @type {Record<string, unknown> | null} */ (decl.id ?? null)
98
+ if (!id || typeof id !== 'object') return null
99
+ return typeof id.name === 'string' ? id.name : null
100
+ }
101
+
102
+ /**
103
+ * Витягує експортоване імʼя з одного `ExportSpecifier` (`export { X }` / `export { X as Y }`).
104
+ * @param {Record<string, unknown> | null | undefined} specifier AST `ExportSpecifier`
105
+ * @returns {string | null} імʼя або `null`
106
+ */
107
+ function nameFromExportSpecifier(specifier) {
108
+ const exported = /** @type {Record<string, unknown> | null} */ (specifier?.exported ?? null)
109
+ if (!exported) return null
110
+ if (exported.type === 'Identifier' && typeof exported.name === 'string') return exported.name
111
+ if (typeof exported.value === 'string') return exported.value
112
+ return null
113
+ }
114
+
115
+ /**
116
+ * Імена з одного `ExportNamedDeclaration` — або з вкладеного `declaration`, або зі списку `specifiers`.
117
+ * @param {Record<string, unknown>} rec AST `ExportNamedDeclaration`
118
+ * @returns {string[]} імена цього експортного вузла
119
+ */
120
+ function namesFromNamedExport(rec) {
121
+ const decl = /** @type {Record<string, unknown> | null} */ (rec.declaration ?? null)
122
+ if (decl) {
123
+ if (decl.type === 'VariableDeclaration') return namesFromVariableDeclaration(decl)
124
+ const fnOrClass = nameFromFnOrClassDeclaration(decl)
125
+ return fnOrClass ? [fnOrClass] : []
126
+ }
127
+ if (!Array.isArray(rec.specifiers)) return []
128
+ /** @type {string[]} */
129
+ const out = []
130
+ for (const s of rec.specifiers) {
131
+ const name = nameFromExportSpecifier(/** @type {Record<string, unknown> | null} */ (s ?? null))
132
+ if (name) out.push(name)
133
+ }
134
+ return out
69
135
  }
70
136
 
71
137
  /**
@@ -87,34 +153,7 @@ function collectNamedExportNames(program) {
87
153
  if (!node || typeof node !== 'object') continue
88
154
  const rec = /** @type {Record<string, unknown>} */ (node)
89
155
  if (rec.type !== 'ExportNamedDeclaration') continue
90
- const decl = /** @type {Record<string, unknown> | null} */ (rec.declaration ?? null)
91
- if (decl) {
92
- // export const X = ... / export let / export var
93
- if (decl.type === 'VariableDeclaration' && Array.isArray(decl.declarations)) {
94
- for (const d of decl.declarations) {
95
- const id = /** @type {Record<string, unknown> | null} */ (d?.id ?? null)
96
- if (id && id.type === 'Identifier' && typeof id.name === 'string') out.push(id.name)
97
- }
98
- }
99
- // export function X / export class X
100
- if (
101
- (decl.type === 'FunctionDeclaration' || decl.type === 'ClassDeclaration') &&
102
- decl.id &&
103
- typeof decl.id === 'object' &&
104
- typeof (/** @type {Record<string, unknown>} */ (decl.id).name) === 'string'
105
- ) {
106
- out.push(/** @type {string} */ (/** @type {Record<string, unknown>} */ (decl.id).name))
107
- }
108
- } else if (Array.isArray(rec.specifiers)) {
109
- // export { X } / export { X as Y }
110
- for (const s of rec.specifiers) {
111
- const exported = /** @type {Record<string, unknown> | null} */ (s?.exported ?? null)
112
- if (!exported) continue
113
- // ESTree: Identifier (name) або Literal (value), залежно від спеки
114
- if (exported.type === 'Identifier' && typeof exported.name === 'string') out.push(exported.name)
115
- else if (typeof exported.value === 'string') out.push(exported.value)
116
- }
117
- }
156
+ out.push(...namesFromNamedExport(rec))
118
157
  }
119
158
  return out
120
159
  }
@@ -23,8 +23,31 @@ import { flattenWorkflowSteps, getStepRun, parseWorkflowYaml } from './gha-workf
23
23
 
24
24
  const WORKFLOWS_DIR_REL = '.github/workflows'
25
25
  const REQUIRED_IGNORES = ['graphql', 'bun']
26
- const DEPCHECK_RUN_RE = /(?:^|[\s;&|])npx\s+depcheck\b([^\n]*)/u
27
- const IGNORES_FLAG_RE = /--ignores\s*=?\s*(?:"([^"]*)"|'([^']*)'|([^\s"']+))/u
26
+ // `npx depcheck` як ціла команда у одному рядку shell-скрипту.
27
+ // `[^\n]*` обмежено явним `\n`-stop'ом — `*` не може backtrack-нутися за межі рядка.
28
+ const DEPCHECK_RUN_RE = /(?:^|[\s;&|])npx[ \t]+depcheck\b([^\n]*)/u
29
+ // `--ignores=…` або `--ignores …` з трьома формами значення (двійкові, одинарні, без лапок).
30
+ // Розділювач — або `=` з опційними пробілами, або один+ пробіл. Альтернативи значення
31
+ // не перетинаються (стартують з різних символів), тож backtrack-у між ними нема.
32
+ const IGNORES_FLAG_RE = /--ignores(?:=[ \t]*|[ \t]+)(?:"([^"]*)"|'([^']*)'|([^\s"']+))/u
33
+
34
+ /**
35
+ * Нормалізує шлях: бекслеші → forward, обрізає trailing-слеші. Без regex-у на trailing,
36
+ * щоб не тригерити `sonarjs/slow-regex` на `\/+$`.
37
+ * @param {string} p вхідний шлях
38
+ * @returns {string} нормалізований шлях
39
+ */
40
+ function normalizePath(p) {
41
+ let end = p.length
42
+ while (end > 0) {
43
+ const cp = p.codePointAt(end - 1)
44
+ if (cp !== 47 && cp !== 92) break
45
+ end--
46
+ }
47
+ let out = end === p.length ? p : p.slice(0, end)
48
+ if (out.includes('\\')) out = out.replaceAll('\\', '/')
49
+ return out
50
+ }
28
51
 
29
52
  /**
30
53
  * Чи містить workflow.on[event].paths хоча б один patten, що починається з `<pkgRoot>/`.
@@ -33,7 +56,7 @@ const IGNORES_FLAG_RE = /--ignores\s*=?\s*(?:"([^"]*)"|'([^']*)'|([^\s"']+))/u
33
56
  * @returns {boolean} `true`, якщо знайдено хоча б один підходящий glob
34
57
  */
35
58
  export function workflowHasPathsScopedToPackage(root, pkgRoot) {
36
- const prefix = `${pkgRoot.replaceAll('\\', '/').replace(/\/+$/, '')}/`
59
+ const prefix = `${normalizePath(pkgRoot)}/`
37
60
  const on = root?.on
38
61
  if (!on || typeof on !== 'object') return false
39
62
  for (const event of /** @type {const} */ (['push', 'pull_request'])) {
@@ -81,9 +104,7 @@ export function extractDepcheckArgs(runText) {
81
104
  export function stepWorkingDirectoryEquals(step, pkgRoot) {
82
105
  const wd = step['working-directory']
83
106
  if (typeof wd !== 'string') return false
84
- const norm = wd.replaceAll('\\', '/').replace(/\/+$/, '')
85
- const expected = pkgRoot.replaceAll('\\', '/').replace(/\/+$/, '')
86
- return norm === expected
107
+ return normalizePath(wd) === normalizePath(pkgRoot)
87
108
  }
88
109
 
89
110
  /**
@@ -19,7 +19,14 @@
19
19
  */
20
20
  import { parseSync } from 'oxc-parser'
21
21
 
22
- import { langFromPath, normalizeSnippet, offsetToLine } from './ast-scan-utils.mjs'
22
+ import {
23
+ dynamicImportModule,
24
+ langFromPath,
25
+ normalizeSnippet,
26
+ offsetToLine,
27
+ requireCallModule,
28
+ walkAstWithAncestors
29
+ } from './ast-scan-utils.mjs'
23
30
 
24
31
  const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/u
25
32
  const FORBIDDEN_MODULE_NAMES = new Set([
@@ -48,55 +55,6 @@ function isForbiddenRedisModule(mod) {
48
55
  return mod.startsWith('ioredis/') || mod.startsWith('redis/') || mod.startsWith('@redis/')
49
56
  }
50
57
 
51
- /**
52
- * Перевіряє, чи це виклик `require('<module>')` з рядковим аргументом.
53
- * @param {Record<string, unknown> | null | undefined} node вузол AST
54
- * @returns {string | null} ім'я модуля з аргументу, інакше `null`
55
- */
56
- function requireCallModule(node) {
57
- if (!node || node.type !== 'CallExpression') return null
58
- const callee = node.callee
59
- if (!callee || callee.type !== 'Identifier' || callee.name !== 'require') return null
60
- const arg = node.arguments?.[0]
61
- if (!arg || arg.type !== 'Literal' || typeof arg.value !== 'string') return null
62
- return arg.value
63
- }
64
-
65
- /**
66
- * Перевіряє, чи це динамічний `import('<module>')` з рядковим аргументом.
67
- * @param {Record<string, unknown> | null | undefined} node вузол AST
68
- * @returns {string | null} ім'я модуля, інакше `null`
69
- */
70
- function dynamicImportModule(node) {
71
- if (!node || node.type !== 'ImportExpression') return null
72
- const src = node.source
73
- if (!src || src.type !== 'Literal' || typeof src.value !== 'string') return null
74
- return src.value
75
- }
76
-
77
- /**
78
- * Простий рекурсивний обхід AST: заходимо в усі обʼєкти/масиви, щоб знайти require/import-вузли.
79
- * @param {unknown} node корінь або під-вузол AST
80
- * @param {(n: unknown) => void} visit виклик для кожного обʼєкта-вузла
81
- * @returns {void}
82
- */
83
- function walkAst(node, visit) {
84
- if (!node || typeof node !== 'object') return
85
- if (Array.isArray(node)) {
86
- for (const item of node) walkAst(item, visit)
87
- return
88
- }
89
- if (typeof node.type === 'string') {
90
- visit(node)
91
- }
92
- for (const key of Object.keys(node)) {
93
- if (key !== 'parent') {
94
- const v = node[key]
95
- if (v && typeof v === 'object') walkAst(v, visit)
96
- }
97
- }
98
- }
99
-
100
58
  /**
101
59
  * Знаходить заборонені імпорти/require з `ioredis` / `node-redis` у тексті.
102
60
  * @param {string} content вихідний код
@@ -130,7 +88,7 @@ export function findRedisImportsInText(content, virtualPath = 'scan.ts') {
130
88
  }
131
89
  }
132
90
 
133
- walkAst(result.program, node => {
91
+ walkAstWithAncestors(result.program, [], node => {
134
92
  const reqMod = requireCallModule(node)
135
93
  if (reqMod && isForbiddenRedisModule(reqMod)) {
136
94
  out.push({
@@ -5,6 +5,10 @@ description: >-
5
5
  read-only аналіз CWD без жодних змін у поточному репо
6
6
  ---
7
7
 
8
+ <!-- markdownlint-disable-file MD024 MD025 -->
9
+ <!-- Файл демонструє шаблон промпта з кількома H1 (`# Завдання`, `# Релевантні файли` тощо)
10
+ — це інтенціональна частина showcase, а не порушення one-title-per-document. -->
11
+
8
12
  # Підготовка LLM-патчу (текстова комунікація між агентами)
9
13
 
10
14
  Скіл готує **самодостатній текстовий промпт** ("патч") для іншої LLM-сесії
@@ -83,16 +87,16 @@ description: >-
83
87
  - Документи правил: <CLAUDE.md / .cursor/rules — або "немає">
84
88
 
85
89
  ## Структура (skim)
86
-
87
90
  ```
91
+
88
92
  <вивід tree -L 2>
89
- ```
93
+ ````
90
94
 
91
95
  # Релевантні файли
92
96
 
93
97
  ## `package.json`
94
98
 
95
- ```json
99
+ ```text
96
100
  <повний вміст або ключові поля>
97
101
  ```
98
102
 
@@ -119,7 +123,11 @@ description: >-
119
123
  - `<команда з scripts — npm test / bun test / lint>`
120
124
  - <конкретні acceptance-checks: "у `engines.node` має бути `>=25`",
121
125
  "CI зелений" тощо>
126
+
127
+ ```
128
+
122
129
  ```
130
+
123
131
  ````
124
132
 
125
133
  ## Правила
@@ -158,7 +166,8 @@ description: >-
158
166
  Очікуваний вивід (схематично):
159
167
 
160
168
  ````
161
- ```markdown
169
+
170
+ ````markdown
162
171
  # Завдання
163
172
 
164
173
  Підняти `engines.node` у `@nitra/eslint-config` до `>=25` і переглянути
@@ -182,6 +191,7 @@ description: >-
182
191
  ```json
183
192
  { "engines": { "node": ">=22" }, "peerDependencies": { "eslint": "^9" } }
184
193
  ```
194
+ ````
185
195
 
186
196
  # Що треба зробити
187
197
 
@@ -193,6 +203,7 @@ description: >-
193
203
 
194
204
  - `bun test`
195
205
  - `node -v` у CI ≥ 25
206
+
196
207
  ```
197
208
  готово до копіювання — встав у чат з агентом у цільовому проєкті
198
- ````
209
+ ```