Check the declared type when a graph writes a typed variable (#271)

A list variable with an item type only kept its shape in the editor.
A graph could write any list into it at runtime, and the List node had
no type at all. Now the declaration decides what a write may store.

- Writing a list into a typed list variable leaves out every item that
  does not have exactly the fields of the type. The rest is written and
  the console names each item that was left out, like Add item does.
- The same rule holds for typed list fields inside a struct variable
  and for lists inside typed items, at any depth.
- A typed struct variable refuses a struct with a field the type does
  not have, and a value that is no struct. A struct with missing fields
  still merges in as before, so a partial Pack keeps working.
- The check runs in the Variables node (own variables and Variables
  assets) and in the Properties node when it sets a behavior variable
  of another object.
- Ports learn their declaration through GraphLookup: what a variable
  declares about its type, and which behavior owns a graph. The rule
  itself lives in the new src/core/graph/typedValues.ts, fitsType moved
  there from SetItem.
- The List node gets a Type setting. With a type its items are shaped
  and marked, follow the struct asset, count when the type is deleted
  and offer Split into fields in For each. The mark never reaches the
  running game.
- A type on a List node holding something other than structs is
  refused with a console line.
- Setting the List node back to None removes the marks, unlike a list
  variable. A node list has no per-item type row, so marked items could
  never be freed there. Undo brings everything back.
- tests/core/kinds.test.ts looked for the value as the first parameter
  of a literal. Type now comes first on the List node, so the test
  finds the value by its key.

Tests:
- Gate: 198 files, 2869 tests (2856 + 13), CHECK EXIT 0
- e2e 55: 19 checks (+55j write at runtime, +2 for 55k List node)
- e2e 54: 16 checks, smokes 24 / 19 / 21
- Five mutations, each fails between 1 and 4 tests
- e2e total 1606 is computed (1603 + 3), no full run

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-30 22:08:22 +02:00
1 parent f2282a81fb
commit 0f6e628616
19 files changed
+682 -52

No files matched your search

+2 -1
View File
@@ -3,8 +3,9 @@ import { itemKindOf } from '../../../properties/listItems';
import { STRUCT_TYPE_SETTING } from '../../nested';
import type { NodeDef } from '../../NodeRegistry';
import { asList, asNumber, asString, asValueItem, type ValueList } from '../../values';
import { fitsType } from '../../typedValues';
import { any, int, list } from '../shared';
import { fitsType, fitToSlot } from './SetItem';
import { fitToSlot } from './SetItem';
export const AddItem: NodeDef = {
type: 'AddItem',
+14 -4
View File
@@ -1,12 +1,15 @@
import { textOf } from '../../../model/Properties';
import { collapsedTitle, PROPERTIES } from '../../nested';
import type { NodeDef } from '../../NodeRegistry';
import {
OBJECT_BEHAVIOR_PARAM,
OBJECT_BEHAVIOR_SETTING,
OBJECT_TYPE_SETTING,
propertyPort,
propertyTargetOf,
} from '../../propertyPorts';
import { objectsOf, readLive, writePort } from '../../liveObjects';
import { objectsOf, readLive } from '../../liveObjects';
import { writeDeclared } from '../../typedValues';
import { asEntity } from '../../values';
import { exec, obj } from '../shared';
@@ -22,7 +25,13 @@ export const Properties: NodeDef = {
params: [OBJECT_TYPE_SETTING, OBJECT_BEHAVIOR_SETTING],
hiddenParams: (params) => (propertyTargetOf(params) === 'behavior' ? [] : [OBJECT_BEHAVIOR_SETTING.key]),
declaresPorts: 'both',
declaredPort: (port, node) => propertyPort(port, node.params),
declaredPort: (port, node, _graph, lookup) => {
const def = propertyPort(port, node.params);
const onBehavior = propertyTargetOf(node.params) === 'behavior';
const behavior = textOf(node.params[OBJECT_BEHAVIOR_PARAM] ?? '');
const typing = onBehavior && behavior ? lookup.variableTyping(behavior, port.key) : undefined;
return typing ? { ...def, typing } : def;
},
derivePorts: ({ node }) => {
const ports = node.ports ?? [];
const shape = ports.some((p) => p.write) ? { inputs: [exec()], outputs: [exec()] } : {};
@@ -32,11 +41,12 @@ export const Properties: NodeDef = {
const target = asEntity(input('object'));
return target ? readLive(target.props, def.outputs, objectsOf(ctx, target)) : {};
},
run: (_ctx, inputs, _params, def) => {
run: (ctx, inputs, _params, def, _entry, lookup) => {
const target = asEntity(inputs.object);
if (target)
for (const port of def.inputs)
if (port.type !== 'exec' && port.key !== 'object') writePort(target.props, port, inputs[port.key]);
if (port.type !== 'exec' && port.key !== 'object')
writeDeclared(target.props, port, inputs[port.key], lookup, (m) => ctx.log(m));
return { next: 'exec' };
},
};
+3 -13
View File
@@ -1,9 +1,9 @@
import { isStruct, type PropertyValue, tagOf, textOf } from '../../../model/Properties';
import { type PropertyValue, textOf } from '../../../model/Properties';
import { itemKindOf, itemLike, makeItem } from '../../../properties/listItems';
import type { StructShape } from '../../lookup';
import { STRUCT_TYPE_SETTING } from '../../nested';
import type { NodeDef } from '../../NodeRegistry';
import { asList, asNumber, asString, asValueItem, isEntity, type Value, type ValueList } from '../../values';
import { fitsType } from '../../typedValues';
import { asList, asNumber, asString, asValueItem, isEntity, type ValueList } from '../../values';
import { any, int, list } from '../shared';
export const SetItem: NodeDef = {
@@ -34,16 +34,6 @@ export const SetItem: NodeDef = {
params: [STRUCT_TYPE_SETTING],
};
/**
* A run knows a struct only by its fields (#254): one fits a type when it has exactly the type's
* field names – none missing, none more.
*/
export function fitsType(value: Value | undefined, type: StructShape): boolean {
if (isEntity(value) || !isStruct(value) || tagOf(value) !== undefined) return false;
const keys = Object.keys(value);
return keys.length === type.fields.length && type.fields.every((field) => field in value);
}
/** `value` as the kind `slot` holds, the way `Set item` and `Add item` write into a list. */
export function fitToSlot(slot: ValueList[number] | undefined, value: ValueList[number]): ValueList[number] {
// An object has no kind to keep and none to take on, so it is written as it is (#157).
+10 -3
View File
@@ -1,5 +1,7 @@
import { withoutItemTypes } from '../../../model/Properties';
import { KIND, kindLabel, type VariableKind } from '../../../properties/kinds';
import { coerceValue, flatDef, type PropertyDef } from '../../../properties/PropertySchema';
import { STRUCT_TYPE_SETTING } from '../../nested';
import type { NodeDef } from '../../NodeRegistry';
/** A node handing out one constant of a kind: its one setting, edited as that kind, is its output. */
@@ -18,7 +20,10 @@ function literal(type: string, kind: Exclude<VariableKind, 'struct' | 'object'>,
inputs: [],
outputs: [{ key: 'value', label: 'value', type: KIND[kind].travels }],
params: [value],
run: (_ctx, _inputs, params) => ({ outputs: { value: coerceValue(value, params.value ?? value.default) } }),
// The items of a typed list carry their type's mark in the node, and a run never sees one (#271).
run: (_ctx, _inputs, params) => ({
outputs: { value: withoutItemTypes(coerceValue(value, params.value ?? value.default)) },
}),
};
}
@@ -55,8 +60,10 @@ export const AssetLiteral = literal(
'asset',
'A constant placeable asset, for a "Spawn" to place or an "Is asset" to compare with.',
);
export const ListLiteral = literal(
const list = literal(
'List',
'list',
'A constant list, edited item by item in the inspector – each item with its own kind.',
'A constant list, edited item by item in the inspector – each item with its own kind. With a type, every item is a struct of that type.',
);
export const ListLiteral: NodeDef = { ...list, params: [STRUCT_TYPE_SETTING, ...list.params] };