@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.
- package/CHANGELOG.md +10 -0
- package/package.json +1 -1
- package/rules/doc-files/check/docs/index.md +10 -0
- package/rules/doc-files/check/docs/main.md +4 -2
- package/rules/doc-files/check/main.mjs +12 -4
- package/rules/doc-files/docgen-crc/docs/index.md +9 -0
- package/rules/doc-files/docgen-crc/docs/main.md +47 -26
- package/rules/doc-files/docgen-crc/main.mjs +21 -4
- package/rules/doc-files/docgen-files-batch/docs/main.md +20 -14
- package/rules/doc-files/docgen-files-batch/main.mjs +54 -20
- package/rules/doc-files/docgen-gen/docs/main.md +35 -3
- package/rules/doc-files/docgen-gen/main.mjs +253 -19
- package/rules/doc-files/docgen-prompts/docs/main.md +18 -12
- package/rules/doc-files/docgen-prompts/main.mjs +19 -12
- package/rules/doc-files/docgen-scan/docs/main.md +44 -32
- package/rules/doc-files/docgen-scan/main.mjs +6 -3
- package/rules/doc-files/docgen-test-context/docs/index.md +9 -0
- package/rules/doc-files/docgen-test-context/docs/main.md +57 -0
- package/rules/doc-files/docgen-test-context/main.mjs +212 -0
- package/rules/doc-files/main.mdc +37 -6
- package/rules/k8s/manifests/main.mjs +94 -38
- package/scripts/lib/lint-surface/lint-lock.mjs +81 -16
- package/scripts/lib/lint-surface/progress.mjs +15 -3
- package/scripts/lib/lint-surface/run-detectors.mjs +9 -1
- package/scripts/lib/lint-surface/types.mjs +2 -0
- package/skills/doc-files/SKILL.md +21 -6
|
@@ -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)
|
|
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
|
-
* @
|
|
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
|
|
7018
|
-
|
|
7019
|
-
|
|
7020
|
-
|
|
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
|
-
* @
|
|
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
|
|
7048
|
-
|
|
7049
|
-
|
|
7050
|
-
|
|
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
|
-
* @
|
|
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
|
-
* @
|
|
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
|
-
|
|
7102
|
-
|
|
7103
|
-
|
|
7140
|
+
targets.push({ kind: 'raw', dir: d })
|
|
7141
|
+
} else {
|
|
7142
|
+
for (const dir of kdirs) targets.push({ kind: 'kustomize', dir })
|
|
7104
7143
|
}
|
|
7105
|
-
|
|
7106
|
-
|
|
7107
|
-
|
|
7108
|
-
|
|
7109
|
-
|
|
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
|
-
|
|
7113
|
-
|
|
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
|
|
103
|
-
if (
|
|
104
|
-
lastWriteAt =
|
|
105
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
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`, за запитом.
|