diff --git a/CHANGELOG.md b/CHANGELOG.md index 04d6e92..5831b51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Enabled TypeScript's `noUncheckedIndexedAccess` check and handled potentially missing array elements and lookup results explicitly. - **BREAKING: deployment modes are gone; hooks storage is a two-value data-backend switch.** `StorageMode = "local" | "hybrid" | "remote"` described *where* something ran, which was never a property of the data layer, and nothing in the codebase ever branched on it — it was reported by `hooks storage status` and the `storage_status` MCP tool and otherwise decorative. It is replaced by `StorageBackend = "sqlite" | "postgresql"`. - `HASNA_HOOKS_STORAGE_MODE` and `HOOKS_STORAGE_MODE` are retired and are **no longer read**. Setting either now raises an error naming the replacement variable and the backend to use, instead of being quietly ignored: `local` became `sqlite`, and `hybrid` / `remote` / `self_hosted` / `self-hosted` / `cloud` all became `postgresql`. - New `HASNA_HOOKS_STORAGE_BACKEND` (fallback `HOOKS_STORAGE_BACKEND`) accepts `sqlite` or `postgresql` (`sqlite3`, `postgres` and `pg` are accepted aliases). **An unrecognised value now throws.** Previously any unknown value — including a typo — fell through `normalizeStorageMode` to `undefined` and then silently to `local`, so a misconfigured mode looked like a working local one. That silent normalisation was the actual defect; the vocabulary was its symptom. diff --git a/src/cli/components/InstallProgress.tsx b/src/cli/components/InstallProgress.tsx index 6380b67..dc7be7d 100644 --- a/src/cli/components/InstallProgress.tsx +++ b/src/cli/components/InstallProgress.tsx @@ -23,11 +23,11 @@ export function InstallProgress({ const install = async () => { const newResults: InstallResult[] = []; - for (let i = 0; i < hooks.length; i++) { + for (const [i, hook] of hooks.entries()) { setCurrent(i); await new Promise((r) => setTimeout(r, 100)); - const result = installHook(hooks[i], { overwrite }); + const result = installHook(hook, { overwrite }); newResults.push(result); setResults([...newResults]); } diff --git a/src/cli/index.tsx b/src/cli/index.tsx index 909fad6..1445af3 100644 --- a/src/cli/index.tsx +++ b/src/cli/index.tsx @@ -124,16 +124,28 @@ function printDisclosureHint(hidden: number, detailCommand: string, options: { i /** Levenshtein distance for did-you-mean suggestions */ function editDistance(a: string, b: string): number { const m = a.length, n = b.length; - const dp: number[][] = Array.from({ length: m + 1 }, (_, i) => [i, ...Array(n).fill(0)]); - for (let j = 0; j <= n; j++) dp[0][j] = j; + const dp: number[][] = [Array.from({ length: n + 1 }, (_, j) => j)]; for (let i = 1; i <= m; i++) { + const previousRow = dp[i - 1]; + if (!previousRow) throw new Error("Unable to calculate edit distance"); + + const row = [i]; for (let j = 1; j <= n; j++) { - dp[i][j] = a[i - 1] === b[j - 1] - ? dp[i - 1][j - 1] - : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); + const deletion = previousRow[j]; + const insertion = row[j - 1]; + const substitution = previousRow[j - 1]; + if (deletion === undefined || insertion === undefined || substitution === undefined) { + throw new Error("Unable to calculate edit distance"); + } + row.push(a.charAt(i - 1) === b.charAt(j - 1) + ? substitution + : 1 + Math.min(deletion, insertion, substitution)); } + dp.push(row); } - return dp[m][n]; + const distance = dp[m]?.[n]; + if (distance === undefined) throw new Error("Unable to calculate edit distance"); + return distance; } function suggestHooks(name: string, max = 3): string[] { @@ -1152,8 +1164,10 @@ logCmd function parseDuration(s: string): number { const m = s.match(/^(\d+)(s|m|h|d)$/); if (!m) return 24 * 60 * 60 * 1000; - const n = parseInt(m[1]); - switch (m[2]) { + const [, amount, unit] = m; + if (!amount || !unit) return 24 * 60 * 60 * 1000; + const n = parseInt(amount); + switch (unit) { case "s": return n * 1000; case "m": return n * 60 * 1000; case "h": return n * 60 * 60 * 1000; diff --git a/src/db/legacy-import.ts b/src/db/legacy-import.ts index 7d2e394..d13b7dc 100644 --- a/src/db/legacy-import.ts +++ b/src/db/legacy-import.ts @@ -83,6 +83,7 @@ function importErrorsLog(db: Database, filePath: string): number { const m = line.match(linePattern); if (!m) continue; const [, timestamp, sessionPrefix, , errorMsg] = m; + if (!timestamp || !errorMsg) continue; db.run( `INSERT OR IGNORE INTO hook_events (id, timestamp, session_id, hook_name, event_type, error) diff --git a/src/lib/installer.test.ts b/src/lib/installer.test.ts index 27e9ce7..1853c06 100644 --- a/src/lib/installer.test.ts +++ b/src/lib/installer.test.ts @@ -199,15 +199,15 @@ describe("installer", () => { test("installs multiple hooks", () => { const results = installHooks(["gitguard", "checkpoint"]); expect(results).toHaveLength(2); - expect(results[0].success).toBe(true); - expect(results[1].success).toBe(true); + expect(results[0]?.success).toBe(true); + expect(results[1]?.success).toBe(true); }); test("returns mixed results for valid and invalid hooks", () => { const results = installHooks(["gitguard", "nonexistent"]); expect(results).toHaveLength(2); - expect(results[0].success).toBe(true); - expect(results[1].success).toBe(false); + expect(results[0]?.success).toBe(true); + expect(results[1]?.success).toBe(false); }); }); diff --git a/src/lib/installer.ts b/src/lib/installer.ts index c81aa5f..123e95c 100644 --- a/src/lib/installer.ts +++ b/src/lib/installer.ts @@ -507,7 +507,8 @@ export function getRegisteredHooksForTarget(scope: Scope = "global", target: Sin const re = /command\s*=\s*["']hooks run ([\w-]+)(?:\s+--profile\s+[\w-]+)?["']/g; let match: RegExpExecArray | null; while ((match = re.exec(config))) { - registered.push(match[1]); + const hookName = match[1]; + if (hookName) registered.push(hookName); } return [...new Set(registered)]; } diff --git a/src/lib/profiles.test.ts b/src/lib/profiles.test.ts index f8a5205..6da2e3c 100644 --- a/src/lib/profiles.test.ts +++ b/src/lib/profiles.test.ts @@ -147,8 +147,11 @@ describe("profiles", () => { const all = listProfiles(); for (let i = 1; i < all.length; i++) { - expect(new Date(all[i].created_at).getTime()).toBeGreaterThanOrEqual( - new Date(all[i - 1].created_at).getTime() + const current = all[i]; + const previous = all[i - 1]; + if (!current || !previous) throw new Error("Expected adjacent profiles"); + expect(new Date(current.created_at).getTime()).toBeGreaterThanOrEqual( + new Date(previous.created_at).getTime() ); } }); diff --git a/src/lib/registry.test.ts b/src/lib/registry.test.ts index 47a9eeb..603c382 100644 --- a/src/lib/registry.test.ts +++ b/src/lib/registry.test.ts @@ -161,25 +161,25 @@ describe("registry", () => { test("finds hooks by name", () => { const results = searchHooks("gitguard"); expect(results).toHaveLength(1); - expect(results[0].name).toBe("gitguard"); + expect(results[0]?.name).toBe("gitguard"); }); test("finds hooks by display name", () => { const results = searchHooks("Git Guard"); expect(results).toHaveLength(1); - expect(results[0].name).toBe("gitguard"); + expect(results[0]?.name).toBe("gitguard"); }); test("finds hooks by description keyword", () => { const results = searchHooks("destructive"); expect(results.length).toBeGreaterThanOrEqual(1); - expect(results[0].name).toBe("gitguard"); + expect(results[0]?.name).toBe("gitguard"); }); test("finds hooks by tag", () => { const results = searchHooks("typosquatting"); expect(results).toHaveLength(1); - expect(results[0].name).toBe("packageage"); + expect(results[0]?.name).toBe("packageage"); }); test("search is case-insensitive", () => { diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 0a69d6f..34cd638 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -803,8 +803,17 @@ export function createHooksServer(): McpServer { function parseDuration(s: string): string | null { const m = s.match(/^(\d+)(s|m|h|d)$/); if (!m) return null; - const n = parseInt(m[1]); - const ms = { s: 1000, m: 60000, h: 3600000, d: 86400000 }[m[2] as "s"|"m"|"h"|"d"]!; + const [, amount, unit] = m; + if (!amount || !unit) return null; + const n = parseInt(amount); + let ms: number; + switch (unit) { + case "s": ms = 1000; break; + case "m": ms = 60000; break; + case "h": ms = 3600000; break; + case "d": ms = 86400000; break; + default: return null; + } return new Date(Date.now() - n * ms).toISOString(); } @@ -877,8 +886,17 @@ export function createHooksServer(): McpServer { function parseDuration(s: string): string { const m = s.match(/^(\d+)(s|m|h|d)$/); if (!m) return s; - const n = parseInt(m[1]); - const ms = { s: 1000, m: 60000, h: 3600000, d: 86400000 }[m[2] as "s"|"m"|"h"|"d"]!; + const [, amount, unit] = m; + if (!amount || !unit) return s; + const n = parseInt(amount); + let ms: number; + switch (unit) { + case "s": ms = 1000; break; + case "m": ms = 60000; break; + case "h": ms = 3600000; break; + case "d": ms = 86400000; break; + default: return s; + } return new Date(Date.now() - n * ms).toISOString(); } @@ -913,8 +931,17 @@ export function createHooksServer(): McpServer { function parseDuration(s: string): string { const m = s.match(/^(\d+)(s|m|h|d)$/); if (!m) return s; - const n = parseInt(m[1]); - const ms = { s: 1000, m: 60000, h: 3600000, d: 86400000 }[m[2] as "s"|"m"|"h"|"d"]!; + const [, amount, unit] = m; + if (!amount || !unit) return s; + const n = parseInt(amount); + let ms: number; + switch (unit) { + case "s": ms = 1000; break; + case "m": ms = 60000; break; + case "h": ms = 3600000; break; + case "d": ms = 86400000; break; + default: return s; + } return new Date(Date.now() - n * ms).toISOString(); } diff --git a/tsconfig.json b/tsconfig.json index a7b25e6..df1c9a7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "moduleResolution": "bundler", "esModuleInterop": true, "strict": true, + "noUncheckedIndexedAccess": true, "skipLibCheck": true, "declaration": true, "outDir": "./dist",