Skip to content

Commit 7d6d853

Browse files
authored
Merge pull request #3551 from scratchfoundation/fix/tolerate-malformed-argument-arrays
fix: tolerate real-world mutation attribute values on procedure blocks
2 parents 1a6b149 + 5508f6e commit 7d6d853

2 files changed

Lines changed: 67 additions & 66 deletions

File tree

src/blocks/procedures.ts

Lines changed: 42 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ type ConnectionMap = Record<
3737
/**
3838
* Possible types for procedure arguments.
3939
*/
40+
type ArgumentDefault = string | number | boolean | null
41+
4042
enum ArgumentType {
4143
STRING = 's',
4244
NUMBER = 'n',
@@ -75,39 +77,17 @@ function getRequiredMutationAttribute(xmlElement: Element, name: string): string
7577
return value
7678
}
7779

78-
/**
79-
* Parse a required mutation attribute as JSON, then validate its type.
80-
* @param xmlElement The mutation element that should contain required attributes.
81-
* @param name The specific mutation attribute to retrieve and parse.
82-
* @param parse Validates and narrows the parsed JSON value.
83-
* @returns Parsed and validated mutation attribute value.
84-
*/
85-
function parseRequiredMutationJson<T>(
86-
xmlElement: Element,
87-
name: string,
88-
parse: (value: unknown, name: string) => T,
89-
): T {
90-
const rawValue = getRequiredMutationAttribute(xmlElement, name)
91-
let parsedValue: unknown
92-
try {
93-
parsedValue = JSON.parse(rawValue)
94-
} catch {
95-
throw new Error(`Invalid JSON in mutation attribute: ${name}`)
96-
}
97-
return parse(parsedValue, name)
98-
}
99-
10080
/**
10181
* Parse an optional mutation attribute as JSON, returning a fallback when the
102-
* attribute is absent. Use this only for attributes that can be safely
103-
* defaulted in isolation, without invalidating structural invariants that
104-
* relate them to other attributes on the same mutation. A present-but-malformed
105-
* attribute still throws, since that indicates corruption rather than an older
106-
* schema.
82+
* attribute is absent or malformed. Older saved projects may omit attributes
83+
* that current serialization always writes, and some projects in the wild
84+
* contain attributes whose values are not valid for the expected type.
85+
* Returning the fallback in both cases lets the project load rather than
86+
* aborting the entire workspace parse.
10787
* @param xmlElement The mutation element that may contain the attribute.
10888
* @param name The specific mutation attribute to retrieve and parse.
10989
* @param parse Validates and narrows the parsed JSON value.
110-
* @param fallback Value to return when the attribute is absent.
90+
* @param fallback Value to return when the attribute is absent or malformed.
11191
* @returns Parsed and validated mutation attribute value, or `fallback`.
11292
*/
11393
function parseOptionalMutationJson<T>(
@@ -120,13 +100,12 @@ function parseOptionalMutationJson<T>(
120100
if (rawValue === null) {
121101
return fallback
122102
}
123-
let parsedValue: unknown
124103
try {
125-
parsedValue = JSON.parse(rawValue)
104+
return parse(JSON.parse(rawValue), name)
126105
} catch {
127-
throw new Error(`Invalid JSON in mutation attribute: ${name}`)
106+
console.warn(`Invalid or unexpected mutation attribute "${name}", using default. Raw value: ${rawValue}`)
107+
return fallback
128108
}
129-
return parse(parsedValue, name)
130109
}
131110

132111
/**
@@ -155,6 +134,26 @@ function parseStringArrayMutationValue(value: unknown, name: string): string[] {
155134
return value
156135
}
157136

137+
/**
138+
* Validate a parsed mutation value as an array of primitives. Argument
139+
* defaults from older projects (especially Scratch 2.0 conversions) contain
140+
* mixed types — strings for text arguments, numbers for numeric arguments,
141+
* and booleans for boolean arguments (e.g. `["",1,1,1,1]`). The old fork
142+
* stored these via untyped `JSON.parse` with no validation, and the VM
143+
* passes them through as-is.
144+
* @param value Parsed mutation value.
145+
* @param name Attribute name used in error messages.
146+
* @returns Validated array value.
147+
*/
148+
function parsePrimitiveArrayMutationValue(value: unknown, name: string): ArgumentDefault[] {
149+
const isArgumentDefault = (entry: unknown): entry is ArgumentDefault =>
150+
entry === null || typeof entry === 'string' || typeof entry === 'number' || typeof entry === 'boolean'
151+
if (!Array.isArray(value) || !value.every(isArgumentDefault)) {
152+
throw new Error(`Expected ArgumentDefault[] JSON value in mutation attribute: ${name}`)
153+
}
154+
return value
155+
}
156+
158157
/**
159158
* A drag strategy for the procedures_prototype block that delegates all drag
160159
* operations to its parent (the procedures_definition block). This lets the
@@ -308,7 +307,7 @@ function callerDomToMutation(this: ProcedureCallBlock, xmlElement: Element) {
308307
this.procCode_ = getRequiredMutationAttribute(xmlElement, 'proccode')
309308
const generateshadows = xmlElement.getAttribute('generateshadows')
310309
this.generateShadows_ = generateshadows !== null ? JSON.parse(generateshadows) === true : false
311-
this.argumentIds_ = parseRequiredMutationJson(xmlElement, 'argumentids', parseStringArrayMutationValue)
310+
this.argumentIds_ = parseOptionalMutationJson(xmlElement, 'argumentids', parseStringArrayMutationValue, [])
312311
this.warp_ = parseOptionalMutationJson(xmlElement, 'warp', parseBooleanMutationValue, false)
313312
this.updateDisplay_()
314313
}
@@ -349,9 +348,14 @@ function definitionDomToMutation(this: ProcedurePrototypeBlock | ProcedureDeclar
349348
const prevArgIds = this.argumentIds_
350349
const prevDisplayNames = this.displayNames_
351350

352-
this.argumentIds_ = parseRequiredMutationJson(xmlElement, 'argumentids', parseStringArrayMutationValue)
353-
this.displayNames_ = parseRequiredMutationJson(xmlElement, 'argumentnames', parseStringArrayMutationValue)
354-
this.argumentDefaults_ = parseRequiredMutationJson(xmlElement, 'argumentdefaults', parseStringArrayMutationValue)
351+
this.argumentIds_ = parseOptionalMutationJson(xmlElement, 'argumentids', parseStringArrayMutationValue, [])
352+
this.displayNames_ = parseOptionalMutationJson(xmlElement, 'argumentnames', parseStringArrayMutationValue, [])
353+
this.argumentDefaults_ = parseOptionalMutationJson(
354+
xmlElement,
355+
'argumentdefaults',
356+
parsePrimitiveArrayMutationValue,
357+
[],
358+
)
355359

356360
// During full XML deserialization (Blockly.Xml.domToWorkspace), the mutation element
357361
// is part of the parsed XML tree and its parent element also contains <value> children
@@ -1341,7 +1345,7 @@ interface ProcedureBlock extends Blockly.BlockSvg {
13411345

13421346
export interface ProcedureDeclarationBlock extends ProcedureBlock {
13431347
displayNames_: string[]
1344-
argumentDefaults_: string[]
1348+
argumentDefaults_: ArgumentDefault[]
13451349
removeFieldCallback: (field: Blockly.Field) => void
13461350
createArgumentEditor_: (argumentType: ArgumentType, displayName: string) => Blockly.BlockSvg
13471351
focusLastEditor_: () => void
@@ -1361,7 +1365,7 @@ interface ProcedureCallBlock extends ProcedureBlock {
13611365

13621366
interface ProcedurePrototypeBlock extends ProcedureBlock {
13631367
displayNames_: string[]
1364-
argumentDefaults_: string[]
1368+
argumentDefaults_: ArgumentDefault[]
13651369
skipArgumentReporters_: boolean
13661370
createArgumentReporter_: (argumentType: ArgumentType, displayName: string) => Blockly.BlockSvg
13671371
updateArgumentReporterNames_: (prevArgIds: string[], prevDisplayNames: string[]) => void

tests/browser/procedures.test.ts

Lines changed: 25 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -272,68 +272,65 @@ describe('legacy mutation tolerance', () => {
272272
).toThrow(/proccode/)
273273
})
274274

275-
// argumentids/argumentnames/argumentdefaults have a structural invariant:
276-
// their lengths must equal the %s/%n/%b token count in proccode. Silently
277-
// defaulting any of them to [] would turn an N-arg procedure into a 0-arg
278-
// block, so keep them required and surface the problem loudly.
279-
it('procedures_prototype mutation without argumentids still throws', () => {
275+
// argumentdefaults in older projects can contain numbers alongside strings
276+
// (e.g. [1] or ["",1,1,1,1]). This regression case verifies those
277+
// mixed primitive values are preserved when loading legacy mutations.
278+
it('procedures_prototype mutation with numeric argumentdefaults preserves types', () => {
280279
expect(() =>
281280
loadXml(`
282281
<xml>
283282
<block type="procedures_definition">
284283
<statement name="custom_block">
285284
<block type="procedures_prototype">
286-
<mutation proccode="test %s" argumentnames='["x"]' argumentdefaults='[""]' warp="false"></mutation>
285+
<mutation proccode="test %n" argumentids='["arg1"]' argumentnames='["x"]' argumentdefaults='[1]' warp="false"></mutation>
287286
</block>
288287
</statement>
289288
</block>
290289
</xml>
291290
`),
292-
).toThrow(/argumentids/)
293-
})
291+
).not.toThrow()
294292

295-
it('procedures_prototype mutation without argumentnames still throws', () => {
296-
expect(() =>
297-
loadXml(`
298-
<xml>
299-
<block type="procedures_definition">
300-
<statement name="custom_block">
301-
<block type="procedures_prototype">
302-
<mutation proccode="test %s" argumentids='["arg1"]' argumentdefaults='[""]' warp="false"></mutation>
303-
</block>
304-
</statement>
305-
</block>
306-
</xml>
307-
`),
308-
).toThrow(/argumentnames/)
293+
const proto = workspace.getAllBlocks(false).find((b) => b.type === 'procedures_prototype')
294+
assert(proto, 'Expected procedures_prototype block')
295+
expect((proto as any).argumentDefaults_).toEqual([1])
309296
})
310297

311-
it('procedures_prototype mutation without argumentdefaults still throws', () => {
298+
it('procedures_prototype mutation with mixed string/number argumentdefaults preserves types', () => {
312299
expect(() =>
313300
loadXml(`
314301
<xml>
315302
<block type="procedures_definition">
316303
<statement name="custom_block">
317304
<block type="procedures_prototype">
318-
<mutation proccode="test %s" argumentids='["arg1"]' argumentnames='["x"]' warp="false"></mutation>
305+
<mutation proccode="test %s %n %n %n %n" argumentids='["a","b","c","d","e"]' argumentnames='["typ","x","y","sx","sy"]' argumentdefaults='["",1,1,1,1]' warp="true"></mutation>
319306
</block>
320307
</statement>
321308
</block>
322309
</xml>
323310
`),
324-
).toThrow(/argumentdefaults/)
311+
).not.toThrow()
312+
313+
const proto = workspace.getAllBlocks(false).find((b) => b.type === 'procedures_prototype')
314+
assert(proto, 'Expected procedures_prototype block')
315+
expect((proto as any).argumentDefaults_).toEqual(['', 1, 1, 1, 1])
325316
})
326317

327-
it('procedures_call mutation without argumentids still throws', () => {
318+
it('procedures_call mutation with warp="null" loads as false', () => {
319+
// Project 10118230 has warp='null' on some call blocks.
320+
// JSON.parse("null") → null, which is not boolean. Should default to false.
328321
expect(() =>
329322
loadXml(`
330323
<xml>
331324
<block type="procedures_call">
332-
<mutation proccode="test %s" warp="false"></mutation>
325+
<mutation proccode="test %s" argumentids='["arg1"]' warp='null'></mutation>
333326
</block>
334327
</xml>
335328
`),
336-
).toThrow(/argumentids/)
329+
).not.toThrow()
330+
331+
const callBlock = workspace.getAllBlocks(false).find((b) => b.type === 'procedures_call')
332+
assert(callBlock, 'Expected procedures_call block')
333+
expect((callBlock as any).warp_).toBe(false)
337334
})
338335
})
339336

0 commit comments

Comments
 (0)