@7n/rules 1.49.26 → 1.50.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.
@@ -6690,8 +6690,13 @@ export async function lint(ctx) {
6690
6690
  // (spec docs/specs/2026-07-02-text-check-per-file-split-design.md §6) ──
6691
6691
  const dirs = await findK8sRoots(root, ignorePaths)
6692
6692
  if (dirs.length > 0) {
6693
- const ks = await runKubescape(dirs, root, verbose)
6694
- if (ks !== 0 && ks !== 127) fail('kubescape знайшов ризики у маніфестах (k8s.mdc)', 'kubescape')
6693
+ const ks = await runKubescape(dirs, root, verbose, { onProgress: ctx.reportProgressDetail })
6694
+ if (ks.status !== 0 && ks.status !== 127) {
6695
+ const message = ks.timedOut
6696
+ ? `kubescape timeout: ${relative(root, ks.target)} (ліміт ${KUBESCAPE_SCAN_TIMEOUT_LABEL})`
6697
+ : 'kubescape знайшов ризики у маніфестах (k8s.mdc)'
6698
+ fail(message, 'kubescape')
6699
+ }
6695
6700
  }
6696
6701
 
6697
6702
  // ── JS/rego cross-file перевірки (колишній check, read-only) ──
@@ -6798,6 +6803,10 @@ export async function findK8sRoots(root, ignorePaths = []) {
6798
6803
  const KUBESCAPE_EXCEPTIONS_FILE = '.kubescape-exceptions.json'
6799
6804
  const KUSTOMIZATION_FILE = 'kustomization.yaml'
6800
6805
  const KUBESCAPE_MISSING_HINT = 'kubescape не знайдено в PATH. Встанови з https://github.com/kubescape/kubescape#readme'
6806
+ /** Bounded wall-clock limit кожного зовнішнього scan; spawnAsync завершує child process. */
6807
+ export const KUBESCAPE_SCAN_TIMEOUT_MS = 5 * 60_000
6808
+ const KUBESCAPE_SCAN_TIMEOUT_LABEL = '5 хв'
6809
+ const KUBESCAPE_SCAN_TIMEOUT_ARG = '5m'
6801
6810
  const KUBERNETES_VERSION = '1.33.9'
6802
6811
  const DATREE_CRD_SCHEMA_LOCATION =
6803
6812
  'https://datreeio.github.io/CRDs-catalog/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json'
@@ -7004,9 +7013,11 @@ async function runKustomizeBuild(kubectlPath, dir, verbose = false) {
7004
7013
  * @param {string|Buffer} manifest вміст маніфеста для сканування.
7005
7014
  * @param {string} root корінь репозиторію (для user-файла винятків).
7006
7015
  * @param {boolean} [verbose] показати повний нативний вивід kubescape (`stdio: 'inherit'`).
7007
- * @returns {Promise<{ status: number, enoent: boolean }>} exit-код і прапорець відсутнього тула.
7016
+ * @param {{ spawn?: typeof spawnAsync, useDefault?: boolean }} [opts] тестова інʼєкція spawn і cache-safe режим після першого target-а.
7017
+ * @returns {Promise<{ status: number, enoent: boolean, timedOut: boolean }>} exit-код, відсутність тула і timeout.
7008
7018
  */
7009
- async function runKubescapeManifest(kubescapePath, manifest, root, verbose = false) {
7019
+ export async function runKubescapeManifest(kubescapePath, manifest, root, verbose = false, opts = {}) {
7020
+ const exec = opts.spawn ?? spawnAsync
7010
7021
  const dir = mkdtempSync(join(tmpdir(), 'nitra-cursor-k8s-'))
7011
7022
  const file = join(dir, 'manifest.yaml')
7012
7023
  const exceptionsArgs = buildKubescapeExceptionsArgs(root, autoJobCronJobProbeExceptions(String(manifest)))
@@ -7014,13 +7025,24 @@ async function runKubescapeManifest(kubescapePath, manifest, root, verbose = fal
7014
7025
  try {
7015
7026
  writeFileSync(file, manifest)
7016
7027
  try {
7017
- const r = await spawnAsync(kubescapePath, ['scan', file, '--severity-threshold', 'high', ...exceptionsArgs], {
7018
- stdio: verbose ? 'inherit' : 'pipe'
7019
- })
7020
- return { status: r.exitCode ?? 1, enoent: false }
7028
+ const r = await exec(
7029
+ kubescapePath,
7030
+ [
7031
+ 'scan',
7032
+ file,
7033
+ '--severity-threshold',
7034
+ 'high',
7035
+ '--scan-timeout',
7036
+ KUBESCAPE_SCAN_TIMEOUT_ARG,
7037
+ ...(opts.useDefault ? ['--use-default'] : []),
7038
+ ...exceptionsArgs
7039
+ ],
7040
+ { stdio: verbose ? 'inherit' : 'pipe', timeoutMs: KUBESCAPE_SCAN_TIMEOUT_MS }
7041
+ )
7042
+ return { status: r.exitCode ?? 1, enoent: false, timedOut: r.timedOut === true }
7021
7043
  } catch (error) {
7022
7044
  const enoent = Boolean(error && error.code === 'ENOENT')
7023
- return { status: 1, enoent }
7045
+ return { status: 1, enoent, timedOut: false }
7024
7046
  }
7025
7047
  } finally {
7026
7048
  cleanupKubescapeExceptionsArgs(exceptionsArgs, root)
@@ -7036,24 +7058,37 @@ async function runKubescapeManifest(kubescapePath, manifest, root, verbose = fal
7036
7058
  * @param {string} dir каталог для сканування.
7037
7059
  * @param {string} root корінь репозиторію (для user-файла винятків).
7038
7060
  * @param {boolean} [verbose] показати повний нативний вивід kubescape (`stdio: 'inherit'`) і хід сканування.
7039
- * @returns {Promise<number>} exit-код (127 якщо тул відсутній).
7061
+ * @param {{ spawn?: typeof spawnAsync, useDefault?: boolean }} [opts] тестова інʼєкція spawn і cache-safe режим після першого target-а.
7062
+ * @returns {Promise<{ status: number, timedOut: boolean }>} exit-код (127 якщо тул відсутній) і timeout.
7040
7063
  */
7041
- async function scanRawK8sDir(kubescapePath, dir, root, verbose = false) {
7064
+ async function scanRawK8sDir(kubescapePath, dir, root, verbose = false, opts = {}) {
7065
+ const exec = opts.spawn ?? spawnAsync
7042
7066
  if (verbose) console.log(`run-k8s: kubescape scan ${dir} (без kustomization — сирий dir-скан)`)
7043
7067
  const yamlText = await readAllYamlTextUnderDir(dir)
7044
7068
  const exceptionsArgs = buildKubescapeExceptionsArgs(root, autoJobCronJobProbeExceptions(yamlText))
7045
7069
  if (verbose && exceptionsArgs.length > 0) console.log(`run-k8s: kubescape exceptions — ${exceptionsArgs[1]}`)
7046
7070
  try {
7047
- const r = await spawnAsync(kubescapePath, ['scan', dir, '--severity-threshold', 'high', ...exceptionsArgs], {
7048
- stdio: verbose ? 'inherit' : 'pipe'
7049
- })
7050
- return r.exitCode ?? 1
7071
+ const r = await exec(
7072
+ kubescapePath,
7073
+ [
7074
+ 'scan',
7075
+ dir,
7076
+ '--severity-threshold',
7077
+ 'high',
7078
+ '--scan-timeout',
7079
+ KUBESCAPE_SCAN_TIMEOUT_ARG,
7080
+ ...(opts.useDefault ? ['--use-default'] : []),
7081
+ ...exceptionsArgs
7082
+ ],
7083
+ { stdio: verbose ? 'inherit' : 'pipe', timeoutMs: KUBESCAPE_SCAN_TIMEOUT_MS }
7084
+ )
7085
+ return { status: r.exitCode ?? 1, timedOut: r.timedOut === true }
7051
7086
  } catch (error) {
7052
7087
  if (error && error.code === 'ENOENT') {
7053
7088
  console.error(KUBESCAPE_MISSING_HINT)
7054
- return 127
7089
+ return { status: 127, timedOut: false }
7055
7090
  }
7056
- return 1
7091
+ return { status: 1, timedOut: false }
7057
7092
  } finally {
7058
7093
  cleanupKubescapeExceptionsArgs(exceptionsArgs, root)
7059
7094
  }
@@ -7066,21 +7101,22 @@ async function scanRawK8sDir(kubescapePath, dir, root, verbose = false) {
7066
7101
  * @param {string[]} kdirs каталоги з kustomization.
7067
7102
  * @param {string} root корінь репозиторію (для user-файла винятків).
7068
7103
  * @param {boolean} [verbose] показати повний нативний вивід kubectl/kubescape і хід сканування.
7069
- * @returns {Promise<number>} 0 якщо всі чисті, інакше перший ненульовий exit-код (127 тул відсутній).
7104
+ * @param {{ spawn?: typeof spawnAsync, useDefault?: boolean }} [opts] тестова інʼєкція spawn і cache-safe режим після першого target-а.
7105
+ * @returns {Promise<{ status: number, target: string|null, timedOut: boolean }>} результат одного або кількох target-ів.
7070
7106
  */
7071
- async function scanKustomizeK8sDirs(kubectlPath, kubescapePath, kdirs, root, verbose = false) {
7107
+ async function scanKustomizeK8sDirs(kubectlPath, kubescapePath, kdirs, root, verbose = false, opts = {}) {
7072
7108
  for (const kdir of kdirs) {
7073
7109
  if (verbose) console.log(`run-k8s: kubectl kustomize ${kdir} | kubescape scan <tmp>`)
7074
7110
  const build = await runKustomizeBuild(kubectlPath, kdir, verbose)
7075
- if (build.status !== 0) return build.status
7076
- const ks = await runKubescapeManifest(kubescapePath, build.stdout, root, verbose)
7111
+ if (build.status !== 0) return { status: build.status, target: kdir, timedOut: false }
7112
+ const ks = await runKubescapeManifest(kubescapePath, build.stdout, root, verbose, opts)
7077
7113
  if (ks.enoent) {
7078
7114
  console.error(KUBESCAPE_MISSING_HINT)
7079
- return 127
7115
+ return { status: 127, target: kdir, timedOut: false }
7080
7116
  }
7081
- if (ks.status !== 0) return ks.status
7117
+ if (ks.status !== 0) return { status: ks.status, target: kdir, timedOut: ks.timedOut }
7082
7118
  }
7083
- return 0
7119
+ return { status: 0, target: null, timedOut: false }
7084
7120
  }
7085
7121
 
7086
7122
  /**
@@ -7090,27 +7126,47 @@ async function scanKustomizeK8sDirs(kubectlPath, kubescapePath, kdirs, root, ver
7090
7126
  * @param {string[]} dirs абсолютні шляхи k8s-коренів.
7091
7127
  * @param {string} root корінь репозиторію (для user-файла винятків).
7092
7128
  * @param {boolean} [verbose] показати повний нативний вивід kubectl/kubescape (`stdio: 'inherit'`) і хід сканування.
7093
- * @returns {Promise<number>} 0 якщо все чисто, інакше перший ненульовий exit-код (127 тул відсутній).
7129
+ * @param {{ kubescapePath?: string, kubectlPath?: string, spawn?: typeof spawnAsync, onProgress?: (progress: { label: string, done: number, total: number, current: string }) => void }} [opts] залежності й granular live progress.
7130
+ * @returns {Promise<{ status: number, target: string|null, timedOut: boolean }>} результат усіх target-ів.
7094
7131
  */
7095
- async function runKubescape(dirs, root, verbose = false) {
7096
- const kubescapePath = ensureTool('kubescape')
7132
+ export async function runKubescape(dirs, root, verbose = false, opts = {}) {
7133
+ const kubescapePath = opts.kubescapePath ?? ensureTool('kubescape')
7097
7134
  let kubectlPath = null
7135
+ /** @type {Array<{ kind: 'raw'|'kustomize', dir: string }>} */
7136
+ const targets = []
7098
7137
  for (const d of dirs) {
7099
7138
  const kdirs = await findKustomizationDirs(d)
7100
7139
  if (kdirs.length === 0) {
7101
- const rawStatus = await scanRawK8sDir(kubescapePath, d, root, verbose)
7102
- if (rawStatus !== 0) return rawStatus
7103
- continue
7140
+ targets.push({ kind: 'raw', dir: d })
7141
+ } else {
7142
+ for (const dir of kdirs) targets.push({ kind: 'kustomize', dir })
7104
7143
  }
7105
- if (kubectlPath === null) {
7106
- kubectlPath = resolveCmd('kubectl')
7107
- if (!kubectlPath) {
7108
- console.error('kubectl не знайдено в PATH. Встанови з https://kubernetes.io/docs/tasks/tools/#kubectl')
7109
- return 127
7144
+ }
7145
+
7146
+ let done = 0
7147
+ let cacheReady = false
7148
+ for (const target of targets) {
7149
+ opts.onProgress?.({ label: 'kubescape', done, total: targets.length, current: relative(root, target.dir) })
7150
+ if (target.kind === 'raw') {
7151
+ const result = await scanRawK8sDir(kubescapePath, target.dir, root, verbose, { ...opts, useDefault: cacheReady })
7152
+ if (result.status !== 0) return { ...result, target: target.dir }
7153
+ } else {
7154
+ if (kubectlPath === null) {
7155
+ kubectlPath = opts.kubectlPath ?? resolveCmd('kubectl')
7156
+ if (!kubectlPath) {
7157
+ console.error('kubectl не знайдено в PATH. Встанови з https://kubernetes.io/docs/tasks/tools/#kubectl')
7158
+ return { status: 127, target: target.dir, timedOut: false }
7159
+ }
7110
7160
  }
7161
+ const result = await scanKustomizeK8sDirs(kubectlPath, kubescapePath, [target.dir], root, verbose, {
7162
+ ...opts,
7163
+ useDefault: cacheReady
7164
+ })
7165
+ if (result.status !== 0) return result
7111
7166
  }
7112
- const buildStatus = await scanKustomizeK8sDirs(kubectlPath, kubescapePath, kdirs, root, verbose)
7113
- if (buildStatus !== 0) return buildStatus
7167
+ cacheReady = true
7168
+ done += 1
7169
+ opts.onProgress?.({ label: 'kubescape', done, total: targets.length, current: relative(root, target.dir) })
7114
7170
  }
7115
- return 0
7171
+ return { status: 0, target: null, timedOut: false }
7116
7172
  }
@@ -66,6 +66,15 @@ const NON_TTY_WAIT_LOG_INTERVAL_MS = 10_000
66
66
  /** Знімок прогресу вважається живим, якщо оновлювався не давніше за це. */
67
67
  const PROGRESS_FRESH_MS = 60_000
68
68
 
69
+ /** Версія контракту published snapshot-а; observer-и ігнорують неповні старі записи. */
70
+ const PROGRESS_SNAPSHOT_VERSION = 2
71
+
72
+ /** Частота heartbeat owner-а: довгий один target лишається видимим для процесу в черзі. */
73
+ const PUBLISH_HEARTBEAT_INTERVAL_MS = 5_000
74
+
75
+ /** Мінімум завершених targets, коли швидкість вже достатня для обережного ETA. */
76
+ const ETA_MIN_COMPLETED_TARGETS = 3
77
+
69
78
  /**
70
79
  * Fingerprint для TTL-дедуплікації: стан робочого дерева + варіант виклику lint.
71
80
  * null (→ дедуплікація вимкнена, черга працює) коли:
@@ -90,26 +99,68 @@ export function lintLockFingerprint(variant, getTreeFp = worktreeFingerprint) {
90
99
  * Publisher прогресу активного прогону: приймає знімки від
91
100
  * `createProgressReporter({ onUpdate })` і (throttled) пише їх у стан-файл,
92
101
  * звідки процеси в черзі читають прогрес-бар активного прогону.
93
- * @param {{file?: string, minIntervalMs?: number}} [opts] override-и для тестів
94
- * @returns {{ onUpdate: (snap: { done: number, total: number, found: number, fixed: number, current: string }) => void, stop: () => void }} publisher
102
+ * @param {{file?: string, minIntervalMs?: number, heartbeatIntervalMs?: number, now?: () => number}} [opts] override-и для тестів
103
+ * @returns {{ onUpdate: (snap: { done: number, total: number, found: number, fixed: number, current: string, detail?: { label: string, done: number, total: number, current: string } | null }) => void, stop: () => void }} publisher
95
104
  */
96
105
  export function createProgressPublisher(opts = {}) {
97
106
  const file = opts.file ?? PROGRESS_FILE
98
107
  const minIntervalMs = opts.minIntervalMs ?? PUBLISH_MIN_INTERVAL_MS
108
+ const heartbeatIntervalMs = opts.heartbeatIntervalMs ?? PUBLISH_HEARTBEAT_INTERVAL_MS
109
+ const now = opts.now ?? Date.now
99
110
  let lastWriteAt = 0
111
+ let lastSnap = null
112
+ let phaseStartedAt = 0
113
+
114
+ /** Записує атомарно достатній для observer-а snapshot; помилки лишаються best-effort. */
115
+ function publish(snap, heartbeat = false) {
116
+ const at = now()
117
+ const detail = snap.detail ?? null
118
+ if (!heartbeat && (!lastSnap || lastSnap.current !== snap.current || lastSnap.detail?.label !== detail?.label))
119
+ phaseStartedAt = at
120
+ const completed = detail?.done ?? 0
121
+ const remaining = Math.max(0, (detail?.total ?? 0) - completed)
122
+ const elapsed = phaseStartedAt > 0 ? at - phaseStartedAt : 0
123
+ const etaMs =
124
+ !heartbeat && detail && completed >= ETA_MIN_COMPLETED_TARGETS && remaining > 0 && elapsed > 0
125
+ ? Math.round((elapsed / completed) * remaining)
126
+ : (lastSnap?.etaMs ?? null)
127
+ const record = {
128
+ version: PROGRESS_SNAPSHOT_VERSION,
129
+ pid: process.pid,
130
+ cwd: processCwd(),
131
+ updatedAt: heartbeat ? (lastSnap?.updatedAt ?? at) : at,
132
+ heartbeatAt: at,
133
+ phase: snap.current,
134
+ step: detail?.label ?? null,
135
+ done: snap.done,
136
+ total: snap.total,
137
+ found: snap.found,
138
+ fixed: snap.fixed,
139
+ current: snap.current,
140
+ detail,
141
+ etaMs
142
+ }
143
+ lastSnap = record
144
+ try {
145
+ mkdirSync(dirname(file), { recursive: true })
146
+ writeFileSync(file, JSON.stringify(record))
147
+ } catch {
148
+ // best-effort: без стан-файлу процеси в черзі просто не побачать бар
149
+ }
150
+ }
151
+ const heartbeat = setInterval(() => {
152
+ if (lastSnap) publish(lastSnap, true)
153
+ }, heartbeatIntervalMs)
154
+ heartbeat.unref?.()
100
155
  return {
101
156
  onUpdate: snap => {
102
- const now = Date.now()
103
- if (now - lastWriteAt < minIntervalMs) return
104
- lastWriteAt = now
105
- try {
106
- mkdirSync(dirname(file), { recursive: true })
107
- writeFileSync(file, JSON.stringify({ pid: process.pid, updatedAt: now, cwd: processCwd(), ...snap }))
108
- } catch {
109
- // best-effort: без стан-файлу процеси в черзі просто не побачать бар
110
- }
157
+ const at = now()
158
+ if (at - lastWriteAt < minIntervalMs) return
159
+ lastWriteAt = at
160
+ publish(snap)
111
161
  },
112
162
  stop: () => {
163
+ clearInterval(heartbeat)
113
164
  try {
114
165
  rmSync(file, { force: true })
115
166
  } catch {
@@ -155,13 +206,14 @@ function listQueue(queueDir) {
155
206
  * застарілий або належить не поточному власнику лока.
156
207
  * @param {number} ownerPid PID власника лока
157
208
  * @param {string} progressFile шлях стан-файлу прогресу
158
- * @returns {{ done: number, total: number, found: number, fixed: number, current: string } | null} знімок
209
+ * @returns {{ done: number, total: number, found: number, fixed: number, current: string, phase: string, step: string|null, detail: { label: string, done: number, total: number, current: string }|null, etaMs: number|null, updatedAt: number, heartbeatAt: number } | null} знімок
159
210
  */
160
- function readOwnerProgress(ownerPid, progressFile) {
211
+ export function readOwnerProgress(ownerPid, progressFile) {
161
212
  try {
162
213
  const snap = JSON.parse(readFileSync(progressFile, 'utf8'))
163
214
  if (snap?.pid !== ownerPid) return null
164
- if (Date.now() - snap.updatedAt > PROGRESS_FRESH_MS) return null
215
+ if (snap?.version !== PROGRESS_SNAPSHOT_VERSION || typeof snap.heartbeatAt !== 'number') return null
216
+ if (Date.now() - snap.heartbeatAt > PROGRESS_FRESH_MS) return null
165
217
  return snap
166
218
  } catch {
167
219
  return null
@@ -180,7 +232,11 @@ export function renderWaitLine(owner, queue, snap) {
180
232
  const myIdx = queue.findIndex(e => e.pid === process.pid)
181
233
  const pos = (myIdx === -1 ? queue.length : myIdx) + 1
182
234
  const ownerDir = owner.cwd ? ` (${basename(owner.cwd)})` : ''
183
- const bar = snap ? ` · ${renderProgressLine(snap)}` : ''
235
+ const eta =
236
+ snap?.etaMs !== null && snap?.etaMs !== undefined && Date.now() - snap.updatedAt <= PROGRESS_FRESH_MS
237
+ ? ` · ETA ≈ ${formatEta(snap.etaMs)}`
238
+ : ''
239
+ const bar = snap ? ` · ${renderProgressLine(snap)}${eta}` : ''
184
240
  const others = queue
185
241
  .filter(e => e.pid !== process.pid)
186
242
  .map(e => `pid ${e.pid} (${basename(e.cwd)})`)
@@ -189,6 +245,13 @@ export function renderWaitLine(owner, queue, snap) {
189
245
  return `⏳ lint --full у черзі #${pos}/${Math.max(queue.length, pos)} · працює pid ${owner.pid}${ownerDir}${bar}${tail}`
190
246
  }
191
247
 
248
+ /** Форматує короткий ETA; caller передає лише вже перевірену обережну оцінку. */
249
+ function formatEta(ms) {
250
+ const seconds = Math.max(1, Math.round(ms / 1000))
251
+ if (seconds < 60) return `${seconds} с`
252
+ return `${Math.ceil(seconds / 60)} хв`
253
+ }
254
+
192
255
  /**
193
256
  * UI очікування для хуків withLock: реєструє процес у черзі, на кожен tick
194
257
  * рендерить рядок стану (TTY — перемальовування одного рядка на stderr;
@@ -213,13 +276,15 @@ function createWaitUi(opts = {}) {
213
276
  } catch {
214
277
  // best-effort: без реєстрації процес просто не видно у списку черги
215
278
  }
279
+ // non-TTY мусить сказати про очікування одразу, а не через перший інтервал poll-а.
280
+ lastAppendAt = 0
216
281
  },
217
282
  onWaitTick: owner => {
218
283
  const line = renderWaitLine(owner, listQueue(queueDir), readOwnerProgress(owner.pid, progressFile))
219
284
  if (isTTY) {
220
285
  // \r + ANSI clear-line: один рядок, що перемальовується на кожен tick
221
286
  log(`\r\u{1B}[2K${line}`)
222
- } else if (Date.now() - lastAppendAt >= NON_TTY_WAIT_LOG_INTERVAL_MS) {
287
+ } else if (lastAppendAt === 0 || Date.now() - lastAppendAt >= NON_TTY_WAIT_LOG_INTERVAL_MS) {
223
288
  lastAppendAt = Date.now()
224
289
  log(`${line}\n`)
225
290
  }
@@ -22,7 +22,7 @@ const BAR_WIDTH = 20
22
22
  * Рендерить один рядок прогресу — спільний формат для TTY-бара цього reporter-а
23
23
  * та для прогресу чужого прогону, який показує процес у черзі
24
24
  * (`lint-lock.mjs` читає знімок зі стан-файлу і показує його тим самим рядком).
25
- * @param {{ done: number, total: number, found: number, fixed: number, current: string, unitLabel?: string, withFixed?: boolean }} snap знімок прогресу
25
+ * @param {{ done: number, total: number, found: number, fixed: number, current: string, unitLabel?: string, withFixed?: boolean, detail?: { label: string, done: number, total: number, current: string } | null }} snap знімок прогресу
26
26
  * @returns {string} готовий рядок бара
27
27
  */
28
28
  export function renderProgressLine(snap) {
@@ -31,7 +31,10 @@ export function renderProgressLine(snap) {
31
31
  const filled = Math.round(progress * BAR_WIDTH)
32
32
  const bar = '█'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled)
33
33
  const ticker = (snap.withFixed ?? true) ? ` · знайдено ${snap.found} · виправлено ${snap.fixed}` : ''
34
- return `[${bar}] ${snap.done}/${snap.total} ${unitLabel}${ticker} · ${snap.current}`
34
+ const sub = snap.detail
35
+ ? ` · ${snap.detail.label} ${snap.detail.done}/${snap.detail.total} · ${snap.detail.current}`
36
+ : ''
37
+ return `[${bar}] ${snap.done}/${snap.total} ${unitLabel}${ticker} · ${snap.current}${sub}`
35
38
  }
36
39
 
37
40
  /**
@@ -61,6 +64,7 @@ function formatBar(_options, params, payload) {
61
64
  * @property {(label: string, tier?: string) => void} concernStart оновити суфікс поточної одиниці
62
65
  * @property {(key: string, count: number) => void} detectSnapshot знімок detect/re-detect (кількість порушень)
63
66
  * @property {(key: string) => void} concernDone одиницю оброблено (закриту чи ні) — бар +1
67
+ * @property {(detail: { label: string, done: number, total: number, current: string } | null) => void} detail опублікувати деталізацію поточного concern-а
64
68
  * @property {() => { done: number, total: number, found: number, fixed: number }} summary поточні лічильники
65
69
  * @property {() => void} stop фінальний render і звільнення TTY-рядка
66
70
  */
@@ -89,6 +93,8 @@ export function createProgressReporter(opts) {
89
93
  const counters = new Map()
90
94
  let done = 0
91
95
  let current = '…'
96
+ /** @type {{ label: string, done: number, total: number, current: string } | null} */
97
+ let detail = null
92
98
 
93
99
  /**
94
100
  * Пер-ключ стан лічильників (лениве створення).
@@ -139,7 +145,7 @@ export function createProgressReporter(opts) {
139
145
  /** Перемальовує бар актуальним payload-ом (TTY) і публікує знімок назовні. */
140
146
  function redraw() {
141
147
  const { found, fixed } = tally()
142
- opts.onUpdate?.({ done, total, found, fixed, current })
148
+ opts.onUpdate?.({ done, total, found, fixed, current, detail })
143
149
  if (!bar) return
144
150
  bar.update(done, { unitLabel, withFixed, found, fixed, current })
145
151
  }
@@ -153,6 +159,12 @@ export function createProgressReporter(opts) {
153
159
 
154
160
  concernStart: (label, tier) => {
155
161
  current = tier ? `${label} (${tier})` : label
162
+ detail = null
163
+ redraw()
164
+ },
165
+
166
+ detail: next => {
167
+ detail = next
156
168
  redraw()
157
169
  },
158
170
 
@@ -527,7 +527,15 @@ async function buildPlan({
527
527
  */
528
528
  async function runPlanItem({ entry, files }, { cwd, verbose, progress, log, signal }) {
529
529
  /** @type {LintContext} */
530
- const ctx = { cwd, ruleId: entry.ruleId, concernId: entry.concern.name, files, verbose, signal }
530
+ const ctx = {
531
+ cwd,
532
+ ruleId: entry.ruleId,
533
+ concernId: entry.concern.name,
534
+ files,
535
+ verbose,
536
+ signal,
537
+ reportProgressDetail: progress?.detail
538
+ }
531
539
  const key = `${entry.ruleId}/${entry.concern.name}`
532
540
  progress?.concernStart(key)
533
541
  if (verbose) {
@@ -24,6 +24,8 @@
24
24
  * @property {AbortSignal} [signal] сигнал скасування — лише у parallel lane `detectAll()`
25
25
  * (`N_RULES_LINT_CONCURRENCY>1`, ADR 260716-1354); async-детектори (`spawnAsync`-based)
26
26
  * прокидають його далі, щоб перерватись при infrastructure-помилці іншого concern-а
27
+ * @property {(detail: { label: string, done: number, total: number, current: string } | null) => void} [reportProgressDetail]
28
+ * optional read-only live detail поточного concern-а для full-lint observer-а
27
29
  */
28
30
 
29
31
  /**
@@ -13,9 +13,10 @@ version: '1.0'
13
13
  у теці `docs/` **поряд із самим файлом** (`<dir>/docs/<stem>.md`). Це **обовʼязковий крок кожної
14
14
  задачі** — як `lint`: після зміни коду його дока має бути перегенерована.
15
15
 
16
- Застарілість визначається **детерміновано за CRC**: кожна дока несе у frontmatter контрольну
17
- суму байтів джерела на момент генерації. Дока **застаріла**, якщо її немає або
18
- `crc(поточне джерело) crc у frontmatter`.
16
+ Застарілість визначається **детерміновано за CRC evidence**: кожна дока несе у frontmatter
17
+ контрольну суму source + повʼязаних test/spec-файлів на момент генерації. Без повʼязаних
18
+ тестів це звичайний CRC source. Дока **застаріла**, якщо її немає або поточний evidence CRC
19
+ не збігається з `crc` у frontmatter.
19
20
 
20
21
  ```markdown
21
22
  ---
@@ -60,6 +61,18 @@ npx @7n/rules lint doc-files
60
61
  (`stale`) → генерує локальною моделлю → пише доку зі **свіжим CRC** (і degraded-маркером,
61
62
  якщо не дотягнула) → друкує прогрес і підсумок.
62
63
 
64
+ Повʼязані тести визначаються за relative reference, який резолвиться у source (`import`,
65
+ `require`, dynamic import, `vi.mock` тощо), плюс naming/layout evidence (`foo.test` → `foo`
66
+ або `module/tests/*` → module `main`/`index`). Shared test helpers, які тест лише імпортує,
67
+ не стають evidence. JS детерміновано рендерить один компактний рядок на test-файл: до двох
68
+ груп, пʼять дослівних прикладів і точний лічильник решти; test-код і сценарії не потрапляють до
69
+ моделі. Зміна такого тесту робить source-доку stale.
70
+
71
+ Авторські коментарі теж є першоджерелом: для JSDoc, rustdoc і Python docstring-ів JS дослівно
72
+ збирає «Огляд» і «Публічний API». Детальний наратив дає `comment-only` (0 LLM); короткий
73
+ pointer або складний flow — `comment+behavior`, де LLM дописує тільки «Поведінку», а judge
74
+ перевіряє лише її. За неповних коментарів лишається звичайний `fallback`.
75
+
63
76
  ### Крок 2: Підтвердження
64
77
 
65
78
  Дочекайся підсумку `✓ OK: <N> ⚠ degraded: <D> ✗ Err: <E>`. Якщо є помилки — перелічи
@@ -80,7 +93,9 @@ preflight). Degraded — не помилка: дока існує, CRC свіж
80
93
  ## Нотатки
81
94
 
82
95
  - Не комітити автоматично — користувач вирішує, коли комітити згенеровану доку.
83
- - Scanner ігнорує `node_modules`, `dist`, `.git`, `__pycache__`, `coverage`, `.cursor`, `.claude`,
84
- усі теки `docs/`, а також `*.test.*` / `*.spec.*` / `*.d.ts`. Кореневий repo `docs/` —
85
- system-wide only: file-level docs туди не пишуться. Список glob-ів — `docgen-ignore.mjs`.
96
+ - Scanner не створює окремих док для `*.test.*` / `*.spec.*`, але використовує повʼязані
97
+ тести як evidence для source-доки. Він ігнорує `node_modules`, `dist`, `.git`,
98
+ `__pycache__`, `coverage`, `.cursor`, `.claude`, усі теки `docs/` і `*.d.ts`.
99
+ Кореневий repo `docs/` — system-wide only: file-level docs туди не пишуться. Список
100
+ glob-ів — `docgen-ignore.mjs`.
86
101
  - Агрегуюча документація (module-summary, доменні доки) — окремий скіл `doc-aggregate`, за запитом.