@cynodia/axiom-core 0.4.0-alpha.1 → 0.4.1-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { expressionFieldIds, walkExpression } from './expressions.js';
1
+ import { constructedFieldIds, expressionFieldIds, walkExpression } from './expressions.js';
2
2
  import { locationExpressions, locationFieldIds, locationRootStateId, locationSelectorFieldIds, } from './location.js';
3
3
  import { isUINode } from './ui.js';
4
4
  /** Ids a `ref` expression mentions anywhere in the tree. */
@@ -42,6 +42,19 @@ export function deriveEdges(nodes) {
42
42
  rootScope.set(node.id, statesOf(node.source, new Map(), states));
43
43
  }
44
44
  }
45
+ // Which states can hold instances of an entity, including nested ones. An entity-scoped
46
+ // constraint reads its fields wherever those instances actually live.
47
+ const entities = new Map(nodes.filter((node) => node.kind === 'entity').map((node) => [node.id, node]));
48
+ const statesByEntity = new Map();
49
+ for (const node of nodes) {
50
+ if (node.kind !== 'state' || node.draft || node.derivation) {
51
+ continue;
52
+ }
53
+ for (const entityId of reachableEntities(node.valueType, entities)) {
54
+ statesByEntity.set(entityId, [...(statesByEntity.get(entityId) ?? []), node.id]);
55
+ }
56
+ }
57
+ const entityScope = (entityId) => new Map([...rootScope, [entityId, statesByEntity.get(entityId) ?? []]]);
45
58
  const link = (from, to, kind, fieldIds = []) => {
46
59
  if (from === to || !known.has(from) || !known.has(to)) {
47
60
  return;
@@ -92,8 +105,14 @@ export function deriveEdges(nodes) {
92
105
  if (node.entityId) {
93
106
  link(node.id, node.entityId, 'constrains', expressionFieldIds(node.expression));
94
107
  }
95
- reads(node.id, node.expression, rootScope);
108
+ reads(node.id, node.expression, node.entityId ? entityScope(node.entityId) : rootScope);
109
+ break;
110
+ case 'transition-constraint': {
111
+ link(node.id, node.entityId, 'constrains', expressionFieldIds(node.expression));
112
+ const holders = statesByEntity.get(node.entityId) ?? [];
113
+ reads(node.id, node.expression, new Map([...rootScope, [node.previousScopeId, holders], [node.proposedScopeId, holders]]));
96
114
  break;
115
+ }
97
116
  case 'route':
98
117
  link(node.id, node.viewId, 'routes-to');
99
118
  break;
@@ -111,7 +130,11 @@ export function deriveEdges(nodes) {
111
130
  },
112
131
  }));
113
132
  }
114
- /** The states an expression ultimately draws its members from. */
133
+ /**
134
+ * The states an expression ultimately draws its members from. Following `field` and calls
135
+ * matters: a collection reached as `coalesce(field(ref(state), lines), [])` still comes
136
+ * from that state, and its members' fields are still reads of it.
137
+ */
115
138
  function statesOf(expression, scope, states) {
116
139
  switch (expression.kind) {
117
140
  case 'ref': {
@@ -125,11 +148,38 @@ function statesOf(expression, scope, states) {
125
148
  case 'find':
126
149
  case 'sort':
127
150
  case 'map':
151
+ case 'flatten':
152
+ return statesOf(expression.source, scope, states);
153
+ case 'conditional':
154
+ return [
155
+ ...new Set([
156
+ ...statesOf(expression.whenTrue, scope, states),
157
+ ...statesOf(expression.whenFalse, scope, states),
158
+ ]),
159
+ ];
160
+ case 'field':
128
161
  return statesOf(expression.source, scope, states);
162
+ case 'call':
163
+ return [...new Set(expression.arguments.flatMap((argument) => statesOf(argument, scope, states)))];
129
164
  default:
130
165
  return [];
131
166
  }
132
167
  }
168
+ /** Entities reachable from a type, following entity fields as well as collections. */
169
+ function reachableEntities(type, entities, seen = new Set()) {
170
+ const found = [];
171
+ for (const entityId of entityIdsIn(type)) {
172
+ if (seen.has(entityId)) {
173
+ continue;
174
+ }
175
+ seen.add(entityId);
176
+ found.push(entityId);
177
+ for (const field of entities.get(entityId)?.fields ?? []) {
178
+ found.push(...reachableEntities(field.valueType, entities, seen));
179
+ }
180
+ }
181
+ return found;
182
+ }
133
183
  function bind(scope, id, targets) {
134
184
  const next = new Map(scope);
135
185
  next.set(id, targets);
@@ -180,7 +230,9 @@ function collectReads(expression, scope, states, found = new Map()) {
180
230
  case 'filter':
181
231
  case 'find':
182
232
  case 'map':
183
- case 'sort': {
233
+ case 'sort':
234
+ case 'every':
235
+ case 'some': {
184
236
  collectReads(expression.source, scope, states, found);
185
237
  const inner = bind(scope, expression.scopeId, statesOf(expression.source, scope, states));
186
238
  const body = expression.kind === 'map'
@@ -191,6 +243,14 @@ function collectReads(expression, scope, states, found = new Map()) {
191
243
  collectReads(body, inner, states, found);
192
244
  return found;
193
245
  }
246
+ case 'flatten':
247
+ collectReads(expression.source, scope, states, found);
248
+ return found;
249
+ case 'conditional':
250
+ collectReads(expression.condition, scope, states, found);
251
+ collectReads(expression.whenTrue, scope, states, found);
252
+ collectReads(expression.whenFalse, scope, states, found);
253
+ return found;
194
254
  default:
195
255
  return found;
196
256
  }
@@ -217,8 +277,9 @@ function linkOperations(actionId, operations, linker, scope) {
217
277
  linker.reads(actionId, operation.value, scope);
218
278
  break;
219
279
  case 'insert':
220
- // Inserting a constructed record writes every field the record declares.
221
- linker.writes(actionId, operation.target, scope, 'writes', expressionFieldIds(operation.value));
280
+ // A constructed record writes the fields it declares. Fields consulted while
281
+ // computing those values are reads, and must not be reported as writes.
282
+ linker.writes(actionId, operation.target, scope, 'writes', constructedFieldIds(operation.value));
222
283
  linker.reads(actionId, operation.value, scope);
223
284
  break;
224
285
  case 'remove':
@@ -48,5 +48,7 @@ export declare const VALIDATION_CODES: {
48
48
  readonly initialValueUnknownField: "INITIAL_VALUE_UNKNOWN_FIELD";
49
49
  readonly initialValueMissingRequiredField: "INITIAL_VALUE_MISSING_REQUIRED_FIELD";
50
50
  readonly initialValueInvalidEntity: "INITIAL_VALUE_INVALID_ENTITY";
51
+ readonly scopeShadowing: "SCOPE_SHADOWING";
52
+ readonly scopeCollidesWithNode: "SCOPE_COLLIDES_WITH_NODE";
51
53
  };
52
54
  //# sourceMappingURL=diagnostics.d.ts.map
@@ -31,4 +31,6 @@ export const VALIDATION_CODES = {
31
31
  initialValueUnknownField: 'INITIAL_VALUE_UNKNOWN_FIELD',
32
32
  initialValueMissingRequiredField: 'INITIAL_VALUE_MISSING_REQUIRED_FIELD',
33
33
  initialValueInvalidEntity: 'INITIAL_VALUE_INVALID_ENTITY',
34
+ scopeShadowing: 'SCOPE_SHADOWING',
35
+ scopeCollidesWithNode: 'SCOPE_COLLIDES_WITH_NODE',
34
36
  };
@@ -5,7 +5,7 @@ import type { LiteralValue } from './nodes.js';
5
5
  * identifier: `ref` resolves an id against the evaluation scope chain (route parameters,
6
6
  * action parameters, iteration scopes, then state), and `field` reads a field by id.
7
7
  */
8
- export type Expression = LiteralExpression | RefExpression | FieldExpression | ObjectExpression | BinaryExpression | UnaryExpression | CallExpression | FilterExpression | FindExpression | MapExpression | SortExpression;
8
+ export type Expression = LiteralExpression | RefExpression | FieldExpression | ObjectExpression | BinaryExpression | UnaryExpression | CallExpression | FilterExpression | FindExpression | MapExpression | SortExpression | EveryExpression | SomeExpression | FlattenExpression | ConditionalExpression;
9
9
  export type ExpressionKind = Expression['kind'];
10
10
  /** Every expression kind the runtime is required to evaluate. */
11
11
  export declare const EXPRESSION_KINDS: readonly ExpressionKind[];
@@ -57,7 +57,7 @@ export interface UnaryExpression {
57
57
  * must be implemented by the runtime: a function that is declared here but unevaluated
58
58
  * would be a construct that typechecks, validates and then does nothing.
59
59
  */
60
- export type BuiltinFunction = 'required' | 'is-empty' | 'length' | 'contains' | 'concat' | 'coalesce' | 'one-of' | 'count' | 'sum' | 'lowercase' | 'to-string' | 'now' | 'uuid';
60
+ export type BuiltinFunction = 'required' | 'is-empty' | 'non-empty' | 'length' | 'contains' | 'concat' | 'coalesce' | 'one-of' | 'count' | 'sum' | 'lowercase' | 'to-string' | 'now' | 'uuid';
61
61
  export declare const BUILTIN_FUNCTIONS: readonly BuiltinFunction[];
62
62
  /** Functions that reduce a collection of numbers to a number. */
63
63
  export declare const AGGREGATE_FUNCTIONS: readonly BuiltinFunction[];
@@ -99,6 +99,32 @@ export interface SortExpression {
99
99
  by: Expression;
100
100
  direction?: 'asc' | 'desc';
101
101
  }
102
+ /** True when every member satisfies the predicate. An empty collection satisfies it. */
103
+ export interface EveryExpression {
104
+ kind: 'every';
105
+ source: Expression;
106
+ scopeId: NodeId;
107
+ predicate: Expression;
108
+ }
109
+ /** True when at least one member satisfies the predicate. An empty collection does not. */
110
+ export interface SomeExpression {
111
+ kind: 'some';
112
+ source: Expression;
113
+ scopeId: NodeId;
114
+ predicate: Expression;
115
+ }
116
+ /** Collapses one level of nesting: Collection<Collection<T>> becomes Collection<T>. */
117
+ export interface FlattenExpression {
118
+ kind: 'flatten';
119
+ source: Expression;
120
+ }
121
+ /** Chooses between two values. Both branches are expressions, never callbacks. */
122
+ export interface ConditionalExpression {
123
+ kind: 'conditional';
124
+ condition: Expression;
125
+ whenTrue: Expression;
126
+ whenFalse: Expression;
127
+ }
102
128
  export declare function literal(value: LiteralValue): LiteralExpression;
103
129
  export declare function ref(targetId: NodeId): RefExpression;
104
130
  export declare function field(source: Expression, id: FieldId): FieldExpression;
@@ -113,8 +139,24 @@ export declare function sort(source: Expression, scopeId: NodeId, by: Expression
113
139
  /** Sums a collection of numbers. An empty collection sums to zero. */
114
140
  export declare function sum(source: Expression): CallExpression;
115
141
  export declare function count(source: Expression): CallExpression;
142
+ /** True when a value exists at all — an empty collection or string still exists. */
143
+ export declare function required(value: Expression): CallExpression;
144
+ /** True when a collection or string has no members. */
145
+ export declare function isEmpty(value: Expression): CallExpression;
146
+ export declare function nonEmpty(value: Expression): CallExpression;
147
+ /** The first value that exists. Only null and undefined are skipped. */
148
+ export declare function coalesce(...values: Expression[]): CallExpression;
149
+ export declare function every(source: Expression, scopeId: NodeId, predicate: Expression): EveryExpression;
150
+ export declare function some(source: Expression, scopeId: NodeId, predicate: Expression): SomeExpression;
151
+ export declare function flatten(source: Expression): FlattenExpression;
152
+ export declare function conditional(condition: Expression, whenTrue: Expression, whenFalse: Expression): ConditionalExpression;
116
153
  /** Visits every sub-expression, parents before children. */
117
154
  export declare function walkExpression(expression: Expression, visit: (node: Expression) => void): void;
155
+ /**
156
+ * Field ids a constructed record assigns. Only the record's own entries count: the
157
+ * expressions that compute those values are reads, not writes.
158
+ */
159
+ export declare function constructedFieldIds(expression: Expression): FieldId[];
118
160
  /** Field ids an expression reads, including nested sources and constructed records. */
119
161
  export declare function expressionFieldIds(expression: Expression): FieldId[];
120
162
  //# sourceMappingURL=expressions.d.ts.map
@@ -11,10 +11,15 @@ export const EXPRESSION_KINDS = [
11
11
  'find',
12
12
  'map',
13
13
  'sort',
14
+ 'every',
15
+ 'some',
16
+ 'flatten',
17
+ 'conditional',
14
18
  ];
15
19
  export const BUILTIN_FUNCTIONS = [
16
20
  'required',
17
21
  'is-empty',
22
+ 'non-empty',
18
23
  'length',
19
24
  'contains',
20
25
  'concat',
@@ -69,6 +74,33 @@ export function sum(source) {
69
74
  export function count(source) {
70
75
  return call('count', source);
71
76
  }
77
+ /** True when a value exists at all — an empty collection or string still exists. */
78
+ export function required(value) {
79
+ return call('required', value);
80
+ }
81
+ /** True when a collection or string has no members. */
82
+ export function isEmpty(value) {
83
+ return call('is-empty', value);
84
+ }
85
+ export function nonEmpty(value) {
86
+ return call('non-empty', value);
87
+ }
88
+ /** The first value that exists. Only null and undefined are skipped. */
89
+ export function coalesce(...values) {
90
+ return call('coalesce', ...values);
91
+ }
92
+ export function every(source, scopeId, predicate) {
93
+ return { kind: 'every', source, scopeId, predicate };
94
+ }
95
+ export function some(source, scopeId, predicate) {
96
+ return { kind: 'some', source, scopeId, predicate };
97
+ }
98
+ export function flatten(source) {
99
+ return { kind: 'flatten', source };
100
+ }
101
+ export function conditional(condition, whenTrue, whenFalse) {
102
+ return { kind: 'conditional', condition, whenTrue, whenFalse };
103
+ }
72
104
  /** Visits every sub-expression, parents before children. */
73
105
  export function walkExpression(expression, visit) {
74
106
  visit(expression);
@@ -106,9 +138,29 @@ export function walkExpression(expression, visit) {
106
138
  walkExpression(expression.source, visit);
107
139
  walkExpression(expression.by, visit);
108
140
  return;
141
+ case 'every':
142
+ case 'some':
143
+ walkExpression(expression.source, visit);
144
+ walkExpression(expression.predicate, visit);
145
+ return;
146
+ case 'flatten':
147
+ walkExpression(expression.source, visit);
148
+ return;
149
+ case 'conditional':
150
+ walkExpression(expression.condition, visit);
151
+ walkExpression(expression.whenTrue, visit);
152
+ walkExpression(expression.whenFalse, visit);
153
+ return;
109
154
  default:
110
155
  }
111
156
  }
157
+ /**
158
+ * Field ids a constructed record assigns. Only the record's own entries count: the
159
+ * expressions that compute those values are reads, not writes.
160
+ */
161
+ export function constructedFieldIds(expression) {
162
+ return expression.kind === 'object' ? expression.entries.map((entry) => entry.fieldId) : [];
163
+ }
112
164
  /** Field ids an expression reads, including nested sources and constructed records. */
113
165
  export function expressionFieldIds(expression) {
114
166
  const found = [];
package/dist/graph.js CHANGED
@@ -16,7 +16,7 @@ export class ApplicationGraph {
16
16
  /** Bumped by every change, so the derived edge index can never serve stale data. */
17
17
  revision = 0;
18
18
  semanticIndex;
19
- constructor(id, name, version = '0.4.0') {
19
+ constructor(id, name, version = '0.4.1') {
20
20
  this.data = { id, name, version, nodes: {}, edges: {} };
21
21
  }
22
22
  get id() {
package/dist/infer.js CHANGED
@@ -141,6 +141,14 @@ export function inferExpressionType(expression, context, scope) {
141
141
  const item = itemTypeOf(inferExpressionType(expression.source, context, scope));
142
142
  return item ? optionalType(item) : undefined;
143
143
  }
144
+ case 'every':
145
+ case 'some':
146
+ return primitiveType('boolean');
147
+ case 'flatten':
148
+ return itemTypeOf(inferExpressionType(expression.source, context, scope));
149
+ case 'conditional':
150
+ return (inferExpressionType(expression.whenTrue, context, scope) ??
151
+ inferExpressionType(expression.whenFalse, context, scope));
144
152
  case 'map': {
145
153
  const item = itemTypeOf(inferExpressionType(expression.source, context, scope));
146
154
  const projected = inferExpressionType(expression.projection, context, withScope(scope, expression.scopeId, item));
@@ -157,6 +165,8 @@ export function scopeForExpression(expression, context, scope) {
157
165
  case 'find':
158
166
  case 'map':
159
167
  case 'sort':
168
+ case 'every':
169
+ case 'some':
160
170
  return withScope(scope, expression.scopeId, itemTypeOf(inferExpressionType(expression.source, context, scope)));
161
171
  default:
162
172
  return scope ?? new Map();
package/dist/ir.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { FieldId, NodeId } from './ids.js';
2
- import type { ActionDef, ConstraintDef, EntityDef, GraphEdge, RouteParameter, StateDef } from './nodes.js';
2
+ import type { ActionDef, ConstraintDef, EntityDef, GraphEdge, RouteParameter, StateDef, TransitionConstraintDef } from './nodes.js';
3
3
  import type { FieldIndexEntry } from './graph.js';
4
4
  import type { UINode } from './ui.js';
5
5
  import type { AnyNode } from './types.js';
@@ -34,6 +34,8 @@ export interface ApplicationIR {
34
34
  actions: Record<NodeId, ActionDef>;
35
35
  uiNodes: Record<NodeId, UINode>;
36
36
  constraints: ConstraintDef[];
37
+ /** Rules about how state may change, enforced on every governed mutation path. */
38
+ transitionConstraints: TransitionConstraintDef[];
37
39
  routes: CompiledRoute[];
38
40
  edges: GraphEdge[];
39
41
  /**
package/dist/nodes.d.ts CHANGED
@@ -62,9 +62,16 @@ export interface FailureMode {
62
62
  code: string;
63
63
  message?: string;
64
64
  }
65
+ /** A condition together with the failure it reports, so the two cannot drift apart. */
66
+ export interface ActionGuard {
67
+ condition: Expression;
68
+ failureMode?: FailureMode;
69
+ }
65
70
  export interface ActionDef extends NodeBase {
66
71
  kind: 'action';
67
72
  parameters?: ActionParameter[];
73
+ /** Preferred over the parallel `preconditions` and `failureModes` arrays. */
74
+ guards?: ActionGuard[];
68
75
  preconditions?: Expression[];
69
76
  operations: Operation[];
70
77
  postconditions?: Expression[];
@@ -74,6 +81,11 @@ export interface ActionDef extends NodeBase {
74
81
  requiresConfirmation?: boolean;
75
82
  confirmationMessage?: string;
76
83
  }
84
+ /**
85
+ * The conditions an action checks, however they were written. `guards` pairs each
86
+ * condition with its failure; the older parallel arrays are matched by position.
87
+ */
88
+ export declare function actionGuards(action: ActionDef): ActionGuard[];
77
89
  export type Operation = SetOperation | InsertOperation | RemoveOperation | ForEachOperation | InvokeOperation | NavigateOperation | NativeOperation;
78
90
  export type OperationKind = Operation['kind'];
79
91
  /** Every operation kind the runtime is required to execute. */
@@ -159,6 +171,29 @@ export interface ConstraintDef extends NodeBase {
159
171
  severity?: 'error' | 'warning';
160
172
  message?: string;
161
173
  }
174
+ /**
175
+ * A rule about how state may change, rather than about what state may be.
176
+ *
177
+ * An ordinary constraint judges the proposed state on its own; a transition constraint
178
+ * sees the instance as it was when the transaction began *and* as the transaction
179
+ * proposes it. That is what lets a rule like "once confirmed, an order may not change"
180
+ * hold no matter which path attempts the write — an action, an input binding, an
181
+ * iteration, or something added later.
182
+ *
183
+ * `previousScopeId` and `proposedScopeId` bind those two instances for the expression.
184
+ * When the instance is being removed, the proposed scope is bound to nothing.
185
+ */
186
+ export interface TransitionConstraintDef extends NodeBase {
187
+ kind: 'transition-constraint';
188
+ /** The entity whose transitions are governed. It must have an identity field. */
189
+ entityId: NodeId;
190
+ previousScopeId: NodeId;
191
+ proposedScopeId: NodeId;
192
+ /** Must hold for every governed transition. */
193
+ expression: Expression;
194
+ severity?: 'error' | 'warning';
195
+ message?: string;
196
+ }
162
197
  export interface RouteParameter {
163
198
  id: NodeId;
164
199
  /** Matches the `:name` placeholder in the route path. */
package/dist/nodes.js CHANGED
@@ -1,3 +1,16 @@
1
+ /**
2
+ * The conditions an action checks, however they were written. `guards` pairs each
3
+ * condition with its failure; the older parallel arrays are matched by position.
4
+ */
5
+ export function actionGuards(action) {
6
+ if (action.guards && action.guards.length > 0) {
7
+ return action.guards;
8
+ }
9
+ return (action.preconditions ?? []).map((condition, index) => ({
10
+ condition,
11
+ ...(action.failureModes?.[index] ? { failureMode: action.failureModes[index] } : {}),
12
+ }));
13
+ }
1
14
  /** Every operation kind the runtime is required to execute. */
2
15
  export const OPERATION_KINDS = [
3
16
  'set',
package/dist/types.d.ts CHANGED
@@ -1,9 +1,9 @@
1
- import type { ActionDef, ConstraintDef, EntityDef, GraphEdge, RouteDef, StateDef } from './nodes.js';
1
+ import type { ActionDef, ConstraintDef, EntityDef, GraphEdge, RouteDef, StateDef, TransitionConstraintDef } from './nodes.js';
2
2
  import type { NodeId } from './ids.js';
3
3
  import type { UINode, UINodeKind } from './ui.js';
4
- export type SemanticNodeKind = 'entity' | 'state' | 'action' | 'constraint' | 'route';
4
+ export type SemanticNodeKind = 'entity' | 'state' | 'action' | 'constraint' | 'transition-constraint' | 'route';
5
5
  export type NodeKind = SemanticNodeKind | UINodeKind;
6
- export type AnyNode = EntityDef | StateDef | ActionDef | ConstraintDef | RouteDef | UINode;
6
+ export type AnyNode = EntityDef | StateDef | ActionDef | ConstraintDef | TransitionConstraintDef | RouteDef | UINode;
7
7
  export type NodeOfKind<K extends NodeKind> = Extract<AnyNode, {
8
8
  kind: K;
9
9
  }>;
package/dist/validate.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { AGGREGATE_FUNCTIONS, BUILTIN_FUNCTIONS } from './expressions.js';
2
2
  import { VALIDATION_CODES } from './diagnostics.js';
3
- import { EDGE_KINDS, isMutationOperation } from './nodes.js';
3
+ import { EDGE_KINDS, actionGuards, isMutationOperation } from './nodes.js';
4
+ import { entityType } from './type-ref.js';
4
5
  import { isUINode, uiChildIds } from './ui.js';
5
6
  import { semanticContextFromGraph } from './context.js';
6
7
  import { inferExpressionType, inferLocationType, isNonNumericCollection, isObviouslyIncompatible, itemTypeOf, } from './infer.js';
@@ -94,6 +95,9 @@ function validateNode(node, context) {
94
95
  case 'constraint':
95
96
  validateConstraint(node, context);
96
97
  return;
98
+ case 'transition-constraint':
99
+ validateTransitionConstraint(node, context);
100
+ return;
97
101
  case 'route':
98
102
  validateRoute(node, context);
99
103
  return;
@@ -219,8 +223,15 @@ function validateAction(action, context) {
219
223
  local.types.set(parameter.id, parameter.valueType);
220
224
  }
221
225
  }
222
- for (const precondition of action.preconditions ?? []) {
223
- validateExpression(precondition, action.id, context, local);
226
+ if (action.guards?.length && action.preconditions?.length) {
227
+ context.errors.push({
228
+ code: VALIDATION_CODES.unsupportedOperation,
229
+ message: `Action ${action.id} declares both guards and preconditions; use guards alone`,
230
+ nodeId: action.id,
231
+ });
232
+ }
233
+ for (const guard of actionGuards(action)) {
234
+ validateExpression(guard.condition, action.id, context, local);
224
235
  }
225
236
  for (const postcondition of action.postconditions ?? []) {
226
237
  validateExpression(postcondition, action.id, context, local);
@@ -234,7 +245,7 @@ function validateOperation(operation, action, context, local) {
234
245
  case 'for-each': {
235
246
  validateExpression(operation.collection, action.id, context, local);
236
247
  requireCollection(operation.collection, action.id, context, local, 'for-each');
237
- const scoped = iterationScope(local, operation.scopeId, operation.collection, context);
248
+ const scoped = iterationScope(local, operation.scopeId, operation.collection, context, action.id);
238
249
  if ((operation.operations ?? []).length === 0) {
239
250
  context.warnings.push({
240
251
  code: VALIDATION_CODES.unsupportedOperation,
@@ -381,6 +392,32 @@ function validateConstraint(constraint, context) {
381
392
  }
382
393
  validateExpression(constraint.expression, constraint.id, context, local);
383
394
  }
395
+ function validateTransitionConstraint(constraint, context) {
396
+ requireKind(constraint.entityId, 'entity', constraint.id, context, VALIDATION_CODES.danglingNodeRef);
397
+ const entity = context.nodes.get(constraint.entityId);
398
+ // Without an identity field there is no way to say which previous instance a proposed
399
+ // instance corresponds to, so the rule could not be evaluated at all.
400
+ if (entity?.kind === 'entity' && !entity.identityFieldId) {
401
+ context.errors.push({
402
+ code: VALIDATION_CODES.unsupportedConstraintScope,
403
+ message: `Transition constraint ${constraint.id} governs ${entity.id}, which has no identity field to match instances by`,
404
+ nodeId: constraint.id,
405
+ });
406
+ }
407
+ if (constraint.previousScopeId === constraint.proposedScopeId) {
408
+ context.errors.push({
409
+ code: VALIDATION_CODES.unsupportedConstraintScope,
410
+ message: `Transition constraint ${constraint.id} uses one scope for both the previous and the proposed instance`,
411
+ nodeId: constraint.id,
412
+ });
413
+ }
414
+ const scope = emptyScope(new Set([constraint.previousScopeId, constraint.proposedScopeId]));
415
+ if (entity?.kind === 'entity') {
416
+ scope.types.set(constraint.previousScopeId, entityType(entity.id));
417
+ scope.types.set(constraint.proposedScopeId, entityType(entity.id));
418
+ }
419
+ validateExpression(constraint.expression, constraint.id, context, scope);
420
+ }
384
421
  function validateRoute(route, context) {
385
422
  const view = context.nodes.get(route.viewId);
386
423
  if (!view || view.kind !== 'view') {
@@ -607,8 +644,27 @@ function validateTypeRef(type, ownerId, context, field, inCollection = false) {
607
644
  function emptyScope(ids = new Set()) {
608
645
  return { ids, types: new Map() };
609
646
  }
610
- /** Extends a scope with the member type of the collection an iteration walks. */
611
- function iterationScope(scope, scopeId, source, context) {
647
+ /**
648
+ * Extends a scope with the member type of the collection an iteration walks.
649
+ *
650
+ * An iteration binder is not a graph node, and reusing an id that is already bound — or
651
+ * one that names a node — makes a reference ambiguous to read and to analyze.
652
+ */
653
+ function iterationScope(scope, scopeId, source, context, ownerId) {
654
+ if (scope.ids.has(scopeId)) {
655
+ context.errors.push({
656
+ code: VALIDATION_CODES.scopeShadowing,
657
+ message: `Scope ${scopeId} in ${ownerId ?? 'an expression'} is already bound by an enclosing iteration`,
658
+ ...(ownerId ? { nodeId: ownerId } : {}),
659
+ });
660
+ }
661
+ if (context.nodes.has(scopeId)) {
662
+ context.errors.push({
663
+ code: VALIDATION_CODES.scopeCollidesWithNode,
664
+ message: `Scope ${scopeId} in ${ownerId ?? 'an expression'} has the same id as a graph node`,
665
+ ...(ownerId ? { nodeId: ownerId } : {}),
666
+ });
667
+ }
612
668
  const item = itemTypeOf(inferExpressionType(source, context.semantics, scope.types));
613
669
  const types = new Map(scope.types);
614
670
  if (item) {
@@ -691,19 +747,46 @@ function validateExpression(expression, ownerId, context, local) {
691
747
  case 'find': {
692
748
  validateExpression(expression.source, ownerId, context, scope);
693
749
  requireCollection(expression.source, ownerId, context, scope, expression.kind);
694
- validateExpression(expression.predicate, ownerId, context, iterationScope(scope, expression.scopeId, expression.source, context));
750
+ validateExpression(expression.predicate, ownerId, context, iterationScope(scope, expression.scopeId, expression.source, context, ownerId));
695
751
  return;
696
752
  }
697
753
  case 'map': {
698
754
  validateExpression(expression.source, ownerId, context, scope);
699
755
  requireCollection(expression.source, ownerId, context, scope, 'map');
700
- validateExpression(expression.projection, ownerId, context, iterationScope(scope, expression.scopeId, expression.source, context));
756
+ validateExpression(expression.projection, ownerId, context, iterationScope(scope, expression.scopeId, expression.source, context, ownerId));
757
+ return;
758
+ }
759
+ case 'every':
760
+ case 'some': {
761
+ validateExpression(expression.source, ownerId, context, scope);
762
+ requireCollection(expression.source, ownerId, context, scope, expression.kind);
763
+ validateExpression(expression.predicate, ownerId, context, iterationScope(scope, expression.scopeId, expression.source, context, ownerId));
764
+ return;
765
+ }
766
+ case 'flatten': {
767
+ validateExpression(expression.source, ownerId, context, scope);
768
+ requireCollection(expression.source, ownerId, context, scope, 'flatten');
769
+ const inner = itemTypeOf(inferExpressionType(expression.source, context.semantics, scope.types));
770
+ if (inner && resolveKnownType(inner)?.kind !== 'collection') {
771
+ context.errors.push({
772
+ code: VALIDATION_CODES.notACollection,
773
+ message: `flatten in ${ownerId} expects a collection of collections but its members are ${describeType(inner)}`,
774
+ nodeId: ownerId,
775
+ });
776
+ }
777
+ return;
778
+ }
779
+ case 'conditional': {
780
+ validateExpression(expression.condition, ownerId, context, scope);
781
+ validateExpression(expression.whenTrue, ownerId, context, scope);
782
+ validateExpression(expression.whenFalse, ownerId, context, scope);
783
+ reportIncompatible(inferExpressionType(expression.whenTrue, context.semantics, scope.types), inferExpressionType(expression.whenFalse, context.semantics, scope.types), ownerId, context);
701
784
  return;
702
785
  }
703
786
  case 'sort': {
704
787
  validateExpression(expression.source, ownerId, context, scope);
705
788
  requireCollection(expression.source, ownerId, context, scope, 'sort');
706
- validateExpression(expression.by, ownerId, context, iterationScope(scope, expression.scopeId, expression.source, context));
789
+ validateExpression(expression.by, ownerId, context, iterationScope(scope, expression.scopeId, expression.source, context, ownerId));
707
790
  return;
708
791
  }
709
792
  default:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cynodia/axiom-core",
3
- "version": "0.4.0-alpha.1",
3
+ "version": "0.4.1-alpha.1",
4
4
  "description": "Application Graph, semantic types, locations and validation for Axiom.",
5
5
  "license": "MIT",
6
6
  "author": "AskTech AS",