pi-deadman 1.0.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/README.md ADDED
@@ -0,0 +1,110 @@
1
+ # pi-deadman
2
+
3
+ Dead man's switch for AI coding agents.
4
+
5
+ Monitors macOS memory pressure, gates heavy operations, and auto-kills runaway processes before your system locks up.
6
+
7
+ ## The Problem
8
+
9
+ AI coding agents run builds, tests, installs, and browser automation with no awareness of system memory state. On an 8 GB Mac, this regularly pushes the system into swap thrashing — minutes of lag, system freezes, sometimes requiring a hard reboot.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pi install git:github.com/gaherwar/pi-deadman
15
+ ```
16
+
17
+ On first run, pi-deadman calibrates a baseline for your machine (~10 seconds of canary tests). After that, it runs in the background — no configuration needed.
18
+
19
+ **macOS only.** Silent no-op on other platforms.
20
+
21
+ ## How It Works
22
+
23
+ ### Pre-Execution Gate
24
+
25
+ Every `bash` tool call goes through a tier × zone matrix:
26
+
27
+ | | GREEN | YELLOW | ORANGE | RED |
28
+ |---|---|---|---|---|
29
+ | **Tier 0–1** (read-only: ls, cat, grep) | ✅ | ✅ | ✅ | ❌ |
30
+ | **Tier 2** (medium: test, server, spawn) | ✅ | ✅ | ✅ | ❌ |
31
+ | **Tier 3** (heavy: npm install, docker, build) | ✅ | ✅ | ❌ | ❌ |
32
+ | **Tier 4** (destructive: kill, pkill, rm -rf) | ✅ | ✅ | ❌ | ❌ |
33
+
34
+ When blocked in **ORANGE**, you choose: run anyway or free memory first.
35
+ When blocked in **RED**, you must kill a process to proceed — or force-run after the first attempt.
36
+
37
+ ### Background Monitor
38
+
39
+ Polls system health at adaptive intervals (5s in GREEN → 1s in RED):
40
+
41
+ - **Canary test** — micro-ops (array sort, Map ops, regex, JSON parse, Buffer alloc) timed via `performance.now()`. Slowdown ratio vs baseline determines zone.
42
+ - **System signals** — swap usage, swap in/out rates, compression ratio, memorystatus level via `sysctl` and `vm_stat`.
43
+ - **Process snapshots** — footprint (true memory) via `proc_pid_rusage` Python helper, stored in a ring buffer of 10 snapshots.
44
+
45
+ ### Watchdog (Confirmed RED)
46
+
47
+ When the system enters confirmed RED (3 consecutive RED polls), the watchdog auto-kills processes across **all pi sessions**. Processes with < 50 MB footprint are never kill candidates.
48
+
49
+ | Priority | Signal | Catches |
50
+ |---|---|---|
51
+ | 1. **Growing** | ≥100 MB delta across 3+ of 10 snapshots | Memory leaks, sawtooth patterns |
52
+ | 2. **Swarm** | ≥3 same-name processes, combined ≥500 MB | Worker pools (vitest ×7, webpack workers) |
53
+ | 3. **Heavy & young** | Age < 10 min AND footprint ≥ 200 MB | Burst allocators |
54
+ | 4. **Newest** | Appeared after last stable non-RED state | Temporal correlation (largest only) |
55
+ | 5. **No match** | Block commands, wait | Pressure from outside pi's tree |
56
+
57
+ Two execution paths:
58
+ - **Slow poll** — full canary + signals + footprint worker. Populates snapshot ring buffer.
59
+ - **Fast watchdog** — independent 2s loop using `ps` (~17ms). Parses fresh `etime` for age. Walks children of ALL pi instances (cross-session). Acts even when the slow poll is stuck during system thrashing.
60
+
61
+ ## Commands
62
+
63
+ | Command | Description |
64
+ |---|---|
65
+ | `/deadman` | Show current zone, memory stats, recent kills |
66
+
67
+ ## Files
68
+
69
+ ```
70
+ pi-deadman/
71
+ ├── extensions/
72
+ │ ├── index.ts — Entry point: tool_call gate, /deadman command
73
+ │ ├── monitor.ts — Background polling, adaptive intervals, watchdog
74
+ │ ├── canary.ts — Performance micro-ops timing
75
+ │ ├── signals.ts — macOS kernel metrics (sysctl, vm_stat)
76
+ │ ├── zones.ts — Zone classification (GREEN/YELLOW/ORANGE/RED)
77
+ │ ├── calibration.ts — Baseline persistence
78
+ │ ├── keywords.ts — Command tier classification (25+ keywords, 5 tiers)
79
+ │ ├── processes.ts — System-wide process list (footprint.py)
80
+ │ ├── watchdog.ts — Kill target selection
81
+ │ ├── tree.ts — Snapshot diffing, growth detection, swarm detection
82
+ │ ├── worker.ts — Persistent Python worker for fast footprint queries
83
+ │ └── logging.ts — JSONL structured logs (GC after 3 days)
84
+ ├── helpers/
85
+ │ ├── footprint.py — proc_pid_rusage footprint extraction
86
+ │ └── footprint_worker.py — Persistent worker process
87
+ └── __tests__/ — 202 tests across 13 files
88
+ ```
89
+
90
+ ## Logs
91
+
92
+ Stored in `~/.pi/deadman/logs/`:
93
+
94
+ - `system.jsonl` — zone, canary, swap, signals per poll
95
+ - `decisions.jsonl` — pass/block/kill per tool call
96
+ - `processes.jsonl` — top process snapshots
97
+ - `tool_impact.jsonl` — swap delta per command
98
+
99
+ ## Development
100
+
101
+ ```bash
102
+ cd pi-deadman
103
+ npm install
104
+ npm test # 202 tests across 13 files
105
+ npx vitest --watch # watch mode
106
+ ```
107
+
108
+ ## License
109
+
110
+ MIT
@@ -0,0 +1,73 @@
1
+ // __tests__/calibration.test.ts
2
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
3
+ import { saveBaseline, loadBaseline, type Baseline, DEFAULT_BASELINE_MS } from "../extensions/calibration";
4
+ import * as fs from "node:fs";
5
+ import * as path from "node:path";
6
+ import * as os from "node:os";
7
+
8
+ describe("Baseline persistence", () => {
9
+ let tmpDir: string;
10
+ let baselinePath: string;
11
+
12
+ beforeEach(() => {
13
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "deadman-test-"));
14
+ baselinePath = path.join(tmpDir, "baseline.json");
15
+ });
16
+
17
+ afterEach(() => {
18
+ fs.rmSync(tmpDir, { recursive: true, force: true });
19
+ });
20
+
21
+ it("saves and loads a baseline", () => {
22
+ const baseline: Baseline = {
23
+ canary_ms: 7.5,
24
+ calibrated_at: "2026-02-28T10:00:00.000Z",
25
+ source: "calibrated",
26
+ };
27
+ saveBaseline(baseline, baselinePath);
28
+ const loaded = loadBaseline(baselinePath);
29
+ expect(loaded).toEqual(baseline);
30
+ });
31
+
32
+ it("returns null for missing file", () => {
33
+ expect(loadBaseline("/nonexistent/path/baseline.json")).toBeNull();
34
+ });
35
+
36
+ it("returns null for corrupted JSON", () => {
37
+ fs.writeFileSync(baselinePath, "not json{{{");
38
+ expect(loadBaseline(baselinePath)).toBeNull();
39
+ });
40
+
41
+ it("returns null for incomplete data", () => {
42
+ fs.writeFileSync(baselinePath, JSON.stringify({ canary_ms: 5 }));
43
+ expect(loadBaseline(baselinePath)).toBeNull();
44
+ });
45
+
46
+ it("creates parent directories if needed", () => {
47
+ const deepPath = path.join(tmpDir, "a", "b", "c", "baseline.json");
48
+ const baseline: Baseline = {
49
+ canary_ms: 8.0,
50
+ calibrated_at: "2026-02-28T10:00:00.000Z",
51
+ source: "calibrated",
52
+ };
53
+ saveBaseline(baseline, deepPath);
54
+ const loaded = loadBaseline(deepPath);
55
+ expect(loaded).toEqual(baseline);
56
+ });
57
+
58
+ it("default baseline backward-compatible with missing source field", () => {
59
+ fs.writeFileSync(baselinePath, JSON.stringify({
60
+ canary_ms: 9.0,
61
+ calibrated_at: "2026-02-28T10:00:00.000Z",
62
+ }));
63
+ const loaded = loadBaseline(baselinePath);
64
+ expect(loaded).not.toBeNull();
65
+ expect(loaded!.source).toBe("calibrated");
66
+ });
67
+ });
68
+
69
+ describe("DEFAULT_BASELINE_MS", () => {
70
+ it("is 10ms (conservative default)", () => {
71
+ expect(DEFAULT_BASELINE_MS).toBe(10.0);
72
+ });
73
+ });
@@ -0,0 +1,68 @@
1
+ // __tests__/canary.test.ts
2
+ import { describe, it, expect } from "vitest";
3
+ import { runCanary, type CanaryResult } from "../extensions/canary";
4
+
5
+ describe("runCanary", () => {
6
+ it("returns a CanaryResult with all 5 sub-timings", async () => {
7
+ const result = await runCanary();
8
+ expect(result).toHaveProperty("sysctl_ms");
9
+ expect(result).toHaveProperty("spawn_ms");
10
+ expect(result).toHaveProperty("read_ms");
11
+ expect(result).toHaveProperty("dir_ms");
12
+ expect(result).toHaveProperty("alloc_ms");
13
+ expect(result).toHaveProperty("total_ms");
14
+ });
15
+
16
+ it("all timings are positive numbers", async () => {
17
+ const result = await runCanary();
18
+ expect(result.sysctl_ms).toBeGreaterThan(0);
19
+ expect(result.spawn_ms).toBeGreaterThan(0);
20
+ expect(result.read_ms).toBeGreaterThan(0);
21
+ expect(result.dir_ms).toBeGreaterThan(0);
22
+ expect(result.alloc_ms).toBeGreaterThan(0);
23
+ expect(result.total_ms).toBeGreaterThan(0);
24
+ });
25
+
26
+ it("total_ms equals sum of 5 sub-timings", async () => {
27
+ const result = await runCanary();
28
+ const sum = result.sysctl_ms + result.spawn_ms + result.read_ms + result.dir_ms + result.alloc_ms;
29
+ expect(result.total_ms).toBeCloseTo(sum, 1);
30
+ });
31
+
32
+ it("completes in under 500ms on a healthy system", async () => {
33
+ const result = await runCanary();
34
+ expect(result.total_ms).toBeLessThan(500);
35
+ });
36
+
37
+ it("sysctl_ms reads kern.ostype successfully", async () => {
38
+ const result = await runCanary();
39
+ expect(result.sysctl_ms).toBeLessThan(50);
40
+ });
41
+
42
+ it("spawn_ms spawns a real process", async () => {
43
+ const result = await runCanary();
44
+ expect(result.spawn_ms).toBeLessThan(100);
45
+ });
46
+
47
+ it("read_ms reads a real file", async () => {
48
+ const result = await runCanary();
49
+ expect(result.read_ms).toBeLessThan(50);
50
+ });
51
+
52
+ it("dir_ms scans a real directory", async () => {
53
+ const result = await runCanary();
54
+ expect(result.dir_ms).toBeLessThan(50);
55
+ });
56
+
57
+ it("alloc_ms allocates and fills memory", async () => {
58
+ const result = await runCanary();
59
+ expect(result.alloc_ms).toBeLessThan(50);
60
+ });
61
+
62
+ it("is deterministic-ish — two runs produce similar results", async () => {
63
+ const r1 = await runCanary();
64
+ const r2 = await runCanary();
65
+ expect(r2.total_ms).toBeLessThan(r1.total_ms * 5);
66
+ expect(r1.total_ms).toBeLessThan(r2.total_ms * 5);
67
+ });
68
+ });
@@ -0,0 +1,188 @@
1
+ // __tests__/fast-watchdog.test.ts — integration test for the fast watchdog loop
2
+ // Spawns a real child process, seeds growth evidence, forces RED, verifies kill
3
+
4
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
5
+ import { Monitor } from "../extensions/monitor";
6
+ import { Zone } from "../extensions/zones";
7
+ import { spawn, type ChildProcess } from "node:child_process";
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ import * as os from "node:os";
11
+
12
+ describe("Fast Watchdog Integration", () => {
13
+ let tmpDir: string;
14
+ let baselinePath: string;
15
+ let logDir: string;
16
+ let monitor: Monitor;
17
+ let childProc: ChildProcess | null = null;
18
+
19
+ beforeEach(() => {
20
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "deadman-watchdog-int-"));
21
+ baselinePath = path.join(tmpDir, "baseline.json");
22
+ logDir = path.join(tmpDir, "logs");
23
+
24
+ fs.mkdirSync(path.dirname(baselinePath), { recursive: true });
25
+ fs.writeFileSync(baselinePath, JSON.stringify({
26
+ canary_ms: 5.0,
27
+ calibrated_at: new Date().toISOString(),
28
+ source: "test",
29
+ }));
30
+
31
+ monitor = new Monitor({ baselinePath, logDir });
32
+ });
33
+
34
+ afterEach(() => {
35
+ monitor.stop();
36
+ if (childProc && !childProc.killed) {
37
+ childProc.kill("SIGKILL");
38
+ }
39
+ fs.rmSync(tmpDir, { recursive: true, force: true });
40
+ });
41
+
42
+ /**
43
+ * Seed the watchdog's snapshot history with fake growth data for a PID.
44
+ * This simulates the monitor having observed the process growing over time.
45
+ */
46
+ function seedGrowthHistory(pid: number, name: string) {
47
+ const state = monitor.getWatchdogState();
48
+ state.snapshotHistory = [
49
+ [{ pid, name, footprint_mb: 200, age_seconds: 10 }],
50
+ [{ pid, name, footprint_mb: 400, age_seconds: 15 }],
51
+ [{ pid, name, footprint_mb: 600, age_seconds: 20 }],
52
+ [{ pid, name, footprint_mb: 800, age_seconds: 25 }],
53
+ ];
54
+ }
55
+
56
+ it("kills a growing child process within 6 seconds of confirmed RED", async () => {
57
+ childProc = spawn("sleep", ["60"], { stdio: "ignore" });
58
+ const childPid = childProc.pid!;
59
+ expect(childPid).toBeGreaterThan(0);
60
+
61
+ const kills: any[] = [];
62
+ monitor.onAutoKill((decision) => {
63
+ kills.push(decision);
64
+ });
65
+
66
+ monitor.start();
67
+ await new Promise(r => setTimeout(r, 1500));
68
+
69
+ // Seed growth evidence for the child — simulates monitor having tracked it
70
+ seedGrowthHistory(childPid, "sleep");
71
+
72
+ // Force RED confirmed
73
+ monitor._forceZone(Zone.RED, true);
74
+
75
+ // Wait up to 6 seconds for the fast watchdog to fire
76
+ const deadline = Date.now() + 6000;
77
+ while (Date.now() < deadline) {
78
+ await new Promise(r => setTimeout(r, 200));
79
+ try {
80
+ process.kill(childPid, 0);
81
+ } catch {
82
+ break;
83
+ }
84
+ }
85
+
86
+ let alive = true;
87
+ try {
88
+ process.kill(childPid, 0);
89
+ } catch {
90
+ alive = false;
91
+ }
92
+
93
+ expect(alive).toBe(false);
94
+ expect(kills.length).toBeGreaterThanOrEqual(1);
95
+ expect(kills[0].targets.some((t: any) => t.pid === childPid)).toBe(true);
96
+
97
+ // Verify decision log
98
+ const decisionsPath = path.join(logDir, "decisions.jsonl");
99
+ expect(fs.existsSync(decisionsPath)).toBe(true);
100
+ const entries = fs.readFileSync(decisionsPath, "utf-8").trim().split("\n").map(l => JSON.parse(l));
101
+ const killEntry = entries.find((e: any) => e.action === "auto_kill_fast");
102
+ expect(killEntry).toBeDefined();
103
+ expect(killEntry.targets.some((t: any) => t.pid === childPid)).toBe(true);
104
+ });
105
+
106
+ it("does NOT kill when zone is GREEN even with growth evidence", async () => {
107
+ childProc = spawn("sleep", ["60"], { stdio: "ignore" });
108
+ const childPid = childProc.pid!;
109
+
110
+ const kills: any[] = [];
111
+ monitor.onAutoKill((decision) => {
112
+ kills.push(decision);
113
+ });
114
+
115
+ monitor.start();
116
+ await new Promise(r => setTimeout(r, 200));
117
+
118
+ seedGrowthHistory(childPid, "sleep");
119
+
120
+ // Zone stays GREEN
121
+ expect(monitor.currentZone).toBe(Zone.GREEN);
122
+
123
+ await new Promise(r => setTimeout(r, 3000));
124
+
125
+ let alive = true;
126
+ try {
127
+ process.kill(childPid, 0);
128
+ } catch {
129
+ alive = false;
130
+ }
131
+ expect(alive).toBe(true);
132
+ expect(kills).toHaveLength(0);
133
+ });
134
+
135
+ it("kills newest process in RED even without growth evidence", async () => {
136
+ // The "newest" filter catches processes that appeared after the last
137
+ // non-RED snapshot. Since the child spawned during warm-up (when system
138
+ // was GREEN), forcing RED should trigger "newest" detection.
139
+ // We seed a cached snapshot with non-zero footprint so the process
140
+ // passes the footprint_mb > 0 guard (0 MB processes are infrastructure noise).
141
+ childProc = spawn("sleep", ["60"], { stdio: "ignore" });
142
+ const childPid = childProc.pid!;
143
+
144
+ const kills: any[] = [];
145
+ monitor.onAutoKill((decision) => {
146
+ kills.push(decision);
147
+ });
148
+
149
+ monitor.start();
150
+ await new Promise(r => setTimeout(r, 1500));
151
+
152
+ // Seed a single snapshot with meaningful footprint (no growth pattern,
153
+ // just proves the process exists with real memory usage).
154
+ // Also set lastNonRedTimestamp explicitly — with hysteresis, the warm-up
155
+ // period (1.5s) isn't long enough for 3 consecutive non-RED polls.
156
+ const state = monitor.getWatchdogState();
157
+ state.snapshotHistory = [
158
+ [{ pid: childPid, name: "sleep", footprint_mb: 150, age_seconds: 3 }],
159
+ ];
160
+ state.lastNonRedTimestamp = Date.now() / 1000 - 5; // system was healthy 5s ago
161
+
162
+ monitor._forceZone(Zone.RED, true);
163
+
164
+ // Wait for the fast watchdog to fire
165
+ const deadline = Date.now() + 6000;
166
+ while (Date.now() < deadline) {
167
+ await new Promise(r => setTimeout(r, 200));
168
+ try {
169
+ process.kill(childPid, 0);
170
+ } catch {
171
+ break;
172
+ }
173
+ }
174
+
175
+ let alive = true;
176
+ try {
177
+ process.kill(childPid, 0);
178
+ } catch {
179
+ alive = false;
180
+ }
181
+ expect(alive).toBe(false);
182
+ expect(kills.length).toBeGreaterThanOrEqual(1);
183
+ });
184
+
185
+ // Cooldown is tested at the unit level in watchdog.test.ts.
186
+ // Integration testing cooldown with real processes is timing-sensitive
187
+ // and flaky — the unit test covers the logic reliably.
188
+ });
@@ -0,0 +1,103 @@
1
+ // __tests__/index.test.ts
2
+ import { describe, it, expect } from "vitest";
3
+ import {
4
+ buildBlockReason,
5
+ getQuickMemorySnapshot,
6
+ buildProcessOptions,
7
+ } from "../extensions/index";
8
+ import { Zone } from "../extensions/zones";
9
+ import type { SnapshotProcess } from "../extensions/tree";
10
+
11
+ describe("buildBlockReason", () => {
12
+ it("includes zone in the message", () => {
13
+ const reason = buildBlockReason(Zone.RED, "npm install", 3);
14
+ expect(reason).toContain("RED");
15
+ });
16
+
17
+ it("includes the command in the message", () => {
18
+ const reason = buildBlockReason(Zone.ORANGE, "docker build .", 3);
19
+ expect(reason).toContain("docker build .");
20
+ });
21
+
22
+ it("includes tier context for ORANGE + tier 3", () => {
23
+ const reason = buildBlockReason(Zone.ORANGE, "npm run build", 3);
24
+ expect(reason).toContain("heavy");
25
+ });
26
+
27
+ it("RED blocks everything — message reflects that", () => {
28
+ const reason = buildBlockReason(Zone.RED, "cat file.txt", 0);
29
+ expect(reason).toContain("RED");
30
+ expect(reason).toContain("critical");
31
+ });
32
+
33
+ it("ORANGE message suggests freeing memory", () => {
34
+ const reason = buildBlockReason(Zone.ORANGE, "npm install", 3);
35
+ expect(reason).toContain("Free memory");
36
+ });
37
+
38
+ it("RED message mentions close applications", () => {
39
+ const reason = buildBlockReason(Zone.RED, "ls", 1);
40
+ expect(reason).toContain("close applications");
41
+ });
42
+ });
43
+
44
+ describe("getQuickMemorySnapshot", () => {
45
+ it("returns swap_used_mb and memorystatus_level", async () => {
46
+ const snap = await getQuickMemorySnapshot();
47
+ expect(snap).toHaveProperty("swap_used_mb");
48
+ expect(snap).toHaveProperty("memorystatus_level");
49
+ expect(typeof snap.swap_used_mb).toBe("number");
50
+ expect(typeof snap.memorystatus_level).toBe("number");
51
+ });
52
+ });
53
+
54
+ describe("buildProcessOptions", () => {
55
+ it("returns pi children first, sorted by footprint descending", () => {
56
+ const piChildren: SnapshotProcess[] = [
57
+ { pid: 1, name: "node", footprint_mb: 100, age_seconds: 10 },
58
+ { pid: 2, name: "python3", footprint_mb: 300, age_seconds: 20 },
59
+ { pid: 3, name: "bash", footprint_mb: 50, age_seconds: 5 },
60
+ ];
61
+ const options = buildProcessOptions(piChildren, 5);
62
+ // Should be sorted: python3 (300), node (100), bash (50)
63
+ expect(options.length).toBeGreaterThanOrEqual(3);
64
+ expect(options[0]).toContain("python3");
65
+ expect(options[0]).toContain("300");
66
+ expect(options[1]).toContain("node");
67
+ });
68
+
69
+ it("limits to top N", () => {
70
+ const piChildren: SnapshotProcess[] = [
71
+ { pid: 1, name: "a", footprint_mb: 100, age_seconds: 10 },
72
+ { pid: 2, name: "b", footprint_mb: 200, age_seconds: 20 },
73
+ { pid: 3, name: "c", footprint_mb: 300, age_seconds: 30 },
74
+ ];
75
+ const options = buildProcessOptions(piChildren, 2);
76
+ // 2 process options + "Kill an external app"
77
+ expect(options.filter(o => !o.startsWith("Kill an external"))).toHaveLength(2);
78
+ });
79
+
80
+ it("includes 'Kill an external app' as last option", () => {
81
+ const piChildren: SnapshotProcess[] = [
82
+ { pid: 1, name: "node", footprint_mb: 100, age_seconds: 10 },
83
+ ];
84
+ const options = buildProcessOptions(piChildren, 5);
85
+ expect(options[options.length - 1]).toContain("external");
86
+ });
87
+
88
+ it("shows only 'Kill an external app' when no pi children", () => {
89
+ const options = buildProcessOptions([], 5);
90
+ expect(options).toHaveLength(1);
91
+ expect(options[0]).toContain("external");
92
+ });
93
+
94
+ it("uses natural language descriptions with name, MB, and age", () => {
95
+ const piChildren: SnapshotProcess[] = [
96
+ { pid: 1, name: "npm", footprint_mb: 340, age_seconds: 12 },
97
+ ];
98
+ const options = buildProcessOptions(piChildren, 5);
99
+ expect(options[0]).toContain("npm");
100
+ expect(options[0]).toContain("340");
101
+ expect(options[0]).toContain("12s");
102
+ });
103
+ });
@@ -0,0 +1,130 @@
1
+ // __tests__/keywords.test.ts
2
+ import { describe, it, expect } from "vitest";
3
+ import { classifyTier, KEYWORD_TIERS } from "../extensions/keywords";
4
+
5
+ describe("classifyTier", () => {
6
+ // Tier 4 — Destructive
7
+ it("classifies 'kill -9 1234' as tier 4", () => {
8
+ expect(classifyTier("kill -9 1234")).toBe(4);
9
+ });
10
+ it("classifies 'pkill node' as tier 4", () => {
11
+ expect(classifyTier("pkill node")).toBe(4);
12
+ });
13
+ it("classifies 'killall Safari' as tier 4", () => {
14
+ expect(classifyTier("killall Safari")).toBe(4);
15
+ });
16
+ it("classifies 'rm -rf /tmp/stuff' as tier 4", () => {
17
+ expect(classifyTier("rm -rf /tmp/stuff")).toBe(4);
18
+ });
19
+
20
+ // Tier 3 — Heavy
21
+ it("classifies 'npm install react' as tier 3", () => {
22
+ expect(classifyTier("npm install react")).toBe(3);
23
+ });
24
+ it("classifies 'docker build .' as tier 3", () => {
25
+ expect(classifyTier("docker build .")).toBe(3);
26
+ });
27
+ it("classifies 'npm run build' as tier 3", () => {
28
+ expect(classifyTier("npm run build")).toBe(3);
29
+ });
30
+ it("classifies 'webpack --mode production' as tier 3", () => {
31
+ expect(classifyTier("webpack --mode production")).toBe(3);
32
+ });
33
+ it("classifies 'cargo build --release' as tier 3", () => {
34
+ expect(classifyTier("cargo build --release")).toBe(3);
35
+ });
36
+ it("classifies 'brew install node' as tier 3", () => {
37
+ expect(classifyTier("brew install node")).toBe(3);
38
+ });
39
+ it("classifies 'pip install -r requirements.txt' as tier 3", () => {
40
+ expect(classifyTier("pip install -r requirements.txt")).toBe(3);
41
+ });
42
+ it("classifies 'make all' as tier 3", () => {
43
+ expect(classifyTier("make all")).toBe(3);
44
+ });
45
+
46
+ // Tier 2 — Medium
47
+ it("classifies 'pytest tests/' as tier 2", () => {
48
+ expect(classifyTier("pytest tests/")).toBe(2);
49
+ });
50
+ it("classifies 'npx jest --watch' as tier 2", () => {
51
+ expect(classifyTier("npx jest --watch")).toBe(2);
52
+ });
53
+ it("classifies 'npx vitest run' as tier 2", () => {
54
+ expect(classifyTier("npx vitest run")).toBe(2);
55
+ });
56
+ it("classifies 'uvicorn app:main' as tier 2", () => {
57
+ expect(classifyTier("uvicorn app:main")).toBe(2);
58
+ });
59
+ it("classifies 'node server.js' as tier 2", () => {
60
+ expect(classifyTier("node server.js")).toBe(2);
61
+ });
62
+ it("classifies 'cargo test' as tier 2", () => {
63
+ expect(classifyTier("cargo test")).toBe(2);
64
+ });
65
+
66
+ // Tier 1 — Light
67
+ it("classifies 'git status' as tier 1", () => {
68
+ expect(classifyTier("git status")).toBe(1);
69
+ });
70
+ it("classifies 'grep -r TODO src/' as tier 1", () => {
71
+ expect(classifyTier("grep -r TODO src/")).toBe(1);
72
+ });
73
+ it("classifies 'curl https://api.example.com' as tier 1", () => {
74
+ expect(classifyTier("curl https://api.example.com")).toBe(1);
75
+ });
76
+ it("classifies 'cat file.txt' as tier 1", () => {
77
+ expect(classifyTier("cat file.txt")).toBe(1);
78
+ });
79
+ it("classifies 'ls -la' as tier 1", () => {
80
+ expect(classifyTier("ls -la")).toBe(1);
81
+ });
82
+ it("classifies 'rg pattern .' as tier 1", () => {
83
+ expect(classifyTier("rg pattern .")).toBe(1);
84
+ });
85
+
86
+ // Tier 0 — Unknown / trivial
87
+ it("classifies unknown commands as tier 0", () => {
88
+ expect(classifyTier("whoami")).toBe(0);
89
+ });
90
+ it("classifies empty string as tier 0", () => {
91
+ expect(classifyTier("")).toBe(0);
92
+ });
93
+ it("classifies undefined as tier 0", () => {
94
+ expect(classifyTier(undefined)).toBe(0);
95
+ });
96
+
97
+ // Highest tier wins
98
+ it("takes highest tier when multiple keywords match", () => {
99
+ expect(classifyTier("npm install && pytest")).toBe(3);
100
+ });
101
+ it("npm install && cat → tier 3 (install wins)", () => {
102
+ expect(classifyTier("npm install && cat package.json")).toBe(3);
103
+ });
104
+
105
+ // Known false positive — documented, safe failure mode
106
+ it("cat package.json matches 'package' → tier 3 (known false positive)", () => {
107
+ expect(classifyTier("cat package.json")).toBe(3);
108
+ });
109
+
110
+ // Case insensitive
111
+ it("is case insensitive", () => {
112
+ expect(classifyTier("NPM INSTALL")).toBe(3);
113
+ expect(classifyTier("Docker Build")).toBe(3);
114
+ });
115
+ });
116
+
117
+ describe("KEYWORD_TIERS", () => {
118
+ it("has entries for all 5 tiers", () => {
119
+ const tiers = new Set(Object.values(KEYWORD_TIERS));
120
+ expect(tiers).toContain(0);
121
+ expect(tiers).toContain(1);
122
+ expect(tiers).toContain(2);
123
+ expect(tiers).toContain(3);
124
+ expect(tiers).toContain(4);
125
+ });
126
+
127
+ it("has at least 20 keywords", () => {
128
+ expect(Object.keys(KEYWORD_TIERS).length).toBeGreaterThanOrEqual(20);
129
+ });
130
+ });