@nitra/cursor 1.28.0 → 1.28.1
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
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
### Added
|
|
20
20
|
|
|
21
21
|
- **`rules/test/js/no-process-chdir.mjs`** — JS concern: сканує `**/*.test.{js,mjs}` і падає на `process.chdir(`. Token-based regex (`/process\.chdir\s*\(/u`) — не зачіпає згадки у JSDoc. 8 unit-тестів.
|
|
22
|
+
- **`rules/test/js/no-relative-fs-path.mjs`** — AST-сканер (`oxc-parser`) для `**/*.test.{js,mjs}`: знаходить виклики FS-функцій з `node:fs`/`node:fs/promises` (`writeFile`, `copyFile`, `mkdir`, `readFile`, `existsSync`, `rename`, `symlink`, `cp`, `*Sync`-варіанти + `writeJson`/`ensureDir`), де path-аргумент — string literal без префікса `/`, `\`, `file:`, `http(s):`, `data:`, чи Windows-disk-letter. Покриває обидва path-arg позиції у `copyFile`/`rename`/`symlink`/`link`/`cp`. Виловив би інцидент 1.28.0 (`tests/check-rule-fixtures.test.mjs`): `copyFile(src, 'default.conf.template')` / `copyFile(src, 'values-dev.ini')` зливали fixture у production tree `npm/`. 17 unit-тестів; на власному репо знайдено 0 порушень серед 106 test files.
|
|
22
23
|
- **`rules/test/js/vitest-config-pool-forks.mjs`** — JS concern: substring-перевірка `pool: 'forks'` у `vitest.config.js`. Defense-in-depth. 6 unit-тестів.
|
|
23
24
|
- **`rules/test/js/data/vitest_config/vitest.config.baseline.js`** — canonical baseline тепер містить `pool: 'forks'` з обґрунтуванням race-bug у docstring.
|
|
24
25
|
- **`rules/test/test.mdc` — секція "Заборона `process.chdir` у тестах"** із описом інциденту, контрактом `withTmpDir`, посиланнями на нові concern'и.
|
package/package.json
CHANGED
|
@@ -361,8 +361,8 @@ async function checkDockerfiles(root, ignorePaths, passFn, failFn) {
|
|
|
361
361
|
* @param {(msg: string) => void} failFn callback при помилці
|
|
362
362
|
* @returns {void}
|
|
363
363
|
*/
|
|
364
|
-
function checkVscodeNginx(passFn, failFn) {
|
|
365
|
-
const extPath = '.vscode/extensions.json'
|
|
364
|
+
function checkVscodeNginx(passFn, failFn, cwd) {
|
|
365
|
+
const extPath = join(cwd, '.vscode/extensions.json')
|
|
366
366
|
if (existsSync(extPath)) {
|
|
367
367
|
const violations = runConftestBatch({
|
|
368
368
|
policyDirRel: 'nginx-default-tpl/vscode_extensions',
|
|
@@ -378,7 +378,7 @@ function checkVscodeNginx(passFn, failFn) {
|
|
|
378
378
|
failFn('Очікується .vscode/extensions.json з ahmadalli.vscode-nginx-conf (див. nginx-default-tpl.mdc)')
|
|
379
379
|
}
|
|
380
380
|
|
|
381
|
-
const setPath = '.vscode/settings.json'
|
|
381
|
+
const setPath = join(cwd, '.vscode/settings.json')
|
|
382
382
|
if (!existsSync(setPath)) {
|
|
383
383
|
failFn('Очікується .vscode/settings.json з форматером nginx і formatOnSave (див. nginx-default-tpl.mdc)')
|
|
384
384
|
return
|
|
@@ -429,7 +429,7 @@ export async function check(cwd = process.cwd()) {
|
|
|
429
429
|
}
|
|
430
430
|
|
|
431
431
|
await checkDockerfiles(root, ignorePaths, pass, fail)
|
|
432
|
-
checkVscodeNginx(pass, fail)
|
|
432
|
+
checkVscodeNginx(pass, fail, root)
|
|
433
433
|
|
|
434
434
|
return reporter.getExitCode()
|
|
435
435
|
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Заборона **relative-path** аргументів у FS-функціях усередині тестів.
|
|
3
|
+
*
|
|
4
|
+
* Контекст (test.mdc, секція "Заборона `process.chdir` у тестах"):
|
|
5
|
+
* Після видалення `withTmpCwd` усі тести отримують `dir` параметром і мають
|
|
6
|
+
* будувати **абсолютні** шляхи через `join(dir, …)`. Якщо хтось забуде префікс
|
|
7
|
+
* і напише `writeFile('foo.json', …)` чи `copyFile(src, 'foo.json')` —
|
|
8
|
+
* relative-path резолвиться у `process.cwd()` (= `npm/`), що зливає тестову
|
|
9
|
+
* фікстуру у production tree. Інцидент v1.28.0: `tests/check-rule-fixtures.test.mjs`
|
|
10
|
+
* залишив `copyFile(src, 'values-dev.ini')` і `copyFile(src, 'default.conf.template')` —
|
|
11
|
+
* створило файли `npm/values-dev.ini` і `npm/default.conf.template`.
|
|
12
|
+
*
|
|
13
|
+
* Сканер AST-based (oxc-parser): знаходить виклики `node:fs`/`node:fs/promises`
|
|
14
|
+
* функцій із **string literal** аргументом-шляхом, який НЕ починається з:
|
|
15
|
+
* - `/`, `\\` — POSIX/Windows absolute;
|
|
16
|
+
* - `file:`/`http`/`data:` — URL-схема (передається до `new URL(...)`);
|
|
17
|
+
* - `${…}` (template-literal з виразом) і `\`…\${dir}\`` патерни — обчислений шлях;
|
|
18
|
+
* - `:` для Windows-літер диску `C:\…` (рідко в тестах, але legit).
|
|
19
|
+
* Виклики, чий path-аргумент — НЕ literal (CallExpression `join(...)`, BinaryExpression,
|
|
20
|
+
* Identifier, MemberExpression) — пропускаємо: припускаємо що це абсолютний шлях.
|
|
21
|
+
*
|
|
22
|
+
* Скани: `**\/*.test.{js,mjs}` з загальними `walkDir` skip + `.n-cursor.json#ignore`.
|
|
23
|
+
*/
|
|
24
|
+
import { readFile } from 'node:fs/promises'
|
|
25
|
+
import { basename, relative } from 'node:path'
|
|
26
|
+
|
|
27
|
+
import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
|
|
28
|
+
import { loadCursorIgnorePaths } from '../../../scripts/lib/load-cursor-config.mjs'
|
|
29
|
+
import { parseProgramOrNull, walkAstWithAncestors } from '../../../scripts/utils/ast-scan-utils.mjs'
|
|
30
|
+
import { walkDir } from '../../../scripts/utils/walkDir.mjs'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* FS-функції з `node:fs` / `node:fs/promises` / sync-API, які приймають path
|
|
34
|
+
* у фіксованих позиціях. Map: ім'я функції → масив 0-індексованих позицій
|
|
35
|
+
* path-аргументів (1-й, 2-й, або обидва — як у `copyFile/rename/symlink/link`).
|
|
36
|
+
*/
|
|
37
|
+
const FS_PATH_ARG_POSITIONS = new Map([
|
|
38
|
+
['writeFile', [0]],
|
|
39
|
+
['writeFileSync', [0]],
|
|
40
|
+
['readFile', [0]],
|
|
41
|
+
['readFileSync', [0]],
|
|
42
|
+
['appendFile', [0]],
|
|
43
|
+
['appendFileSync', [0]],
|
|
44
|
+
['mkdir', [0]],
|
|
45
|
+
['mkdirSync', [0]],
|
|
46
|
+
['rmdir', [0]],
|
|
47
|
+
['rmdirSync', [0]],
|
|
48
|
+
['rm', [0]],
|
|
49
|
+
['rmSync', [0]],
|
|
50
|
+
['unlink', [0]],
|
|
51
|
+
['unlinkSync', [0]],
|
|
52
|
+
['access', [0]],
|
|
53
|
+
['accessSync', [0]],
|
|
54
|
+
['stat', [0]],
|
|
55
|
+
['statSync', [0]],
|
|
56
|
+
['lstat', [0]],
|
|
57
|
+
['lstatSync', [0]],
|
|
58
|
+
['chmod', [0]],
|
|
59
|
+
['chmodSync', [0]],
|
|
60
|
+
['chown', [0]],
|
|
61
|
+
['chownSync', [0]],
|
|
62
|
+
['truncate', [0]],
|
|
63
|
+
['truncateSync', [0]],
|
|
64
|
+
['existsSync', [0]],
|
|
65
|
+
['readdir', [0]],
|
|
66
|
+
['readdirSync', [0]],
|
|
67
|
+
['copyFile', [0, 1]],
|
|
68
|
+
['copyFileSync', [0, 1]],
|
|
69
|
+
['rename', [0, 1]],
|
|
70
|
+
['renameSync', [0, 1]],
|
|
71
|
+
['symlink', [0, 1]],
|
|
72
|
+
['symlinkSync', [0, 1]],
|
|
73
|
+
['link', [0, 1]],
|
|
74
|
+
['linkSync', [0, 1]],
|
|
75
|
+
['cp', [0, 1]],
|
|
76
|
+
['cpSync', [0, 1]],
|
|
77
|
+
// test-helpers абсолютні-only форми (зайвий захист)
|
|
78
|
+
['writeJson', [0]],
|
|
79
|
+
['ensureDir', [0]]
|
|
80
|
+
])
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Префікси абсолютних шляхів або очевидно-обчислених. Якщо literal починається з
|
|
84
|
+
* одного з них — це OK (тест свідомо передає absolute чи URL).
|
|
85
|
+
*/
|
|
86
|
+
const ABSOLUTE_PREFIXES = ['/', '\\', 'file:', 'http:', 'https:', 'data:']
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Чи string literal — relative path (тобто баг). Перевіряє лише string-літерали
|
|
90
|
+
* та template literals без виразів. Виклики `join(...)` / `resolve(...)` /
|
|
91
|
+
* перемінні з ${} — пропускаємо (припускаємо absolute).
|
|
92
|
+
* @param {object} arg AST node аргументу
|
|
93
|
+
* @returns {string|null} relative-path значення (для меседжа), або null якщо OK
|
|
94
|
+
*/
|
|
95
|
+
function extractRelativeLiteralPath(arg) {
|
|
96
|
+
if (!arg) return null
|
|
97
|
+
if (arg.type === 'Literal' && typeof arg.value === 'string') {
|
|
98
|
+
return isRelativeString(arg.value) ? arg.value : null
|
|
99
|
+
}
|
|
100
|
+
if (arg.type === 'TemplateLiteral' && arg.expressions.length === 0) {
|
|
101
|
+
const raw = arg.quasis.map(q => q.value.cooked).join('')
|
|
102
|
+
return isRelativeString(raw) ? raw : null
|
|
103
|
+
}
|
|
104
|
+
// Не string-literal — не аналізуємо (припускаємо обчислений absolute через join/resolve).
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Чи рядок виглядає як relative path. Порожній рядок — false (це не path).
|
|
110
|
+
* Windows-disk-letter `C:\…` — absolute, бо містить `:` між літерою і `\`.
|
|
111
|
+
* @param {string} s рядок-шлях
|
|
112
|
+
* @returns {boolean} true якщо relative
|
|
113
|
+
*/
|
|
114
|
+
function isRelativeString(s) {
|
|
115
|
+
if (!s) return false
|
|
116
|
+
for (const prefix of ABSOLUTE_PREFIXES) {
|
|
117
|
+
if (s.startsWith(prefix)) return false
|
|
118
|
+
}
|
|
119
|
+
// Windows drive letter, наприклад `C:\foo` або `C:/foo`.
|
|
120
|
+
if (/^[A-Za-z]:[\\/]/u.test(s)) return false
|
|
121
|
+
return true
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Витягує ім'я FS-функції з callee:
|
|
126
|
+
* - `writeFile(…)` → "writeFile" (Identifier callee)
|
|
127
|
+
* - `fs.writeFile(…)` чи `fsp.writeFile(…)` → "writeFile" (MemberExpression)
|
|
128
|
+
* - `await fs.promises.writeFile(…)` → "writeFile"
|
|
129
|
+
* Повертає null для будь-якого іншого виклику.
|
|
130
|
+
* @param {object} callee AST callee node
|
|
131
|
+
* @returns {string|null} ім'я FS-функції або null
|
|
132
|
+
*/
|
|
133
|
+
function extractFsFunctionName(callee) {
|
|
134
|
+
if (!callee) return null
|
|
135
|
+
if (callee.type === 'Identifier') {
|
|
136
|
+
return FS_PATH_ARG_POSITIONS.has(callee.name) ? callee.name : null
|
|
137
|
+
}
|
|
138
|
+
if (callee.type === 'MemberExpression' && !callee.computed && callee.property?.type === 'Identifier') {
|
|
139
|
+
const name = callee.property.name
|
|
140
|
+
return FS_PATH_ARG_POSITIONS.has(name) ? name : null
|
|
141
|
+
}
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Чи файл — JS-тест (`*.test.mjs` / `*.test.js`).
|
|
147
|
+
* @param {string} absPath абсолютний шлях
|
|
148
|
+
* @returns {boolean}
|
|
149
|
+
*/
|
|
150
|
+
function isTestFile(absPath) {
|
|
151
|
+
const name = basename(absPath)
|
|
152
|
+
return name.endsWith('.test.mjs') || name.endsWith('.test.js')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Знаходить порушення у одному тестовому файлі.
|
|
157
|
+
* @param {string} body вміст тесту
|
|
158
|
+
* @returns {Array<{line: number, fn: string, path: string, argPos: number}>} порушення
|
|
159
|
+
*/
|
|
160
|
+
function findOffendersInBody(body) {
|
|
161
|
+
const program = parseProgramOrNull(body, 'test.mjs')
|
|
162
|
+
if (!program) return []
|
|
163
|
+
const offenders = []
|
|
164
|
+
const lineOffsets = computeLineOffsets(body)
|
|
165
|
+
walkAstWithAncestors(program, [], node => {
|
|
166
|
+
if (node?.type !== 'CallExpression') return
|
|
167
|
+
const fnName = extractFsFunctionName(node.callee)
|
|
168
|
+
if (!fnName) return
|
|
169
|
+
const positions = FS_PATH_ARG_POSITIONS.get(fnName)
|
|
170
|
+
for (const pos of positions) {
|
|
171
|
+
const arg = node.arguments?.[pos]
|
|
172
|
+
const relPath = extractRelativeLiteralPath(arg)
|
|
173
|
+
if (relPath !== null) {
|
|
174
|
+
const start = arg?.start ?? node.start ?? 0
|
|
175
|
+
const line = offsetToLineFromCache(lineOffsets, start)
|
|
176
|
+
offenders.push({ line, fn: fnName, path: relPath, argPos: pos })
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
return offenders
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Кешований offset→line: бінарний пошук по newline-offsets.
|
|
185
|
+
* @param {string} body source
|
|
186
|
+
* @returns {number[]} offsets newline char positions
|
|
187
|
+
*/
|
|
188
|
+
function computeLineOffsets(body) {
|
|
189
|
+
const offsets = [0]
|
|
190
|
+
for (let i = 0; i < body.length; i += 1) {
|
|
191
|
+
if (body[i] === '\n') offsets.push(i + 1)
|
|
192
|
+
}
|
|
193
|
+
return offsets
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* @param {number[]} offsets newline-offsets
|
|
198
|
+
* @param {number} offset 0-індекс символу
|
|
199
|
+
* @returns {number} 1-індексований рядок
|
|
200
|
+
*/
|
|
201
|
+
function offsetToLineFromCache(offsets, offset) {
|
|
202
|
+
let lo = 0
|
|
203
|
+
let hi = offsets.length - 1
|
|
204
|
+
while (lo < hi) {
|
|
205
|
+
const mid = (lo + hi + 1) >>> 1
|
|
206
|
+
if (offsets[mid] <= offset) lo = mid
|
|
207
|
+
else hi = mid - 1
|
|
208
|
+
}
|
|
209
|
+
return lo + 1
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Перевіряє, що жоден `*.test.{mjs,js}` файл не передає relative-path як 1-й
|
|
214
|
+
* (або для `copyFile`/`rename`/`symlink`/`link`/`cp` — 1-й і 2-й) аргумент
|
|
215
|
+
* у FS-функцію з `node:fs` / `node:fs/promises`.
|
|
216
|
+
* @param {string} [cwdParam] корінь репозиторію
|
|
217
|
+
* @returns {Promise<number>} 0 — чисто, 1 — є порушення
|
|
218
|
+
*/
|
|
219
|
+
export async function check(cwdParam = process.cwd()) {
|
|
220
|
+
const reporter = createCheckReporter()
|
|
221
|
+
const { pass, fail } = reporter
|
|
222
|
+
|
|
223
|
+
const cwd = cwdParam
|
|
224
|
+
const ignorePaths = await loadCursorIgnorePaths(cwd)
|
|
225
|
+
|
|
226
|
+
/** @type {string[]} */
|
|
227
|
+
const testFiles = []
|
|
228
|
+
await walkDir(
|
|
229
|
+
cwd,
|
|
230
|
+
absPath => {
|
|
231
|
+
if (isTestFile(absPath)) testFiles.push(absPath)
|
|
232
|
+
},
|
|
233
|
+
ignorePaths
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
/** @type {Array<{file: string, line: number, fn: string, path: string, argPos: number}>} */
|
|
237
|
+
const offenders = []
|
|
238
|
+
for (const absPath of testFiles) {
|
|
239
|
+
const body = await readFile(absPath, 'utf8')
|
|
240
|
+
const found = findOffendersInBody(body)
|
|
241
|
+
for (const o of found) {
|
|
242
|
+
offenders.push({ file: relative(cwd, absPath), ...o })
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (offenders.length === 0) {
|
|
247
|
+
pass(`Жоден з ${testFiles.length} тестових файлів не передає relative-path у FS-функції (test.mdc)`)
|
|
248
|
+
return reporter.getExitCode()
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
for (const { file, line, fn, path, argPos } of offenders) {
|
|
252
|
+
const which = argPos === 0 ? '1-й аргумент' : `${argPos + 1}-й аргумент`
|
|
253
|
+
fail(
|
|
254
|
+
`${file}:${line}: ${fn}() — ${which} '${path}' relative; використовуй join(dir, …) (test.mdc, no-relative-fs-path)`
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return reporter.getExitCode()
|
|
259
|
+
}
|
package/rules/test/test.mdc
CHANGED
|
@@ -73,9 +73,13 @@ Recursive globs ловлять файли всередині `tests/` так с
|
|
|
73
73
|
- Concern-функції правил — `await check(dir)`, `await applies(dir)`, `await fix(dir)`; усі production функції приймають перший параметр `cwd = process.cwd()` (default зберігає CLI-сумісність).
|
|
74
74
|
- `vitest.config.js` додатково ставить `pool: 'forks'` як defense-in-depth: навіть якщо хтось пропустить правило вище, fork-ізоляція не дасть race у production tree.
|
|
75
75
|
|
|
76
|
-
Це **обов'язково** і для тестів пакета `@nitra/cursor`, і для кожного проєкту-споживача.
|
|
76
|
+
Це **обов'язково** і для тестів пакета `@nitra/cursor`, і для кожного проєкту-споживача. Триплет перевірок:
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
- **`no-process-chdir`** (`rules/test/js/no-process-chdir.mjs`) — сканує `**/*.test.{js,mjs}` і падає з ❌ на будь-яке вживання `process.chdir(`.
|
|
79
|
+
- **`no-relative-fs-path`** (`rules/test/js/no-relative-fs-path.mjs`) — AST-сканер (`oxc-parser`): знаходить виклики FS-функцій із `node:fs`/`node:fs/promises` (`writeFile`, `copyFile`, `mkdir`, `readFile`, `existsSync`, `rename`, `symlink`, `cp`, … включно з `*Sync`-варіантами та `writeJson`/`ensureDir`-хелперами), де path-аргумент — це **string literal** без префікса `/`, `\`, `file:`, `http(s):`, `data:`, чи Windows-disk-letter `C:\`. Виклики `copyFile`/`rename`/`symlink`/`link`/`cp` перевіряють обидва path-аргументи. Виклики з обчисленим path (`join(dir, …)`, змінна, template-literal з виразом) пропускаються. Виловив би інцидент v1.28.0 у `tests/check-rule-fixtures.test.mjs` (`copyFile(src, 'default.conf.template')` → файл у production tree).
|
|
80
|
+
- **`vitest-config-pool-forks`** (`rules/test/js/vitest-config-pool-forks.mjs`) — substring-перевірка `pool: 'forks'` у `vitest.config.js`. Defense-in-depth.
|
|
81
|
+
|
|
82
|
+
Canonical `vitest.config.js` (для довідки — `pool: 'forks'` + `include` + `coverage`) — у `rules/test/js/data/vitest_config/vitest.config.baseline.js` (концерн `stryker_config` копіює його у кожен JS-root).
|
|
79
83
|
|
|
80
84
|
## Покриття + мутаційне тестування
|
|
81
85
|
|