@cynodia/axiom-core 0.16.0-alpha.1 → 0.16.0-alpha.2
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.
- package/dist/authority.js +11 -11
- package/dist/derive-edges.js +19 -4
- package/dist/diagnostics.d.ts +4 -0
- package/dist/diagnostics.js +7 -0
- package/dist/expressions.d.ts +11 -5
- package/dist/expressions.js +21 -3
- package/dist/infer.d.ts +9 -6
- package/dist/infer.js +39 -7
- package/dist/location.d.ts +15 -5
- package/dist/location.js +42 -2
- package/dist/nodes.d.ts +28 -1
- package/dist/nodes.js +42 -0
- package/dist/resolve-presentation.js +2 -1
- package/dist/semantic-diff.js +36 -16
- package/dist/server-ir.js +5 -4
- package/dist/validate-location.d.ts +6 -2
- package/dist/validate-location.js +29 -1
- package/dist/validate-presentation.js +2 -1
- package/dist/validate.js +59 -5
- package/package.json +1 -1
package/dist/authority.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { referencedIds } from './derive-edges.js';
|
|
2
|
-
import { actionGuards, isMutationOperation } from './nodes.js';
|
|
2
|
+
import { actionGuards, actionOperations, isMutationOperation, operationChildren } from './nodes.js';
|
|
3
3
|
import { locationExpressions, locationProviderEntityId, locationRootStateId } from './location.js';
|
|
4
4
|
import { queryExpressions } from './query.js';
|
|
5
5
|
export const AUTHORITIES = ['client', 'server'];
|
|
@@ -121,7 +121,7 @@ function operationExpressions(operation) {
|
|
|
121
121
|
switch (operation.kind) {
|
|
122
122
|
case 'for-each':
|
|
123
123
|
found.push(operation.collection);
|
|
124
|
-
for (const nested of operation
|
|
124
|
+
for (const nested of operationChildren(operation)) {
|
|
125
125
|
found.push(...operationExpressions(nested));
|
|
126
126
|
}
|
|
127
127
|
break;
|
|
@@ -157,11 +157,11 @@ function operationExpressions(operation) {
|
|
|
157
157
|
}
|
|
158
158
|
/** Whether an action calls out to an integration anywhere in its top-level operations. */
|
|
159
159
|
export function actionUsesIntegration(action) {
|
|
160
|
-
return (action
|
|
160
|
+
return actionOperations(action).some((operation) => operation.kind === 'integration-query' || operation.kind === 'integration-effect');
|
|
161
161
|
}
|
|
162
162
|
/** Whether an action reaches an object store anywhere in its top-level operations. */
|
|
163
163
|
export function actionUsesStorage(action) {
|
|
164
|
-
return (action
|
|
164
|
+
return actionOperations(action).some((operation) => operation.kind === 'blob-metadata' ||
|
|
165
165
|
operation.kind === 'blob-commit' ||
|
|
166
166
|
operation.kind === 'blob-delete');
|
|
167
167
|
}
|
|
@@ -170,7 +170,7 @@ export function actionUsesStorage(action) {
|
|
|
170
170
|
* data provider, so an action that reads authoritative data through a query executes there.
|
|
171
171
|
*/
|
|
172
172
|
export function actionUsesQuery(action) {
|
|
173
|
-
return (action
|
|
173
|
+
return actionOperations(action).some((operation) => operation.kind === 'query');
|
|
174
174
|
}
|
|
175
175
|
/**
|
|
176
176
|
* Whether an action writes a `provider-record` location anywhere in its operations
|
|
@@ -183,11 +183,11 @@ export function actionWritesProviderRecord(action) {
|
|
|
183
183
|
return locationProviderEntityId(operation.target) !== undefined;
|
|
184
184
|
}
|
|
185
185
|
if (operation.kind === 'for-each') {
|
|
186
|
-
return targets(operation
|
|
186
|
+
return targets(operationChildren(operation));
|
|
187
187
|
}
|
|
188
188
|
return false;
|
|
189
189
|
});
|
|
190
|
-
return targets(action
|
|
190
|
+
return targets(actionOperations(action));
|
|
191
191
|
}
|
|
192
192
|
/** The states an action writes, following `for-each`, `invoke` and declared native effects. */
|
|
193
193
|
export function statesWrittenBy(action, context, visited = new Set()) {
|
|
@@ -204,7 +204,7 @@ export function statesWrittenBy(action, context, visited = new Set()) {
|
|
|
204
204
|
}
|
|
205
205
|
switch (operation.kind) {
|
|
206
206
|
case 'for-each':
|
|
207
|
-
walk(operation
|
|
207
|
+
walk(operationChildren(operation));
|
|
208
208
|
break;
|
|
209
209
|
case 'invoke': {
|
|
210
210
|
const target = context.actions.get(operation.actionId);
|
|
@@ -238,7 +238,7 @@ export function statesWrittenBy(action, context, visited = new Set()) {
|
|
|
238
238
|
}
|
|
239
239
|
}
|
|
240
240
|
};
|
|
241
|
-
walk(action
|
|
241
|
+
walk(actionOperations(action));
|
|
242
242
|
return found;
|
|
243
243
|
}
|
|
244
244
|
/** The states an action reads: its guards, its values, its selectors, its authorization. */
|
|
@@ -252,11 +252,11 @@ export function statesReadByAction(action, context, visited = new Set()) {
|
|
|
252
252
|
...(action.postconditions ?? []),
|
|
253
253
|
...(action.authorization ? [action.authorization] : []),
|
|
254
254
|
];
|
|
255
|
-
for (const operation of action
|
|
255
|
+
for (const operation of actionOperations(action)) {
|
|
256
256
|
expressions.push(...operationExpressions(operation));
|
|
257
257
|
}
|
|
258
258
|
const found = statesReadBy(expressions, context);
|
|
259
|
-
for (const operation of action
|
|
259
|
+
for (const operation of actionOperations(action)) {
|
|
260
260
|
if (operation.kind === 'invoke') {
|
|
261
261
|
const target = context.actions.get(operation.actionId);
|
|
262
262
|
if (target) {
|
package/dist/derive-edges.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { constructedFieldIds, expressionDefsIn, expressionFieldIds, walkExpression } from './expressions.js';
|
|
2
|
+
import { actionOperations, operationChildren } from './nodes.js';
|
|
2
3
|
import { locationExpressions, locationFieldIds, locationRootStateId, locationSelectorFieldIds, } from './location.js';
|
|
3
4
|
import { isGroupFieldId } from './group.js';
|
|
4
5
|
import { isUINode } from './ui.js';
|
|
@@ -251,12 +252,22 @@ export function deriveEdges(nodes) {
|
|
|
251
252
|
},
|
|
252
253
|
}));
|
|
253
254
|
}
|
|
255
|
+
function isPlainObject(value) {
|
|
256
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
257
|
+
}
|
|
254
258
|
/**
|
|
255
259
|
* The states an expression ultimately draws its members from. Following `field` and calls
|
|
256
260
|
* matters: a collection reached as `coalesce(field(ref(state), lines), [])` still comes
|
|
257
261
|
* from that state, and its members' fields are still reads of it.
|
|
262
|
+
*
|
|
263
|
+
* Total over malformed input (spec16pt2 §12-24): a candidate expression can be
|
|
264
|
+
* AI-generated, deserialized or hand-tampered.
|
|
258
265
|
*/
|
|
259
|
-
function statesOf(
|
|
266
|
+
function statesOf(expressionInput, scope, states, defs = new Map()) {
|
|
267
|
+
if (!isPlainObject(expressionInput) || typeof expressionInput.kind !== 'string') {
|
|
268
|
+
return [];
|
|
269
|
+
}
|
|
270
|
+
const expression = expressionInput;
|
|
260
271
|
switch (expression.kind) {
|
|
261
272
|
case 'ref': {
|
|
262
273
|
const bound = scope.get(expression.targetId);
|
|
@@ -364,7 +375,7 @@ function bind(scope, id, targets) {
|
|
|
364
375
|
* projecting a field of each member is recorded as a read of that field of the state the
|
|
365
376
|
* members came from.
|
|
366
377
|
*/
|
|
367
|
-
function collectReads(
|
|
378
|
+
function collectReads(expressionInput, scope, states, found = new Map(), defs = new Map()) {
|
|
368
379
|
const record = (stateId, fieldId) => {
|
|
369
380
|
const entry = found.get(stateId) ?? new Set();
|
|
370
381
|
if (fieldId) {
|
|
@@ -372,6 +383,10 @@ function collectReads(expression, scope, states, found = new Map(), defs = new M
|
|
|
372
383
|
}
|
|
373
384
|
found.set(stateId, entry);
|
|
374
385
|
};
|
|
386
|
+
if (!isPlainObject(expressionInput) || typeof expressionInput.kind !== 'string') {
|
|
387
|
+
return found;
|
|
388
|
+
}
|
|
389
|
+
const expression = expressionInput;
|
|
375
390
|
switch (expression.kind) {
|
|
376
391
|
case 'ref':
|
|
377
392
|
for (const stateId of statesOf(expression, scope, states, defs)) {
|
|
@@ -455,7 +470,7 @@ function linkAction(action, linker) {
|
|
|
455
470
|
for (const expression of [...(action.preconditions ?? []), ...(action.postconditions ?? [])]) {
|
|
456
471
|
linker.reads(action.id, expression, linker.scope);
|
|
457
472
|
}
|
|
458
|
-
linkOperations(action.id, action
|
|
473
|
+
linkOperations(action.id, actionOperations(action), linker, linker.scope);
|
|
459
474
|
}
|
|
460
475
|
function linkOperations(actionId, operations, linker, scope) {
|
|
461
476
|
for (const operation of operations) {
|
|
@@ -476,7 +491,7 @@ function linkOperations(actionId, operations, linker, scope) {
|
|
|
476
491
|
case 'for-each': {
|
|
477
492
|
linker.reads(actionId, operation.collection, scope);
|
|
478
493
|
const inner = bind(scope, operation.scopeId, statesOf(operation.collection, scope, linker.states));
|
|
479
|
-
linkOperations(actionId, operation
|
|
494
|
+
linkOperations(actionId, operationChildren(operation), linker, inner);
|
|
480
495
|
break;
|
|
481
496
|
}
|
|
482
497
|
case 'invoke':
|
package/dist/diagnostics.d.ts
CHANGED
|
@@ -212,5 +212,9 @@ export declare const VALIDATION_CODES: {
|
|
|
212
212
|
readonly authorizationUnknownPolicy: "AUTHORIZATION_UNKNOWN_POLICY";
|
|
213
213
|
/** An `AuthorizationPolicyDef.allow` expression calls `now` / `uuid` / `random` — authorization must be deterministic (spec15 §34). */
|
|
214
214
|
readonly authorizationNondeterministic: "AUTHORIZATION_NONDETERMINISTIC";
|
|
215
|
+
/** `ActionDef.operations`, or a `for-each`'s nested `operations`, is present but not an array. */
|
|
216
|
+
readonly invalidOperationCollection: "INVALID_OPERATION_COLLECTION";
|
|
217
|
+
/** An operation array entry that is not a plain object with a recognized `kind`. */
|
|
218
|
+
readonly invalidOperation: "INVALID_OPERATION";
|
|
215
219
|
};
|
|
216
220
|
//# sourceMappingURL=diagnostics.d.ts.map
|
package/dist/diagnostics.js
CHANGED
|
@@ -211,4 +211,11 @@ export const VALIDATION_CODES = {
|
|
|
211
211
|
authorizationUnknownPolicy: 'AUTHORIZATION_UNKNOWN_POLICY',
|
|
212
212
|
/** An `AuthorizationPolicyDef.allow` expression calls `now` / `uuid` / `random` — authorization must be deterministic (spec15 §34). */
|
|
213
213
|
authorizationNondeterministic: 'AUTHORIZATION_NONDETERMINISTIC',
|
|
214
|
+
// Validation totality (0.16pt2, spec16pt2 §12-24). A candidate graph can be AI-generated,
|
|
215
|
+
// deserialized or hand-tampered, so `validateGraph` must reject a malformed runtime
|
|
216
|
+
// *shape* structurally — never assume TypeScript's compile-time types already hold.
|
|
217
|
+
/** `ActionDef.operations`, or a `for-each`'s nested `operations`, is present but not an array. */
|
|
218
|
+
invalidOperationCollection: 'INVALID_OPERATION_COLLECTION',
|
|
219
|
+
/** An operation array entry that is not a plain object with a recognized `kind`. */
|
|
220
|
+
invalidOperation: 'INVALID_OPERATION',
|
|
214
221
|
};
|
package/dist/expressions.d.ts
CHANGED
|
@@ -203,15 +203,21 @@ export declare function groupItems(source: Expression): FieldExpression;
|
|
|
203
203
|
*/
|
|
204
204
|
export declare function expressionRef(expressionId: NodeId, args?: Record<string, Expression>): ExpressionRefExpression;
|
|
205
205
|
export declare function conditional(condition: Expression, whenTrue: Expression, whenFalse: Expression): ConditionalExpression;
|
|
206
|
-
/**
|
|
207
|
-
|
|
206
|
+
/**
|
|
207
|
+
* Visits every sub-expression, parents before children. Total over malformed input
|
|
208
|
+
* (spec16pt2 §12-24): a candidate expression tree can arrive from AI generation,
|
|
209
|
+
* deserialization or hand-tampering (a deleted field, a tampered array element), and this
|
|
210
|
+
* is the shared visitor most expression-derived analysis in the codebase is built on — a
|
|
211
|
+
* malformed node here simply is not visited, rather than throwing while reading `.kind`.
|
|
212
|
+
*/
|
|
213
|
+
export declare function walkExpression(expressionInput: unknown, visit: (node: Expression) => void): void;
|
|
208
214
|
/**
|
|
209
215
|
* Field ids a constructed record assigns. Only the record's own entries count: the
|
|
210
216
|
* expressions that compute those values are reads, not writes.
|
|
211
217
|
*/
|
|
212
|
-
export declare function constructedFieldIds(expression:
|
|
218
|
+
export declare function constructedFieldIds(expression: unknown): FieldId[];
|
|
213
219
|
/** Expression definitions an expression reaches directly, in tree order. */
|
|
214
|
-
export declare function expressionDefsIn(expression:
|
|
220
|
+
export declare function expressionDefsIn(expression: unknown): NodeId[];
|
|
215
221
|
/** Field ids an expression reads, including nested sources and constructed records. */
|
|
216
|
-
export declare function expressionFieldIds(expression:
|
|
222
|
+
export declare function expressionFieldIds(expression: unknown): FieldId[];
|
|
217
223
|
//# sourceMappingURL=expressions.d.ts.map
|
package/dist/expressions.js
CHANGED
|
@@ -128,8 +128,21 @@ export function expressionRef(expressionId, args) {
|
|
|
128
128
|
export function conditional(condition, whenTrue, whenFalse) {
|
|
129
129
|
return { kind: 'conditional', condition, whenTrue, whenFalse };
|
|
130
130
|
}
|
|
131
|
-
|
|
132
|
-
|
|
131
|
+
function isPlainObject(value) {
|
|
132
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Visits every sub-expression, parents before children. Total over malformed input
|
|
136
|
+
* (spec16pt2 §12-24): a candidate expression tree can arrive from AI generation,
|
|
137
|
+
* deserialization or hand-tampering (a deleted field, a tampered array element), and this
|
|
138
|
+
* is the shared visitor most expression-derived analysis in the codebase is built on — a
|
|
139
|
+
* malformed node here simply is not visited, rather than throwing while reading `.kind`.
|
|
140
|
+
*/
|
|
141
|
+
export function walkExpression(expressionInput, visit) {
|
|
142
|
+
if (!isPlainObject(expressionInput) || typeof expressionInput.kind !== 'string') {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const expression = expressionInput;
|
|
133
146
|
visit(expression);
|
|
134
147
|
switch (expression.kind) {
|
|
135
148
|
case 'field':
|
|
@@ -195,7 +208,12 @@ export function walkExpression(expression, visit) {
|
|
|
195
208
|
* expressions that compute those values are reads, not writes.
|
|
196
209
|
*/
|
|
197
210
|
export function constructedFieldIds(expression) {
|
|
198
|
-
|
|
211
|
+
if (!isPlainObject(expression) || expression.kind !== 'object' || !Array.isArray(expression.entries)) {
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
214
|
+
return expression.entries
|
|
215
|
+
.filter((entry) => isPlainObject(entry) && typeof entry.fieldId === 'string')
|
|
216
|
+
.map((entry) => entry.fieldId);
|
|
199
217
|
}
|
|
200
218
|
/** Expression definitions an expression reaches directly, in tree order. */
|
|
201
219
|
export function expressionDefsIn(expression) {
|
package/dist/infer.d.ts
CHANGED
|
@@ -2,7 +2,6 @@ import type { Expression } from './expressions.js';
|
|
|
2
2
|
import type { FieldId, NodeId } from './ids.js';
|
|
3
3
|
import type { EntityDef, ExpressionDef, StateDef } from './nodes.js';
|
|
4
4
|
import type { FieldIndexEntry } from './graph.js';
|
|
5
|
-
import type { Location } from './location.js';
|
|
6
5
|
import type { TypeRef } from './type-ref.js';
|
|
7
6
|
/**
|
|
8
7
|
* The lookups static analysis needs. Both an authoring graph and a compiled IR can
|
|
@@ -23,10 +22,14 @@ export interface LocationCapabilities {
|
|
|
23
22
|
readable: boolean;
|
|
24
23
|
writable: boolean;
|
|
25
24
|
}
|
|
26
|
-
/**
|
|
27
|
-
|
|
25
|
+
/**
|
|
26
|
+
* The type of the value a location addresses, where it can be determined statically.
|
|
27
|
+
* Total over malformed input (spec16pt2 §12-20): a location that cannot be understood at
|
|
28
|
+
* all has no inferrable type, rather than a thrown error.
|
|
29
|
+
*/
|
|
30
|
+
export declare function inferLocationType(location: unknown, context: SemanticContext): TypeRef | undefined;
|
|
28
31
|
/** Derived state is readable but never writable; everything else follows its root. */
|
|
29
|
-
export declare function locationCapabilities(location:
|
|
32
|
+
export declare function locationCapabilities(location: unknown, context: SemanticContext): LocationCapabilities;
|
|
30
33
|
/** The member type of a collection, ignoring optionality. */
|
|
31
34
|
export declare function itemTypeOf(type: TypeRef | undefined): TypeRef | undefined;
|
|
32
35
|
/** Types bound by enclosing iteration scopes, keyed by scope id. */
|
|
@@ -36,7 +39,7 @@ export type ScopeTypes = ReadonlyMap<NodeId, TypeRef>;
|
|
|
36
39
|
* determined statically — 0.4 deliberately stops short of a complete type checker, but
|
|
37
40
|
* iteration scopes are tracked so that projections and aggregations can be checked.
|
|
38
41
|
*/
|
|
39
|
-
export declare function inferExpressionType(
|
|
42
|
+
export declare function inferExpressionType(expressionInput: unknown, context: SemanticContext, scope?: ScopeTypes,
|
|
40
43
|
/** Definitions already being inferred, so a cyclic reference stops rather than recurses. */
|
|
41
44
|
visiting?: ReadonlySet<NodeId>): TypeRef | undefined;
|
|
42
45
|
/** The scope bindings an expression introduces for its own sub-expressions. */
|
|
@@ -49,5 +52,5 @@ export declare function isObviouslyIncompatible(target: TypeRef | undefined, val
|
|
|
49
52
|
/** True when a type is a collection whose members are clearly not numbers. */
|
|
50
53
|
export declare function isNonNumericCollection(type: TypeRef | undefined): boolean;
|
|
51
54
|
/** Renders a location for people. The stored representation stays id-based. */
|
|
52
|
-
export declare function formatLocation(location:
|
|
55
|
+
export declare function formatLocation(location: unknown, context: SemanticContext): string;
|
|
53
56
|
//# sourceMappingURL=infer.d.ts.map
|
package/dist/infer.js
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
|
+
import { isPlainLocation } from './location.js';
|
|
1
2
|
import { collectionType, entityType, groupType, optionalType, primitiveType } from './type-ref.js';
|
|
2
3
|
import { GROUP_ITEMS_FIELD, GROUP_KEY_FIELD } from './group.js';
|
|
4
|
+
function isPlainObject(value) {
|
|
5
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
6
|
+
}
|
|
3
7
|
function unwrap(type) {
|
|
4
8
|
return type?.kind === 'optional' ? unwrap(type.valueType) : type;
|
|
5
9
|
}
|
|
6
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* The type of the value a location addresses, where it can be determined statically.
|
|
12
|
+
* Total over malformed input (spec16pt2 §12-20): a location that cannot be understood at
|
|
13
|
+
* all has no inferrable type, rather than a thrown error.
|
|
14
|
+
*/
|
|
7
15
|
export function inferLocationType(location, context) {
|
|
16
|
+
if (!isPlainLocation(location)) {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
8
19
|
switch (location.kind) {
|
|
9
20
|
case 'state':
|
|
10
21
|
return context.getState(location.stateId)?.valueType;
|
|
@@ -46,6 +57,9 @@ export function locationCapabilities(location, context) {
|
|
|
46
57
|
return { readable: true, writable: root.derivation === undefined };
|
|
47
58
|
}
|
|
48
59
|
function locationProviderRoot(location) {
|
|
60
|
+
if (!isPlainLocation(location)) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
49
63
|
switch (location.kind) {
|
|
50
64
|
case 'provider-record':
|
|
51
65
|
return true;
|
|
@@ -58,6 +72,9 @@ function locationProviderRoot(location) {
|
|
|
58
72
|
}
|
|
59
73
|
}
|
|
60
74
|
function rootState(location, context) {
|
|
75
|
+
if (!isPlainLocation(location)) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
61
78
|
switch (location.kind) {
|
|
62
79
|
case 'state':
|
|
63
80
|
return context.getState(location.stateId);
|
|
@@ -89,9 +106,15 @@ function withScope(scope, id, type) {
|
|
|
89
106
|
* determined statically — 0.4 deliberately stops short of a complete type checker, but
|
|
90
107
|
* iteration scopes are tracked so that projections and aggregations can be checked.
|
|
91
108
|
*/
|
|
92
|
-
export function inferExpressionType(
|
|
109
|
+
export function inferExpressionType(expressionInput, context, scope,
|
|
93
110
|
/** Definitions already being inferred, so a cyclic reference stops rather than recurses. */
|
|
94
111
|
visiting = new Set()) {
|
|
112
|
+
// spec16pt2 §12-24 — a malformed sub-expression (a deleted field, a tampered array
|
|
113
|
+
// element) has no inferrable type, rather than a thrown error reading `.kind`.
|
|
114
|
+
if (!isPlainObject(expressionInput)) {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
const expression = expressionInput;
|
|
95
118
|
switch (expression.kind) {
|
|
96
119
|
case 'literal': {
|
|
97
120
|
const value = expression.value;
|
|
@@ -306,6 +329,9 @@ export function isNonNumericCollection(type) {
|
|
|
306
329
|
}
|
|
307
330
|
/** Renders a location for people. The stored representation stays id-based. */
|
|
308
331
|
export function formatLocation(location, context) {
|
|
332
|
+
if (!isPlainLocation(location)) {
|
|
333
|
+
return 'unknown location';
|
|
334
|
+
}
|
|
309
335
|
const name = (id) => context.getName?.(id) ?? id;
|
|
310
336
|
const fieldName = (id) => context.getField(id)?.field.name ?? id;
|
|
311
337
|
switch (location.kind) {
|
|
@@ -315,12 +341,18 @@ export function formatLocation(location, context) {
|
|
|
315
341
|
return `${formatLocation(location.target, context)} → ${fieldName(location.fieldId)}`;
|
|
316
342
|
case 'collection-item': {
|
|
317
343
|
const parent = formatLocation(location.collection, context);
|
|
344
|
+
if (!isPlainObject(location.selector)) {
|
|
345
|
+
return `${parent} → [invalid selector]`;
|
|
346
|
+
}
|
|
318
347
|
if (location.selector.kind === 'identity') {
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
348
|
+
const selectorValue = location.selector.value;
|
|
349
|
+
const value = !isPlainObject(selectorValue)
|
|
350
|
+
? 'expression'
|
|
351
|
+
: selectorValue.kind === 'ref'
|
|
352
|
+
? name(selectorValue.targetId)
|
|
353
|
+
: selectorValue.kind === 'literal'
|
|
354
|
+
? JSON.stringify(selectorValue.value)
|
|
355
|
+
: 'expression';
|
|
324
356
|
return `${parent} → [${fieldName(location.selector.fieldId)} = ${value}]`;
|
|
325
357
|
}
|
|
326
358
|
return `${parent} → [index]`;
|
package/dist/location.d.ts
CHANGED
|
@@ -71,6 +71,13 @@ export declare function providerRecordLocation(sourceEntityId: NodeId, identityF
|
|
|
71
71
|
export declare function providerRecordFieldLocation(sourceEntityId: NodeId, identityFieldId: FieldId, identityValue: Expression, fieldId: FieldId): FieldLocation;
|
|
72
72
|
/** Convenience for the common shape: one field of one item of a collection state. */
|
|
73
73
|
export declare function itemFieldLocation(stateId: NodeId, identityFieldId: FieldId, identityValue: Expression, fieldId: FieldId): FieldLocation;
|
|
74
|
+
/**
|
|
75
|
+
* A plain object carrying one of the four location kinds — total, never throws. A candidate
|
|
76
|
+
* `Location` can arrive from AI generation, deserialization or hand-tampering, so every
|
|
77
|
+
* traversal below checks this before reading `.kind` rather than trusting the compile-time
|
|
78
|
+
* `Location` type (spec16pt2 §12-20).
|
|
79
|
+
*/
|
|
80
|
+
export declare function isPlainLocation(value: unknown): value is Location;
|
|
74
81
|
/**
|
|
75
82
|
* The node every location is ultimately rooted in.
|
|
76
83
|
*
|
|
@@ -79,19 +86,22 @@ export declare function itemFieldLocation(stateId: NodeId, identityFieldId: Fiel
|
|
|
79
86
|
* entity id** instead. Callers that resolve the result as a state (`context.states.get`)
|
|
80
87
|
* naturally get `undefined` and skip it, which is correct: nothing materialized holds the
|
|
81
88
|
* record. Use `locationProviderEntityId` to detect the provider-backed case explicitly.
|
|
89
|
+
*
|
|
90
|
+
* Total over malformed input: an empty string (never a real `NodeId`) for a location that
|
|
91
|
+
* cannot be understood at all, rather than a thrown error (spec16pt2 §12-13).
|
|
82
92
|
*/
|
|
83
|
-
export declare function locationRootStateId(location:
|
|
93
|
+
export declare function locationRootStateId(location: unknown): NodeId;
|
|
84
94
|
/** The source entity of the `provider-record` a location is rooted in, or `undefined` if it is state-rooted. */
|
|
85
|
-
export declare function locationProviderEntityId(location:
|
|
95
|
+
export declare function locationProviderEntityId(location: unknown): NodeId | undefined;
|
|
86
96
|
/**
|
|
87
97
|
* Expressions embedded in a location — selector values, indexes and a provider-record's
|
|
88
98
|
* identity value. These are read dependencies of whatever uses the location.
|
|
89
99
|
*/
|
|
90
|
-
export declare function locationExpressions(location:
|
|
100
|
+
export declare function locationExpressions(location: unknown): Expression[];
|
|
91
101
|
/** Fields a write through this location touches, outermost first. */
|
|
92
|
-
export declare function locationFieldIds(location:
|
|
102
|
+
export declare function locationFieldIds(location: unknown): FieldId[];
|
|
93
103
|
/** Fields a location reads in order to address itself, such as identity selectors. */
|
|
94
|
-
export declare function locationSelectorFieldIds(location:
|
|
104
|
+
export declare function locationSelectorFieldIds(location: unknown): FieldId[];
|
|
95
105
|
/**
|
|
96
106
|
* Structural equality. Two locations that address the same position are equal even
|
|
97
107
|
* though the value a selector expression produces is only known at run time.
|
package/dist/location.js
CHANGED
|
@@ -24,6 +24,23 @@ export function providerRecordFieldLocation(sourceEntityId, identityFieldId, ide
|
|
|
24
24
|
export function itemFieldLocation(stateId, identityFieldId, identityValue, fieldId) {
|
|
25
25
|
return fieldLocation(itemLocation(stateLocation(stateId), identitySelector(identityFieldId, identityValue)), fieldId);
|
|
26
26
|
}
|
|
27
|
+
function isPlainObject(value) {
|
|
28
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
29
|
+
}
|
|
30
|
+
const LOCATION_KINDS = ['state', 'provider-record', 'field', 'collection-item'];
|
|
31
|
+
/**
|
|
32
|
+
* A plain object carrying one of the four location kinds — total, never throws. A candidate
|
|
33
|
+
* `Location` can arrive from AI generation, deserialization or hand-tampering, so every
|
|
34
|
+
* traversal below checks this before reading `.kind` rather than trusting the compile-time
|
|
35
|
+
* `Location` type (spec16pt2 §12-20).
|
|
36
|
+
*/
|
|
37
|
+
export function isPlainLocation(value) {
|
|
38
|
+
return isPlainObject(value) && LOCATION_KINDS.includes(value.kind);
|
|
39
|
+
}
|
|
40
|
+
/** A plain object carrying a recognized `CollectionSelector.kind`, guarding `.selector` access. */
|
|
41
|
+
function isPlainSelector(value) {
|
|
42
|
+
return isPlainObject(value) && (value.kind === 'identity' || value.kind === 'index');
|
|
43
|
+
}
|
|
27
44
|
/**
|
|
28
45
|
* The node every location is ultimately rooted in.
|
|
29
46
|
*
|
|
@@ -32,8 +49,14 @@ export function itemFieldLocation(stateId, identityFieldId, identityValue, field
|
|
|
32
49
|
* entity id** instead. Callers that resolve the result as a state (`context.states.get`)
|
|
33
50
|
* naturally get `undefined` and skip it, which is correct: nothing materialized holds the
|
|
34
51
|
* record. Use `locationProviderEntityId` to detect the provider-backed case explicitly.
|
|
52
|
+
*
|
|
53
|
+
* Total over malformed input: an empty string (never a real `NodeId`) for a location that
|
|
54
|
+
* cannot be understood at all, rather than a thrown error (spec16pt2 §12-13).
|
|
35
55
|
*/
|
|
36
56
|
export function locationRootStateId(location) {
|
|
57
|
+
if (!isPlainLocation(location)) {
|
|
58
|
+
return '';
|
|
59
|
+
}
|
|
37
60
|
switch (location.kind) {
|
|
38
61
|
case 'state':
|
|
39
62
|
return location.stateId;
|
|
@@ -44,11 +67,14 @@ export function locationRootStateId(location) {
|
|
|
44
67
|
case 'collection-item':
|
|
45
68
|
return locationRootStateId(location.collection);
|
|
46
69
|
default:
|
|
47
|
-
|
|
70
|
+
return '';
|
|
48
71
|
}
|
|
49
72
|
}
|
|
50
73
|
/** The source entity of the `provider-record` a location is rooted in, or `undefined` if it is state-rooted. */
|
|
51
74
|
export function locationProviderEntityId(location) {
|
|
75
|
+
if (!isPlainLocation(location)) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
52
78
|
switch (location.kind) {
|
|
53
79
|
case 'provider-record':
|
|
54
80
|
return location.sourceEntityId;
|
|
@@ -65,6 +91,9 @@ export function locationProviderEntityId(location) {
|
|
|
65
91
|
* identity value. These are read dependencies of whatever uses the location.
|
|
66
92
|
*/
|
|
67
93
|
export function locationExpressions(location) {
|
|
94
|
+
if (!isPlainLocation(location)) {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
68
97
|
switch (location.kind) {
|
|
69
98
|
case 'state':
|
|
70
99
|
return [];
|
|
@@ -73,6 +102,9 @@ export function locationExpressions(location) {
|
|
|
73
102
|
case 'field':
|
|
74
103
|
return locationExpressions(location.target);
|
|
75
104
|
case 'collection-item': {
|
|
105
|
+
if (!isPlainSelector(location.selector)) {
|
|
106
|
+
return locationExpressions(location.collection);
|
|
107
|
+
}
|
|
76
108
|
const own = location.selector.kind === 'identity' ? [location.selector.value] : [location.selector.index];
|
|
77
109
|
return [...locationExpressions(location.collection), ...own];
|
|
78
110
|
}
|
|
@@ -82,10 +114,16 @@ export function locationExpressions(location) {
|
|
|
82
114
|
}
|
|
83
115
|
/** Fields a write through this location touches, outermost first. */
|
|
84
116
|
export function locationFieldIds(location) {
|
|
117
|
+
if (!isPlainLocation(location)) {
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
85
120
|
return location.kind === 'field' ? [location.fieldId, ...locationFieldIds(location.target)] : [];
|
|
86
121
|
}
|
|
87
122
|
/** Fields a location reads in order to address itself, such as identity selectors. */
|
|
88
123
|
export function locationSelectorFieldIds(location) {
|
|
124
|
+
if (!isPlainLocation(location)) {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
89
127
|
switch (location.kind) {
|
|
90
128
|
case 'state':
|
|
91
129
|
return [];
|
|
@@ -96,7 +134,9 @@ export function locationSelectorFieldIds(location) {
|
|
|
96
134
|
case 'collection-item':
|
|
97
135
|
return [
|
|
98
136
|
...locationSelectorFieldIds(location.collection),
|
|
99
|
-
...(location.selector.kind === 'identity'
|
|
137
|
+
...(isPlainSelector(location.selector) && location.selector.kind === 'identity'
|
|
138
|
+
? [location.selector.fieldId]
|
|
139
|
+
: []),
|
|
100
140
|
];
|
|
101
141
|
default:
|
|
102
142
|
return [];
|
package/dist/nodes.d.ts
CHANGED
|
@@ -315,7 +315,34 @@ export interface ForEachOperation {
|
|
|
315
315
|
scopeId: NodeId;
|
|
316
316
|
operations: MutationOperation[];
|
|
317
317
|
}
|
|
318
|
-
|
|
318
|
+
/**
|
|
319
|
+
* Total over any input (spec16pt2 §12-19): a candidate `Operation` may arrive from
|
|
320
|
+
* AI generation, deserialization or hand-tampering, so the runtime type `Operation` is not
|
|
321
|
+
* proof of runtime shape. `null`/a primitive/an array safely reports `false` rather than
|
|
322
|
+
* throwing when `.kind` is read.
|
|
323
|
+
*/
|
|
324
|
+
export declare function isMutationOperation(operation: unknown): operation is MutationOperation;
|
|
325
|
+
/** A plain object carrying one of the closed `OPERATION_KINDS` — total, never throws. */
|
|
326
|
+
export declare function isPlainOperation(operation: unknown): operation is Operation;
|
|
327
|
+
/**
|
|
328
|
+
* `value.operations` if it is genuinely an array, else `[]` — no per-element filtering.
|
|
329
|
+
* Exported for `validateGraph`, the one caller that must see every raw array element,
|
|
330
|
+
* malformed or not, in order to report each one structurally (spec16pt2 §20-21).
|
|
331
|
+
*/
|
|
332
|
+
export declare function rawOperations(value: unknown): readonly unknown[];
|
|
333
|
+
/**
|
|
334
|
+
* `ActionDef.operations`, safe over malformed input (spec16pt2 §12-13): only a genuine
|
|
335
|
+
* array of well-formed operations is ever returned — never assumed from a truthy-but-non-array
|
|
336
|
+
* value like `{}` (exactly the shape `(action.operations ?? []).some(...)` treats as an array
|
|
337
|
+
* and crashes on), and never including a malformed array *element* either, so every consumer
|
|
338
|
+
* that just wants to reason about an action's real operations can iterate the result without
|
|
339
|
+
* its own defensive check. `validateGraph` is the one caller that must see and diagnose a
|
|
340
|
+
* malformed element rather than have it silently skipped, so it walks the raw array itself
|
|
341
|
+
* (`nodes.ts` is not the place that decides what gets reported — `validate.ts` is).
|
|
342
|
+
*/
|
|
343
|
+
export declare function actionOperations(action: unknown): Operation[];
|
|
344
|
+
/** A `for-each` operation's nested `operations`, equally total and equally filtered. */
|
|
345
|
+
export declare function operationChildren(operation: unknown): MutationOperation[];
|
|
319
346
|
export declare function forEach(collection: Expression, scopeId: NodeId, operations: MutationOperation[]): ForEachOperation;
|
|
320
347
|
export interface InvokeOperation {
|
|
321
348
|
kind: 'invoke';
|
package/dist/nodes.js
CHANGED
|
@@ -49,9 +49,51 @@ export const BLOB_OPERATION_KINDS = [
|
|
|
49
49
|
'blob-commit',
|
|
50
50
|
'blob-delete',
|
|
51
51
|
];
|
|
52
|
+
/**
|
|
53
|
+
* Total over any input (spec16pt2 §12-19): a candidate `Operation` may arrive from
|
|
54
|
+
* AI generation, deserialization or hand-tampering, so the runtime type `Operation` is not
|
|
55
|
+
* proof of runtime shape. `null`/a primitive/an array safely reports `false` rather than
|
|
56
|
+
* throwing when `.kind` is read.
|
|
57
|
+
*/
|
|
52
58
|
export function isMutationOperation(operation) {
|
|
59
|
+
if (!isPlainOperation(operation)) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
53
62
|
return operation.kind === 'set' || operation.kind === 'insert' || operation.kind === 'remove';
|
|
54
63
|
}
|
|
64
|
+
/** A plain object carrying one of the closed `OPERATION_KINDS` — total, never throws. */
|
|
65
|
+
export function isPlainOperation(operation) {
|
|
66
|
+
return (!!operation &&
|
|
67
|
+
typeof operation === 'object' &&
|
|
68
|
+
!Array.isArray(operation) &&
|
|
69
|
+
OPERATION_KINDS.includes(operation.kind));
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* `value.operations` if it is genuinely an array, else `[]` — no per-element filtering.
|
|
73
|
+
* Exported for `validateGraph`, the one caller that must see every raw array element,
|
|
74
|
+
* malformed or not, in order to report each one structurally (spec16pt2 §20-21).
|
|
75
|
+
*/
|
|
76
|
+
export function rawOperations(value) {
|
|
77
|
+
const operations = value?.operations;
|
|
78
|
+
return Array.isArray(operations) ? operations : [];
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* `ActionDef.operations`, safe over malformed input (spec16pt2 §12-13): only a genuine
|
|
82
|
+
* array of well-formed operations is ever returned — never assumed from a truthy-but-non-array
|
|
83
|
+
* value like `{}` (exactly the shape `(action.operations ?? []).some(...)` treats as an array
|
|
84
|
+
* and crashes on), and never including a malformed array *element* either, so every consumer
|
|
85
|
+
* that just wants to reason about an action's real operations can iterate the result without
|
|
86
|
+
* its own defensive check. `validateGraph` is the one caller that must see and diagnose a
|
|
87
|
+
* malformed element rather than have it silently skipped, so it walks the raw array itself
|
|
88
|
+
* (`nodes.ts` is not the place that decides what gets reported — `validate.ts` is).
|
|
89
|
+
*/
|
|
90
|
+
export function actionOperations(action) {
|
|
91
|
+
return rawOperations(action).filter(isPlainOperation);
|
|
92
|
+
}
|
|
93
|
+
/** A `for-each` operation's nested `operations`, equally total and equally filtered. */
|
|
94
|
+
export function operationChildren(operation) {
|
|
95
|
+
return rawOperations(operation).filter(isMutationOperation);
|
|
96
|
+
}
|
|
55
97
|
export function forEach(collection, scopeId, operations) {
|
|
56
98
|
return { kind: 'for-each', collection, scopeId, operations };
|
|
57
99
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { actionOperations } from './nodes.js';
|
|
1
2
|
import { formSubmitActionId, isUINode, uiChildIds } from './ui.js';
|
|
2
3
|
import { TEXT_ROLE_HEADING_LEVELS, normalizeDensity, normalizeLayout, normalizePadding, normalizeRole, } from './presentation.js';
|
|
3
4
|
import { DEFAULT_THEME } from './theme.js';
|
|
@@ -269,7 +270,7 @@ function semanticLayer(node, index) {
|
|
|
269
270
|
}
|
|
270
271
|
/** Declared destructive intent, or a removal the graph performs. */
|
|
271
272
|
export function isDestructiveAction(action) {
|
|
272
|
-
return action.destructive === true || (action
|
|
273
|
+
return action.destructive === true || actionOperations(action).some((op) => op.kind === 'remove');
|
|
273
274
|
}
|
|
274
275
|
/** Boolean and temporal values read better formatted than printed raw — §32, §33. */
|
|
275
276
|
function formatLayer(node, index) {
|
package/dist/semantic-diff.js
CHANGED
|
@@ -34,12 +34,21 @@ export const SEMANTIC_DIFF_CATEGORIES = [
|
|
|
34
34
|
'presentation',
|
|
35
35
|
'metadata',
|
|
36
36
|
];
|
|
37
|
-
/**
|
|
38
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Kinds `diffSchema` already classifies in full field-level detail; excluded from the
|
|
39
|
+
* generic node loop. `read-policy` is deliberately **not** here (spec16pt2 F3, §33, §37):
|
|
40
|
+
* `diffSchema`'s `ReadPolicyShape` only tracks which entity a policy governs — correctly,
|
|
41
|
+
* for `schemaFingerprint`'s persistence-relevant purpose — and never its `predicate`, so a
|
|
42
|
+
* rule edited from owner-only to public would otherwise produce no diff entry anywhere even
|
|
43
|
+
* though it moves `semanticFingerprint`. Routing it through the generic full-node loop
|
|
44
|
+
* instead catches exactly that, tagged `authorization`, at the cost of a redundant (never
|
|
45
|
+
* missing) second entry when `diffSchema` also reports the policy's entity moving.
|
|
46
|
+
*/
|
|
47
|
+
const SCHEMA_OWNED_KINDS = new Set(['entity', 'state', 'relationship']);
|
|
39
48
|
const PROVIDER_KINDS = new Set(['integration', 'integration-operation', 'subscription', 'storage']);
|
|
40
49
|
const PRESENTATION_KINDS = new Set([...UI_NODE_KINDS, 'route']);
|
|
41
50
|
function kindCategory(kind) {
|
|
42
|
-
if (kind === 'authorization-policy')
|
|
51
|
+
if (kind === 'authorization-policy' || kind === 'read-policy')
|
|
43
52
|
return 'authorization';
|
|
44
53
|
if (kind === 'query')
|
|
45
54
|
return 'query';
|
|
@@ -54,7 +63,21 @@ function kindCategory(kind) {
|
|
|
54
63
|
return 'semantic';
|
|
55
64
|
}
|
|
56
65
|
/** Field names whose change on a node makes the change authorization-semantic too (spec16 §156). */
|
|
57
|
-
|
|
66
|
+
/**
|
|
67
|
+
* Every field whose change makes a node's diff entry authorization-relevant (spec16pt2 F3,
|
|
68
|
+
* §32-42). `readPolicyId` closes the exact alpha.1 finding: attaching, detaching or
|
|
69
|
+
* replacing the row-level policy a `QueryDef` reads through controls **who may observe**
|
|
70
|
+
* its rows, which is authorization semantics by spec16pt2 §32's own test — the field name
|
|
71
|
+
* containing "Policy" is a coincidence this list must not rely on (§41 forbids matching by
|
|
72
|
+
* substring), so each entry here is a deliberate, individually-justified addition.
|
|
73
|
+
*/
|
|
74
|
+
const AUTHZ_FIELDS = [
|
|
75
|
+
'authorizationPolicy',
|
|
76
|
+
'authorization',
|
|
77
|
+
'startPolicy',
|
|
78
|
+
'instanceAccessPolicy',
|
|
79
|
+
'readPolicyId',
|
|
80
|
+
];
|
|
58
81
|
function isPlainObject(value) {
|
|
59
82
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
60
83
|
}
|
|
@@ -99,22 +122,19 @@ export function semanticDiff(before, after) {
|
|
|
99
122
|
if (strippedEqual) {
|
|
100
123
|
categories.add('metadata');
|
|
101
124
|
}
|
|
102
|
-
else
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if (
|
|
125
|
+
else {
|
|
126
|
+
// spec16pt2 §40: categories are additive, never exclusive. An authorization-bearing
|
|
127
|
+
// field change is tagged `authorization` *in addition to* the node's own kind
|
|
128
|
+
// category — never in its place — so `QueryDef.readPolicyId` detaching reads as
|
|
129
|
+
// `[query, authorization]`, not merely `[authorization]` (F3, §34).
|
|
130
|
+
categories.add(kindCategory(kind));
|
|
131
|
+
if (isPlainObject(previous) && isPlainObject(node)) {
|
|
132
|
+
const changedFields = [...new Set([...Object.keys(previous), ...Object.keys(node)])].filter((key) => !equalJSON(previous[key], node[key]));
|
|
133
|
+
if (changedFields.some((key) => AUTHZ_FIELDS.includes(key))) {
|
|
111
134
|
categories.add('authorization');
|
|
112
135
|
}
|
|
113
136
|
}
|
|
114
137
|
}
|
|
115
|
-
else {
|
|
116
|
-
categories.add(kindCategory(kind));
|
|
117
|
-
}
|
|
118
138
|
entries.push({
|
|
119
139
|
changeKind: 'changed',
|
|
120
140
|
nodeId: id,
|
package/dist/server-ir.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { actionOperations, operationChildren } from './nodes.js';
|
|
1
2
|
import { walkExpression } from './expressions.js';
|
|
2
3
|
import { queryExpressions } from './query.js';
|
|
3
4
|
import { migrationExpressions } from './migration.js';
|
|
@@ -123,7 +124,7 @@ export function usesExternalIOVocabulary(ir) {
|
|
|
123
124
|
if ((ir.subscriptions?.length ?? 0) > 0 || (ir.storages?.length ?? 0) > 0) {
|
|
124
125
|
return true;
|
|
125
126
|
}
|
|
126
|
-
return Object.values(ir.actions).some((action) => (action
|
|
127
|
+
return Object.values(ir.actions).some((action) => actionOperations(action).some((operation) => SERVER_IR_V5_OPERATION_KINDS.includes(operation.kind)));
|
|
127
128
|
}
|
|
128
129
|
/**
|
|
129
130
|
* Whether a document uses 0.11's schema-evolution vocabulary — a `MigrationDef`, or a
|
|
@@ -221,7 +222,7 @@ export function usesQueryVocabulary(ir) {
|
|
|
221
222
|
return true;
|
|
222
223
|
}
|
|
223
224
|
// A `query` operation inside an action is also v6 vocabulary.
|
|
224
|
-
return Object.values(ir.actions).some((action) => (action
|
|
225
|
+
return Object.values(ir.actions).some((action) => actionOperations(action).some((operation) => operation.kind === 'query'));
|
|
225
226
|
}
|
|
226
227
|
/** The higher of two contracts, ordered by `SERVER_IR_CONTRACTS`. */
|
|
227
228
|
export function maxContract(a, b) {
|
|
@@ -349,7 +350,7 @@ function actionExpressions(action) {
|
|
|
349
350
|
break;
|
|
350
351
|
case 'for-each':
|
|
351
352
|
found.push(operation.collection);
|
|
352
|
-
walkOperations(operation
|
|
353
|
+
walkOperations(operationChildren(operation));
|
|
353
354
|
break;
|
|
354
355
|
case 'invoke':
|
|
355
356
|
found.push(...Object.values(operation.arguments ?? {}));
|
|
@@ -379,6 +380,6 @@ function actionExpressions(action) {
|
|
|
379
380
|
}
|
|
380
381
|
}
|
|
381
382
|
};
|
|
382
|
-
walkOperations(action
|
|
383
|
+
walkOperations(actionOperations(action));
|
|
383
384
|
return found;
|
|
384
385
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { ValidationIssue } from './diagnostics.js';
|
|
2
2
|
import type { NodeId } from './ids.js';
|
|
3
3
|
import type { SemanticContext } from './infer.js';
|
|
4
|
-
import type { Location } from './location.js';
|
|
5
4
|
export interface LocationValidationOptions {
|
|
6
5
|
/** Node the location belongs to, for diagnostics. */
|
|
7
6
|
ownerId?: NodeId;
|
|
@@ -11,6 +10,11 @@ export interface LocationValidationOptions {
|
|
|
11
10
|
/**
|
|
12
11
|
* Structural validation of a location: does every state, field and selector it names
|
|
13
12
|
* exist, do they fit together, and may it be written to?
|
|
13
|
+
*
|
|
14
|
+
* Total over malformed input (spec16pt2 §12-24): a candidate `Location` can arrive from AI
|
|
15
|
+
* generation, deserialization or hand-tampering. `walk` establishes shape before any
|
|
16
|
+
* dereference, so a `null`/primitive/malformed location is reported as one structured
|
|
17
|
+
* `ValidationIssue`, never a native exception.
|
|
14
18
|
*/
|
|
15
|
-
export declare function validateLocation(location:
|
|
19
|
+
export declare function validateLocation(location: unknown, context: SemanticContext, options?: LocationValidationOptions): ValidationIssue[];
|
|
16
20
|
//# sourceMappingURL=validate-location.d.ts.map
|
|
@@ -1,9 +1,29 @@
|
|
|
1
1
|
import { VALIDATION_CODES } from './diagnostics.js';
|
|
2
2
|
import { inferExpressionType, inferLocationType, locationCapabilities } from './infer.js';
|
|
3
|
-
import { locationRootStateId } from './location.js';
|
|
3
|
+
import { isPlainLocation, locationRootStateId } from './location.js';
|
|
4
|
+
function isPlainObject(value) {
|
|
5
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
/** A short, safe description of a malformed value for a diagnostic message. Never throws. */
|
|
8
|
+
function describeShape(value) {
|
|
9
|
+
if (value === null)
|
|
10
|
+
return 'null';
|
|
11
|
+
if (value === undefined)
|
|
12
|
+
return 'undefined';
|
|
13
|
+
if (Array.isArray(value))
|
|
14
|
+
return 'an array';
|
|
15
|
+
if (typeof value === 'object')
|
|
16
|
+
return 'an object with no recognized "kind"';
|
|
17
|
+
return `a ${typeof value}`;
|
|
18
|
+
}
|
|
4
19
|
/**
|
|
5
20
|
* Structural validation of a location: does every state, field and selector it names
|
|
6
21
|
* exist, do they fit together, and may it be written to?
|
|
22
|
+
*
|
|
23
|
+
* Total over malformed input (spec16pt2 §12-24): a candidate `Location` can arrive from AI
|
|
24
|
+
* generation, deserialization or hand-tampering. `walk` establishes shape before any
|
|
25
|
+
* dereference, so a `null`/primitive/malformed location is reported as one structured
|
|
26
|
+
* `ValidationIssue`, never a native exception.
|
|
7
27
|
*/
|
|
8
28
|
export function validateLocation(location, context, options = {}) {
|
|
9
29
|
const problems = [];
|
|
@@ -21,6 +41,10 @@ export function validateLocation(location, context, options = {}) {
|
|
|
21
41
|
return problems;
|
|
22
42
|
}
|
|
23
43
|
function walk(location, context, report) {
|
|
44
|
+
if (!isPlainLocation(location)) {
|
|
45
|
+
report(VALIDATION_CODES.unknownStateRef, `Location is not a valid location structure: expected an object with a recognized "kind", got ${describeShape(location)}`);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
24
48
|
switch (location.kind) {
|
|
25
49
|
case 'state': {
|
|
26
50
|
if (!context.getState(location.stateId)) {
|
|
@@ -68,6 +92,10 @@ function walk(location, context, report) {
|
|
|
68
92
|
report(VALIDATION_CODES.selectorOnNonCollection, `An item selector was applied to a ${resolved.kind} value`);
|
|
69
93
|
return;
|
|
70
94
|
}
|
|
95
|
+
if (!isPlainObject(location.selector) || (location.selector.kind !== 'identity' && location.selector.kind !== 'index')) {
|
|
96
|
+
report(VALIDATION_CODES.invalidSelectorType, `Item selector is not a valid selector structure: expected { kind: 'identity' | 'index', ... }, got ${describeShape(location.selector)}`);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
71
99
|
if (location.selector.kind !== 'identity') {
|
|
72
100
|
// An index has to be a number. Type inference is partial, so this rejects only
|
|
73
101
|
// what is statically certain to be wrong.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { VALIDATION_CODES } from './diagnostics.js';
|
|
2
|
+
import { actionOperations } from './nodes.js';
|
|
2
3
|
import { ACTION_UX_ROLES, ALIGNMENTS, BOUNDED_SIZES, CONTROL_VARIANTS, DENSITIES, DEVICE_CLASSES, EMPHASIS_LEVELS, ICON_NAMES, JUSTIFICATIONS, LAYOUT_KINDS, PRESENTATION_ROLES, SIZING_VALUES, SPACING_TOKENS, SURFACE_ROLES, TEXT_ROLES, TREATMENTS, HEADING_LEVELS, UX_ROLES, VALUE_FORMAT_KINDS, } from './presentation.js';
|
|
3
4
|
import { isDestructiveAction } from './resolve-presentation.js';
|
|
4
5
|
import { APPEARANCES, RADIUS_TOKENS, SEMANTIC_COLOR_ROLES } from './theme.js';
|
|
@@ -759,7 +760,7 @@ function checkUnmarkedDestructiveActions(bag, index, actions) {
|
|
|
759
760
|
if (!bound.has(action.id) || action.destructive === true) {
|
|
760
761
|
continue;
|
|
761
762
|
}
|
|
762
|
-
if ((action
|
|
763
|
+
if (actionOperations(action).some((operation) => operation.kind === 'remove')) {
|
|
763
764
|
bag.warnings.push({
|
|
764
765
|
code: VALIDATION_CODES.destructiveActionUnmarked,
|
|
765
766
|
message: `${action.name ?? action.id} removes data but does not declare "destructive"`,
|
package/dist/validate.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AGGREGATE_FUNCTIONS, BUILTIN_FUNCTIONS, expressionDefsIn, walkExpression } from './expressions.js';
|
|
2
2
|
import { VALIDATION_CODES } from './diagnostics.js';
|
|
3
|
-
import { EDGE_KINDS, actionGuards, isMutationOperation } from './nodes.js';
|
|
3
|
+
import { EDGE_KINDS, actionGuards, isMutationOperation, isPlainOperation, rawOperations } from './nodes.js';
|
|
4
4
|
import { SUBSCRIPTION_BACKPRESSURE_POLICIES, subscriptionBackpressure, subscriptionQueueLimit, } from './subscriptions.js';
|
|
5
5
|
import { BLOB_REF_FIELDS } from './storage.js';
|
|
6
6
|
import { queryPaginationStrategy, sortKeyDirection } from './query.js';
|
|
@@ -120,6 +120,9 @@ export function validateGraph(graph, options = {}) {
|
|
|
120
120
|
warnings.push(...migration.warnings);
|
|
121
121
|
return { valid: errors.length === 0, errors, warnings };
|
|
122
122
|
}
|
|
123
|
+
function isPlainObject(value) {
|
|
124
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
125
|
+
}
|
|
123
126
|
function validateNode(node, context) {
|
|
124
127
|
if (isUINode(node)) {
|
|
125
128
|
validateUiNode(node, context);
|
|
@@ -297,25 +300,64 @@ function validateAction(action, context) {
|
|
|
297
300
|
for (const postcondition of action.postconditions ?? []) {
|
|
298
301
|
validateExpression(postcondition, action.id, context, local);
|
|
299
302
|
}
|
|
303
|
+
// spec16pt2 F1 — a candidate ActionDef.operations can arrive from AI generation,
|
|
304
|
+
// deserialization or hand-tampering, so its runtime shape is checked before it is ever
|
|
305
|
+
// iterated. A present-but-non-array value is a distinct, structural defect from "absent"
|
|
306
|
+
// (spec16pt2 §15): absent means no operations, non-array means the graph is malformed.
|
|
307
|
+
if (action.operations !== undefined && !Array.isArray(action.operations)) {
|
|
308
|
+
context.errors.push({
|
|
309
|
+
code: VALIDATION_CODES.invalidOperationCollection,
|
|
310
|
+
message: `Action ${action.id} declares operations as ${describeOperationShape(action.operations)}, not an array`,
|
|
311
|
+
nodeId: action.id,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
300
314
|
let scoped = local;
|
|
301
|
-
for (const operation of action
|
|
315
|
+
for (const operation of rawOperations(action)) {
|
|
302
316
|
scoped = validateOperation(operation, action, context, scoped);
|
|
303
317
|
}
|
|
304
318
|
}
|
|
319
|
+
/** A short, safe description of a malformed value for a diagnostic message. Never throws. */
|
|
320
|
+
function describeOperationShape(value) {
|
|
321
|
+
if (value === null)
|
|
322
|
+
return 'null';
|
|
323
|
+
if (Array.isArray(value))
|
|
324
|
+
return 'an array of the wrong shape';
|
|
325
|
+
if (typeof value === 'object')
|
|
326
|
+
return 'an object';
|
|
327
|
+
return `a ${typeof value}`;
|
|
328
|
+
}
|
|
305
329
|
function validateOperation(operation, action, context, local) {
|
|
330
|
+
// spec16pt2 F1/F2 — an operation entry (top-level, or nested inside a for-each) can itself
|
|
331
|
+
// be malformed: `null`, a primitive, or an object with no recognized `kind`. Establish
|
|
332
|
+
// shape before the switch below ever reads `.kind`/`.target`/`.value` (spec16pt2 §20).
|
|
333
|
+
if (!isPlainOperation(operation)) {
|
|
334
|
+
context.errors.push({
|
|
335
|
+
code: VALIDATION_CODES.invalidOperation,
|
|
336
|
+
message: `Action ${action.id} declares an operation that is ${describeOperationShape(operation)}, not a recognized operation`,
|
|
337
|
+
nodeId: action.id,
|
|
338
|
+
});
|
|
339
|
+
return local;
|
|
340
|
+
}
|
|
306
341
|
switch (operation.kind) {
|
|
307
342
|
case 'for-each': {
|
|
308
343
|
validateExpression(operation.collection, action.id, context, local);
|
|
309
344
|
requireCollection(operation.collection, action.id, context, local, 'for-each');
|
|
310
345
|
const scoped = iterationScope(local, operation.scopeId, operation.collection, context, action.id);
|
|
311
|
-
if (
|
|
346
|
+
if (operation.operations !== undefined && !Array.isArray(operation.operations)) {
|
|
347
|
+
context.errors.push({
|
|
348
|
+
code: VALIDATION_CODES.invalidOperationCollection,
|
|
349
|
+
message: `A for-each in ${action.id} declares operations as ${describeOperationShape(operation.operations)}, not an array`,
|
|
350
|
+
nodeId: action.id,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
else if (rawOperations(operation).length === 0) {
|
|
312
354
|
context.warnings.push({
|
|
313
355
|
code: VALIDATION_CODES.unsupportedOperation,
|
|
314
356
|
message: `A for-each in ${action.id} performs no mutations`,
|
|
315
357
|
nodeId: action.id,
|
|
316
358
|
});
|
|
317
359
|
}
|
|
318
|
-
for (const nested of operation
|
|
360
|
+
for (const nested of rawOperations(operation)) {
|
|
319
361
|
if (!isMutationOperation(nested)) {
|
|
320
362
|
context.errors.push({
|
|
321
363
|
code: VALIDATION_CODES.unsupportedOperation,
|
|
@@ -1954,8 +1996,20 @@ function iterationScope(scope, scopeId, source, context, ownerId) {
|
|
|
1954
1996
|
}
|
|
1955
1997
|
return { ids: new Set([...scope.ids, scopeId]), types };
|
|
1956
1998
|
}
|
|
1957
|
-
function validateExpression(
|
|
1999
|
+
function validateExpression(expressionInput, ownerId, context, local) {
|
|
1958
2000
|
const scope = local instanceof Set ? emptyScope(local) : local;
|
|
2001
|
+
// spec16pt2 §12-24 — a candidate expression can be malformed (a deleted/tampered field,
|
|
2002
|
+
// an array-for-object mutation): establish shape before the switch below ever reads
|
|
2003
|
+
// `.kind`, exactly like the operation/location totality fix above.
|
|
2004
|
+
if (!isPlainObject(expressionInput) || typeof expressionInput.kind !== 'string') {
|
|
2005
|
+
context.errors.push({
|
|
2006
|
+
code: VALIDATION_CODES.unsupportedExpression,
|
|
2007
|
+
message: `${ownerId} contains an expression that is not a recognized structure`,
|
|
2008
|
+
nodeId: ownerId,
|
|
2009
|
+
});
|
|
2010
|
+
return;
|
|
2011
|
+
}
|
|
2012
|
+
const expression = expressionInput;
|
|
1959
2013
|
switch (expression.kind) {
|
|
1960
2014
|
case 'literal':
|
|
1961
2015
|
return;
|