The story analysis

Version: 2

The story analysis is what the check settled about a movement, as data: the scopes it opened, the symbols it bound, the writes and the nodes it saw, the bodies a lane ends in, and what each movement may do. A surface that draws a movement's story reads this instead of walking the syntax tree and resolving names again. It is the check's own walk, so the verdict and the picture come from one call and cannot disagree.

Versions #

The shapes #

The shapes are TypeScript-ish: x?: T is left out when the fact is absent, never written as null. Spans are the syntax tree's (1-based line and column). A scope is an id into scopes, a symbol an id into symbols.

interface StoryAnalysis {
  storyAnalysisVersion: number;
  diagnostics: Diagnostic[];             // the check's
  scopes: StoryScope[];                  // the file's scope is 0
  symbols: StorySymbol[];
  writes: StoryWrite[];
  nodes: StoryNode[];
  bodies: StoryBody[];
  shapes: StoryShape[];
}

interface StoryScope {
  parent?: number;                       // absent on the file's scope
  span?: Span;                           // absent for a scope with no text of its own
  symbols: Record<string, number>;       // name to symbol id; the last binding of the name
}

interface StorySymbol {
  name: string;
  kind: 'adapter' | 'credential' | 'plugin' | 'fileImport' | 'binding' | 'instance' | 'param' | 'movement' | 'shape' | 'alias' | 'type';
  span: Span;
  adapter?: string;
  address?: StoryAddress;
  extracted?: { entity?: string };       // the binding holds an extraction's graph; entity: the node's name, absent at its root
  effects?: StoryEffects;                // movements only
}

interface StoryAddress {
  instance: string;                      // a system's binding name
  position?: string;                     // its record type, where one place holds it
}

interface StoryEffects {
  reads: string[];                       // instance binding names, or a source a plugin or built-in declares ("the web", "files")
  writes: string[];
  ai: boolean;
  now: boolean;
  suspend: boolean;
  partial: boolean;                      // a callee or a site could not be placed
}

interface StoryWrite {
  span: Span;
  scope: number;
  fields: FieldEntry[];                  // the syntax tree's field lines, spreads written out (a spread's line carries `spread`)
  hasUniqueBy: boolean;
  action: 'create' | 'update' | 'find';  // update: a write to a position; find: a match
  parents: Array<{ edge: string; type?: string }>;
  binding?: string;
  target?: { instance: string; recordType?: string };
}

interface StoryBody {
  span: Span;
  kind: 'movement' | 'arm' | 'else' | 'combinatorArm';
  terminates: boolean;
}

interface StoryShape {                   // a file-level node declaration
  name: string;
  span: Span;
  root: ShapeNode;                       // the declaration the checker resolved, `extends` folded in
}

Every node carries span and scope, and one of 14 kinds:

type StoryNode = { span: Span; scope: number } & (
  | { kind: 'instance'; name: string; adapterType?: string }
  | { kind: 'listen'; instance: string; adapterType?: string; fires: string; firesMovement: boolean; path?: string }
  | { kind: 'link'; from: string; edge: string; to: { kind: 'handle'; name: string } | { kind: 'match'; span: Span } }
  | { kind: 'unlink'; from: string; edge: string; to: { kind: 'handle'; name: string } }
  | { kind: 'await'; binding?: string; source: StoryAwaitedSource }
  | { kind: 'combinator'; combinator: CombinatorKind; binding?: string; arms: Array<{ span: Span; scope?: number }> }
  | { kind: 'branch'; arms: Array<{ span: Span; scope: number; conditionSpan: Span }>; otherwise?: { span: Span; scope: number } }
  | { kind: 'extract'; binding?: string; node?: StoryExtractNode }
  | { kind: 'call'; binding?: string; callee: string; isMovement: boolean; argParams?: Array<string | null> }
  | { kind: 'callReading'; reading: StoryCallReading }
  | { kind: 'traversal'; from?: 'graph' | 'result'; root?: string; steps: TraversalStep[]; landings: Array<StoryAddress | null> }
  | { kind: 'valuePath'; key: string; root?: string; steps: TraversalStep[]; landings: Array<StoryAddress | null> }
  | { kind: 'delete'; subject: string }
  | { kind: 'refresh'; subject: string }
);

type StoryAwaitedSource =
  | { kind: 'traversal'; root?: string; edge?: string; listenable?: boolean }
  | { kind: 'sleep'; duration: string }
  | { kind: 'until'; every?: string };

type StoryCallReading =
  | { kind: 'value'; expr: ExprSlot }
  | { kind: 'collection'; collection: CollectionOpExpression }
  | { kind: 'members'; members: MembersExpression }
  | { kind: 'refused'; refusal: { code: string; message: string; span: Span } };

interface StoryExtractNode {
  name: string;
  fields: Array<{ name: string; at: Span; type?: string; fieldType?: SchemaFieldType | null }>;   // type: in the author's words (`text`, `number`); fieldType: the same type as data, in the catalog's spelling (`"number"`, `{ kind: "enum", options }`), a borrowed field path resolved to its declared type; null with no resolved annotation
  children: StoryExtractNode[];
}

Bodies #

A body is a statement list a lane is capped by. terminates says whether it leaves, by the rule of its kind:

The effects rule #

A movement's effects come from the sites inside its span, closures written inside it included.

The refused pin #

The check takes no pin; the pin rule is the caller's. A caller handed a pin this release does not read answers without asking the artifact (refused_story_analysis in crates/movement-checker/src/story.rs, and the same shape from storyAnalysisInRust):

{
  storyAnalysisVersion: 2,
  diagnostics: [/* the language version diagnostic alone */],
  scopes: [], symbols: [], writes: [], nodes: [], bodies: [], shapes: [],
}

Changes #