From ebb8fe102756a7f731a90e47c5ce9d90e57ef210 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 12 Jul 2026 15:30:56 -0700 Subject: [PATCH] refactor(builder): type the AI-response/patch boundary applyPatch's `ops` param was typed `any[]`, discarding the SitesmithPatchOp discriminated union that SitesmithResponse.ops already carried at the call site. Type it ops: SitesmithPatchOp[] so op.props/op.tree/op.op narrow correctly per-variant in the switch instead of being `any`, and update the unit tests' inline op literals to SitesmithPatchOp[] so tsc checks them too. SerializedTreeNode/SitesmithPatchOp themselves, and buildNodeTree/ sanitizeAiTree's tree params, were already typed from a prior task -- this closes the one remaining any at the op-handling site. Runtime validation (protected-key guard, style-merge guard, unknown-resolvedName soft-skip) is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- craft/src/utils/apply-ai-response.test.ts | 9 +++++---- craft/src/utils/apply-ai-response.ts | 11 ++++++++--- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/craft/src/utils/apply-ai-response.test.ts b/craft/src/utils/apply-ai-response.test.ts index 26a361d..b06e84a 100644 --- a/craft/src/utils/apply-ai-response.test.ts +++ b/craft/src/utils/apply-ai-response.test.ts @@ -1,5 +1,6 @@ import { describe, test, expect, vi } from 'vitest'; import { sanitizeAiTree, __test } from './apply-ai-response'; +import type { SitesmithPatchOp } from '../types/sitesmith'; describe('findNodeIdByAiNodeId', () => { const query = { @@ -173,7 +174,7 @@ describe('applyPatch', () => { text: 'Old', style: { color: 'red', fontSize: '16px' }, }); - const ops = [ + const ops: SitesmithPatchOp[] = [ { op: 'update_props', node_id: 'ai-node-1', @@ -194,7 +195,7 @@ describe('applyPatch', () => { text: 'Old', style: { color: 'red', fontSize: '16px' }, }); - const ops = [ + const ops: SitesmithPatchOp[] = [ { op: 'update_props', node_id: 'ai-node-1', @@ -211,7 +212,7 @@ describe('applyPatch', () => { const { query, actions, propsById } = makeSingleNodeHarness('craft-1', 'ai-node-1', { style: { color: 'red', fontSize: '16px' }, }); - const ops = [ + const ops: SitesmithPatchOp[] = [ { op: 'update_props', node_id: 'ai-node-1', props: { style: { color: 'blue' } } }, ]; __test.applyPatch(actions, query, ops); @@ -221,7 +222,7 @@ describe('applyPatch', () => { test('an op with an unknown resolvedName tree is skipped without throwing; other ops in the batch still apply', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const { query, actions, propsById } = makeSingleNodeHarness('craft-1', 'ai-node-1', {}); - const ops = [ + const ops: SitesmithPatchOp[] = [ { op: 'insert_after', node_id: 'ai-node-1', diff --git a/craft/src/utils/apply-ai-response.ts b/craft/src/utils/apply-ai-response.ts index 814208b..0891372 100644 --- a/craft/src/utils/apply-ai-response.ts +++ b/craft/src/utils/apply-ai-response.ts @@ -1,7 +1,7 @@ import { useEditor } from '@craftjs/core'; import type { NodeTree } from '@craftjs/core'; import { usePages } from '../state/PageContext'; -import { SitesmithResponse, SerializedTreeNode } from '../types/sitesmith'; +import { SitesmithResponse, SerializedTreeNode, SitesmithPatchOp } from '../types/sitesmith'; import { sanitizeAiTree, flattenTreeForCraft } from './craft-tree'; // Re-exported for existing callers/tests that import sanitizeAiTree from @@ -155,7 +155,7 @@ export function useApplyAiResponse() { function applyPatch( actions: any, query: any, - ops: any[], + ops: SitesmithPatchOp[], ): { ok: boolean; message?: string } { for (const op of ops) { const id = findNodeIdByAiNodeId(query, op.node_id); @@ -237,7 +237,12 @@ function applyPatch( break; default: - console.warn('sitesmith patch: unknown op', (op as any).op); + // Every SitesmithPatchOp variant is handled above, so `op` is typed + // `never` here at compile time — but the AI response is untrusted + // JSON (see useSitesmith.ts's `j: SendResult = await r.json()`), so + // a malformed/unrecognized `op` field can still reach this branch at + // runtime. Log the raw value rather than assuming a shape. + console.warn('sitesmith patch: unknown op', op); } } return { ok: true };