@7n/test 0.9.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 +22 -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 +3 -1
  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
@@ -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 { /* include on read error */ }
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 лінтить лише те, що змінено в робочому дереві: 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: не виконує операцій запису (ФС/БД).
@@ -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
+ - Кешує результати в межах одного прогону.