@ngockhoale/ukit 2.4.2 → 2.5.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 +57 -0
- package/README.md +20 -0
- package/manifests/platform.full.yaml +51 -112
- package/package.json +2 -1
- package/scripts/index/refresh-index.mjs +47 -22
- package/src/cli/commands/doctor.js +132 -2
- package/src/cli/commands/uninstall.js +18 -0
- package/src/core/applyPlan.js +17 -2
- package/src/core/compact/threshold.js +36 -6
- package/src/core/diffPlan.js +35 -0
- package/src/core/fileOps.js +26 -0
- package/src/core/projectImportant.js +430 -0
- package/src/core/sensitiveValueScanner.js +118 -0
- package/src/core/status.js +55 -1
- package/src/core/uninstall.js +183 -3
- package/src/diagnostics/classifyHang.js +246 -0
- package/src/index/buildIndex.js +1033 -62
- package/templates/.claude/hooks/auto-allow-bash.sh +82 -93
- package/templates/.claude/hooks/block-dangerous.sh +31 -5
- package/templates/.claude/hooks/completion-gate.sh +51 -10
- package/templates/.claude/hooks/compress-output.sh +38 -6
- package/templates/.claude/hooks/context-hardcap-gate.sh +35 -6
- package/templates/.claude/hooks/context-window-guard.sh +128 -18
- package/templates/.claude/hooks/handoff-model-guard.sh +31 -5
- package/templates/.claude/hooks/handoff-resume.sh +31 -5
- package/templates/.claude/hooks/post-edit-verify.sh +31 -5
- package/templates/.claude/hooks/pre-edit-backup.sh +31 -5
- package/templates/.claude/hooks/project-important.sh +67 -0
- package/templates/.claude/hooks/protect-files.sh +31 -5
- package/templates/.claude/hooks/record-execution.sh +31 -5
- package/templates/.claude/hooks/sensitive-data-guard.sh +124 -56
- package/templates/.claude/hooks/skill-router.sh +31 -5
- package/templates/.claude/hooks/stale-spec-guard.sh +31 -5
- package/templates/.claude/hooks/task-watchdog.sh +108 -123
- package/templates/.claude/hooks/verification-guard.sh +107 -112
- package/templates/.claude/hooks/vision-router.sh +49 -13
- package/templates/.claude/settings.json +5 -5
- package/templates/.claude/ukit/index/lib/index-core.mjs +960 -63
- package/templates/.claude/ukit/index/refresh-index.mjs +47 -22
- package/templates/.claude/ukit/index/route-task.mjs +610 -4
- package/templates/.claude/ukit/runtime/async-lock.mjs +340 -0
- package/templates/.claude/ukit/runtime/compact-threshold.mjs +73 -24
- package/templates/.claude/ukit/runtime/context-capacity.mjs +144 -0
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +664 -170
- package/templates/.claude/ukit/runtime/hook-chain-budget.mjs +92 -0
- package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +84 -29
- package/templates/.claude/ukit/runtime/hook-input.sh +85 -5
- package/templates/.claude/ukit/runtime/hook-payload-store.mjs +160 -0
- package/templates/.claude/ukit/runtime/hook-process.mjs +250 -0
- package/templates/.claude/ukit/runtime/hook-telemetry.mjs +255 -0
- package/templates/.claude/ukit/runtime/hook-telemetry.sh +60 -0
- package/templates/.claude/ukit/runtime/project-important.mjs +381 -0
- package/templates/.claude/ukit/runtime/sensitive-value-scanner.mjs +128 -0
- package/templates/.claude/ukit/runtime/stop-coordinator.mjs +509 -0
- package/templates/.claude/ukit/runtime/task-watchdog.mjs +180 -6
- package/templates/.claude/ukit/runtime/transcript-tail.mjs +1 -1
- package/templates/.omp/hooks/pre/ukit-bridge.js +178 -61
- package/templates/AGENTS.md +8 -0
- package/templates/PROJECT_IMPORTANT.md +9 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sensitiveValueScanner.js (TASK-036)
|
|
3
|
+
*
|
|
4
|
+
* Shared source-side extraction of the high-confidence secret *value* scanner
|
|
5
|
+
* that lives in templates/.claude/hooks/sensitive-data-guard.sh. One semantic
|
|
6
|
+
* implementation is consumed by the runtime hook mirror (TASK-038/039) and the
|
|
7
|
+
* PROJECT_IMPORTANT.md renderer (TASK-037).
|
|
8
|
+
*
|
|
9
|
+
* Scope is the value scanner only: vendor/API-key patterns, JWT, private-key
|
|
10
|
+
* block, SHA-256 exact-value allowlist matching, and the
|
|
11
|
+
* security.sensitiveDataGate config read. File/path classification and Bash
|
|
12
|
+
* command-shape logic stay in the hook.
|
|
13
|
+
*
|
|
14
|
+
* Leak-safety contract: return values carry labels only — never a secret
|
|
15
|
+
* value, prefix, suffix, excerpt, or hash of a matched secret.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
|
|
20
|
+
function sha256(value) {
|
|
21
|
+
return createHash('sha256').update(value).digest('hex');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// --- high-confidence secret value patterns ---
|
|
25
|
+
// Semantically identical to TOKEN_PATTERNS in sensitive-data-guard.sh.
|
|
26
|
+
// AKIA/ASIA/AIza prefixes are pure base64-compatible text, so an encoded blob
|
|
27
|
+
// can contain a coincidental key-shaped substring — those rules must stand
|
|
28
|
+
// alone with no base64/base64url character on either side.
|
|
29
|
+
const TOKEN_PATTERNS = [
|
|
30
|
+
{ label: 'OpenAI/Anthropic-style API key', re: /\bsk-(?:proj-|ant-|svc-|acct-|admin-)?[A-Za-z0-9_-]{20,}/g },
|
|
31
|
+
{ label: 'AWS access key id', re: /(?<![A-Za-z0-9+/_-])(?:AKIA|ASIA)[0-9A-Z]{16}(?![A-Za-z0-9+/_-])/g },
|
|
32
|
+
{ label: 'GitHub token', re: /\bgh[pousr]_[A-Za-z0-9]{30,}\b/g },
|
|
33
|
+
{ label: 'GitHub fine-grained token', re: /\bgithub_pat_[A-Za-z0-9_]{20,}/g },
|
|
34
|
+
{ label: 'GitLab token', re: /\bglpat-[A-Za-z0-9_-]{20,}/g },
|
|
35
|
+
{ label: 'Slack token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}/g },
|
|
36
|
+
{ label: 'Google API key', re: /(?<![A-Za-z0-9+/_-])AIza[0-9A-Za-z_-]{20,}(?![A-Za-z0-9+/_-])/g },
|
|
37
|
+
{ label: 'Stripe live key', re: /\b[srp]k_live_[A-Za-z0-9]{20,}/g },
|
|
38
|
+
{ label: 'JWT', re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g },
|
|
39
|
+
{ label: 'private key block', re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----/g },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const SHA256_HEX_RE = /^[0-9a-f]{64}$/i;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Scan text for high-confidence secret values.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} text
|
|
48
|
+
* @param {{ allowlistHashes?: string[], gateEnabled?: boolean }} [options]
|
|
49
|
+
* allowlistHashes: SHA-256 hex digests of explicitly approved exact values.
|
|
50
|
+
* gateEnabled: pass isSensitiveDataGateEnabled(config); when false the scan
|
|
51
|
+
* reports the gate's exit-0 semantics (no detection) like today's hook.
|
|
52
|
+
* @returns {{ hasSecret: boolean, allowed: boolean, labels: string[] }}
|
|
53
|
+
* labels only — never values, excerpts, or hashes.
|
|
54
|
+
*/
|
|
55
|
+
export function scanText(text, options = {}) {
|
|
56
|
+
const { allowlistHashes = [], gateEnabled = true } = options || {};
|
|
57
|
+
const clean = { hasSecret: false, allowed: false, labels: [] };
|
|
58
|
+
if (gateEnabled === false) return clean;
|
|
59
|
+
if (typeof text !== 'string' || text.length === 0) return clean;
|
|
60
|
+
|
|
61
|
+
const allowed = new Set(
|
|
62
|
+
Array.isArray(allowlistHashes) ? allowlistHashes.filter((h) => typeof h === 'string') : [],
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const labels = new Set();
|
|
66
|
+
let sawAllowlisted = false;
|
|
67
|
+
for (const { label, re } of TOKEN_PATTERNS) {
|
|
68
|
+
re.lastIndex = 0;
|
|
69
|
+
let match;
|
|
70
|
+
while ((match = re.exec(text)) !== null) {
|
|
71
|
+
if (allowed.has(sha256(match[0]))) {
|
|
72
|
+
sawAllowlisted = true;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
labels.add(label);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (labels.size === 0) {
|
|
80
|
+
return { hasSecret: false, allowed: sawAllowlisted, labels: [] };
|
|
81
|
+
}
|
|
82
|
+
return { hasSecret: true, allowed: false, labels: [...labels] };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Gate toggle: enabled unless security.sensitiveDataGate === false
|
|
87
|
+
* (mirrors the hook's explicit-false check on .ukit/storage/config.json).
|
|
88
|
+
*
|
|
89
|
+
* @param {object|null|undefined} config injected config object
|
|
90
|
+
* @returns {boolean}
|
|
91
|
+
*/
|
|
92
|
+
export function isSensitiveDataGateEnabled(config) {
|
|
93
|
+
if (config && config.security && config.security.sensitiveDataGate === false) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Load the SHA-256 exact-value allowlist from an injected config object.
|
|
101
|
+
* Accepts security.allowlist.values (mirror of allowlist.json) or
|
|
102
|
+
* security.sensitiveDataAllowlist; keeps only well-formed 64-hex digests.
|
|
103
|
+
*
|
|
104
|
+
* @param {object|null|undefined} config
|
|
105
|
+
* @returns {string[]} SHA-256 hex digests
|
|
106
|
+
*/
|
|
107
|
+
export function loadSensitiveAllowlist(config) {
|
|
108
|
+
if (!config || typeof config !== 'object') return [];
|
|
109
|
+
const security = config.security && typeof config.security === 'object' ? config.security : {};
|
|
110
|
+
const candidates = [];
|
|
111
|
+
if (security.allowlist && Array.isArray(security.allowlist.values)) {
|
|
112
|
+
candidates.push(...security.allowlist.values);
|
|
113
|
+
}
|
|
114
|
+
if (Array.isArray(security.sensitiveDataAllowlist)) {
|
|
115
|
+
candidates.push(...security.sensitiveDataAllowlist);
|
|
116
|
+
}
|
|
117
|
+
return candidates.filter((v) => typeof v === 'string' && SHA256_HEX_RE.test(v));
|
|
118
|
+
}
|
package/src/core/status.js
CHANGED
|
@@ -7,6 +7,7 @@ import { buildPromptCacheStats } from './token/index.js';
|
|
|
7
7
|
import { countMemoryItems } from './memory/store.js';
|
|
8
8
|
import { buildCompactPressureState } from './compact/threshold.js';
|
|
9
9
|
import { detectProjectContext } from '../context/detectProjectContext.js';
|
|
10
|
+
import { inspectProjectImportant, inspectProjectImportantWiring } from './projectImportant.js';
|
|
10
11
|
|
|
11
12
|
function formatPrimaryAgent(agentKey) {
|
|
12
13
|
if (agentKey === 'claude-code') return 'Claude Code';
|
|
@@ -151,6 +152,29 @@ export async function buildStatusReport(projectRoot) {
|
|
|
151
152
|
const memoryCounts = await countMemoryItems(projectRoot);
|
|
152
153
|
const adapters = await detectAdapterLabels(projectRoot, config.agent);
|
|
153
154
|
|
|
155
|
+
// TASK-044 — typed projectImportant state + conditional adapter wiring (spec §13).
|
|
156
|
+
const installMeta = await readStatusJson(
|
|
157
|
+
path.join(projectRoot, '.claude', 'ukit', '.ukit', 'install.json'),
|
|
158
|
+
);
|
|
159
|
+
const trackedPaths = Array.isArray(installMeta?.files)
|
|
160
|
+
? installMeta.files
|
|
161
|
+
.map((entry) => (typeof entry === 'string' ? entry : entry?.p))
|
|
162
|
+
.filter((entry) => typeof entry === 'string')
|
|
163
|
+
: [];
|
|
164
|
+
const piInspection = await inspectProjectImportant({ projectRoot, config });
|
|
165
|
+
const piWiring = await inspectProjectImportantWiring(projectRoot, { trackedPaths });
|
|
166
|
+
const projectImportant = {
|
|
167
|
+
state: piInspection.state,
|
|
168
|
+
codePointCount: piInspection.codePointCount,
|
|
169
|
+
oversized: piInspection.oversized,
|
|
170
|
+
wiring: {
|
|
171
|
+
claude: piWiring.claude.wired,
|
|
172
|
+
omp: piWiring.omp.installed ? piWiring.omp.wired : undefined,
|
|
173
|
+
codex: piWiring.codex.installed ? piWiring.codex.wired : undefined,
|
|
174
|
+
opencode: piWiring.opencode.installed ? piWiring.opencode.wired : undefined,
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
|
|
154
178
|
return {
|
|
155
179
|
version: config.version,
|
|
156
180
|
projectName: projectContext.project.name,
|
|
@@ -176,14 +200,43 @@ export async function buildStatusReport(projectRoot) {
|
|
|
176
200
|
),
|
|
177
201
|
defaultModel: config.router.defaultModel,
|
|
178
202
|
advisorEnabled: Boolean(config.router.advisorEnabled),
|
|
203
|
+
projectImportant,
|
|
179
204
|
};
|
|
180
205
|
}
|
|
181
206
|
|
|
207
|
+
const PROJECT_IMPORTANT_STATE_LABELS = {
|
|
208
|
+
ready: (pi) => `ready / ${pi.codePointCount.toLocaleString('en-US')} of 6,000 code points`,
|
|
209
|
+
oversized: () => 'oversized / >6,000 code points / injects first 6,000',
|
|
210
|
+
empty: () => 'empty / not injected',
|
|
211
|
+
missing: () => 'missing / not injected',
|
|
212
|
+
'invalid-utf8': () => 'invalid UTF-8 / not injected',
|
|
213
|
+
'unsafe-control': () => 'unsupported control characters / not injected',
|
|
214
|
+
'secret-blocked': () => 'secret-blocked / not injected',
|
|
215
|
+
'unsafe-type': () => 'unsafe symlink / not injected',
|
|
216
|
+
unreadable: () => 'unreadable / not injected',
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
function formatProjectImportantRules(pi) {
|
|
220
|
+
const formatter = PROJECT_IMPORTANT_STATE_LABELS[pi?.state] ?? (() => 'unreadable / not injected');
|
|
221
|
+
return formatter(pi);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function formatProjectImportantWiring(pi) {
|
|
225
|
+
const mark = (wired) => (wired ? '✓' : '✗');
|
|
226
|
+
const parts = [`Claude runtime ${mark(pi.wiring.claude)}`];
|
|
227
|
+
if (pi.wiring.omp !== undefined) parts.push(`omp runtime ${mark(pi.wiring.omp)}`);
|
|
228
|
+
if (pi.wiring.codex !== undefined) parts.push(`Codex fallback ${mark(pi.wiring.codex)}`);
|
|
229
|
+
if (pi.wiring.opencode !== undefined) parts.push(`OpenCode fallback ${mark(pi.wiring.opencode)}`);
|
|
230
|
+
return parts.join(' / ');
|
|
231
|
+
}
|
|
232
|
+
|
|
182
233
|
export function formatStatusReport(report) {
|
|
183
234
|
return [
|
|
184
235
|
`UKit v${report.version} — Status`,
|
|
185
236
|
'─────────────────────',
|
|
186
237
|
`${padLabel('Project')} ${report.projectName}`,
|
|
238
|
+
`${padLabel('Project rules')} ${formatProjectImportantRules(report.projectImportant)}`,
|
|
239
|
+
`${padLabel('Rule wiring')} ${formatProjectImportantWiring(report.projectImportant)}`,
|
|
187
240
|
`${padLabel('Adapters')} ${report.adapters.join(', ')}`,
|
|
188
241
|
`${padLabel('Memory items')} ${report.memoryCounts.projectCount} project / ${report.memoryCounts.sessionCount} session / ${report.memoryCounts.userCount} user`,
|
|
189
242
|
`${padLabel('Last compact')} ${report.lastCompact}`,
|
|
@@ -191,7 +244,8 @@ export function formatStatusReport(report) {
|
|
|
191
244
|
`${padLabel('Compact lanes')} ${report.compactLanes}`,
|
|
192
245
|
`${padLabel('Ctx pressure')} ${report.contextPressure}`,
|
|
193
246
|
`${padLabel('Threshold cmp')} ${report.thresholdCompact}`,
|
|
194
|
-
`${padLabel('
|
|
247
|
+
`${padLabel('Local p-cache')} ${report.promptCache}`,
|
|
248
|
+
`${padLabel('Provider cache')} unknown (host telemetry unavailable)`,
|
|
195
249
|
`${padLabel('Cache lanes')} ${report.cacheLanes}`,
|
|
196
250
|
`${padLabel('Output comp.')} ${report.outputCompression}`,
|
|
197
251
|
`${padLabel('Output lanes')} ${report.outputLanes}`,
|
package/src/core/uninstall.js
CHANGED
|
@@ -2,12 +2,154 @@ import fs from 'node:fs/promises';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import {
|
|
4
4
|
cleanupEmptyParents,
|
|
5
|
+
copyFileRawExclusive,
|
|
5
6
|
readJsonIfExists,
|
|
6
7
|
removeLinkOrDir,
|
|
7
8
|
removeLinkOnly,
|
|
8
9
|
resolveProjectRelativePath,
|
|
9
10
|
} from './fileOps.js';
|
|
10
11
|
import { removeGitignoreBlock } from './ensureGitignore.js';
|
|
12
|
+
import { PROJECT_IMPORTANT_FILENAME } from './projectImportant.js';
|
|
13
|
+
|
|
14
|
+
// PROJECT_IMPORTANT.md is owner content, never UKit-managed. It must NEVER appear
|
|
15
|
+
// in ALLOWED_UNINSTALL_PREFIXES and is never deleted/renamed/chmodded/rewritten/
|
|
16
|
+
// tracked/followed. Before any destructive uninstall step a regular source gets a
|
|
17
|
+
// raw-byte backup beside it at the project root (never under .claude/.ukit/.codex/
|
|
18
|
+
// .omp — those may be deleted): `<name>.ukit-backup`, then `.ukit-backup.1`, `.2`, …
|
|
19
|
+
// via bounded exclusive-create collision walk. See docs/PROJECT_IMPORTANT_SPEC.md §15.
|
|
20
|
+
const IMPORTANT_BACKUP_SUFFIX = '.ukit-backup';
|
|
21
|
+
const IMPORTANT_BACKUP_COLLISION_LIMIT = 100;
|
|
22
|
+
|
|
23
|
+
function importantBackupCandidateName(index) {
|
|
24
|
+
return index === 0
|
|
25
|
+
? `${PROJECT_IMPORTANT_FILENAME}${IMPORTANT_BACKUP_SUFFIX}`
|
|
26
|
+
: `${PROJECT_IMPORTANT_FILENAME}${IMPORTANT_BACKUP_SUFFIX}.${index}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Read-only inspection used by BOTH dry-run and real runs: resolves which backup
|
|
30
|
+
// candidate would be used without creating anything. An existing backup whose
|
|
31
|
+
// bytes already match the source is reused; a different-bytes or non-regular
|
|
32
|
+
// occupant pushes the walk to the next suffix. Nothing is ever overwritten.
|
|
33
|
+
async function planImportantBackup(projectRoot) {
|
|
34
|
+
const sourcePath = path.join(projectRoot, PROJECT_IMPORTANT_FILENAME);
|
|
35
|
+
const plan = {
|
|
36
|
+
sourcePath,
|
|
37
|
+
sourceBytes: null,
|
|
38
|
+
sourceExists: false,
|
|
39
|
+
sourceIsRegular: false,
|
|
40
|
+
wouldBackup: false,
|
|
41
|
+
backupPath: null,
|
|
42
|
+
reused: false,
|
|
43
|
+
exhausted: false,
|
|
44
|
+
preservedPaths: [],
|
|
45
|
+
backupWarnings: [],
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
let stat;
|
|
49
|
+
try {
|
|
50
|
+
stat = await fs.lstat(sourcePath); // lstat: never follow a source symlink
|
|
51
|
+
} catch {
|
|
52
|
+
return plan; // no source — nothing to preserve or back up
|
|
53
|
+
}
|
|
54
|
+
plan.sourceExists = true;
|
|
55
|
+
plan.preservedPaths.push(sourcePath);
|
|
56
|
+
|
|
57
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
58
|
+
// Symlink/non-regular source: never followed or copied — preserved in place.
|
|
59
|
+
plan.backupWarnings.push(
|
|
60
|
+
`[UKit] ${PROJECT_IMPORTANT_FILENAME} is not a regular file; preserved in place without a backup. Run \`ukit doctor\`.`,
|
|
61
|
+
);
|
|
62
|
+
return plan;
|
|
63
|
+
}
|
|
64
|
+
plan.sourceIsRegular = true;
|
|
65
|
+
plan.sourceBytes = await fs.readFile(sourcePath); // raw bytes, no decode
|
|
66
|
+
|
|
67
|
+
for (let index = 0; index <= IMPORTANT_BACKUP_COLLISION_LIMIT; index += 1) {
|
|
68
|
+
const candidatePath = path.join(projectRoot, importantBackupCandidateName(index));
|
|
69
|
+
let candidateStat;
|
|
70
|
+
try {
|
|
71
|
+
candidateStat = await fs.lstat(candidatePath);
|
|
72
|
+
} catch {
|
|
73
|
+
candidateStat = null;
|
|
74
|
+
}
|
|
75
|
+
if (!candidateStat) {
|
|
76
|
+
plan.wouldBackup = true;
|
|
77
|
+
plan.backupPath = candidatePath;
|
|
78
|
+
return plan;
|
|
79
|
+
}
|
|
80
|
+
if (candidateStat.isFile() && !candidateStat.isSymbolicLink()) {
|
|
81
|
+
const existingBytes = await fs.readFile(candidatePath).catch(() => null);
|
|
82
|
+
if (existingBytes && existingBytes.equals(plan.sourceBytes)) {
|
|
83
|
+
plan.backupPath = candidatePath;
|
|
84
|
+
plan.reused = true;
|
|
85
|
+
return plan;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// different bytes or non-regular — try next suffix
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
plan.exhausted = true;
|
|
92
|
+
return plan;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Real-run backup: byte-identical raw copy via exclusive create (COPYFILE_EXCL —
|
|
96
|
+
// never overwrites). EEXIST means the slot filled between plan and copy; the walk
|
|
97
|
+
// re-checks it (equal bytes → reuse, else next suffix). Any other failure cleans
|
|
98
|
+
// up a possible partial copy and ABORTS uninstall before managed deletion.
|
|
99
|
+
async function createImportantBackup(projectRoot, sourcePath, sourceBytes) {
|
|
100
|
+
for (let index = 0; index <= IMPORTANT_BACKUP_COLLISION_LIMIT; index += 1) {
|
|
101
|
+
const candidatePath = path.join(projectRoot, importantBackupCandidateName(index));
|
|
102
|
+
let candidateStat;
|
|
103
|
+
try {
|
|
104
|
+
candidateStat = await fs.lstat(candidatePath);
|
|
105
|
+
} catch {
|
|
106
|
+
candidateStat = null;
|
|
107
|
+
}
|
|
108
|
+
if (candidateStat) {
|
|
109
|
+
if (candidateStat.isFile() && !candidateStat.isSymbolicLink()) {
|
|
110
|
+
const existingBytes = await fs.readFile(candidatePath).catch(() => null);
|
|
111
|
+
if (existingBytes && existingBytes.equals(sourceBytes)) {
|
|
112
|
+
return candidatePath; // reused — already byte-identical
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
await copyFileRawExclusive(sourcePath, candidatePath); // preserves mode where supported
|
|
119
|
+
return candidatePath;
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (error?.code === 'EEXIST') {
|
|
122
|
+
// Lost the race — the slot now exists. Reuse it when its bytes match the
|
|
123
|
+
// source; otherwise advance to the next suffix.
|
|
124
|
+
try {
|
|
125
|
+
const racedStat = await fs.lstat(candidatePath);
|
|
126
|
+
if (racedStat.isFile() && !racedStat.isSymbolicLink()) {
|
|
127
|
+
const racedBytes = await fs.readFile(candidatePath).catch(() => null);
|
|
128
|
+
if (racedBytes && racedBytes.equals(sourceBytes)) {
|
|
129
|
+
return candidatePath;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
// vanished again — either way, move on
|
|
134
|
+
}
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
await fs.rm(candidatePath, { force: true }); // clean partial backup
|
|
139
|
+
} catch {
|
|
140
|
+
// best-effort cleanup only
|
|
141
|
+
}
|
|
142
|
+
// Error carries no source content — only the failing operation.
|
|
143
|
+
throw new Error(
|
|
144
|
+
`[UKit] Uninstall aborted: could not back up ${PROJECT_IMPORTANT_FILENAME} (${error?.code || error?.message}). Managed paths were not removed.`,
|
|
145
|
+
{ cause: error },
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
throw new Error(
|
|
150
|
+
`[UKit] Uninstall aborted: no free backup name for ${PROJECT_IMPORTANT_FILENAME} after ${IMPORTANT_BACKUP_COLLISION_LIMIT} collisions. Managed paths were not removed.`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
11
153
|
|
|
12
154
|
// Use lstat (not access) so broken symlinks are also detected as existing.
|
|
13
155
|
async function pathExistsLstat(targetPath) {
|
|
@@ -254,13 +396,42 @@ export async function uninstallUkit({ projectRoot, dryRun = false }) {
|
|
|
254
396
|
);
|
|
255
397
|
}
|
|
256
398
|
|
|
399
|
+
// PROJECT_IMPORTANT.md preservation — read-only plan first (spec §15). The
|
|
400
|
+
// source is never touched: only inspected via lstat + readFile.
|
|
401
|
+
const importantPlan = await planImportantBackup(projectRoot);
|
|
402
|
+
|
|
257
403
|
if (dryRun) {
|
|
258
|
-
//
|
|
404
|
+
// Dry run creates nothing: report the collision-resolved candidate and the
|
|
405
|
+
// preserved/refused state only. lstat (not access) keeps broken symlinks visible.
|
|
259
406
|
const wouldRemove = [];
|
|
260
407
|
for (const { abs } of safeEntries) {
|
|
261
408
|
if (await pathExistsLstat(abs)) wouldRemove.push(abs);
|
|
262
409
|
}
|
|
263
|
-
return {
|
|
410
|
+
return {
|
|
411
|
+
removed: 0,
|
|
412
|
+
attempted: allEntries.length,
|
|
413
|
+
wasInstalled: true,
|
|
414
|
+
wouldRemove,
|
|
415
|
+
wouldBackup: importantPlan.wouldBackup,
|
|
416
|
+
backupPaths: importantPlan.backupPath ? [importantPlan.backupPath] : [],
|
|
417
|
+
preservedPaths: importantPlan.preservedPaths,
|
|
418
|
+
backupWarnings: importantPlan.backupWarnings,
|
|
419
|
+
skippedSymlinkParents,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Backup runs BEFORE any destructive step. A failure here aborts uninstall —
|
|
424
|
+
// managed paths stay intact and the source is never modified.
|
|
425
|
+
const backupPaths = [];
|
|
426
|
+
if (importantPlan.wouldBackup) {
|
|
427
|
+
const backupPath = await createImportantBackup(
|
|
428
|
+
projectRoot,
|
|
429
|
+
importantPlan.sourcePath,
|
|
430
|
+
importantPlan.sourceBytes,
|
|
431
|
+
);
|
|
432
|
+
backupPaths.push(backupPath);
|
|
433
|
+
} else if (importantPlan.backupPath && importantPlan.reused) {
|
|
434
|
+
backupPaths.push(importantPlan.backupPath); // existing byte-identical backup
|
|
264
435
|
}
|
|
265
436
|
|
|
266
437
|
// Remove all paths in parallel
|
|
@@ -289,5 +460,14 @@ export async function uninstallUkit({ projectRoot, dryRun = false }) {
|
|
|
289
460
|
await cleanupEmptyParents(removedPath, projectRoot);
|
|
290
461
|
}
|
|
291
462
|
|
|
292
|
-
return {
|
|
463
|
+
return {
|
|
464
|
+
removed,
|
|
465
|
+
attempted: allEntries.length,
|
|
466
|
+
wasInstalled: true,
|
|
467
|
+
wouldBackup: importantPlan.wouldBackup,
|
|
468
|
+
backupPaths,
|
|
469
|
+
preservedPaths: importantPlan.preservedPaths,
|
|
470
|
+
backupWarnings: importantPlan.backupWarnings,
|
|
471
|
+
skippedSymlinkParents,
|
|
472
|
+
};
|
|
293
473
|
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// classifyHang.js — evidence-only hang classifier for the C19 liveness wave (TASK-032).
|
|
2
|
+
//
|
|
3
|
+
// What this is for: C19 removed a family of unbounded paths, and the remaining failure
|
|
4
|
+
// mode is telling ONE stall class apart from another. A prompt freeze used to be
|
|
5
|
+
// indistinguishable from an indexing freeze or a gateway stall, so operators had no
|
|
6
|
+
// evidence-based next step. This module turns an evidence bundle into exactly one
|
|
7
|
+
// declared lane, or `unknown` when the evidence cannot prove any of them.
|
|
8
|
+
//
|
|
9
|
+
// Contracts (asserted by tests/liveness/classifyHang.test.js):
|
|
10
|
+
// * PURE and CLOCK-FREE. Every rule is a function of the bundle only, so identical
|
|
11
|
+
// evidence always produces byte-identical output. No Date.now(), no randomness, no
|
|
12
|
+
// I/O — the caller supplies both the measurement and the clock.
|
|
13
|
+
// * NEVER GUESSES. A lane is only named when the evidence PROVES it:
|
|
14
|
+
// - `deadline` with zero survivors is a clean reap, not a process-tree leak;
|
|
15
|
+
// - survivors beside `failureKind: 'signal'` are NOT a leak: `signal` is documented
|
|
16
|
+
// by the runner as a signal IT DID NOT SEND, so there is no escalation to blame
|
|
17
|
+
// and the bundle is `unknown` (the runner's own `deadline` verdict is the only
|
|
18
|
+
// escalation proof);
|
|
19
|
+
// - a non-zero hook exit that finished inside its budget is a verdict, not a hang;
|
|
20
|
+
// - a non-typed index error is not a discovery deadline.
|
|
21
|
+
// Everything else is `unknown` with a concrete next check.
|
|
22
|
+
// * The UPSTREAM lane is never a hook. Stream idle with no hook active classifies
|
|
23
|
+
// `gateway-stream-idle`; the presence of hook evidence removes that lane entirely, so
|
|
24
|
+
// a gateway stall can no longer be reported as UKit's fault (or the reverse).
|
|
25
|
+
// * Exactly four fields, always: `{class, confidence, evidenceIds, recommendedNextCheck}`.
|
|
26
|
+
// `evidenceIds` names the fields the decision rests on, so a reviewer can re-derive it.
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The six declared stall lanes. One bundle resolves to at most one of these.
|
|
30
|
+
* Ordered by decision precedence (see LANE_PRECEDENCE) — the first lane whose evidence
|
|
31
|
+
* is present AND conclusive owns the classification.
|
|
32
|
+
*/
|
|
33
|
+
export const HANG_CLASSES = Object.freeze([
|
|
34
|
+
'process-tree-leak',
|
|
35
|
+
'hook-overrun',
|
|
36
|
+
'index-deadline',
|
|
37
|
+
'lock-contention',
|
|
38
|
+
'context-capacity',
|
|
39
|
+
'gateway-stream-idle',
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/** The explicit "could not prove a lane" result. Never a member of HANG_CLASSES. */
|
|
43
|
+
export const UNKNOWN_CLASS = 'unknown';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Evidence kinds a bundle may carry. Exported so callers and diagnostics can enumerate
|
|
47
|
+
* what the classifier understands instead of guessing at field names.
|
|
48
|
+
*/
|
|
49
|
+
export const EVIDENCE_KINDS = Object.freeze([
|
|
50
|
+
'hook-telemetry',
|
|
51
|
+
'hook-active',
|
|
52
|
+
'process-result',
|
|
53
|
+
'index-error',
|
|
54
|
+
'lock-outcome',
|
|
55
|
+
'context-capacity',
|
|
56
|
+
'stream-idle',
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
// Hook-runner outcomes that PROVE the runner ended the child (TASK-018 taxonomy). These
|
|
60
|
+
// are kill verdicts, so they name a lane; `ok` / `exit-code` are verdicts about the hook's
|
|
61
|
+
// own decision and prove nothing about liveness.
|
|
62
|
+
const KILL_OUTCOMES = new Set(['timeout', 'output-overflow', 'budget-exhausted']);
|
|
63
|
+
|
|
64
|
+
// A leak only exists AFTER the runner's OWN escalation attempt, and only if something is
|
|
65
|
+
// still alive. `failureKind: 'deadline'` is the runner's kill verdict — the deadline timer
|
|
66
|
+
// armed the TERM → KILL sequence, so survivors beside it really are an escapee (H03).
|
|
67
|
+
//
|
|
68
|
+
// `signal` is explicitly NOT escalation evidence: the runner documents it as "the child
|
|
69
|
+
// died from a signal this runner did not send" (hook-process.mjs), which is an unprompted
|
|
70
|
+
// EXTERNAL kill. Survivors beside an external signal may be unrelated processes, and naming
|
|
71
|
+
// a lane off it would fabricate a TERM → KILL diagnosis the evidence cannot support. Such a
|
|
72
|
+
// bundle is `unknown`, never `process-tree-leak`.
|
|
73
|
+
const RUNNER_ESCALATION_KINDS = new Set(['deadline']);
|
|
74
|
+
|
|
75
|
+
const UNKNOWN_CHECK = 'Collect hook telemetry, process-exit evidence, or an index/lock/context measurement for this stall — none of the current evidence proves a lane.';
|
|
76
|
+
|
|
77
|
+
const NEXT_CHECK = Object.freeze({
|
|
78
|
+
'process-tree-leak': 'Re-run the failing command with the process-tree runner and list the surviving PIDs: a process group survived its TERM→KILL escalation (check for a child that ignores or detaches from SIGTERM).',
|
|
79
|
+
'hook-overrun': 'Inspect the cited hook telemetry row: the hook was killed at its deadline or overflowed its output cap. Measure that hook alone and reduce its work below its budget.',
|
|
80
|
+
'index-deadline': 'Re-run discovery with a bounded deadline and inspect the recorded phase: git enumeration or the filesystem fallback consumed the whole budget.',
|
|
81
|
+
'lock-contention': 'Inspect the lock holder state for the named target: the acquisition budget expired or was aborted before the critical section ran (fail-closed, no mutation happened).',
|
|
82
|
+
'context-capacity': 'Re-check the negotiated context capacity against the estimate: the session reached its advisory cap, so compaction must run before more context is added.',
|
|
83
|
+
'gateway-stream-idle': 'Watch the stream idle window: no hook was active while the stream went silent, so treat this as an upstream/gateway stall and verify CLAUDE_STREAM_IDLE_TIMEOUT_MS and the non-streaming fallback.',
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
function isObject(value) {
|
|
87
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isPositiveNumber(value) {
|
|
91
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function resultFor(cls, confidence, evidenceIds, recommendedNextCheck) {
|
|
95
|
+
return { class: cls, confidence, evidenceIds, recommendedNextCheck };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function unknown(evidenceIds = []) {
|
|
99
|
+
return resultFor(UNKNOWN_CLASS, 'none', evidenceIds, UNKNOWN_CHECK);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─── per-lane evidence evaluators ────────────────────────────────────────────
|
|
103
|
+
// Each returns { evidenceIds, confidence } when the evidence PROVES the lane, else null.
|
|
104
|
+
|
|
105
|
+
function detectProcessTreeLeak(bundle) {
|
|
106
|
+
const processResult = bundle.processResult;
|
|
107
|
+
if (!isObject(processResult)) return null;
|
|
108
|
+
const survivors = processResult.survivors;
|
|
109
|
+
// `survivors` must be an explicit, non-empty array: a missing field is unknown
|
|
110
|
+
// evidence (the caller never probed), and an empty array is a clean reap.
|
|
111
|
+
if (!Array.isArray(survivors) || survivors.length === 0) return null;
|
|
112
|
+
// Survivors alone proves nothing — the runner must have escalated. `signal` is an
|
|
113
|
+
// external kill, so it stays `unknown` rather than claiming a leak.
|
|
114
|
+
if (!RUNNER_ESCALATION_KINDS.has(processResult.failureKind)) return null;
|
|
115
|
+
return { evidenceIds: ['process-result'], confidence: 'high' };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function detectHookOverrun(bundle) {
|
|
119
|
+
const rows = bundle.hookTelemetry;
|
|
120
|
+
if (!Array.isArray(rows)) return null;
|
|
121
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
122
|
+
const row = rows[i];
|
|
123
|
+
if (isObject(row) && KILL_OUTCOMES.has(row.outcome)) {
|
|
124
|
+
// Cite only the failing row — the row index is the evidence locator.
|
|
125
|
+
return { evidenceIds: [`hook-telemetry[${i}]`], confidence: 'high' };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function detectIndexDeadline(bundle) {
|
|
132
|
+
const indexError = bundle.indexError;
|
|
133
|
+
if (!isObject(indexError)) return null;
|
|
134
|
+
// Only the typed liveness failure is proof; a message alone could be anything.
|
|
135
|
+
const typed = indexError.name === 'IndexDiscoveryTimeoutError'
|
|
136
|
+
|| indexError.code === 'INDEX_DISCOVERY_TIMEOUT';
|
|
137
|
+
if (!typed) return null;
|
|
138
|
+
return { evidenceIds: ['index-error'], confidence: 'high' };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function detectLockContention(bundle) {
|
|
142
|
+
const lockOutcome = bundle.lockOutcome;
|
|
143
|
+
if (!isObject(lockOutcome) || lockOutcome.ok !== false) return null;
|
|
144
|
+
// TASK-028's typed envelope: the callback never ran, so no mutation was lost.
|
|
145
|
+
if (lockOutcome.reason !== 'busy' && lockOutcome.reason !== 'aborted') return null;
|
|
146
|
+
return { evidenceIds: ['lock-outcome'], confidence: 'high' };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function detectContextCapacity(bundle) {
|
|
150
|
+
const context = bundle.context;
|
|
151
|
+
if (!isObject(context)) return null;
|
|
152
|
+
const capTokens = context.capTokens;
|
|
153
|
+
const estimatedTokens = context.estimatedTokens;
|
|
154
|
+
if (!isPositiveNumber(capTokens) || !isPositiveNumber(estimatedTokens)) return null;
|
|
155
|
+
if (estimatedTokens < capTokens) return null;
|
|
156
|
+
return { evidenceIds: ['context-capacity'], confidence: 'high' };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function detectGatewayStreamIdle(bundle) {
|
|
160
|
+
const stream = bundle.stream;
|
|
161
|
+
if (!isObject(stream)) return null;
|
|
162
|
+
const idleMs = stream.idleMs;
|
|
163
|
+
const idleTimeoutMs = stream.idleTimeoutMs;
|
|
164
|
+
// Both numbers are required: without the negotiated timeout there is nothing to
|
|
165
|
+
// compare against, and an idle duration alone proves no stall.
|
|
166
|
+
if (!isPositiveNumber(idleMs) || !isPositiveNumber(idleTimeoutMs)) return null;
|
|
167
|
+
if (idleMs < idleTimeoutMs) return null;
|
|
168
|
+
// A hook was active while the stream was silent → the silence may be UKit's, so the
|
|
169
|
+
// upstream lane is removed entirely. Never blame the gateway over hook evidence.
|
|
170
|
+
// `null`/`undefined` both mean "no hook was active" — only a real value withholds it.
|
|
171
|
+
if (bundle.hookActive !== null && bundle.hookActive !== undefined) return null;
|
|
172
|
+
return { evidenceIds: ['stream-idle'], confidence: 'high' };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Precedence: the most specific, evidence-backed lane wins. The upstream lane is LAST —
|
|
176
|
+
// it is the only lane whose proof is the ABSENCE of other evidence, so it may only be
|
|
177
|
+
// reached once every hook/index/lock/context claim had its chance.
|
|
178
|
+
const LANE_PRECEDENCE = Object.freeze([
|
|
179
|
+
['process-tree-leak', detectProcessTreeLeak],
|
|
180
|
+
['hook-overrun', detectHookOverrun],
|
|
181
|
+
['index-deadline', detectIndexDeadline],
|
|
182
|
+
['lock-contention', detectLockContention],
|
|
183
|
+
['context-capacity', detectContextCapacity],
|
|
184
|
+
['gateway-stream-idle', detectGatewayStreamIdle],
|
|
185
|
+
]);
|
|
186
|
+
|
|
187
|
+
// Evidence that was considered but is NOT sufficient to name a lane. Reported on the
|
|
188
|
+
// `unknown` result so a caller sees what was looked at, never just "nothing".
|
|
189
|
+
function inconclusiveEvidenceIds(bundle) {
|
|
190
|
+
const ids = [];
|
|
191
|
+
if (Array.isArray(bundle.hookTelemetry) && bundle.hookTelemetry.length > 0) {
|
|
192
|
+
ids.push('hook-telemetry');
|
|
193
|
+
}
|
|
194
|
+
if (isObject(bundle.hookActive)) ids.push('hook-active');
|
|
195
|
+
if (isObject(bundle.stream)) ids.push('stream-idle');
|
|
196
|
+
if (isObject(bundle.processResult)) ids.push('process-result');
|
|
197
|
+
if (isObject(bundle.indexError)) ids.push('index-error');
|
|
198
|
+
if (isObject(bundle.lockOutcome)) ids.push('lock-outcome');
|
|
199
|
+
if (isObject(bundle.context)) ids.push('context-capacity');
|
|
200
|
+
return ids;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Classify ONE stall from an evidence bundle.
|
|
205
|
+
*
|
|
206
|
+
* @param {object} [bundle] evidence collected by a scenario or a live diagnostic:
|
|
207
|
+
* `hookTelemetry` — redacted telemetry rows (TASK-019 schema; `outcome` is the
|
|
208
|
+
* runner's taxonomy, `hook` names the emitter).
|
|
209
|
+
* `hookActive` — `{hook, activeMs}` when a hook held the hot path. Presence alone
|
|
210
|
+
* removes the upstream lane.
|
|
211
|
+
* `processResult` — `{failureKind, signal, code, elapsedMs, survivors}` from the
|
|
212
|
+
* process-tree runner. `survivors` is the escalation probe.
|
|
213
|
+
* `indexError` — the thrown error (or its shape) from index discovery.
|
|
214
|
+
* `lockOutcome` — the typed `{ok:false, reason:'busy'|'aborted', waitedMs}` envelope.
|
|
215
|
+
* `context` — `{capTokens, estimatedTokens, ...}` from capacity negotiation.
|
|
216
|
+
* `stream` — `{idleMs, idleTimeoutMs, ...}` from stream timing.
|
|
217
|
+
* @returns {{ class: string, confidence: 'high'|'none', evidenceIds: string[],
|
|
218
|
+
* recommendedNextCheck: string }} exactly four fields; `class` is either a
|
|
219
|
+
* member of HANG_CLASSES or UNKNOWN_CLASS.
|
|
220
|
+
*/
|
|
221
|
+
export function classifyHang(bundle = {}) {
|
|
222
|
+
if (!isObject(bundle)) return unknown();
|
|
223
|
+
|
|
224
|
+
for (const [lane, detect] of LANE_PRECEDENCE) {
|
|
225
|
+
const evidence = detect(bundle);
|
|
226
|
+
if (evidence) return resultFor(lane, evidence.confidence, evidence.evidenceIds, NEXT_CHECK[lane]);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return unknown(inconclusiveEvidenceIds(bundle));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* C19 finding → lane map. Every finding this cycle addressed has a lane whose evidence a
|
|
234
|
+
* scenario in `tests/liveness/hangScenarios.test.js` produces, so no finding is left
|
|
235
|
+
* without a classifier answer. Exposed as data (not prose) so the liveness suite can fail
|
|
236
|
+
* when coverage regresses.
|
|
237
|
+
*/
|
|
238
|
+
export const C19_LANE_MAP = Object.freeze([
|
|
239
|
+
Object.freeze({ findings: Object.freeze(['H01', 'H02', 'H05', 'H08', 'H21']), lane: 'hook-overrun' }),
|
|
240
|
+
Object.freeze({ findings: Object.freeze(['H03']), lane: 'process-tree-leak' }),
|
|
241
|
+
Object.freeze({ findings: Object.freeze(['H09', 'H10', 'H11', 'H12', 'H13', 'H14', 'H15']), lane: 'index-deadline' }),
|
|
242
|
+
Object.freeze({ findings: Object.freeze(['H16', 'H17', 'H18', 'H19', 'H20']), lane: 'lock-contention' }),
|
|
243
|
+
Object.freeze({ findings: Object.freeze(['H04', 'H22']), lane: 'context-capacity' }),
|
|
244
|
+
Object.freeze({ findings: Object.freeze(['H23', 'H24']), lane: 'hook-overrun' }),
|
|
245
|
+
Object.freeze({ findings: Object.freeze(['H25']), lane: 'gateway-stream-idle' }),
|
|
246
|
+
]);
|