@7n/test 0.8.0 → 0.10.0
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 +18 -3
- package/src/lib/runtime-probe.mjs +390 -0
- package/src/lib/vitest-shim.mjs +73 -0
- package/src/run.mjs +30 -26
- 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
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Main auto-test loop: coverage →
|
|
2
|
+
* Main auto-test loop: coverage → gen-tests → loop until max → mutation + fix.
|
|
3
3
|
*
|
|
4
4
|
* Phase 1 (loop, max 5 iterations):
|
|
5
5
|
* 1. Measure per-file line coverage via vitest+lcov.
|
|
6
|
-
* 2.
|
|
7
|
-
* 3.
|
|
6
|
+
* 2. Generate tests for all files below threshold — coverage % is the signal.
|
|
7
|
+
* 3. Bootstrap: when no tests exist, scan sources and quickClassify locally.
|
|
8
8
|
* 4. Repeat until coverage maxes out or no improvement.
|
|
9
9
|
*
|
|
10
10
|
* Phase 2:
|
|
@@ -12,11 +12,13 @@
|
|
|
12
12
|
* 6. Auto-fix survived mutants via pi agent.
|
|
13
13
|
*/
|
|
14
14
|
import { measureCoveragePerFile, getUncoveredFiles, findSourceFiles } from './coverage-per-file.mjs'
|
|
15
|
-
import {
|
|
15
|
+
import { quickClassify } from './assess-need.mjs'
|
|
16
16
|
import { generateTests } from './gen-tests.mjs'
|
|
17
17
|
import { fixFailingTests } from './fix-tests.mjs'
|
|
18
18
|
import { runCoverageSteps } from './coverage/coverage.mjs'
|
|
19
19
|
import { withLock } from './scripts/utils/with-lock.mjs'
|
|
20
|
+
import { readFileSync } from 'node:fs'
|
|
21
|
+
import { join } from 'node:path'
|
|
20
22
|
|
|
21
23
|
const MAX_ITERATIONS = 5
|
|
22
24
|
const COVERAGE_THRESHOLD = 80
|
|
@@ -53,17 +55,28 @@ export async function runAutoTest(dir, opts = {}) {
|
|
|
53
55
|
console.log('⚠ Vitest coverage не повернула даних — перевір налаштування vitest.')
|
|
54
56
|
break
|
|
55
57
|
}
|
|
56
|
-
console.log(`\n── Bootstrap: ${sourceFiles.length}
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
console.log(`\n── Bootstrap: ${sourceFiles.length} джерел — класифікую локально ──\n`)
|
|
59
|
+
|
|
60
|
+
const bootstrapFiles = sourceFiles
|
|
61
|
+
.map(f => {
|
|
62
|
+
try {
|
|
63
|
+
const q = quickClassify(readFileSync(join(dir, f), 'utf8'))
|
|
64
|
+
if (q?.needsTests === false) return null
|
|
65
|
+
} catch {
|
|
66
|
+
/* include on read error */
|
|
67
|
+
}
|
|
68
|
+
return { file: f, pct: 0 }
|
|
69
|
+
})
|
|
70
|
+
.filter(Boolean)
|
|
71
|
+
|
|
72
|
+
if (bootstrapFiles.length === 0) {
|
|
73
|
+
console.log('✓ Жоден файл не потребує unit-тестів (реекспорти/типи).')
|
|
62
74
|
break
|
|
63
75
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
76
|
+
|
|
77
|
+
console.log(`\n→ Bootstrap-тести для (${bootstrapFiles.length}):`)
|
|
78
|
+
for (const f of bootstrapFiles) console.log(` • ${f.file}`)
|
|
79
|
+
await generateTests(bootstrapFiles, dir)
|
|
67
80
|
continue
|
|
68
81
|
}
|
|
69
82
|
|
|
@@ -84,21 +97,12 @@ export async function runAutoTest(dir, opts = {}) {
|
|
|
84
97
|
}
|
|
85
98
|
prevUncoveredCount = uncovered.length
|
|
86
99
|
|
|
87
|
-
console.log(`\n
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if (needsTests.length === 0) {
|
|
92
|
-
console.log('✓ LLM вирішила: жоден непокритий файл не потребує unit-тестів.')
|
|
93
|
-
break
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
console.log(`\n→ Потребують тестів (${needsTests.length}):`)
|
|
97
|
-
for (const f of needsTests) {
|
|
98
|
-
console.log(` • ${f.file} (${f.pct.toFixed(1)}%) — ${f.reason}`)
|
|
100
|
+
console.log(`\n→ Генерую тести для ${uncovered.length} файлів:`)
|
|
101
|
+
for (const f of uncovered) {
|
|
102
|
+
console.log(` • ${f.file} (${f.pct.toFixed(1)}%)`)
|
|
99
103
|
}
|
|
100
104
|
|
|
101
|
-
await generateTests(
|
|
105
|
+
await generateTests(uncovered, dir)
|
|
102
106
|
}
|
|
103
107
|
|
|
104
108
|
if (opts.noMutation) {
|
|
@@ -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: не виконує операцій запису (ФС/БД).
|