I'm using Windows. Looks like DF reliably crashes when attempting to delete something that's already been deleted, so I can check using lua.
While a crash can indicate that DF has already deleted something, the inverse is not necessarily true: the lack of a crash does
not mean that DF has not already deleted something, nor does it mean that the crash won't occur in the future or in different environments (other systems, DF versions, DFHack versions, etc.).
My suggestion (which unfortunately also doesn't work on Windows) is to use the MALLOC_PERTURB_ environment variable (Linux) or MallocScribble (macOS), which will overwrite freed memory with a specific bit pattern. This would allow you to use "memview" or something similar before and after deleted an object to see what else was freed.
Looks like deleting tree_info deletes extent_east, etc., (didn't check tree_tiles) but deleting a plant doesn't delete its tree_info. Guess I can't rely on it.
I verified on Linux that this is not the case:
t = df.plant_tree_info:new()
t.extent_east = df.new('int16_t', 120)
~t -- in lua interpreter
t:delete()
~t
delete() frees the parent plant_tree_info struct, and causes it to be overwritten with garbage, but extent_east is left intact (all 0s), meaning that it was not deleted.
In general, delete() can't call "delete" or free() on sub-fields because it doesn't know whether it's safe (or which one is correct) any more than you do. DF's destructor does this, but we cannot call it directly unless it is virtual (which it is not in most cases, including for plant_tree_info).