@7n/test 0.9.0 → 0.10.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 +28 -0
- package/package.json +4 -2
- package/src/assess-need.mjs +4 -1
- package/src/classify-exports.mjs +108 -0
- package/src/coverage-classify/docs/apply.md +28 -0
- package/src/coverage-classify/docs/cache.md +34 -0
- package/src/coverage-classify/docs/index.md +40 -0
- package/src/coverage-classify/docs/prompt.md +30 -0
- package/src/coverage-classify/docs/verdict-schema.md +29 -0
- package/src/coverage-fix-extract.mjs +10 -10
- package/src/coverage-per-file.mjs +68 -43
- package/src/docs/assess-need.md +31 -0
- package/src/docs/classify-exports.md +30 -0
- package/src/docs/coverage-fix-extract.md +35 -0
- package/src/docs/coverage-fix.md +31 -0
- package/src/docs/coverage-per-file.md +34 -0
- package/src/docs/fix-tests.md +32 -0
- package/src/docs/gen-tests.md +38 -0
- package/src/docs/index.md +34 -0
- package/src/docs/run.md +33 -0
- package/src/fix-tests.mjs +233 -87
- package/src/gen-tests.mjs +1102 -49
- package/src/lib/ast-analyze.mjs +287 -0
- package/src/lib/docs/ast-analyze.md +45 -0
- package/src/lib/docs/index.md +14 -0
- package/src/lib/docs/pi-client.md +29 -0
- package/src/lib/docs/runtime-probe.md +36 -0
- package/src/lib/docs/vitest-shim.md +31 -0
- package/src/lib/pi-client.mjs +80 -28
- package/src/lib/runtime-probe.mjs +390 -0
- package/src/lib/vitest-shim.mjs +73 -0
- package/src/run.mjs +3 -1
- package/src/scripts/lib/changed-files.mjs +5 -5
- package/src/scripts/lib/docs/changed-files.md +32 -0
- package/src/scripts/lib/docs/read-n-cursor-config-lite.md +39 -0
- package/src/scripts/utils/docs/index.md +13 -0
- package/src/scripts/utils/docs/lock-cache-dir.md +32 -0
- package/src/scripts/utils/docs/with-lock.md +38 -0
- package/src/scripts/utils/docs/worktree-fingerprint.md +34 -0
- package/src/scripts/utils/lock-cache-dir.mjs +1 -1
- package/src/scripts/utils/with-lock.mjs +2 -3
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime probing of exported functions.
|
|
3
|
+
*
|
|
4
|
+
* Three probe strategies — all best-effort (return {} on any failure):
|
|
5
|
+
*
|
|
6
|
+
* 1. probeModule — calls each export with edge-case primitives, returns actual outputs.
|
|
7
|
+
* 2. probeFetchCalls — intercepts globalThis.fetch to capture real URL/init per export.
|
|
8
|
+
* 3. probeTimeVariants — runs each export at hours [0,9,14,22], reports time-sensitive ones.
|
|
9
|
+
* 4. probeHelpers — extracts non-exported helper functions from source and calls them
|
|
10
|
+
* with generic param combos to reveal their actual output shapes.
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync } from 'node:fs'
|
|
13
|
+
import { spawnSync } from 'node:child_process'
|
|
14
|
+
|
|
15
|
+
const PROBE_TIMEOUT_MS = 10_000
|
|
16
|
+
|
|
17
|
+
/** Generic argument combos to try when probing async/fetch functions. */
|
|
18
|
+
const FETCH_ARG_COMBOS = [
|
|
19
|
+
['test text'],
|
|
20
|
+
['test text', {}],
|
|
21
|
+
['test text', { id: 'test_id' }],
|
|
22
|
+
['test text', { chat_id: 'test_id' }],
|
|
23
|
+
['test', 'test_id'],
|
|
24
|
+
[null]
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
/** Hours sampled for time-variant detection. */
|
|
28
|
+
const PROBE_HOURS = [0, 9, 14, 22]
|
|
29
|
+
|
|
30
|
+
/** Generic param combos for internal helper introspection. */
|
|
31
|
+
const HELPER_PARAM_COMBOS = [
|
|
32
|
+
{},
|
|
33
|
+
{ id: 'test_id' },
|
|
34
|
+
{ chat_id: 'custom' },
|
|
35
|
+
{ silent: true },
|
|
36
|
+
{ parse_mode: 'HTML' },
|
|
37
|
+
{ chat_id: 'custom', parse_mode: 'MarkdownV2', silent: true }
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Edge-case inputs used for all function probes.
|
|
42
|
+
* Each entry: [jsLiteral, displayRepr]
|
|
43
|
+
*/
|
|
44
|
+
const PROBE_INPUTS = [
|
|
45
|
+
['null', 'null'],
|
|
46
|
+
['undefined', 'undefined'],
|
|
47
|
+
['""', '""'],
|
|
48
|
+
['"*"', '"*"'],
|
|
49
|
+
['"_"', '"_"'],
|
|
50
|
+
['"["', '"["'],
|
|
51
|
+
['"]"', '"]"'],
|
|
52
|
+
['"("', '"("'],
|
|
53
|
+
['")"', '")"'],
|
|
54
|
+
['"~"', '"~"'],
|
|
55
|
+
['"`"', '"`"'],
|
|
56
|
+
['">"', '">"'],
|
|
57
|
+
['"#"', '"#"'],
|
|
58
|
+
['"+"', '"+"'],
|
|
59
|
+
['"-"', '"-"'],
|
|
60
|
+
['"="', '"="'],
|
|
61
|
+
['"|"', '"|"'],
|
|
62
|
+
['"{"', '"{"'],
|
|
63
|
+
['"}"', '"}"'],
|
|
64
|
+
['"."', '"."'],
|
|
65
|
+
['"!"', '"!"'],
|
|
66
|
+
[String.raw`"\\"`, String.raw`"\\"`],
|
|
67
|
+
['"hello"', '"hello"'],
|
|
68
|
+
['"hello world"', '"hello world"'],
|
|
69
|
+
['0', '0'],
|
|
70
|
+
['42', '42']
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Пробує експорти модуля у дочірньому процесі й повертає фактичні виходи.
|
|
75
|
+
* @param {string} absFilePath абсолютний шлях до джерела модуля
|
|
76
|
+
* @param {string[]} exportedNames назви експортів для probing
|
|
77
|
+
* @param {string[]} [envKeys] ключі env, які треба підставити test-значеннями; за замовчуванням порожній список
|
|
78
|
+
* @returns {Record<string, Array<{input: string, output: string}> | {constant: string}>} фактичні probe-результати для кожного export
|
|
79
|
+
*/
|
|
80
|
+
export function probeModule(absFilePath, exportedNames, envKeys = []) {
|
|
81
|
+
const envStubs = envKeys.map(k => `process.env[${JSON.stringify(k)}] = '__probe__'`).join('\n')
|
|
82
|
+
|
|
83
|
+
const script = `
|
|
84
|
+
import { createRequire } from 'node:module'
|
|
85
|
+
${envStubs}
|
|
86
|
+
globalThis.fetch = async () => ({ status: 200, json: async () => ({}) })
|
|
87
|
+
|
|
88
|
+
const probeInputs = ${JSON.stringify(PROBE_INPUTS)}
|
|
89
|
+
const names = ${JSON.stringify(exportedNames)}
|
|
90
|
+
const filePath = ${JSON.stringify(absFilePath)}
|
|
91
|
+
|
|
92
|
+
let mod
|
|
93
|
+
try {
|
|
94
|
+
mod = await import(filePath)
|
|
95
|
+
} catch (e) {
|
|
96
|
+
process.stdout.write(JSON.stringify({ __importError: e.message }))
|
|
97
|
+
process.exit(0)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const results = {}
|
|
101
|
+
for (const name of names) {
|
|
102
|
+
const val = mod[name]
|
|
103
|
+
if (typeof val === 'function') {
|
|
104
|
+
results[name] = []
|
|
105
|
+
for (const [inputLiteral, repr] of probeInputs) {
|
|
106
|
+
let input
|
|
107
|
+
// safe eval of primitive literals
|
|
108
|
+
try { input = new Function('return ' + inputLiteral)() } catch { continue }
|
|
109
|
+
try {
|
|
110
|
+
const out = await Promise.resolve(val(input))
|
|
111
|
+
if (out !== undefined) {
|
|
112
|
+
results[name].push({ input: repr, output: JSON.stringify(out) })
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
// skip — function threw on this input
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
} else if (val !== undefined) {
|
|
119
|
+
try { results[name] = { constant: JSON.stringify(val) } } catch { /* skip non-serializable */ }
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
process.stdout.write(JSON.stringify(results))
|
|
123
|
+
`
|
|
124
|
+
|
|
125
|
+
const proc = spawnSync('node', ['--input-type=module'], {
|
|
126
|
+
input: script,
|
|
127
|
+
encoding: 'utf8',
|
|
128
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
129
|
+
env: { ...process.env }
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
if (!proc.stdout) return {}
|
|
133
|
+
try {
|
|
134
|
+
const parsed = JSON.parse(proc.stdout)
|
|
135
|
+
if (parsed.__importError) return {}
|
|
136
|
+
return parsed
|
|
137
|
+
} catch {
|
|
138
|
+
return {}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Helper: run an isolated node ESM script and parse JSON stdout
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Запускає ізольований node ESM script і парсить JSON зі stdout.
|
|
148
|
+
* @param {string} script код для виконання
|
|
149
|
+
* @param {number} [timeout] ліміт очікування в мс; за замовчуванням `PROBE_TIMEOUT_MS`
|
|
150
|
+
* @returns {Record<string, unknown> | null} розпарсений JSON або `null` при помилці
|
|
151
|
+
*/
|
|
152
|
+
function runProbeScript(script, timeout = PROBE_TIMEOUT_MS) {
|
|
153
|
+
const proc = spawnSync('node', ['--input-type=module'], {
|
|
154
|
+
input: script,
|
|
155
|
+
encoding: 'utf8',
|
|
156
|
+
timeout,
|
|
157
|
+
env: { ...process.env }
|
|
158
|
+
})
|
|
159
|
+
if (!proc.stdout) return null
|
|
160
|
+
try {
|
|
161
|
+
const parsed = JSON.parse(proc.stdout)
|
|
162
|
+
return parsed.__importError ? null : parsed
|
|
163
|
+
} catch {
|
|
164
|
+
return null
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// Probe 2: fetch-capture
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Перехоплює `fetch` і збирає реальні URL/init, які будує кожен export.
|
|
174
|
+
* @param {string} absFilePath абсолютний шлях до джерела модуля
|
|
175
|
+
* @param {string[]} exportedNames назви експортів для probing
|
|
176
|
+
* @param {string[]} [envKeys] env-ключі, які треба підмінити `test_value`; за замовчуванням порожній список
|
|
177
|
+
* @returns {Record<string, Array<{args: string, url: string, init: unknown}>>} зібрані fetch-виклики по export
|
|
178
|
+
*/
|
|
179
|
+
export function probeFetchCalls(absFilePath, exportedNames, envKeys = []) {
|
|
180
|
+
const envStubs = envKeys.map(k => `process.env[${JSON.stringify(k)}] = 'test_value'`).join('\n')
|
|
181
|
+
|
|
182
|
+
const script = `
|
|
183
|
+
${envStubs}
|
|
184
|
+
const __calls = []
|
|
185
|
+
globalThis.fetch = async (url, init) => {
|
|
186
|
+
__calls.push({ url: String(url), init: init ?? null })
|
|
187
|
+
return { status: 200, json: async () => ({}) }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
let mod
|
|
191
|
+
try { mod = await import(${JSON.stringify(absFilePath)}) }
|
|
192
|
+
catch (e) { process.stdout.write(JSON.stringify({ __importError: e.message })); process.exit(0) }
|
|
193
|
+
|
|
194
|
+
const argCombos = ${JSON.stringify(FETCH_ARG_COMBOS)}
|
|
195
|
+
const results = {}
|
|
196
|
+
|
|
197
|
+
for (const name of ${JSON.stringify(exportedNames)}) {
|
|
198
|
+
const fn = mod[name]
|
|
199
|
+
if (typeof fn !== 'function') continue
|
|
200
|
+
for (const args of argCombos) {
|
|
201
|
+
__calls.length = 0
|
|
202
|
+
try { await Promise.resolve(fn(...args)) } catch { /* ignore */ }
|
|
203
|
+
if (__calls.length > 0) {
|
|
204
|
+
results[name] = __calls.map(c => ({ args: JSON.stringify(args), url: c.url, init: c.init }))
|
|
205
|
+
break
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
process.stdout.write(JSON.stringify(results))
|
|
210
|
+
`
|
|
211
|
+
return runProbeScript(script) ?? {}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// Probe 3: time-variant detection
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Запускає кожен export у кількох годинах доби й повертає time-sensitive варіанти.
|
|
220
|
+
* @param {string} absFilePath абсолютний шлях до джерела модуля
|
|
221
|
+
* @param {string[]} exportedNames назви експортів для probing
|
|
222
|
+
* @param {string[]} [envKeys] env-ключі, які треба підмінити `test_value`; за замовчуванням порожній список
|
|
223
|
+
* @returns {Record<string, Record<number, string>>} лише exports, чий вихід змінюється залежно від години
|
|
224
|
+
*/
|
|
225
|
+
export function probeTimeVariants(absFilePath, exportedNames, envKeys = []) {
|
|
226
|
+
const envStubs = envKeys.map(k => `process.env[${JSON.stringify(k)}] = 'test_value'`).join('\n')
|
|
227
|
+
|
|
228
|
+
// Use generic arg combos (same as fetch probe) to get meaningful calls
|
|
229
|
+
const script = `
|
|
230
|
+
${envStubs}
|
|
231
|
+
|
|
232
|
+
let mod
|
|
233
|
+
try { mod = await import(${JSON.stringify(absFilePath)}) }
|
|
234
|
+
catch (e) { process.stdout.write(JSON.stringify({ __importError: e.message })); process.exit(0) }
|
|
235
|
+
|
|
236
|
+
const hours = ${JSON.stringify(PROBE_HOURS)}
|
|
237
|
+
const argCombos = ${JSON.stringify(FETCH_ARG_COMBOS)}
|
|
238
|
+
const results = {}
|
|
239
|
+
|
|
240
|
+
for (const name of ${JSON.stringify(exportedNames)}) {
|
|
241
|
+
const fn = mod[name]
|
|
242
|
+
if (typeof fn !== 'function') continue
|
|
243
|
+
const byHour = {}
|
|
244
|
+
for (const h of hours) {
|
|
245
|
+
const fixedMs = new Date('2024-01-15T00:00:00').setHours(h, 0, 0, 0)
|
|
246
|
+
const Orig = globalThis.Date
|
|
247
|
+
globalThis.Date = class FakeDate extends Orig {
|
|
248
|
+
constructor(...a) { super(...(a.length ? a : [fixedMs])) }
|
|
249
|
+
static now() { return fixedMs }
|
|
250
|
+
}
|
|
251
|
+
Object.setPrototypeOf(globalThis.Date, Orig)
|
|
252
|
+
// Capture fetch URL (more informative than return value for side-effectful fns)
|
|
253
|
+
let captured = null
|
|
254
|
+
globalThis.fetch = async (url) => { captured = String(url); return { status: 200, json: async () => ({}) } }
|
|
255
|
+
try {
|
|
256
|
+
for (const args of argCombos) {
|
|
257
|
+
captured = null
|
|
258
|
+
try { await Promise.resolve(fn(...args)) } catch { /* ignore */ }
|
|
259
|
+
if (captured) break
|
|
260
|
+
}
|
|
261
|
+
byHour[h] = captured ?? '__no_fetch__'
|
|
262
|
+
} catch {
|
|
263
|
+
byHour[h] = '__error__'
|
|
264
|
+
} finally {
|
|
265
|
+
globalThis.Date = Orig
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
// Report only functions where fetch URL changes across hours
|
|
269
|
+
const fetched = Object.values(byHour).filter(v => v !== '__no_fetch__' && v !== '__error__')
|
|
270
|
+
if (fetched.length > 0 && new Set(fetched).size > 1) results[name] = byHour
|
|
271
|
+
}
|
|
272
|
+
process.stdout.write(JSON.stringify(results))
|
|
273
|
+
`
|
|
274
|
+
return runProbeScript(script) ?? {}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
// Probe 4: internal helper introspection
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Знаходить початок top-level declaration для helper-а в source.
|
|
283
|
+
* @param {string} source текст модуля
|
|
284
|
+
* @param {string} name назва helper-а
|
|
285
|
+
* @returns {number} індекс початку або `-1`, якщо declaration не знайдено
|
|
286
|
+
*/
|
|
287
|
+
function findDeclarationStart(source, name) {
|
|
288
|
+
const prefixes = [`const ${name} =`, `let ${name} =`, `var ${name} =`, `function ${name}(`, `async function ${name}(`]
|
|
289
|
+
let start = -1
|
|
290
|
+
for (const prefix of prefixes) {
|
|
291
|
+
const direct = source.indexOf(prefix)
|
|
292
|
+
if (direct !== -1 && (start === -1 || direct < start)) start = direct
|
|
293
|
+
const lineStart = source.indexOf(`\n${prefix}`)
|
|
294
|
+
if (lineStart !== -1) {
|
|
295
|
+
const candidate = lineStart + 1
|
|
296
|
+
if (start === -1 || candidate < start) start = candidate
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return start
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Знаходить межу до наступної top-level declaration.
|
|
304
|
+
* @param {string} sourceTail текст після declaration
|
|
305
|
+
* @returns {number} індекс межі або `-1`, якщо наступної declaration немає
|
|
306
|
+
*/
|
|
307
|
+
function findNextDeclarationEnd(sourceTail) {
|
|
308
|
+
const markers = ['\nconst ', '\nlet ', '\nvar ', '\nfunction ', '\nasync function ', '\nexport ']
|
|
309
|
+
let end = -1
|
|
310
|
+
for (const marker of markers) {
|
|
311
|
+
const idx = sourceTail.indexOf(marker)
|
|
312
|
+
if (idx !== -1 && (end === -1 || idx < end)) end = idx
|
|
313
|
+
}
|
|
314
|
+
return end
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Витягує top-level declaration helper-а з source.
|
|
319
|
+
* @param {string} source текст модуля
|
|
320
|
+
* @param {string} name назва helper-а
|
|
321
|
+
* @param {number} [maxLength] максимальна довжина fallback-сніпета; за замовчуванням `800`
|
|
322
|
+
* @returns {string|null} сніпет helper-а або `null`, якщо declaration не знайдено
|
|
323
|
+
*/
|
|
324
|
+
function extractHelperSource(source, name, maxLength = 800) {
|
|
325
|
+
const start = findDeclarationStart(source, name)
|
|
326
|
+
if (start === -1) return null
|
|
327
|
+
const after = source.slice(start)
|
|
328
|
+
const next = findNextDeclarationEnd(after)
|
|
329
|
+
return after.slice(0, next === -1 ? Math.min(after.length, maxLength) : next)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Витягує неекспортовані helper-и з source та проганяє їх крізь generic param combos.
|
|
334
|
+
* Best-effort: повертає `{}` при будь-якій помилці.
|
|
335
|
+
* @param {string} absFilePath абсолютний шлях до source-модуля
|
|
336
|
+
* @param {string[]} helperNames назви internal (non-exported) helper-ів
|
|
337
|
+
* @param {string[]} [envKeys] env-ключі, які треба підмінити `test_value`; за замовчуванням порожній список
|
|
338
|
+
* @returns {Record<string, Array<{params: Record<string, unknown>, result: unknown}>>} результати probe для кожного helper-а
|
|
339
|
+
*/
|
|
340
|
+
export function probeHelpers(absFilePath, helperNames, envKeys = []) {
|
|
341
|
+
if (!helperNames.length) return {}
|
|
342
|
+
|
|
343
|
+
let source
|
|
344
|
+
try {
|
|
345
|
+
source = readFileSync(absFilePath, 'utf8')
|
|
346
|
+
} catch {
|
|
347
|
+
return {}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const extracted = {}
|
|
351
|
+
for (const name of helperNames) {
|
|
352
|
+
const snippet = extractHelperSource(source, name)
|
|
353
|
+
if (snippet) extracted[name] = snippet
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (Object.keys(extracted).length === 0) return {}
|
|
357
|
+
|
|
358
|
+
const envStubs = envKeys.map(k => `process.env[${JSON.stringify(k)}] = 'test_value'`).join('\n')
|
|
359
|
+
const helperDefs = Object.values(extracted).join('\n\n')
|
|
360
|
+
const combos = JSON.stringify(HELPER_PARAM_COMBOS)
|
|
361
|
+
const names = JSON.stringify(Object.keys(extracted))
|
|
362
|
+
|
|
363
|
+
const script = `
|
|
364
|
+
import { env } from 'node:process'
|
|
365
|
+
${envStubs}
|
|
366
|
+
|
|
367
|
+
${helperDefs}
|
|
368
|
+
|
|
369
|
+
const combos = ${combos}
|
|
370
|
+
const results = {}
|
|
371
|
+
for (const name of ${names}) {
|
|
372
|
+
const fns = { ${Object.keys(extracted)
|
|
373
|
+
.map(n => `${n}: typeof ${n} !== 'undefined' ? ${n} : null`)
|
|
374
|
+
.join(', ')} }
|
|
375
|
+
const fn = fns[name]
|
|
376
|
+
if (typeof fn !== 'function') continue
|
|
377
|
+
results[name] = []
|
|
378
|
+
for (const params of combos) {
|
|
379
|
+
try {
|
|
380
|
+
const r = fn(params)
|
|
381
|
+
results[name].push({ params, result: JSON.parse(JSON.stringify(r)) })
|
|
382
|
+
} catch {
|
|
383
|
+
// helper threw on this combo — skip
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
process.stdout.write(JSON.stringify(results))
|
|
388
|
+
`
|
|
389
|
+
return runProbeScript(script) ?? {}
|
|
390
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared bundled-vitest utilities.
|
|
3
|
+
*
|
|
4
|
+
* Target projects do NOT need vitest installed — we use the vitest bundled
|
|
5
|
+
* with `@7n/test` as a fallback, with a shim config so the run works even
|
|
6
|
+
* without a vitest.config.js in the target project. When the target DOES
|
|
7
|
+
* have vitest installed locally, its own binary is preferred instead: that
|
|
8
|
+
* resolves the target's `vitest.config.*` (setupFiles, environment, plugins,
|
|
9
|
+
* nested per-package configs) and third-party environment providers (e.g.
|
|
10
|
+
* `happy-dom`) from the target's own `node_modules`, which the bundled copy
|
|
11
|
+
* cannot see.
|
|
12
|
+
*/
|
|
13
|
+
import { createRequire } from 'node:module'
|
|
14
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
15
|
+
import { tmpdir } from 'node:os'
|
|
16
|
+
import { dirname, join } from 'node:path'
|
|
17
|
+
|
|
18
|
+
const _require = createRequire(import.meta.url)
|
|
19
|
+
const _bundledVitestPkg = _require.resolve('vitest/package.json')
|
|
20
|
+
const _bundledVitestDir = dirname(_bundledVitestPkg)
|
|
21
|
+
|
|
22
|
+
/** Absolute path to the bundled vitest CLI (fallback when the target has no local vitest). */
|
|
23
|
+
export const VITEST_BIN = join(_bundledVitestDir, 'vitest.mjs')
|
|
24
|
+
|
|
25
|
+
/** Absolute path to the bundled vitest/config entry (for use in shim import). */
|
|
26
|
+
const VITEST_CONFIG_ENTRY = join(_bundledVitestDir, 'dist', 'config.js')
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Minimal vitest config written to OS temp, imported by the bundled vitest.
|
|
30
|
+
* Using file:// URL so the import resolves from 7n-test's node_modules,
|
|
31
|
+
* not from the target project's (possibly absent) vitest installation.
|
|
32
|
+
*/
|
|
33
|
+
export const VITEST_SHIM_CONFIG = join(tmpdir(), '7n-vitest-shim', 'vitest.config.mjs')
|
|
34
|
+
|
|
35
|
+
let _shimWritten = false
|
|
36
|
+
|
|
37
|
+
/** Ensures the bundled-vitest shim config file exists (idempotent). */
|
|
38
|
+
export function ensureVitestShim() {
|
|
39
|
+
if (_shimWritten) return
|
|
40
|
+
mkdirSync(dirname(VITEST_SHIM_CONFIG), { recursive: true })
|
|
41
|
+
writeFileSync(
|
|
42
|
+
VITEST_SHIM_CONFIG,
|
|
43
|
+
`import { defineConfig } from ${JSON.stringify('file://' + VITEST_CONFIG_ENTRY)}\n` +
|
|
44
|
+
`export default defineConfig({\n` +
|
|
45
|
+
` test: { environment: 'node' },\n` +
|
|
46
|
+
` coverage: {\n` +
|
|
47
|
+
` provider: 'v8',\n` +
|
|
48
|
+
// Exclude test files that use non-vitest runners (bun:test, jest) from
|
|
49
|
+
// the coverage scan so they don't cause import errors or skewed data.
|
|
50
|
+
` exclude: ['**/node_modules/**'],\n` +
|
|
51
|
+
` },\n` +
|
|
52
|
+
`})\n`
|
|
53
|
+
)
|
|
54
|
+
_shimWritten = true
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolves which vitest binary and extra CLI args to run for a target project.
|
|
59
|
+
* Prefers the target's own locally-installed vitest (native config discovery,
|
|
60
|
+
* dependencies resolved from its own node_modules); falls back to the bundled
|
|
61
|
+
* vitest + shim config when the target has no local vitest installed.
|
|
62
|
+
* @param {string} dir target project root
|
|
63
|
+
* @returns {{ bin: string, configArgs: string[] }} vitest bin path and `--config` args to prepend
|
|
64
|
+
*/
|
|
65
|
+
export function resolveVitestRun(dir) {
|
|
66
|
+
try {
|
|
67
|
+
const localPkg = _require.resolve('vitest/package.json', { paths: [dir] })
|
|
68
|
+
return { bin: join(dirname(localPkg), 'vitest.mjs'), configArgs: [] }
|
|
69
|
+
} catch {
|
|
70
|
+
ensureVitestShim()
|
|
71
|
+
return { bin: VITEST_BIN, configArgs: ['--config', VITEST_SHIM_CONFIG] }
|
|
72
|
+
}
|
|
73
|
+
}
|
package/src/run.mjs
CHANGED
|
@@ -62,7 +62,9 @@ export async function runAutoTest(dir, opts = {}) {
|
|
|
62
62
|
try {
|
|
63
63
|
const q = quickClassify(readFileSync(join(dir, f), 'utf8'))
|
|
64
64
|
if (q?.needsTests === false) return null
|
|
65
|
-
} catch {
|
|
65
|
+
} catch {
|
|
66
|
+
/* include on read error */
|
|
67
|
+
}
|
|
66
68
|
return { file: f, pct: 0 }
|
|
67
69
|
})
|
|
68
70
|
.filter(Boolean)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Збір змінених файлів для quick-режиму lint-оркестратора.
|
|
3
3
|
*
|
|
4
|
-
* Quick
|
|
4
|
+
* Quick перевіряє лише те, що змінено в робочому дереві: tracked-modified + staged
|
|
5
5
|
* (`git diff HEAD`) і нові untracked (`git ls-files --others --exclude-standard`).
|
|
6
6
|
* Видалені файли не повертаються. Поза git-репо або при помилці git — порожній список.
|
|
7
7
|
*/
|
|
@@ -49,12 +49,12 @@ export function resolveChangedBase(cwd = process.cwd()) {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
|
-
* Список змінених + untracked файлів **відносно базового
|
|
52
|
+
* Список змінених + untracked файлів **відносно базового коміту**.
|
|
53
53
|
*
|
|
54
54
|
* `git diff <base>` (без `..`/`...`, без `HEAD`) порівнює base-комміт із поточним
|
|
55
|
-
* **робочим деревом** — тобто однаково ловить і
|
|
55
|
+
* **робочим деревом** — тобто однаково ловить і зафіксоване від base, і staged, і
|
|
56
56
|
* незакомічені модифікації. Це гарантує однакову поведінку незалежно від того, чи
|
|
57
|
-
* зміни вже
|
|
57
|
+
* зміни вже зафіксовані у worktree. Без `base` — fallback на `collectChangedFiles`
|
|
58
58
|
* (робоче дерево vs HEAD).
|
|
59
59
|
* @param {string|null} [base] базовий комміт
|
|
60
60
|
* @param {string} [cwd] корінь репо
|
|
@@ -64,7 +64,7 @@ export function collectChangedFilesSince(base, cwd = process.cwd()) {
|
|
|
64
64
|
if (!base) return collectChangedFiles(cwd)
|
|
65
65
|
// Fail-closed: недосяжний base (rebase/force-update/shallow prune) інакше дав би `git diff`
|
|
66
66
|
// exit 128 → порожній список → gate мовчки пройшов би без перевірки. Краще явна помилка.
|
|
67
|
-
const verify = spawnSync('git', ['rev-parse', '--verify', '--quiet',
|
|
67
|
+
const verify = spawnSync('git', ['rev-parse', '--verify', '--quiet', base + '^{commit}'], { cwd, encoding: 'utf8' })
|
|
68
68
|
if (verify.status !== 0 || verify.error) {
|
|
69
69
|
throw new Error(
|
|
70
70
|
`collectChangedFilesSince: base-комміт «${base}» недосяжний у ${cwd} ` +
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: JS Module
|
|
3
|
+
title: changed-files.mjs
|
|
4
|
+
resource: npm/src/scripts/lib/changed-files.mjs
|
|
5
|
+
docgen:
|
|
6
|
+
crc: 55da635c
|
|
7
|
+
model: omlx/gemma-4-e4b-it-OptiQ-4bit
|
|
8
|
+
score: 100
|
|
9
|
+
issues: judge:inaccurate:0.98
|
|
10
|
+
judgeModel: openai-codex/gpt-5.4-mini
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Огляд
|
|
14
|
+
|
|
15
|
+
Файл відповідає за збір списків шляхів до файлів, що були змінені або додані у робочому дереві. Він забезпечує механізм для quick-режиму lint-оркестратора, ідентифікуючи змінений контент відносно останнього коміту (`HEAD`) (tracked-modified + staged) та нових невідстежених файлів (`git ls-files --others --exclude-standard`). Видалені файли не повертаються. У випадку роботи поза Git-репозиторієм або при помилці Git, повертається порожній список.
|
|
16
|
+
|
|
17
|
+
## Поведінка
|
|
18
|
+
|
|
19
|
+
Поведінка
|
|
20
|
+
collectChangedFiles збирає унікальний список шляхів до файлів, які були змінені або додані у робочому дереві відносно `HEAD`, включаючи нові відстежувані та невідстежувані файли.
|
|
21
|
+
resolveChangedBase визначає базовий коміт для обмежених перевірок, шукаючи `main` або `origin/main` як точку відліку.
|
|
22
|
+
collectChangedFilesSince збирає унікальний список шляхів до файлів, які змінилися або були додані порівняно з заданим базовим комітом, або викликає `collectChangedFiles` у випадку відсутності базового коміту.
|
|
23
|
+
|
|
24
|
+
## Публічний API
|
|
25
|
+
|
|
26
|
+
collectChangedFiles — надає список файлів, які були змінені або додані в робоче дерево.
|
|
27
|
+
resolveChangedBase — визначає точку відліку для порівняння змін, пріоритезуючи `main` локальної гілки, потім `origin/main`; якщо вони відсутні, порівнює робоче дерево з поточним HEAD.
|
|
28
|
+
collectChangedFilesSince — надає список файлів, які відрізняються від зазначеного базового коміту.
|
|
29
|
+
|
|
30
|
+
## Гарантії поведінки
|
|
31
|
+
|
|
32
|
+
- Read-only: не виконує операцій запису (ФС/БД).
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: JS Module
|
|
3
|
+
title: read-n-cursor-config-lite.mjs
|
|
4
|
+
resource: npm/src/scripts/lib/read-n-cursor-config-lite.mjs
|
|
5
|
+
docgen:
|
|
6
|
+
crc: a38ac2f4
|
|
7
|
+
model: omlx/gemma-4-e4b-it-OptiQ-4bit
|
|
8
|
+
score: 100
|
|
9
|
+
issues: judge:inaccurate:0.99
|
|
10
|
+
judgeModel: openai-codex/gpt-5.4-mini
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Огляд
|
|
14
|
+
|
|
15
|
+
Модуль забезпечує мінімалістичний, read-only доступ до конфігурації `.n-cursor.json`. Він дозволяє зовнішнім інструментам (наприклад, для debug-тестування) зчитувати стан набору правил, не ініціалізуючи повний CLI. Відсутність `.n-cursor.json` трактується як режим "open by default", активуючи всі правила. Якщо файл присутній, правила можуть бути активні лише у списку `rules`, але їхня активація анулюється, якщо правило міститься у списку `disable-rules`. Функції надають значення `rules` та `disableRules` з конфігу.
|
|
16
|
+
|
|
17
|
+
## Поведінка
|
|
18
|
+
|
|
19
|
+
readNCursorConfigLite читає вміст файлу .n-cursor.json у вказаному каталозі, якщо такий файл існує; повертає об'єкт, що описує, чи існує конфігурація, які правила явно включені в `rules` та які правила вимкнено у `disableRules`.
|
|
20
|
+
isRuleEnabled визначає, чи активне певне правило на основі прочитаного конфігу; якщо файл .n-cursor.json відсутній, правило вважається активним.
|
|
21
|
+
|
|
22
|
+
## Публічний API
|
|
23
|
+
|
|
24
|
+
I understand. As a technical writer, I will generate concise behavioral documentation in Ukrainian, adhering strictly to the specified constraints: clean Markdown, focusing on _What_ and _Why_ (not _How_), no introductions or conclusions, no code blocks, and excluding signatures, types, parameters, `stdlib` modules, regex descriptions, or private internal names.
|
|
25
|
+
|
|
26
|
+
I must use the exact names provided for the functions in the bulleted list, transforming them into short, intent-driven descriptions.
|
|
27
|
+
|
|
28
|
+
Here is the required output format:
|
|
29
|
+
|
|
30
|
+
- `readNCursorConfigLite` — [Concise description of the file's purpose/action]
|
|
31
|
+
- `isRuleEnabled` — [Concise description based on the configuration logic provided]
|
|
32
|
+
|
|
33
|
+
I will ensure the anchors point to `.n-cursor.json` and avoid generic filler phrases.
|
|
34
|
+
|
|
35
|
+
Please provide the code or context you want me to document.
|
|
36
|
+
|
|
37
|
+
## Гарантії поведінки
|
|
38
|
+
|
|
39
|
+
- Read-only: не виконує операцій запису (ФС/БД).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: Directory Index
|
|
3
|
+
title: npm/src/scripts/utils
|
|
4
|
+
resource: npm/src/scripts/utils/
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# npm/src/scripts/utils
|
|
8
|
+
|
|
9
|
+
| Файл | Тип |
|
|
10
|
+
| --------------------------------------------------- | --------- |
|
|
11
|
+
| [lock-cache-dir.mjs](lock-cache-dir.md) | JS Module |
|
|
12
|
+
| [with-lock.mjs](with-lock.md) | JS Module |
|
|
13
|
+
| [worktree-fingerprint.mjs](worktree-fingerprint.md) | JS Module |
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: JS Module
|
|
3
|
+
title: lock-cache-dir.mjs
|
|
4
|
+
resource: npm/src/scripts/utils/lock-cache-dir.mjs
|
|
5
|
+
docgen:
|
|
6
|
+
crc: 76c84505
|
|
7
|
+
model: omlx/gemma-4-e4b-it-OptiQ-4bit
|
|
8
|
+
score: 100
|
|
9
|
+
issues: judge:inaccurate:0.97
|
|
10
|
+
judgeModel: openai-codex/gpt-5.4-mini
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Огляд
|
|
14
|
+
|
|
15
|
+
Цей модуль визначає уніфікований, спільний шлях для кешування лок-станів (`owner.json`, `result.json`) у всіх git-worktree. При наявності git-репозиторію, лок-стан розміщується у спільному каталозі `<git-common-dir>/n-cursor/<key>` (всередині `.git`), що забезпечує, щоб ресурсомісткі команди, запущені в різних worktree (наприклад, eslint), серіалізувалися та уникнули паралельного перевантаження CPU/диск на macOS. У разі відсутності git-репозиторію, використовується історичний fallback у per-checkout `node_modules/.cache/n-cursor/<key>`.
|
|
16
|
+
|
|
17
|
+
## Поведінка
|
|
18
|
+
|
|
19
|
+
Поведінка:
|
|
20
|
+
|
|
21
|
+
1. Викликається функція resolveLockCacheDir з ключем локу.
|
|
22
|
+
2. Функція з'ясовує робочу директорію.
|
|
23
|
+
3. Виконується команда `git rev-parse --git-common-dir` у робочій директорії для визначення спільної директорії git-репозиторію.
|
|
24
|
+
4. Якщо команда Git не спрацювала або не повернула шлях, функція повертає шлях до локального кешу (`node_modules/.cache/n-cursor/<key>`) у поточній робочій директорії.
|
|
25
|
+
5. Якщо спірна директорія була знайдена, функція обчислює та повертає абсолютний шлях до кешу (`<git-common-dir>/n-cursor/<key>`) у корені репозиторію.
|
|
26
|
+
6. Функція не модифікує файлову систему; вона лише обчислює шлях для кешування лок-станів (таких як `owner.json` та `result.json`) для забезпечення серіалізації важких команд між різними git-worktree.
|
|
27
|
+
|
|
28
|
+
## Гарантії поведінки
|
|
29
|
+
|
|
30
|
+
- Read-only: не виконує операцій запису (ФС/БД).
|
|
31
|
+
- Кешує результати в межах одного прогону.
|
|
32
|
+
- Свідомо пропускає шляхи: `.git`, `node_modules`.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: JS Module
|
|
3
|
+
title: with-lock.mjs
|
|
4
|
+
resource: npm/src/scripts/utils/with-lock.mjs
|
|
5
|
+
docgen:
|
|
6
|
+
crc: f6c6a780
|
|
7
|
+
model: omlx/gemma-4-e4b-it-OptiQ-4bit
|
|
8
|
+
score: 100
|
|
9
|
+
issues: judge:inaccurate:0.99
|
|
10
|
+
judgeModel: openai-codex/gpt-5.4-mini
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Огляд
|
|
14
|
+
|
|
15
|
+
Файл реалізує механізм гарантування атомарності та уникнення повторного виконання (dedup) для ресурсомістких операцій. Він забезпечує, щоб важкі команди виконувалися лише один раз протягом встановленого терміну дії, якщо їхній результат збережений. Система спирається на конфігурації `owner.json` та `result.json` для керування станом блокування та кешу.
|
|
16
|
+
|
|
17
|
+
Цей файл забезпечує атомарне виконання складних команд. Це досягається за допомогою механізму блокування, що базується на створенні директорії (`mkdirSync-based lock`), та валідації живості PID. Для уникнення повторних викликів використовується deduplication на основі хешу SHA256 з обмеженим часом життя (TTL), що дозволяє використовувати кешований результат.
|
|
18
|
+
|
|
19
|
+
## Поведінка
|
|
20
|
+
|
|
21
|
+
shouldDedup перевіряє, чи можна повторно використати збережений результат, порівнюючи його з поточним відбитком робочого дерева та перевіряючи термін дії кешу.
|
|
22
|
+
|
|
23
|
+
withLock виконує важку команду, гарантуючи її атомарність за допомогою механізму блокування. Перед виконанням він перевіряє кеш, а після завершення зберігає результат для майбутньої дедуплікації.
|
|
24
|
+
|
|
25
|
+
## Публічний API
|
|
26
|
+
|
|
27
|
+
Зрозумів. Я буду діяти як технічний письменник, створюючи лаконічну поведінкову документацію на українській мові у чистому Markdown. Я дотримуватимуся всіх обмежень: лише "ЩО" і "НАВІЩО", без вступів/висновків, без ```-блоків, без сигнатур/типів/параметрів/stdlib-модулів/regex/приватних імен.
|
|
28
|
+
|
|
29
|
+
Я інтегрую прив'язку до конфігів `owner.json` та `result.json`.
|
|
30
|
+
|
|
31
|
+
Для наданого списку я перетворю його на стислі маркери у форматі «назва — що робить», використовуючи власні слова, без типів і сигнатур, та використовуючи надані назви:
|
|
32
|
+
|
|
33
|
+
- shouldDedup — Визначає, чи можна ігнорувати повторне виконання, якщо результат вже є у кешованому `result.json`.
|
|
34
|
+
- withLock — Гарантує послідовне виконання ресурсомісткої операції за допомогою атомарної блокировки, а також усуває дублікати за унікальним ідентифікатором (fingerprint).
|
|
35
|
+
|
|
36
|
+
## Гарантії поведінки
|
|
37
|
+
|
|
38
|
+
- Кешує результати в межах одного прогону.
|