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) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 15:30:56 -07:00
parent b88b242b6a
commit ebb8fe1027
2 changed files with 13 additions and 7 deletions
+5 -4
View File
@@ -1,5 +1,6 @@
import { describe, test, expect, vi } from 'vitest'; import { describe, test, expect, vi } from 'vitest';
import { sanitizeAiTree, __test } from './apply-ai-response'; import { sanitizeAiTree, __test } from './apply-ai-response';
import type { SitesmithPatchOp } from '../types/sitesmith';
describe('findNodeIdByAiNodeId', () => { describe('findNodeIdByAiNodeId', () => {
const query = { const query = {
@@ -173,7 +174,7 @@ describe('applyPatch', () => {
text: 'Old', text: 'Old',
style: { color: 'red', fontSize: '16px' }, style: { color: 'red', fontSize: '16px' },
}); });
const ops = [ const ops: SitesmithPatchOp[] = [
{ {
op: 'update_props', op: 'update_props',
node_id: 'ai-node-1', node_id: 'ai-node-1',
@@ -194,7 +195,7 @@ describe('applyPatch', () => {
text: 'Old', text: 'Old',
style: { color: 'red', fontSize: '16px' }, style: { color: 'red', fontSize: '16px' },
}); });
const ops = [ const ops: SitesmithPatchOp[] = [
{ {
op: 'update_props', op: 'update_props',
node_id: 'ai-node-1', node_id: 'ai-node-1',
@@ -211,7 +212,7 @@ describe('applyPatch', () => {
const { query, actions, propsById } = makeSingleNodeHarness('craft-1', 'ai-node-1', { const { query, actions, propsById } = makeSingleNodeHarness('craft-1', 'ai-node-1', {
style: { color: 'red', fontSize: '16px' }, style: { color: 'red', fontSize: '16px' },
}); });
const ops = [ const ops: SitesmithPatchOp[] = [
{ op: 'update_props', node_id: 'ai-node-1', props: { style: { color: 'blue' } } }, { op: 'update_props', node_id: 'ai-node-1', props: { style: { color: 'blue' } } },
]; ];
__test.applyPatch(actions, query, ops); __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', () => { 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 warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const { query, actions, propsById } = makeSingleNodeHarness('craft-1', 'ai-node-1', {}); const { query, actions, propsById } = makeSingleNodeHarness('craft-1', 'ai-node-1', {});
const ops = [ const ops: SitesmithPatchOp[] = [
{ {
op: 'insert_after', op: 'insert_after',
node_id: 'ai-node-1', node_id: 'ai-node-1',
+8 -3
View File
@@ -1,7 +1,7 @@
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import type { NodeTree } from '@craftjs/core'; import type { NodeTree } from '@craftjs/core';
import { usePages } from '../state/PageContext'; import { usePages } from '../state/PageContext';
import { SitesmithResponse, SerializedTreeNode } from '../types/sitesmith'; import { SitesmithResponse, SerializedTreeNode, SitesmithPatchOp } from '../types/sitesmith';
import { sanitizeAiTree, flattenTreeForCraft } from './craft-tree'; import { sanitizeAiTree, flattenTreeForCraft } from './craft-tree';
// Re-exported for existing callers/tests that import sanitizeAiTree from // Re-exported for existing callers/tests that import sanitizeAiTree from
@@ -155,7 +155,7 @@ export function useApplyAiResponse() {
function applyPatch( function applyPatch(
actions: any, actions: any,
query: any, query: any,
ops: any[], ops: SitesmithPatchOp[],
): { ok: boolean; message?: string } { ): { ok: boolean; message?: string } {
for (const op of ops) { for (const op of ops) {
const id = findNodeIdByAiNodeId(query, op.node_id); const id = findNodeIdByAiNodeId(query, op.node_id);
@@ -237,7 +237,12 @@ function applyPatch(
break; break;
default: 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 }; return { ok: true };