@shapeshift-labs/frontier-lang 0.1.0
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/LICENSE +21 -0
- package/README.md +205 -0
- package/dist/index.d.ts +202 -0
- package/dist/index.js +608 -0
- package/examples/todo.mjs +83 -0
- package/package.json +48 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ShapeShift Labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# `@shapeshift-labs/frontier-lang`
|
|
2
|
+
|
|
3
|
+
Patch-native semantic source kernel for replayable programs, merge admission, and generated TypeScript projections.
|
|
4
|
+
|
|
5
|
+
This package is the first small slice of a Frontier-oriented language idea:
|
|
6
|
+
|
|
7
|
+
> Source is semantic state. Editing is patches. Compilation is projection. Merge is replay plus proof/evidence.
|
|
8
|
+
|
|
9
|
+
The package does not try to be a full programming language yet. It provides the runtime-neutral source graph and compiler contracts that a concrete `.frontier` parser can target later.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm install @shapeshift-labs/frontier-lang
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The package is ESM-first and dependency-free at runtime. Local checkout examples import from `dist`, so run `npm run build` before running files under `examples/`.
|
|
18
|
+
|
|
19
|
+
## Why
|
|
20
|
+
|
|
21
|
+
Git merges text. Large agent swarms make concurrent semantic changes: APIs, state cells, routes, effects, migrations, tests, generated artifacts, and ownership surfaces.
|
|
22
|
+
|
|
23
|
+
`frontier-lang` models those as typed graph nodes and semantic patch bundles so tooling can classify a merge before it becomes a pile of conflicting files.
|
|
24
|
+
|
|
25
|
+
## Example
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import {
|
|
29
|
+
actionNode,
|
|
30
|
+
classifyMerge,
|
|
31
|
+
createDocument,
|
|
32
|
+
createPatch,
|
|
33
|
+
emitTypeScript,
|
|
34
|
+
entityNode,
|
|
35
|
+
hashDocumentBase,
|
|
36
|
+
stateNode
|
|
37
|
+
} from "@shapeshift-labs/frontier-lang";
|
|
38
|
+
|
|
39
|
+
const todo = entityNode({
|
|
40
|
+
id: "ent_todo",
|
|
41
|
+
name: "Todo",
|
|
42
|
+
fields: [
|
|
43
|
+
{ id: "field_todo_id", name: "id", type: "TodoId", key: true },
|
|
44
|
+
{ id: "field_todo_title", name: "title", type: "Text", merge: { kind: "conflict" } },
|
|
45
|
+
{
|
|
46
|
+
id: "field_todo_tags",
|
|
47
|
+
name: "tags",
|
|
48
|
+
type: "Set<Text>",
|
|
49
|
+
merge: { kind: "union", law: "semilattice" }
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const state = stateNode({
|
|
55
|
+
id: "state_todo",
|
|
56
|
+
name: "TodoDb",
|
|
57
|
+
collections: [
|
|
58
|
+
{
|
|
59
|
+
id: "collection_todos",
|
|
60
|
+
name: "todos",
|
|
61
|
+
type: "Map<TodoId, Todo>",
|
|
62
|
+
merge: { kind: "byKey", law: "commutative" }
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const addTodo = actionNode({
|
|
68
|
+
id: "action_add_todo",
|
|
69
|
+
name: "addTodo",
|
|
70
|
+
input: "{ title: Text }",
|
|
71
|
+
returns: "Patch",
|
|
72
|
+
reads: ["TodoDb.todos"],
|
|
73
|
+
writes: ["TodoDb.todos"],
|
|
74
|
+
uses: ["Clock"]
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const document = createDocument({
|
|
78
|
+
id: "mod_todo",
|
|
79
|
+
name: "TodoApp",
|
|
80
|
+
nodes: [todo, state, addTodo]
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const baseHash = hashDocumentBase(document);
|
|
84
|
+
|
|
85
|
+
const left = createPatch({
|
|
86
|
+
id: "patch_add_tags",
|
|
87
|
+
baseHash,
|
|
88
|
+
operations: [
|
|
89
|
+
{
|
|
90
|
+
op: "updateNode",
|
|
91
|
+
id: "ent_todo",
|
|
92
|
+
set: { metadata: { changedBy: "left" } },
|
|
93
|
+
touches: [{ id: "field_todo_tags", access: "write" }]
|
|
94
|
+
}
|
|
95
|
+
],
|
|
96
|
+
evidence: [{ id: "test_tags", kind: "test", status: "passed" }]
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const right = createPatch({
|
|
100
|
+
id: "patch_add_more_tags",
|
|
101
|
+
baseHash,
|
|
102
|
+
operations: [
|
|
103
|
+
{
|
|
104
|
+
op: "updateNode",
|
|
105
|
+
id: "ent_todo",
|
|
106
|
+
set: { metadata: { changedBy: "right" } },
|
|
107
|
+
touches: [{ id: "field_todo_tags", access: "write" }]
|
|
108
|
+
}
|
|
109
|
+
],
|
|
110
|
+
evidence: [{ id: "test_more_tags", kind: "test", status: "passed" }]
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
console.log(classifyMerge(document, left, right).status);
|
|
114
|
+
// safe-by-merge-law
|
|
115
|
+
|
|
116
|
+
console.log(emitTypeScript(document));
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Concrete Language Shape
|
|
120
|
+
|
|
121
|
+
A later parser could project source that looks like this into the graph above. This is future syntax, not a shipped parser yet:
|
|
122
|
+
|
|
123
|
+
```frontier
|
|
124
|
+
entity Todo @id("ent_todo") {
|
|
125
|
+
id @id("field_todo_id"): TodoId @key
|
|
126
|
+
|
|
127
|
+
title @id("field_todo_title"): Text {
|
|
128
|
+
merge conflict
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
tags @id("field_todo_tags"): Set<Text> {
|
|
132
|
+
merge union law semilattice
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
state TodoDb @id("state_todo") {
|
|
137
|
+
todos @id("collection_todos"): Map<TodoId, Todo> {
|
|
138
|
+
merge byKey law commutative
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
action addTodo(input: { title: Text })
|
|
143
|
+
reads TodoDb.todos
|
|
144
|
+
writes TodoDb.todos
|
|
145
|
+
uses Clock
|
|
146
|
+
returns Patch
|
|
147
|
+
{
|
|
148
|
+
patch {
|
|
149
|
+
TodoDb.todos[TodoId.new()] = Todo {
|
|
150
|
+
title: input.title
|
|
151
|
+
tags: Set.empty()
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Generated Outputs And Host Capabilities
|
|
158
|
+
|
|
159
|
+
There are two separate boundaries to keep distinct:
|
|
160
|
+
|
|
161
|
+
1. **JS/TS as optional projection targets.** Frontier semantic source can do the graph, replay, and merge work first, then project generated targets. This package currently ships `emitTypeScript`; JavaScript can be a later projection target or a build output derived from generated TypeScript. In this mode, JS packages are not part of the canonical source model.
|
|
162
|
+
2. **JS packages as host capabilities.** Some real systems still need React, Playwright, SQLite clients, storage SDKs, crypto libraries, or browser APIs. The language can use those through explicit capability adapters such as `Network`, `Storage<T>`, `ReactView`, or `SqlClient`.
|
|
163
|
+
|
|
164
|
+
The second mode should be opt-in. Importing arbitrary JS directly into semantic source would pull late-bound JavaScript behavior back into the merge problem. A better design is:
|
|
165
|
+
|
|
166
|
+
Future syntax sketch:
|
|
167
|
+
|
|
168
|
+
```frontier
|
|
169
|
+
capability ReactView from npm("react") {
|
|
170
|
+
effects dom
|
|
171
|
+
boundary generated
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Today, the source-kernel shape for this is an effect/capability contract: actions list capabilities in `uses`, effect nodes name the capability and resources, and target nodes describe generated output. The host adapter resolves `npm:react` or browser APIs outside the canonical semantic graph.
|
|
176
|
+
|
|
177
|
+
That keeps JS useful without making JavaScript the canonical language semantics.
|
|
178
|
+
|
|
179
|
+
## API Surface
|
|
180
|
+
|
|
181
|
+
- `createDocument`
|
|
182
|
+
- `entityNode`, `stateNode`, `actionNode`, `viewNode`, `migrationNode`, `effectNode`, `moduleNode`, `targetNode`
|
|
183
|
+
- `createPatch`
|
|
184
|
+
- `applySemanticPatch`
|
|
185
|
+
- `replayDocument`
|
|
186
|
+
- `classifyMerge`
|
|
187
|
+
- `emitTypeScript`
|
|
188
|
+
- `stableStringify`
|
|
189
|
+
- `hashSemanticValue`
|
|
190
|
+
- `hashDocumentBase`
|
|
191
|
+
- `validateDocument`
|
|
192
|
+
|
|
193
|
+
## Status
|
|
194
|
+
|
|
195
|
+
Experimental. This is the source-kernel package for proving the core loop:
|
|
196
|
+
|
|
197
|
+
```txt
|
|
198
|
+
semantic source graph -> patch/replay -> merge classification -> generated TypeScript
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
The next layers are a concrete `.frontier` parser, richer compiler targets, verifier hooks, and adapters to the existing Frontier package family.
|
|
202
|
+
|
|
203
|
+
## License
|
|
204
|
+
|
|
205
|
+
MIT. See [LICENSE](./LICENSE).
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
export type JsonPrimitive = string | number | boolean | null;
|
|
2
|
+
export type JsonValue = JsonPrimitive | JsonValue[] | { readonly [key: string]: JsonValue };
|
|
3
|
+
export type JsonObject = { readonly [key: string]: JsonValue };
|
|
4
|
+
export type SemanticId = string;
|
|
5
|
+
export type NodeKind = "module" | "entity" | "state" | "action" | "view" | "migration" | "effect" | "target";
|
|
6
|
+
export type MergePolicyKind = "conflict" | "union" | "max" | "lastWriterWins" | "byKey" | "preserveMoves" | "manual" | "custom";
|
|
7
|
+
|
|
8
|
+
export interface MergePolicy {
|
|
9
|
+
readonly kind: MergePolicyKind;
|
|
10
|
+
readonly law?: "semilattice" | "commutative" | "associative" | "idempotent";
|
|
11
|
+
readonly condition?: string;
|
|
12
|
+
readonly customName?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SemanticRegion {
|
|
16
|
+
readonly id: string;
|
|
17
|
+
readonly access?: "read" | "write" | "readwrite" | "effect" | "schema" | "evidence";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface BaseNode {
|
|
21
|
+
readonly kind: NodeKind;
|
|
22
|
+
readonly id: SemanticId;
|
|
23
|
+
readonly name: string;
|
|
24
|
+
readonly parentId?: SemanticId;
|
|
25
|
+
readonly regions?: readonly SemanticRegion[];
|
|
26
|
+
readonly metadata?: JsonObject;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface CompileTarget {
|
|
30
|
+
readonly language: "typescript" | "javascript" | string;
|
|
31
|
+
readonly packageName?: string;
|
|
32
|
+
readonly emitPath?: string;
|
|
33
|
+
readonly moduleFormat?: "esm" | "commonjs";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ModuleNode extends BaseNode {
|
|
37
|
+
readonly kind: "module";
|
|
38
|
+
readonly imports?: readonly string[];
|
|
39
|
+
readonly targets?: readonly CompileTarget[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface FieldDeclaration {
|
|
43
|
+
readonly id: SemanticId;
|
|
44
|
+
readonly name: string;
|
|
45
|
+
readonly type: string;
|
|
46
|
+
readonly key?: boolean;
|
|
47
|
+
readonly merge?: MergePolicy;
|
|
48
|
+
readonly metadata?: JsonObject;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface EntityNode extends BaseNode {
|
|
52
|
+
readonly kind: "entity";
|
|
53
|
+
readonly fields: readonly FieldDeclaration[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface StateCollection {
|
|
57
|
+
readonly id: SemanticId;
|
|
58
|
+
readonly name: string;
|
|
59
|
+
readonly type: string;
|
|
60
|
+
readonly merge?: MergePolicy;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface StateNode extends BaseNode {
|
|
64
|
+
readonly kind: "state";
|
|
65
|
+
readonly collections: readonly StateCollection[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ActionNode extends BaseNode {
|
|
69
|
+
readonly kind: "action";
|
|
70
|
+
readonly input?: string;
|
|
71
|
+
readonly returns?: string;
|
|
72
|
+
readonly reads?: readonly string[];
|
|
73
|
+
readonly writes?: readonly string[];
|
|
74
|
+
readonly uses?: readonly string[];
|
|
75
|
+
readonly throws?: readonly string[];
|
|
76
|
+
readonly body?: readonly JsonObject[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface ViewNode extends BaseNode {
|
|
80
|
+
readonly kind: "view";
|
|
81
|
+
readonly reads?: readonly string[];
|
|
82
|
+
readonly dispatches?: readonly string[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface MigrationNode extends BaseNode {
|
|
86
|
+
readonly kind: "migration";
|
|
87
|
+
readonly fromVersion: string;
|
|
88
|
+
readonly toVersion: string;
|
|
89
|
+
readonly changes: readonly JsonObject[];
|
|
90
|
+
readonly invariants?: readonly string[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface EffectNode extends BaseNode {
|
|
94
|
+
readonly kind: "effect";
|
|
95
|
+
readonly capability: string;
|
|
96
|
+
readonly resources?: readonly string[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface TargetNode extends BaseNode {
|
|
100
|
+
readonly kind: "target";
|
|
101
|
+
readonly target: CompileTarget;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type SemanticNode = ModuleNode | EntityNode | StateNode | ActionNode | ViewNode | MigrationNode | EffectNode | TargetNode;
|
|
105
|
+
|
|
106
|
+
export interface FrontierLangDocument {
|
|
107
|
+
readonly kind: "frontier.lang.document";
|
|
108
|
+
readonly version: 1;
|
|
109
|
+
readonly id: SemanticId;
|
|
110
|
+
readonly name: string;
|
|
111
|
+
readonly rootIds: readonly SemanticId[];
|
|
112
|
+
readonly nodes: Readonly<Record<SemanticId, SemanticNode>>;
|
|
113
|
+
readonly history?: readonly ReplayEvent[];
|
|
114
|
+
readonly metadata?: JsonObject;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export type SemanticPatchOperation =
|
|
118
|
+
| { readonly op: "upsertNode"; readonly node: SemanticNode; readonly touches?: readonly SemanticRegion[] }
|
|
119
|
+
| { readonly op: "removeNode"; readonly id: SemanticId; readonly touches?: readonly SemanticRegion[] }
|
|
120
|
+
| { readonly op: "renameNode"; readonly id: SemanticId; readonly name: string; readonly touches?: readonly SemanticRegion[] }
|
|
121
|
+
| { readonly op: "moveNode"; readonly id: SemanticId; readonly parentId?: SemanticId; readonly touches?: readonly SemanticRegion[] }
|
|
122
|
+
| { readonly op: "updateNode"; readonly id: SemanticId; readonly set: JsonObject; readonly touches?: readonly SemanticRegion[] }
|
|
123
|
+
| { readonly op: "addEvidence"; readonly evidence: EvidenceRecord; readonly touches?: readonly SemanticRegion[] };
|
|
124
|
+
|
|
125
|
+
export interface EvidenceRecord {
|
|
126
|
+
readonly id: string;
|
|
127
|
+
readonly kind: "typecheck" | "test" | "replay" | "proof" | "trace" | "review" | "note";
|
|
128
|
+
readonly status: "passed" | "failed" | "unknown";
|
|
129
|
+
readonly path?: string;
|
|
130
|
+
readonly summary?: string;
|
|
131
|
+
readonly metadata?: JsonObject;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface SemanticPatchBundle {
|
|
135
|
+
readonly kind: "frontier.lang.patch";
|
|
136
|
+
readonly version: 1;
|
|
137
|
+
readonly id: string;
|
|
138
|
+
readonly baseHash?: string;
|
|
139
|
+
readonly targetHash?: string;
|
|
140
|
+
readonly author?: string;
|
|
141
|
+
readonly risk?: "low" | "medium" | "high" | "unknown";
|
|
142
|
+
readonly operations: readonly SemanticPatchOperation[];
|
|
143
|
+
readonly evidence?: readonly EvidenceRecord[];
|
|
144
|
+
readonly metadata?: JsonObject;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface ReplayEvent {
|
|
148
|
+
readonly id: string;
|
|
149
|
+
readonly at?: string;
|
|
150
|
+
readonly actor?: string;
|
|
151
|
+
readonly patch: SemanticPatchBundle;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export type MergeStatus =
|
|
155
|
+
| "safe-by-disjoint-region"
|
|
156
|
+
| "safe-by-same-change"
|
|
157
|
+
| "safe-by-merge-law"
|
|
158
|
+
| "conflict-by-overlap"
|
|
159
|
+
| "conflict-by-effect-overlap"
|
|
160
|
+
| "unknown-by-dynamic-effect"
|
|
161
|
+
| "unknown-needs-review";
|
|
162
|
+
|
|
163
|
+
export interface MergeAdmission {
|
|
164
|
+
readonly status: MergeStatus;
|
|
165
|
+
readonly autoMergeable: boolean;
|
|
166
|
+
readonly reasons: readonly string[];
|
|
167
|
+
readonly overlappingNodeIds: readonly SemanticId[];
|
|
168
|
+
readonly overlappingRegions: readonly string[];
|
|
169
|
+
readonly overlappingEffects: readonly string[];
|
|
170
|
+
readonly evidence: readonly EvidenceRecord[];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface EmitTypeScriptOptions {
|
|
174
|
+
readonly banner?: string;
|
|
175
|
+
readonly includeRuntimeTypes?: boolean;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export declare function moduleNode(input: Omit<ModuleNode, "kind">): ModuleNode;
|
|
179
|
+
export declare function entityNode(input: Omit<EntityNode, "kind">): EntityNode;
|
|
180
|
+
export declare function stateNode(input: Omit<StateNode, "kind">): StateNode;
|
|
181
|
+
export declare function actionNode(input: Omit<ActionNode, "kind">): ActionNode;
|
|
182
|
+
export declare function viewNode(input: Omit<ViewNode, "kind">): ViewNode;
|
|
183
|
+
export declare function migrationNode(input: Omit<MigrationNode, "kind">): MigrationNode;
|
|
184
|
+
export declare function effectNode(input: Omit<EffectNode, "kind">): EffectNode;
|
|
185
|
+
export declare function targetNode(input: Omit<TargetNode, "kind">): TargetNode;
|
|
186
|
+
export declare function createPatch(input: Omit<SemanticPatchBundle, "kind" | "version">): SemanticPatchBundle;
|
|
187
|
+
export declare function createDocument(input: {
|
|
188
|
+
readonly id: SemanticId;
|
|
189
|
+
readonly name: string;
|
|
190
|
+
readonly nodes: readonly SemanticNode[];
|
|
191
|
+
readonly rootIds?: readonly SemanticId[];
|
|
192
|
+
readonly history?: readonly ReplayEvent[];
|
|
193
|
+
readonly metadata?: JsonObject;
|
|
194
|
+
}): FrontierLangDocument;
|
|
195
|
+
export declare function stableStringify(value: unknown): string;
|
|
196
|
+
export declare function hashSemanticValue(value: unknown): string;
|
|
197
|
+
export declare function hashDocumentBase(document: FrontierLangDocument): string;
|
|
198
|
+
export declare function validateDocument(document: FrontierLangDocument): readonly string[];
|
|
199
|
+
export declare function applySemanticPatch(document: FrontierLangDocument, patch: SemanticPatchBundle, event?: ReplayEvent): FrontierLangDocument;
|
|
200
|
+
export declare function replayDocument(initial: FrontierLangDocument, events: readonly ReplayEvent[]): FrontierLangDocument;
|
|
201
|
+
export declare function classifyMerge(base: FrontierLangDocument, left: SemanticPatchBundle, right: SemanticPatchBundle): MergeAdmission;
|
|
202
|
+
export declare function emitTypeScript(document: FrontierLangDocument, options?: EmitTypeScriptOptions): string;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
export function moduleNode(input) {
|
|
2
|
+
return { ...input, kind: "module" };
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function entityNode(input) {
|
|
6
|
+
return { ...input, kind: "entity" };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function stateNode(input) {
|
|
10
|
+
return { ...input, kind: "state" };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function actionNode(input) {
|
|
14
|
+
return { ...input, kind: "action" };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function viewNode(input) {
|
|
18
|
+
return { ...input, kind: "view" };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function migrationNode(input) {
|
|
22
|
+
return { ...input, kind: "migration" };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function effectNode(input) {
|
|
26
|
+
return { ...input, kind: "effect" };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function targetNode(input) {
|
|
30
|
+
return { ...input, kind: "target" };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createPatch(input) {
|
|
34
|
+
return {
|
|
35
|
+
...input,
|
|
36
|
+
kind: "frontier.lang.patch",
|
|
37
|
+
version: 1
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createDocument(input) {
|
|
42
|
+
const nodes = {};
|
|
43
|
+
for (const node of input.nodes) {
|
|
44
|
+
if (nodes[node.id]) {
|
|
45
|
+
throw new Error(`Duplicate semantic node id: ${node.id}`);
|
|
46
|
+
}
|
|
47
|
+
nodes[node.id] = node;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
kind: "frontier.lang.document",
|
|
52
|
+
version: 1,
|
|
53
|
+
id: input.id,
|
|
54
|
+
name: input.name,
|
|
55
|
+
rootIds: input.rootIds ?? input.nodes.filter((node) => !node.parentId).map((node) => node.id),
|
|
56
|
+
nodes,
|
|
57
|
+
history: input.history,
|
|
58
|
+
metadata: input.metadata
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function stableStringify(value) {
|
|
63
|
+
if (value === null || typeof value !== "object") {
|
|
64
|
+
return JSON.stringify(value);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (Array.isArray(value)) {
|
|
68
|
+
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const entries = Object.entries(value)
|
|
72
|
+
.filter(([, item]) => item !== undefined)
|
|
73
|
+
.sort(([left], [right]) => ordinalCompare(left, right));
|
|
74
|
+
|
|
75
|
+
return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`).join(",")}}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function hashSemanticValue(value) {
|
|
79
|
+
const serialized = stableStringify(value);
|
|
80
|
+
let hash = 0x811c9dc5;
|
|
81
|
+
for (let index = 0; index < serialized.length; index += 1) {
|
|
82
|
+
hash ^= serialized.charCodeAt(index);
|
|
83
|
+
hash = Math.imul(hash, 0x01000193);
|
|
84
|
+
}
|
|
85
|
+
return `fnv1a32:${(hash >>> 0).toString(16).padStart(8, "0")}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function hashDocumentBase(document) {
|
|
89
|
+
return hashSemanticValue(stripHistory(document));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function validateDocument(document) {
|
|
93
|
+
const issues = [];
|
|
94
|
+
const rootSet = new Set();
|
|
95
|
+
|
|
96
|
+
for (const rootId of document.rootIds) {
|
|
97
|
+
if (rootSet.has(rootId)) {
|
|
98
|
+
issues.push(`Duplicate root node: ${rootId}`);
|
|
99
|
+
}
|
|
100
|
+
rootSet.add(rootId);
|
|
101
|
+
if (!document.nodes[rootId]) {
|
|
102
|
+
issues.push(`Missing root node: ${rootId}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (const [nodeId, node] of Object.entries(document.nodes)) {
|
|
107
|
+
if (node.id !== nodeId) {
|
|
108
|
+
issues.push(`Node record key ${nodeId} does not match node id ${node.id}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (node.parentId && !document.nodes[node.parentId]) {
|
|
112
|
+
issues.push(`Node ${node.id} references missing parent ${node.parentId}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (node.parentId === node.id) {
|
|
116
|
+
issues.push(`Node ${node.id} cannot be its own parent`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!node.parentId && !rootSet.has(node.id)) {
|
|
120
|
+
issues.push(`Parentless node ${node.id} is missing from rootIds`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (node.parentId && rootSet.has(node.id)) {
|
|
124
|
+
issues.push(`Node ${node.id} has a parent and cannot be a root`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (hasAncestorCycle(document.nodes, node.id)) {
|
|
128
|
+
issues.push(`Node ${node.id} is part of a parent cycle`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (node.kind === "entity") {
|
|
132
|
+
const fieldIds = new Set();
|
|
133
|
+
for (const field of node.fields) {
|
|
134
|
+
if (fieldIds.has(field.id)) {
|
|
135
|
+
issues.push(`Entity ${node.id} has duplicate field id ${field.id}`);
|
|
136
|
+
}
|
|
137
|
+
fieldIds.add(field.id);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return issues;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function applySemanticPatch(document, patch, event) {
|
|
146
|
+
if (patch.baseHash && patch.baseHash !== hashDocumentBase(document)) {
|
|
147
|
+
throw new Error(`Patch ${patch.id} base hash does not match document`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
let nodes = { ...document.nodes };
|
|
151
|
+
let rootIds = [...document.rootIds];
|
|
152
|
+
|
|
153
|
+
for (const operation of patch.operations) {
|
|
154
|
+
switch (operation.op) {
|
|
155
|
+
case "upsertNode": {
|
|
156
|
+
nodes = { ...nodes, [operation.node.id]: operation.node };
|
|
157
|
+
if (!operation.node.parentId && !rootIds.includes(operation.node.id)) {
|
|
158
|
+
rootIds = [...rootIds, operation.node.id];
|
|
159
|
+
}
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
case "removeNode": {
|
|
163
|
+
const nextNodes = { ...nodes };
|
|
164
|
+
delete nextNodes[operation.id];
|
|
165
|
+
nodes = nextNodes;
|
|
166
|
+
rootIds = rootIds.filter((id) => id !== operation.id);
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
case "renameNode": {
|
|
170
|
+
const node = requireNode(nodes, operation.id);
|
|
171
|
+
nodes = { ...nodes, [operation.id]: { ...node, name: operation.name } };
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
case "moveNode": {
|
|
175
|
+
const node = requireNode(nodes, operation.id);
|
|
176
|
+
nodes = { ...nodes, [operation.id]: { ...node, parentId: operation.parentId } };
|
|
177
|
+
rootIds = operation.parentId
|
|
178
|
+
? rootIds.filter((id) => id !== operation.id)
|
|
179
|
+
: unique([...rootIds, operation.id]);
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
case "updateNode": {
|
|
183
|
+
if (
|
|
184
|
+
Object.hasOwn(operation.set, "id") ||
|
|
185
|
+
Object.hasOwn(operation.set, "kind") ||
|
|
186
|
+
Object.hasOwn(operation.set, "parentId")
|
|
187
|
+
) {
|
|
188
|
+
throw new Error(`Patch ${patch.id} cannot update semantic node identity, kind, or parent`);
|
|
189
|
+
}
|
|
190
|
+
const node = requireNode(nodes, operation.id);
|
|
191
|
+
nodes = { ...nodes, [operation.id]: { ...node, ...operation.set } };
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
case "addEvidence":
|
|
195
|
+
break;
|
|
196
|
+
default:
|
|
197
|
+
throw new Error(`Unexpected operation: ${operation.op}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const next = {
|
|
202
|
+
...document,
|
|
203
|
+
rootIds,
|
|
204
|
+
nodes
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const issues = validateDocument(next);
|
|
208
|
+
if (issues.length > 0) {
|
|
209
|
+
throw new Error(`Invalid document after patch ${patch.id}: ${issues.join("; ")}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (patch.targetHash && patch.targetHash !== hashDocumentBase(next)) {
|
|
213
|
+
throw new Error(`Patch ${patch.id} target hash does not match result`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
...next,
|
|
218
|
+
history: [
|
|
219
|
+
...(document.history ?? []),
|
|
220
|
+
event ?? {
|
|
221
|
+
id: `event:${patch.id}`,
|
|
222
|
+
patch
|
|
223
|
+
}
|
|
224
|
+
]
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function replayDocument(initial, events) {
|
|
229
|
+
return events.reduce((document, event) => applySemanticPatch(document, event.patch, event), initial);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function classifyMerge(base, left, right) {
|
|
233
|
+
const baseHash = hashDocumentBase(base);
|
|
234
|
+
const reasons = [];
|
|
235
|
+
|
|
236
|
+
if (left.baseHash && left.baseHash !== baseHash) {
|
|
237
|
+
reasons.push(`Left patch base hash ${left.baseHash} does not match current base ${baseHash}`);
|
|
238
|
+
}
|
|
239
|
+
if (right.baseHash && right.baseHash !== baseHash) {
|
|
240
|
+
reasons.push(`Right patch base hash ${right.baseHash} does not match current base ${baseHash}`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const leftSummary = summarizePatch(left);
|
|
244
|
+
const rightSummary = summarizePatch(right);
|
|
245
|
+
const overlappingNodeIds = intersection(leftSummary.nodeIds, rightSummary.nodeIds);
|
|
246
|
+
const overlappingRegions = intersection(leftSummary.regions, rightSummary.regions);
|
|
247
|
+
const overlappingEffects = intersection(leftSummary.effects, rightSummary.effects);
|
|
248
|
+
const evidence = [...(left.evidence ?? []), ...(right.evidence ?? [])];
|
|
249
|
+
const failedEvidence = evidence.filter((record) => record.status === "failed");
|
|
250
|
+
|
|
251
|
+
if (reasons.length > 0) {
|
|
252
|
+
return {
|
|
253
|
+
status: "unknown-needs-review",
|
|
254
|
+
autoMergeable: false,
|
|
255
|
+
reasons,
|
|
256
|
+
overlappingNodeIds,
|
|
257
|
+
overlappingRegions,
|
|
258
|
+
overlappingEffects,
|
|
259
|
+
evidence
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (failedEvidence.length > 0) {
|
|
264
|
+
return {
|
|
265
|
+
status: "unknown-needs-review",
|
|
266
|
+
autoMergeable: false,
|
|
267
|
+
reasons: [`Failed evidence prevents auto-merge: ${failedEvidence.map((record) => record.id).join(", ")}`],
|
|
268
|
+
overlappingNodeIds,
|
|
269
|
+
overlappingRegions,
|
|
270
|
+
overlappingEffects,
|
|
271
|
+
evidence
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (stableStringify(left.operations) === stableStringify(right.operations)) {
|
|
276
|
+
return {
|
|
277
|
+
status: "safe-by-same-change",
|
|
278
|
+
autoMergeable: true,
|
|
279
|
+
reasons: ["Both patches contain the same semantic operations."],
|
|
280
|
+
overlappingNodeIds,
|
|
281
|
+
overlappingRegions,
|
|
282
|
+
overlappingEffects,
|
|
283
|
+
evidence
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const dynamicEffects = new Set(["dynamic", "eval", "unsafeEval", "ffi", "reflection", "proxy"]);
|
|
288
|
+
const hasDynamic = [...leftSummary.effects, ...rightSummary.effects].some((effect) =>
|
|
289
|
+
dynamicEffects.has(effect)
|
|
290
|
+
);
|
|
291
|
+
if (hasDynamic) {
|
|
292
|
+
return {
|
|
293
|
+
status: "unknown-by-dynamic-effect",
|
|
294
|
+
autoMergeable: false,
|
|
295
|
+
reasons: ["At least one patch touches a dynamic or opaque effect boundary."],
|
|
296
|
+
overlappingNodeIds,
|
|
297
|
+
overlappingRegions,
|
|
298
|
+
overlappingEffects,
|
|
299
|
+
evidence
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (overlappingEffects.length > 0) {
|
|
304
|
+
return {
|
|
305
|
+
status: "conflict-by-effect-overlap",
|
|
306
|
+
autoMergeable: false,
|
|
307
|
+
reasons: [`Both patches touch effect(s): ${overlappingEffects.join(", ")}`],
|
|
308
|
+
overlappingNodeIds,
|
|
309
|
+
overlappingRegions,
|
|
310
|
+
overlappingEffects,
|
|
311
|
+
evidence
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (overlappingNodeIds.length > 0 || overlappingRegions.length > 0) {
|
|
316
|
+
const laws = collectMergeLaws(base, [...overlappingNodeIds, ...overlappingRegions]);
|
|
317
|
+
if (laws.length > 0 && laws.every((law) => law === "semilattice" || law === "commutative")) {
|
|
318
|
+
return withReplayGate(base, left, right, {
|
|
319
|
+
status: "safe-by-merge-law",
|
|
320
|
+
autoMergeable: true,
|
|
321
|
+
reasons: [`Overlaps are covered by merge law(s): ${unique(laws).join(", ")}`],
|
|
322
|
+
overlappingNodeIds,
|
|
323
|
+
overlappingRegions,
|
|
324
|
+
overlappingEffects,
|
|
325
|
+
evidence
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
status: "conflict-by-overlap",
|
|
331
|
+
autoMergeable: false,
|
|
332
|
+
reasons: ["Patches touch the same semantic node or region without a known merge law."],
|
|
333
|
+
overlappingNodeIds,
|
|
334
|
+
overlappingRegions,
|
|
335
|
+
overlappingEffects,
|
|
336
|
+
evidence
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return withReplayGate(base, left, right, {
|
|
341
|
+
status: "safe-by-disjoint-region",
|
|
342
|
+
autoMergeable: true,
|
|
343
|
+
reasons: ["Patches touch disjoint semantic nodes, regions, and effects."],
|
|
344
|
+
overlappingNodeIds,
|
|
345
|
+
overlappingRegions,
|
|
346
|
+
overlappingEffects,
|
|
347
|
+
evidence
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function emitTypeScript(document, options = {}) {
|
|
352
|
+
const lines = [];
|
|
353
|
+
const banner = options.banner ?? "Generated by @shapeshift-labs/frontier-lang.";
|
|
354
|
+
lines.push(`// ${banner}`);
|
|
355
|
+
lines.push("");
|
|
356
|
+
|
|
357
|
+
if (options.includeRuntimeTypes ?? true) {
|
|
358
|
+
lines.push("export type FrontierPatchOperation =");
|
|
359
|
+
lines.push(" | { op: 'set'; path: string; value: unknown }");
|
|
360
|
+
lines.push(" | { op: 'remove'; path: string }");
|
|
361
|
+
lines.push(" | { op: 'insert'; path: string; value: unknown }");
|
|
362
|
+
lines.push(" | { op: 'merge'; path: string; value: unknown };");
|
|
363
|
+
lines.push("");
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
for (const node of Object.values(document.nodes)) {
|
|
367
|
+
if (node.kind === "entity") {
|
|
368
|
+
lines.push(`export interface ${safeIdentifier(node.name)} {`);
|
|
369
|
+
for (const field of node.fields) {
|
|
370
|
+
lines.push(` ${safeIdentifier(field.name)}: ${toTypeScriptType(field.type)};`);
|
|
371
|
+
}
|
|
372
|
+
lines.push("}");
|
|
373
|
+
lines.push("");
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
for (const node of Object.values(document.nodes)) {
|
|
378
|
+
if (node.kind === "action") {
|
|
379
|
+
const inputType = node.input ? toTypeScriptType(node.input) : "unknown";
|
|
380
|
+
const returnType = node.returns ? toTypeScriptType(node.returns) : "FrontierPatchOperation[]";
|
|
381
|
+
lines.push(`export function ${safeIdentifier(node.name)}(`);
|
|
382
|
+
lines.push(" state: unknown,");
|
|
383
|
+
lines.push(` input: ${inputType},`);
|
|
384
|
+
lines.push(" env: Record<string, unknown> = {}");
|
|
385
|
+
lines.push(`): ${returnType} {`);
|
|
386
|
+
lines.push(" void state;");
|
|
387
|
+
lines.push(" void input;");
|
|
388
|
+
lines.push(" void env;");
|
|
389
|
+
lines.push(` return [] as ${returnType};`);
|
|
390
|
+
lines.push("}");
|
|
391
|
+
lines.push("");
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return `${lines.join("\n").trimEnd()}\n`;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function stripHistory(document) {
|
|
399
|
+
const { history: _history, ...rest } = document;
|
|
400
|
+
return rest;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function requireNode(nodes, id) {
|
|
404
|
+
const node = nodes[id];
|
|
405
|
+
if (!node) {
|
|
406
|
+
throw new Error(`Unknown semantic node id: ${id}`);
|
|
407
|
+
}
|
|
408
|
+
return node;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function hasAncestorCycle(nodes, startId) {
|
|
412
|
+
const seen = new Set();
|
|
413
|
+
let current = nodes[startId];
|
|
414
|
+
while (current?.parentId) {
|
|
415
|
+
if (seen.has(current.parentId)) {
|
|
416
|
+
return true;
|
|
417
|
+
}
|
|
418
|
+
seen.add(current.parentId);
|
|
419
|
+
current = nodes[current.parentId];
|
|
420
|
+
}
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function summarizePatch(patch) {
|
|
425
|
+
const nodeIds = [];
|
|
426
|
+
const regions = [];
|
|
427
|
+
const effects = [];
|
|
428
|
+
|
|
429
|
+
for (const operation of patch.operations) {
|
|
430
|
+
if ("id" in operation && typeof operation.id === "string") {
|
|
431
|
+
nodeIds.push(operation.id);
|
|
432
|
+
}
|
|
433
|
+
if (operation.op === "upsertNode") {
|
|
434
|
+
nodeIds.push(operation.node.id);
|
|
435
|
+
for (const region of operation.node.regions ?? []) {
|
|
436
|
+
regions.push(region.id);
|
|
437
|
+
}
|
|
438
|
+
if (operation.node.kind === "action") {
|
|
439
|
+
effects.push(...(operation.node.uses ?? []));
|
|
440
|
+
}
|
|
441
|
+
if (operation.node.kind === "effect") {
|
|
442
|
+
effects.push(operation.node.capability);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (operation.op === "updateNode") {
|
|
446
|
+
if (Array.isArray(operation.set.uses)) {
|
|
447
|
+
effects.push(...operation.set.uses.filter((value) => typeof value === "string"));
|
|
448
|
+
}
|
|
449
|
+
if (typeof operation.set.capability === "string") {
|
|
450
|
+
effects.push(operation.set.capability);
|
|
451
|
+
}
|
|
452
|
+
if (Array.isArray(operation.set.regions)) {
|
|
453
|
+
for (const region of operation.set.regions) {
|
|
454
|
+
if (region && typeof region === "object" && region.access === "effect" && typeof region.id === "string") {
|
|
455
|
+
effects.push(region.id);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
for (const region of operation.touches ?? []) {
|
|
462
|
+
regions.push(region.id);
|
|
463
|
+
if (region.access === "effect") {
|
|
464
|
+
effects.push(region.id);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return {
|
|
470
|
+
nodeIds: unique(nodeIds),
|
|
471
|
+
regions: unique(regions),
|
|
472
|
+
effects: unique(effects)
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function withReplayGate(base, left, right, admission) {
|
|
477
|
+
if (!admission.autoMergeable) {
|
|
478
|
+
return admission;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
try {
|
|
482
|
+
const leftForReplay = stripPatchAdmissionHashes(left);
|
|
483
|
+
const rightForReplay = stripPatchAdmissionHashes(right);
|
|
484
|
+
const leftThenRight = applySemanticPatch(applySemanticPatch(base, leftForReplay), rightForReplay);
|
|
485
|
+
const rightThenLeft = applySemanticPatch(applySemanticPatch(base, rightForReplay), leftForReplay);
|
|
486
|
+
const leftHash = hashDocumentBase(leftThenRight);
|
|
487
|
+
const rightHash = hashDocumentBase(rightThenLeft);
|
|
488
|
+
if (leftHash !== rightHash) {
|
|
489
|
+
return {
|
|
490
|
+
...admission,
|
|
491
|
+
status: "unknown-needs-review",
|
|
492
|
+
autoMergeable: false,
|
|
493
|
+
reasons: [
|
|
494
|
+
...admission.reasons,
|
|
495
|
+
`Replay order is not commutative: left-right ${leftHash}, right-left ${rightHash}`
|
|
496
|
+
]
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
return admission;
|
|
500
|
+
} catch (error) {
|
|
501
|
+
return {
|
|
502
|
+
...admission,
|
|
503
|
+
status: "unknown-needs-review",
|
|
504
|
+
autoMergeable: false,
|
|
505
|
+
reasons: [...admission.reasons, `Replay gate failed: ${error instanceof Error ? error.message : String(error)}`]
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function stripPatchAdmissionHashes(patch) {
|
|
511
|
+
const { baseHash: _baseHash, targetHash: _targetHash, ...rest } = patch;
|
|
512
|
+
return rest;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function collectMergeLaws(document, overlaps) {
|
|
516
|
+
const overlapSet = new Set(overlaps);
|
|
517
|
+
const laws = [];
|
|
518
|
+
|
|
519
|
+
for (const node of Object.values(document.nodes)) {
|
|
520
|
+
if (node.kind === "entity") {
|
|
521
|
+
for (const field of node.fields) {
|
|
522
|
+
if (overlapSet.has(field.id) || overlapSet.has(`${node.name}.${field.name}`)) {
|
|
523
|
+
if (field.merge?.law) {
|
|
524
|
+
laws.push(field.merge.law);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
if (node.kind === "state") {
|
|
530
|
+
for (const collection of node.collections) {
|
|
531
|
+
if (overlapSet.has(collection.id) || overlapSet.has(`${node.name}.${collection.name}`)) {
|
|
532
|
+
if (collection.merge?.law) {
|
|
533
|
+
laws.push(collection.merge.law);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
return laws;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function intersection(left, right) {
|
|
544
|
+
const rightSet = new Set(right);
|
|
545
|
+
return unique(left.filter((item) => rightSet.has(item)));
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function unique(items) {
|
|
549
|
+
return [...new Set(items)];
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function ordinalCompare(left, right) {
|
|
553
|
+
if (left < right) {
|
|
554
|
+
return -1;
|
|
555
|
+
}
|
|
556
|
+
if (left > right) {
|
|
557
|
+
return 1;
|
|
558
|
+
}
|
|
559
|
+
return 0;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function safeIdentifier(name) {
|
|
563
|
+
const identifier = name.replace(/[^A-Za-z0-9_$]/g, "_");
|
|
564
|
+
if (/^[A-Za-z_$]/.test(identifier)) {
|
|
565
|
+
return identifier;
|
|
566
|
+
}
|
|
567
|
+
return `_${identifier}`;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function toTypeScriptType(type) {
|
|
571
|
+
const primitive = {
|
|
572
|
+
Text: "string",
|
|
573
|
+
String: "string",
|
|
574
|
+
Bool: "boolean",
|
|
575
|
+
Boolean: "boolean",
|
|
576
|
+
Int: "number",
|
|
577
|
+
Float: "number",
|
|
578
|
+
Number: "number",
|
|
579
|
+
Instant: "string",
|
|
580
|
+
Json: "unknown",
|
|
581
|
+
Patch: "FrontierPatchOperation[]"
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
if (primitive[type]) {
|
|
585
|
+
return primitive[type];
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const setMatch = /^Set<(.+)>$/.exec(type);
|
|
589
|
+
if (setMatch) {
|
|
590
|
+
return `ReadonlySet<${toTypeScriptType(setMatch[1].trim())}>`;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const listMatch = /^(List|MoveList)<(.+)>$/.exec(type);
|
|
594
|
+
if (listMatch) {
|
|
595
|
+
return `readonly ${toTypeScriptType(listMatch[2].trim())}[]`;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const mapMatch = /^Map<(.+),(.+)>$/.exec(type);
|
|
599
|
+
if (mapMatch) {
|
|
600
|
+
return `ReadonlyMap<${toTypeScriptType(mapMatch[1].trim())}, ${toTypeScriptType(mapMatch[2].trim())}>`;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (/^\{.*\}$/.test(type)) {
|
|
604
|
+
return "Record<string, unknown>";
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
return safeIdentifier(type);
|
|
608
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import {
|
|
2
|
+
actionNode,
|
|
3
|
+
classifyMerge,
|
|
4
|
+
createDocument,
|
|
5
|
+
createPatch,
|
|
6
|
+
emitTypeScript,
|
|
7
|
+
entityNode,
|
|
8
|
+
hashDocumentBase,
|
|
9
|
+
stateNode
|
|
10
|
+
} from "../dist/index.js";
|
|
11
|
+
|
|
12
|
+
const todo = entityNode({
|
|
13
|
+
id: "ent_todo",
|
|
14
|
+
name: "Todo",
|
|
15
|
+
fields: [
|
|
16
|
+
{ id: "field_todo_id", name: "id", type: "TodoId", key: true },
|
|
17
|
+
{ id: "field_todo_title", name: "title", type: "Text", merge: { kind: "conflict" } },
|
|
18
|
+
{
|
|
19
|
+
id: "field_todo_tags",
|
|
20
|
+
name: "tags",
|
|
21
|
+
type: "Set<Text>",
|
|
22
|
+
merge: { kind: "union", law: "semilattice" }
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const state = stateNode({
|
|
28
|
+
id: "state_todo",
|
|
29
|
+
name: "TodoDb",
|
|
30
|
+
collections: [
|
|
31
|
+
{
|
|
32
|
+
id: "collection_todos",
|
|
33
|
+
name: "todos",
|
|
34
|
+
type: "Map<TodoId, Todo>",
|
|
35
|
+
merge: { kind: "byKey", law: "commutative" }
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const addTodo = actionNode({
|
|
41
|
+
id: "action_add_todo",
|
|
42
|
+
name: "addTodo",
|
|
43
|
+
input: "{ title: Text }",
|
|
44
|
+
returns: "Patch",
|
|
45
|
+
reads: ["TodoDb.todos"],
|
|
46
|
+
writes: ["TodoDb.todos"],
|
|
47
|
+
uses: ["Clock"]
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const document = createDocument({
|
|
51
|
+
id: "mod_todo",
|
|
52
|
+
name: "TodoApp",
|
|
53
|
+
nodes: [todo, state, addTodo]
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const baseHash = hashDocumentBase(document);
|
|
57
|
+
const left = createPatch({
|
|
58
|
+
id: "patch_left_tags",
|
|
59
|
+
baseHash,
|
|
60
|
+
operations: [
|
|
61
|
+
{
|
|
62
|
+
op: "updateNode",
|
|
63
|
+
id: "ent_todo",
|
|
64
|
+
set: { metadata: { left: true } },
|
|
65
|
+
touches: [{ id: "field_todo_tags", access: "write" }]
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
});
|
|
69
|
+
const right = createPatch({
|
|
70
|
+
id: "patch_right_tags",
|
|
71
|
+
baseHash,
|
|
72
|
+
operations: [
|
|
73
|
+
{
|
|
74
|
+
op: "updateNode",
|
|
75
|
+
id: "ent_todo",
|
|
76
|
+
set: { metadata: { right: true } },
|
|
77
|
+
touches: [{ id: "field_todo_tags", access: "write" }]
|
|
78
|
+
}
|
|
79
|
+
]
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
console.log(classifyMerge(document, left, right));
|
|
83
|
+
console.log(emitTypeScript(document));
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@shapeshift-labs/frontier-lang",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Patch-native semantic source kernel for replayable programs, merge admission, and generated TypeScript projections.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"examples",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "node scripts/build.mjs",
|
|
23
|
+
"test": "npm run build && node test/smoke.mjs",
|
|
24
|
+
"prepare": "npm run build",
|
|
25
|
+
"prepack": "npm test"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"frontier",
|
|
29
|
+
"semantic-merge",
|
|
30
|
+
"patch",
|
|
31
|
+
"replay",
|
|
32
|
+
"compiler",
|
|
33
|
+
"typescript"
|
|
34
|
+
],
|
|
35
|
+
"author": "ShapeShift Labs",
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/siliconjungle/-shapeshift-labs-frontier-lang.git"
|
|
40
|
+
},
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/siliconjungle/-shapeshift-labs-frontier-lang/issues"
|
|
43
|
+
},
|
|
44
|
+
"homepage": "https://github.com/siliconjungle/-shapeshift-labs-frontier-lang#readme",
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
}
|
|
48
|
+
}
|