|
7 | 7 | repairListVariableValues, |
8 | 8 | replaceBlocksRangeByUCF, |
9 | 9 | replaceScriptByUCF, |
| 10 | + replaceTargetScriptsByUCFSections, |
10 | 11 | } from "./workspaceRangeTools"; |
11 | 12 | import { setGetBlockInfoTool, setRuntime } from "./converter"; |
12 | 13 | import scratchBlocksCatalog from "./scratch_blocks.json"; |
@@ -1883,6 +1884,103 @@ export class AITools { |
1883 | 1884 | return { errors, warnings }; |
1884 | 1885 | } |
1885 | 1886 |
|
| 1887 | + private _getScriptStructureFingerprint(section: VirtualScriptSection) { |
| 1888 | + const blocks = ucfToScratch(normalizeModelUCF(section.code), { |
| 1889 | + runtime: this.vm.runtime, |
| 1890 | + includeComments: true, |
| 1891 | + }); |
| 1892 | + const opcodeCounts: Record<string, number> = {}; |
| 1893 | + const definedProcedures: string[] = []; |
| 1894 | + const calledProcedures: string[] = []; |
| 1895 | + |
| 1896 | + blocks.forEach((block: any) => { |
| 1897 | + const opcode = String(block?.opcode || ""); |
| 1898 | + opcodeCounts[opcode] = (opcodeCounts[opcode] || 0) + 1; |
| 1899 | + const proccode = block?.mutation?.proccode; |
| 1900 | + if (opcode === "procedures_prototype" && proccode) { |
| 1901 | + definedProcedures.push(normalizeProcedureSignature(proccode)); |
| 1902 | + } else if (opcode === "procedures_call" && proccode) { |
| 1903 | + calledProcedures.push(normalizeProcedureSignature(proccode)); |
| 1904 | + } |
| 1905 | + }); |
| 1906 | + |
| 1907 | + return { |
| 1908 | + blockCount: blocks.length, |
| 1909 | + topOpcodes: blocks.filter((block: any) => block?.topLevel).map((block: any) => block.opcode).sort(), |
| 1910 | + opcodeCounts, |
| 1911 | + definedProcedures: definedProcedures.sort(), |
| 1912 | + calledProcedures: calledProcedures.sort(), |
| 1913 | + }; |
| 1914 | + } |
| 1915 | + |
| 1916 | + private _verifyTargetSyncResult(entry: VirtualFileEntry, expectedContent: string) { |
| 1917 | + const currentEntry = this._getVirtualFile(entry.path); |
| 1918 | + if (!currentEntry || currentEntry.kind !== "target") { |
| 1919 | + return { |
| 1920 | + success: false, |
| 1921 | + error: `Could not read back virtual target file after sync: ${entry.path}`, |
| 1922 | + }; |
| 1923 | + } |
| 1924 | + |
| 1925 | + const expectedSections = extractVirtualScriptSections(expectedContent); |
| 1926 | + const actualSections = extractVirtualScriptSections(currentEntry.content); |
| 1927 | + const expectedIds = expectedSections.map((section) => section.scriptId); |
| 1928 | + const actualIds = actualSections.map((section) => section.scriptId); |
| 1929 | + const missingScriptIds = expectedIds.filter((scriptId) => !actualIds.includes(scriptId)); |
| 1930 | + const extraScriptIds = actualIds.filter((scriptId) => !expectedIds.includes(scriptId)); |
| 1931 | + |
| 1932 | + if (missingScriptIds.length || extraScriptIds.length || expectedSections.length !== actualSections.length) { |
| 1933 | + return { |
| 1934 | + success: false, |
| 1935 | + error: "VM readback script set does not match patched virtual file.", |
| 1936 | + expectedScriptIds: expectedIds, |
| 1937 | + actualScriptIds: actualIds, |
| 1938 | + missingScriptIds, |
| 1939 | + extraScriptIds, |
| 1940 | + }; |
| 1941 | + } |
| 1942 | + |
| 1943 | + const actualById = new Map(actualSections.map((section) => [section.scriptId, section])); |
| 1944 | + const mismatches: any[] = []; |
| 1945 | + |
| 1946 | + expectedSections.forEach((expectedSection) => { |
| 1947 | + const actualSection = actualById.get(expectedSection.scriptId); |
| 1948 | + if (!actualSection) return; |
| 1949 | + if (expectedSection.normalizedCode === actualSection.normalizedCode) return; |
| 1950 | + |
| 1951 | + try { |
| 1952 | + const expectedFingerprint = this._getScriptStructureFingerprint(expectedSection); |
| 1953 | + const actualFingerprint = this._getScriptStructureFingerprint(actualSection); |
| 1954 | + if (JSON.stringify(expectedFingerprint) !== JSON.stringify(actualFingerprint)) { |
| 1955 | + mismatches.push({ |
| 1956 | + scriptId: expectedSection.scriptId, |
| 1957 | + expected: expectedFingerprint, |
| 1958 | + actual: actualFingerprint, |
| 1959 | + }); |
| 1960 | + } |
| 1961 | + } catch (error) { |
| 1962 | + mismatches.push({ |
| 1963 | + scriptId: expectedSection.scriptId, |
| 1964 | + error: error instanceof Error ? error.message : String(error), |
| 1965 | + }); |
| 1966 | + } |
| 1967 | + }); |
| 1968 | + |
| 1969 | + if (mismatches.length) { |
| 1970 | + return { |
| 1971 | + success: false, |
| 1972 | + error: "VM readback script structure does not match patched virtual file.", |
| 1973 | + mismatches, |
| 1974 | + }; |
| 1975 | + } |
| 1976 | + |
| 1977 | + return { |
| 1978 | + success: true, |
| 1979 | + scriptCount: actualSections.length, |
| 1980 | + scriptIds: actualIds, |
| 1981 | + }; |
| 1982 | + } |
| 1983 | + |
1886 | 1984 | private _validateVirtualTargetFile(entry: VirtualFileEntry, content: string) { |
1887 | 1985 | const sections = extractVirtualScriptSections(content); |
1888 | 1986 | const diagnostics: any = { |
@@ -2135,6 +2233,38 @@ export class AITools { |
2135 | 2233 | } |
2136 | 2234 | } |
2137 | 2235 |
|
| 2236 | + if (operations.length > 0) { |
| 2237 | + const result: any = await replaceTargetScriptsByUCFSections( |
| 2238 | + this.vm, |
| 2239 | + window.Blockly.getMainWorkspace() as Blockly.WorkspaceSvg, |
| 2240 | + entry.targetId || "", |
| 2241 | + newSections.map((section) => ({ scriptId: section.scriptId, code: section.code })), |
| 2242 | + { |
| 2243 | + includeComments: true, |
| 2244 | + }, |
| 2245 | + ); |
| 2246 | + if (!result.success) { |
| 2247 | + throw new Error(this._formatSyncFailure("Failed to sync target", entry.path, result)); |
| 2248 | + } |
| 2249 | + |
| 2250 | + const verification = this._verifyTargetSyncResult(entry, newContent); |
| 2251 | + if (!verification.success) { |
| 2252 | + throw new Error(`Target sync verification failed for ${entry.path}: ${verification.error} ${JSON.stringify(verification).slice(0, 1200)}`); |
| 2253 | + } |
| 2254 | + |
| 2255 | + return { |
| 2256 | + path: entry.path, |
| 2257 | + targetId: entry.targetId, |
| 2258 | + operationCount: operations.length, |
| 2259 | + operations: operations.map((operation) => ({ |
| 2260 | + type: operation.type, |
| 2261 | + scriptId: operation.section?.scriptId || operation.oldSection?.scriptId, |
| 2262 | + result, |
| 2263 | + })), |
| 2264 | + verification, |
| 2265 | + }; |
| 2266 | + } |
| 2267 | + |
2138 | 2268 | const results = []; |
2139 | 2269 | for (const operation of operations) { |
2140 | 2270 | if (operation.type === "replace") { |
@@ -4834,6 +4964,12 @@ export class AITools { |
4834 | 4964 | this._moveMenuInputsToFields(result); |
4835 | 4965 | this._dedupeFieldAndInputNames(result); |
4836 | 4966 | this._normalizeArgumentReporterInfo(resolvedOpcode, result); |
| 4967 | + if (!result.found) { |
| 4968 | + const triedText = resolvedOpcode !== requestedOpcode ? ` (also tried "${resolvedOpcode}")` : ""; |
| 4969 | + throw new Error( |
| 4970 | + `Unknown Scratch block opcode "${requestedOpcode}"${triedText}. Use searchBlocks or getBlockHelp to find the exact DSL call/opcode before applying a patch.`, |
| 4971 | + ); |
| 4972 | + } |
4837 | 4973 |
|
4838 | 4974 | if (!result.found) { |
4839 | 4975 | if (resolvedOpcode !== requestedOpcode) { |
|
0 commit comments