@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/package.json +4 -2
  3. package/src/assess-need.mjs +4 -1
  4. package/src/classify-exports.mjs +108 -0
  5. package/src/coverage-classify/docs/apply.md +28 -0
  6. package/src/coverage-classify/docs/cache.md +34 -0
  7. package/src/coverage-classify/docs/index.md +40 -0
  8. package/src/coverage-classify/docs/prompt.md +30 -0
  9. package/src/coverage-classify/docs/verdict-schema.md +29 -0
  10. package/src/coverage-fix-extract.mjs +10 -10
  11. package/src/coverage-per-file.mjs +68 -43
  12. package/src/docs/assess-need.md +31 -0
  13. package/src/docs/classify-exports.md +30 -0
  14. package/src/docs/coverage-fix-extract.md +35 -0
  15. package/src/docs/coverage-fix.md +31 -0
  16. package/src/docs/coverage-per-file.md +34 -0
  17. package/src/docs/fix-tests.md +32 -0
  18. package/src/docs/gen-tests.md +38 -0
  19. package/src/docs/index.md +34 -0
  20. package/src/docs/run.md +33 -0
  21. package/src/fix-tests.mjs +233 -87
  22. package/src/gen-tests.mjs +1102 -49
  23. package/src/lib/ast-analyze.mjs +287 -0
  24. package/src/lib/docs/ast-analyze.md +45 -0
  25. package/src/lib/docs/index.md +14 -0
  26. package/src/lib/docs/pi-client.md +29 -0
  27. package/src/lib/docs/runtime-probe.md +36 -0
  28. package/src/lib/docs/vitest-shim.md +31 -0
  29. package/src/lib/pi-client.mjs +18 -3
  30. package/src/lib/runtime-probe.mjs +390 -0
  31. package/src/lib/vitest-shim.mjs +73 -0
  32. package/src/run.mjs +30 -26
  33. package/src/scripts/lib/changed-files.mjs +5 -5
  34. package/src/scripts/lib/docs/changed-files.md +32 -0
  35. package/src/scripts/lib/docs/read-n-cursor-config-lite.md +39 -0
  36. package/src/scripts/utils/docs/index.md +13 -0
  37. package/src/scripts/utils/docs/lock-cache-dir.md +32 -0
  38. package/src/scripts/utils/docs/with-lock.md +38 -0
  39. package/src/scripts/utils/docs/worktree-fingerprint.md +34 -0
  40. package/src/scripts/utils/lock-cache-dir.mjs +1 -1
  41. 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 → assess → gen-tests → loop until max → mutation + fix.
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. pi CLI assesses which uncovered files actually need tests.
7
- * 3. pi agent generates tests for those files.
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 { assessNeed } from './assess-need.mjs'
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} файлів без тестів оцінюю потребу ──\n`)
57
- const bootstrapFiles = sourceFiles.map(f => ({ file: f, pct: 0 }))
58
- const assessed = await assessNeed(bootstrapFiles, dir)
59
- const needsTests = assessed.filter(f => f.needsTests)
60
- if (needsTests.length === 0) {
61
- console.log('✓ LLM вирішила: жоден файл не потребує unit-тестів.')
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
- console.log(`\n→ Bootstrap-тести для (${needsTests.length}):`)
65
- for (const f of needsTests) console.log(` • ${f.file} ${f.reason}`)
66
- await generateTests(needsTests, dir)
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── Оцінюю ${uncovered.length} непокритих файлів (LLM) ──\n`)
88
- const assessed = await assessNeed(uncovered, dir)
89
- const needsTests = assessed.filter(f => f.needsTests)
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(needsTests, dir)
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 лінтить лише те, що змінено в робочому дереві: tracked-modified + staged
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
- * **робочим деревом** — тобто однаково ловить і закомічене від base, і staged, і
55
+ * **робочим деревом** — тобто однаково ловить і зафіксоване від base, і staged, і
56
56
  * незакомічені модифікації. Це гарантує однакову поведінку незалежно від того, чи
57
- * зміни вже закомічені у worktree. Без `base` — fallback на `collectChangedFiles`
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', `${base}^{commit}`], { cwd, encoding: 'utf8' })
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: не виконує операцій запису (ФС/БД).