@nitra/cursor 1.8.220 → 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.
- package/CHANGELOG.md +6 -0
- package/bin/n-cursor.js +25 -4
- package/mdc/ci4.mdc +51 -0
- package/package.json +1 -1
- package/scripts/auto-skills.mjs +8 -1
- package/scripts/check-bun.mjs +3 -3
- package/scripts/check-changelog.mjs +2 -3
- package/scripts/check-image-avif.mjs +14 -6
- package/scripts/check-image-compress.mjs +1 -1
- package/scripts/check-js-run.mjs +58 -47
- package/scripts/check-k8s.mjs +128 -51
- package/scripts/check-npm-module.mjs +1 -4
- package/scripts/check-php.mjs +5 -5
- package/scripts/claude-stop-hook.mjs +2 -2
- package/scripts/lint-conftest.mjs +15 -7
- package/scripts/lint-ga.mjs +1 -1
- package/scripts/run-shellcheck-text.mjs +94 -64
- package/scripts/sync-claude-config.mjs +1 -1
- package/scripts/utils/ast-scan-utils.mjs +28 -0
- package/scripts/utils/bun-sql-scan.mjs +53 -34
- package/scripts/utils/bunyan-imports.mjs +10 -61
- package/scripts/utils/conn-file-rules.mjs +76 -37
- package/scripts/utils/depcheck-workflow.mjs +27 -6
- package/scripts/utils/redis-imports.mjs +9 -51
- package/skills/llm-patch/SKILL.md +16 -5
|
@@ -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
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
* @
|
|
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
|
|
291
|
-
if (!
|
|
292
|
-
|
|
293
|
-
if (
|
|
294
|
-
|
|
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
|
-
|
|
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
|
|
424
|
-
|
|
425
|
-
|
|
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,
|
|
440
|
-
snippet: normalizeSnippet(content.slice(
|
|
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 {
|
|
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
|
-
|
|
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
|
-
* Канонічний шаблон імені
|
|
23
|
-
*
|
|
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
|
|
28
|
-
|
|
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
|
|
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
|
|
68
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
```
|
|
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
|
-
|
|
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
|
+
```
|