@aetherwing/fcp-core 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.
Files changed (50) hide show
  1. package/dist/event-log.d.ts +78 -0
  2. package/dist/event-log.d.ts.map +1 -0
  3. package/dist/event-log.js +184 -0
  4. package/dist/event-log.js.map +1 -0
  5. package/dist/formatter.d.ts +19 -0
  6. package/dist/formatter.d.ts.map +1 -0
  7. package/dist/formatter.js +64 -0
  8. package/dist/formatter.js.map +1 -0
  9. package/dist/index.d.ts +8 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +15 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/parsed-op.d.ts +32 -0
  14. package/dist/parsed-op.d.ts.map +1 -0
  15. package/dist/parsed-op.js +41 -0
  16. package/dist/parsed-op.js.map +1 -0
  17. package/dist/server.d.ts +71 -0
  18. package/dist/server.d.ts.map +1 -0
  19. package/dist/server.js +140 -0
  20. package/dist/server.js.map +1 -0
  21. package/dist/session.d.ts +40 -0
  22. package/dist/session.d.ts.map +1 -0
  23. package/dist/session.js +142 -0
  24. package/dist/session.js.map +1 -0
  25. package/dist/tokenizer.d.ts +26 -0
  26. package/dist/tokenizer.d.ts.map +1 -0
  27. package/dist/tokenizer.js +114 -0
  28. package/dist/tokenizer.js.map +1 -0
  29. package/dist/verb-registry.d.ts +41 -0
  30. package/dist/verb-registry.d.ts.map +1 -0
  31. package/dist/verb-registry.js +65 -0
  32. package/dist/verb-registry.js.map +1 -0
  33. package/package.json +30 -0
  34. package/src/event-log.ts +209 -0
  35. package/src/formatter.ts +70 -0
  36. package/src/index.ts +40 -0
  37. package/src/parsed-op.ts +64 -0
  38. package/src/server.ts +241 -0
  39. package/src/session.ts +163 -0
  40. package/src/tokenizer.ts +108 -0
  41. package/src/verb-registry.ts +84 -0
  42. package/tests/event-log.test.ts +177 -0
  43. package/tests/formatter.test.ts +61 -0
  44. package/tests/parsed-op.test.ts +95 -0
  45. package/tests/server.test.ts +94 -0
  46. package/tests/session.test.ts +210 -0
  47. package/tests/tokenizer.test.ts +144 -0
  48. package/tests/verb-registry.test.ts +76 -0
  49. package/tsconfig.json +19 -0
  50. package/vitest.config.ts +7 -0
package/src/session.ts ADDED
@@ -0,0 +1,163 @@
1
+ import { EventLog } from "./event-log.js";
2
+ import { tokenize, isKeyValue, parseKeyValue } from "./tokenizer.js";
3
+
4
+ /**
5
+ * Hooks that a domain must implement for session lifecycle operations.
6
+ */
7
+ export interface SessionHooks<Model> {
8
+ /** Create a new empty model with the given title/params. */
9
+ onNew(params: Record<string, string>): Model;
10
+ /** Open a model from a file path. */
11
+ onOpen(path: string): Promise<Model>;
12
+ /** Save a model to a file path. */
13
+ onSave(model: Model, path: string): Promise<void>;
14
+ /** Rebuild any derived indices after undo/redo. */
15
+ onRebuildIndices(model: Model): void;
16
+ /** Return a compact digest string for drift detection. */
17
+ getDigest(model: Model): string;
18
+ }
19
+
20
+ /**
21
+ * Routes session-level actions (new, open, save, checkpoint, undo, redo)
22
+ * to the appropriate handler. Framework handles checkpoint/undo/redo;
23
+ * domain handles new/open/save via hooks.
24
+ */
25
+ export class SessionDispatcher<Model, Event> {
26
+ private _model: Model | null = null;
27
+ private _filePath: string | null = null;
28
+ private hooks: SessionHooks<Model>;
29
+ private eventLog: EventLog<Event>;
30
+ private reverseEvent: (event: Event, model: Model) => void;
31
+ private replayEvent: (event: Event, model: Model) => void;
32
+
33
+ constructor(
34
+ hooks: SessionHooks<Model>,
35
+ eventLog: EventLog<Event>,
36
+ options: {
37
+ reverseEvent: (event: Event, model: Model) => void;
38
+ replayEvent: (event: Event, model: Model) => void;
39
+ },
40
+ ) {
41
+ this.hooks = hooks;
42
+ this.eventLog = eventLog;
43
+ this.reverseEvent = options.reverseEvent;
44
+ this.replayEvent = options.replayEvent;
45
+ }
46
+
47
+ /**
48
+ * Dispatch a session action string. Returns a result message.
49
+ */
50
+ async dispatch(action: string): Promise<string> {
51
+ const tokens = tokenize(action);
52
+ if (tokens.length === 0) return "empty action";
53
+
54
+ const cmd = tokens[0].toLowerCase();
55
+
56
+ switch (cmd) {
57
+ case "new": {
58
+ const params: Record<string, string> = {};
59
+ const positionals: string[] = [];
60
+ for (let i = 1; i < tokens.length; i++) {
61
+ if (isKeyValue(tokens[i])) {
62
+ const { key, value } = parseKeyValue(tokens[i]);
63
+ params[key] = value;
64
+ } else {
65
+ positionals.push(tokens[i]);
66
+ }
67
+ }
68
+ if (positionals.length > 0) {
69
+ params["title"] = positionals[0];
70
+ }
71
+ this._model = this.hooks.onNew(params);
72
+ this._filePath = null;
73
+ const title = params["title"] ?? "Untitled";
74
+ return `new "${title}" created`;
75
+ }
76
+
77
+ case "open": {
78
+ const path = tokens[1];
79
+ if (!path) return "open requires a file path";
80
+ try {
81
+ this._model = await this.hooks.onOpen(path);
82
+ this._filePath = path;
83
+ return `opened "${path}"`;
84
+ } catch (e: unknown) {
85
+ const msg = e instanceof Error ? e.message : String(e);
86
+ return `error: ${msg}`;
87
+ }
88
+ }
89
+
90
+ case "save": {
91
+ if (!this._model) return "error: no model to save";
92
+ let savePath = this._filePath;
93
+ for (let i = 1; i < tokens.length; i++) {
94
+ if (isKeyValue(tokens[i])) {
95
+ const { key, value } = parseKeyValue(tokens[i]);
96
+ if (key === "as") savePath = value;
97
+ }
98
+ }
99
+ if (!savePath) return "error: no file path. Use save as:./file";
100
+ try {
101
+ await this.hooks.onSave(this._model, savePath);
102
+ this._filePath = savePath;
103
+ return `saved "${savePath}"`;
104
+ } catch (e: unknown) {
105
+ const msg = e instanceof Error ? e.message : String(e);
106
+ return `error: ${msg}`;
107
+ }
108
+ }
109
+
110
+ case "checkpoint": {
111
+ const name = tokens[1];
112
+ if (!name) return "checkpoint requires a name";
113
+ this.eventLog.checkpoint(name);
114
+ return `checkpoint "${name}" created`;
115
+ }
116
+
117
+ case "undo": {
118
+ if (!this._model) return "nothing to undo";
119
+ // undo to:NAME or undo [count]
120
+ if (tokens.length >= 2 && tokens[1].startsWith("to:")) {
121
+ const name = tokens[1].slice(3);
122
+ if (!name) return "undo to: requires a checkpoint name";
123
+ const events = this.eventLog.undoTo(name);
124
+ if (!events) return `cannot undo to "${name}"`;
125
+ for (const ev of events) {
126
+ this.reverseEvent(ev, this._model);
127
+ }
128
+ this.hooks.onRebuildIndices(this._model);
129
+ return `undone ${events.length} event${events.length !== 1 ? "s" : ""} to checkpoint "${name}"`;
130
+ }
131
+ const events = this.eventLog.undo();
132
+ if (events.length === 0) return "nothing to undo";
133
+ for (const ev of events) {
134
+ this.reverseEvent(ev, this._model);
135
+ }
136
+ this.hooks.onRebuildIndices(this._model);
137
+ return `undone ${events.length} event${events.length !== 1 ? "s" : ""}`;
138
+ }
139
+
140
+ case "redo": {
141
+ if (!this._model) return "nothing to redo";
142
+ const events = this.eventLog.redo();
143
+ if (events.length === 0) return "nothing to redo";
144
+ for (const ev of events) {
145
+ this.replayEvent(ev, this._model);
146
+ }
147
+ this.hooks.onRebuildIndices(this._model);
148
+ return `redone ${events.length} event${events.length !== 1 ? "s" : ""}`;
149
+ }
150
+
151
+ default:
152
+ return `unknown session action "${cmd}"`;
153
+ }
154
+ }
155
+
156
+ get model(): Model | null {
157
+ return this._model;
158
+ }
159
+
160
+ get filePath(): string | null {
161
+ return this._filePath;
162
+ }
163
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Tokenize an operation string by whitespace, respecting quoted strings.
3
+ * "add svc \"Auth Service\" theme:blue" -> ["add", "svc", "Auth Service", "theme:blue"]
4
+ */
5
+ export function tokenize(input: string): string[] {
6
+ const tokens: string[] = [];
7
+ let i = 0;
8
+ const len = input.length;
9
+
10
+ while (i < len) {
11
+ // Skip whitespace
12
+ while (i < len && input[i] === " ") i++;
13
+ if (i >= len) break;
14
+
15
+ if (input[i] === '"') {
16
+ // Quoted string
17
+ i++; // skip opening quote
18
+ let token = "";
19
+ while (i < len && input[i] !== '"') {
20
+ if (input[i] === "\\" && i + 1 < len) {
21
+ const next = input[i + 1];
22
+ if (next === "n") {
23
+ token += "\n";
24
+ i += 2;
25
+ } else {
26
+ i++;
27
+ token += input[i];
28
+ i++;
29
+ }
30
+ } else {
31
+ token += input[i];
32
+ i++;
33
+ }
34
+ }
35
+ if (i < len) i++; // skip closing quote
36
+ tokens.push(token);
37
+ } else {
38
+ // Unquoted token — handle embedded quotes (e.g., key:"value")
39
+ let token = "";
40
+ while (i < len && input[i] !== " ") {
41
+ if (input[i] === '"') {
42
+ // Embedded quoted value
43
+ i++; // skip opening quote
44
+ while (i < len && input[i] !== '"') {
45
+ if (input[i] === "\\" && i + 1 < len) {
46
+ const next = input[i + 1];
47
+ if (next === "n") {
48
+ token += "\n";
49
+ i += 2;
50
+ } else {
51
+ i++;
52
+ token += input[i];
53
+ i++;
54
+ }
55
+ } else {
56
+ token += input[i];
57
+ i++;
58
+ }
59
+ }
60
+ if (i < len) i++; // skip closing quote
61
+ } else {
62
+ token += input[i];
63
+ i++;
64
+ }
65
+ }
66
+ // Convert literal \n in unquoted tokens to actual newlines
67
+ tokens.push(token.replace(/\\n/g, "\n"));
68
+ }
69
+ }
70
+
71
+ return tokens;
72
+ }
73
+
74
+ /**
75
+ * Check if a token is a key:value pair.
76
+ * Must contain ":" but not start with "@" (selectors) and not be an arrow.
77
+ */
78
+ export function isKeyValue(token: string): boolean {
79
+ if (token.startsWith("@")) return false;
80
+ if (isArrow(token)) return false;
81
+ const colonIdx = token.indexOf(":");
82
+ return colonIdx > 0 && colonIdx < token.length - 1;
83
+ }
84
+
85
+ /**
86
+ * Parse a key:value token. The value may include colons (e.g., "style:orthogonal").
87
+ */
88
+ export function parseKeyValue(token: string): { key: string; value: string } {
89
+ const colonIdx = token.indexOf(":");
90
+ return {
91
+ key: token.slice(0, colonIdx),
92
+ value: token.slice(colonIdx + 1),
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Check if a token is an arrow operator.
98
+ */
99
+ export function isArrow(token: string): boolean {
100
+ return token === "->" || token === "<->" || token === "--";
101
+ }
102
+
103
+ /**
104
+ * Check if a token is a selector (@-prefixed).
105
+ */
106
+ export function isSelector(token: string): boolean {
107
+ return token.startsWith("@");
108
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Specification for a single verb in an FCP protocol.
3
+ */
4
+ export interface VerbSpec {
5
+ verb: string;
6
+ syntax: string;
7
+ category: string;
8
+ params?: string[];
9
+ description?: string;
10
+ }
11
+
12
+ /**
13
+ * Registry of verb specifications. Provides lookup by verb name and
14
+ * reference card generation grouped by category.
15
+ */
16
+ export class VerbRegistry {
17
+ private specs = new Map<string, VerbSpec>();
18
+ private categories = new Map<string, VerbSpec[]>();
19
+
20
+ /**
21
+ * Register a single verb specification.
22
+ */
23
+ register(spec: VerbSpec): void {
24
+ this.specs.set(spec.verb, spec);
25
+ const list = this.categories.get(spec.category) ?? [];
26
+ list.push(spec);
27
+ this.categories.set(spec.category, list);
28
+ }
29
+
30
+ /**
31
+ * Register multiple verb specifications at once.
32
+ */
33
+ registerMany(specs: VerbSpec[]): void {
34
+ for (const spec of specs) {
35
+ this.register(spec);
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Look up a verb specification by name.
41
+ */
42
+ lookup(verb: string): VerbSpec | undefined {
43
+ return this.specs.get(verb);
44
+ }
45
+
46
+ /**
47
+ * Generate a reference card string grouped by category.
48
+ * Optional `sections` adds extra static sections (e.g., domain-specific
49
+ * reference material) appended after the verb listing.
50
+ */
51
+ generateReferenceCard(sections?: Record<string, string>): string {
52
+ const lines: string[] = [];
53
+
54
+ for (const [category, specs] of this.categories) {
55
+ lines.push(`${category.toUpperCase()}:`);
56
+ for (const spec of specs) {
57
+ lines.push(` ${spec.syntax}`);
58
+ }
59
+ lines.push("");
60
+ }
61
+
62
+ if (sections) {
63
+ for (const [title, content] of Object.entries(sections)) {
64
+ lines.push(`${title.toUpperCase()}:`);
65
+ lines.push(content);
66
+ lines.push("");
67
+ }
68
+ }
69
+
70
+ // Remove trailing empty line
71
+ while (lines.length > 0 && lines[lines.length - 1] === "") {
72
+ lines.pop();
73
+ }
74
+
75
+ return lines.join("\n");
76
+ }
77
+
78
+ /**
79
+ * All registered verb specifications.
80
+ */
81
+ get verbs(): VerbSpec[] {
82
+ return [...this.specs.values()];
83
+ }
84
+ }
@@ -0,0 +1,177 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { EventLog } from "../src/event-log.js";
3
+
4
+ describe("EventLog", () => {
5
+ it("appends events and advances cursor", () => {
6
+ const log = new EventLog<string>();
7
+ log.append("a");
8
+ log.append("b");
9
+ expect(log.cursor).toBe(2);
10
+ expect(log.length).toBe(2);
11
+ });
12
+
13
+ it("returns recent events in chronological order", () => {
14
+ const log = new EventLog<string>();
15
+ log.append("a");
16
+ log.append("b");
17
+ log.append("c");
18
+ expect(log.recent(2)).toEqual(["b", "c"]);
19
+ expect(log.recent()).toEqual(["a", "b", "c"]);
20
+ });
21
+
22
+ describe("undo", () => {
23
+ it("returns most recent event", () => {
24
+ const log = new EventLog<string>();
25
+ log.append("a");
26
+ log.append("b");
27
+ const undone = log.undo();
28
+ expect(undone).toEqual(["b"]);
29
+ expect(log.cursor).toBe(1);
30
+ });
31
+
32
+ it("returns multiple events in reverse order", () => {
33
+ const log = new EventLog<string>();
34
+ log.append("a");
35
+ log.append("b");
36
+ log.append("c");
37
+ const undone = log.undo(2);
38
+ expect(undone).toEqual(["c", "b"]);
39
+ expect(log.cursor).toBe(1);
40
+ });
41
+
42
+ it("returns empty array when nothing to undo", () => {
43
+ const log = new EventLog<string>();
44
+ expect(log.undo()).toEqual([]);
45
+ });
46
+
47
+ it("skips checkpoint sentinels", () => {
48
+ const log = new EventLog<string>();
49
+ log.append("a");
50
+ log.checkpoint("cp1");
51
+ log.append("b");
52
+ const undone = log.undo(2);
53
+ expect(undone).toEqual(["b", "a"]);
54
+ });
55
+ });
56
+
57
+ describe("redo", () => {
58
+ it("re-applies undone events in forward order", () => {
59
+ const log = new EventLog<string>();
60
+ log.append("a");
61
+ log.append("b");
62
+ log.undo(2);
63
+ const redone = log.redo(2);
64
+ expect(redone).toEqual(["a", "b"]);
65
+ expect(log.cursor).toBe(2);
66
+ });
67
+
68
+ it("returns empty array when nothing to redo", () => {
69
+ const log = new EventLog<string>();
70
+ log.append("a");
71
+ expect(log.redo()).toEqual([]);
72
+ });
73
+
74
+ it("skips checkpoint sentinels during redo", () => {
75
+ const log = new EventLog<string>();
76
+ log.append("a");
77
+ log.checkpoint("cp1");
78
+ log.append("b");
79
+ log.undo(2);
80
+ const redone = log.redo(2);
81
+ expect(redone).toEqual(["a", "b"]);
82
+ });
83
+ });
84
+
85
+ describe("redo tail truncation", () => {
86
+ it("truncates redo history on new append", () => {
87
+ const log = new EventLog<string>();
88
+ log.append("a");
89
+ log.append("b");
90
+ log.undo(); // cursor at 1, "b" is in redo tail
91
+ log.append("c"); // should truncate "b"
92
+ expect(log.length).toBe(2); // "a", "c"
93
+ expect(log.redo()).toEqual([]); // no redo available
94
+ expect(log.recent()).toEqual(["a", "c"]);
95
+ });
96
+ });
97
+
98
+ describe("checkpoint", () => {
99
+ it("creates a checkpoint that can be undone to", () => {
100
+ const log = new EventLog<string>();
101
+ log.append("a");
102
+ log.checkpoint("v1");
103
+ log.append("b");
104
+ log.append("c");
105
+ const undone = log.undoTo("v1");
106
+ expect(undone).toEqual(["c", "b"]);
107
+ // cursor should be at checkpoint position
108
+ expect(log.recent()).toEqual(["a"]);
109
+ });
110
+
111
+ it("returns null for unknown checkpoint", () => {
112
+ const log = new EventLog<string>();
113
+ log.append("a");
114
+ expect(log.undoTo("nonexistent")).toBeNull();
115
+ });
116
+
117
+ it("returns null if checkpoint is at/beyond cursor", () => {
118
+ const log = new EventLog<string>();
119
+ log.append("a");
120
+ log.checkpoint("v1");
121
+ // Undo past the checkpoint
122
+ log.undo(); // undo "a" (checkpoint is skipped)
123
+ // Now cursor is before the checkpoint, so undoTo should still work
124
+ // Actually: checkpoint is at position 1, cursor after undo of "a" is at 0
125
+ // So target(1) >= cursor(0) is false, it should work
126
+ // Let me test a different scenario where checkpoint IS at cursor
127
+ const log2 = new EventLog<string>();
128
+ log2.checkpoint("v1"); // checkpoint at position 0
129
+ log2.append("a");
130
+ // cursor is 2, checkpoint is 0 — should work
131
+ const result = log2.undoTo("v1");
132
+ expect(result).toEqual(["a"]);
133
+ });
134
+
135
+ it("removes checkpoints beyond cursor on truncation", () => {
136
+ const log = new EventLog<string>();
137
+ log.append("a");
138
+ log.checkpoint("v1");
139
+ log.append("b");
140
+ log.undo(2); // undo b and a, cursor before checkpoint
141
+ log.append("x"); // truncates everything including checkpoint
142
+ expect(log.undoTo("v1")).toBeNull(); // checkpoint gone
143
+ });
144
+ });
145
+
146
+ describe("cursor management", () => {
147
+ it("starts at 0", () => {
148
+ const log = new EventLog<string>();
149
+ expect(log.cursor).toBe(0);
150
+ });
151
+
152
+ it("advances on append", () => {
153
+ const log = new EventLog<string>();
154
+ log.append("a");
155
+ expect(log.cursor).toBe(1);
156
+ log.append("b");
157
+ expect(log.cursor).toBe(2);
158
+ });
159
+
160
+ it("moves back on undo", () => {
161
+ const log = new EventLog<string>();
162
+ log.append("a");
163
+ log.append("b");
164
+ log.undo();
165
+ expect(log.cursor).toBe(1);
166
+ });
167
+
168
+ it("moves forward on redo", () => {
169
+ const log = new EventLog<string>();
170
+ log.append("a");
171
+ log.append("b");
172
+ log.undo();
173
+ log.redo();
174
+ expect(log.cursor).toBe(2);
175
+ });
176
+ });
177
+ });
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { formatResult, suggest } from "../src/formatter.js";
3
+
4
+ describe("formatResult", () => {
5
+ it("formats success with prefix", () => {
6
+ expect(formatResult(true, "svc AuthService", "+")).toBe("+ svc AuthService");
7
+ });
8
+
9
+ it("formats success without prefix", () => {
10
+ expect(formatResult(true, "done")).toBe("done");
11
+ });
12
+
13
+ it("formats error", () => {
14
+ expect(formatResult(false, "something broke")).toBe("ERROR: something broke");
15
+ });
16
+
17
+ it("formats error ignoring prefix", () => {
18
+ expect(formatResult(false, "bad input", "+")).toBe("ERROR: bad input");
19
+ });
20
+
21
+ it("handles standard prefix conventions", () => {
22
+ expect(formatResult(true, "AuthService", "+")).toBe("+ AuthService"); // created
23
+ expect(formatResult(true, "edge A->B", "~")).toBe("~ edge A->B"); // modified
24
+ expect(formatResult(true, "styled A", "*")).toBe("* styled A"); // changed
25
+ expect(formatResult(true, "A", "-")).toBe("- A"); // removed
26
+ expect(formatResult(true, "group Backend", "!")).toBe("! group Backend"); // meta
27
+ expect(formatResult(true, "layout", "@")).toBe("@ layout"); // bulk
28
+ });
29
+ });
30
+
31
+ describe("suggest", () => {
32
+ const candidates = ["add", "remove", "connect", "style", "label", "badge"];
33
+
34
+ it("suggests exact match", () => {
35
+ expect(suggest("add", candidates)).toBe("add");
36
+ });
37
+
38
+ it("suggests for single-char typo", () => {
39
+ expect(suggest("ad", candidates)).toBe("add");
40
+ });
41
+
42
+ it("suggests for transposition", () => {
43
+ expect(suggest("styel", candidates)).toBe("style");
44
+ });
45
+
46
+ it("suggests for substitution", () => {
47
+ expect(suggest("labek", candidates)).toBe("label");
48
+ });
49
+
50
+ it("returns null for distant strings", () => {
51
+ expect(suggest("zzzzzzz", candidates)).toBeNull();
52
+ });
53
+
54
+ it("returns null for empty candidates", () => {
55
+ expect(suggest("test", [])).toBeNull();
56
+ });
57
+
58
+ it("picks closest when multiple are close", () => {
59
+ expect(suggest("bade", candidates)).toBe("badge");
60
+ });
61
+ });
@@ -0,0 +1,95 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parseOp, isParseError } from "../src/parsed-op.js";
3
+
4
+ describe("parseOp", () => {
5
+ it("parses a simple verb with positionals", () => {
6
+ const result = parseOp("add svc AuthService");
7
+ expect(isParseError(result)).toBe(false);
8
+ if (isParseError(result)) return;
9
+ expect(result.verb).toBe("add");
10
+ expect(result.positionals).toEqual(["svc", "AuthService"]);
11
+ expect(result.params).toEqual({});
12
+ expect(result.selectors).toEqual([]);
13
+ });
14
+
15
+ it("extracts key:value params", () => {
16
+ const result = parseOp("add svc AuthService theme:blue near:Gateway");
17
+ if (isParseError(result)) return;
18
+ expect(result.verb).toBe("add");
19
+ expect(result.positionals).toEqual(["svc", "AuthService"]);
20
+ expect(result.params).toEqual({ theme: "blue", near: "Gateway" });
21
+ });
22
+
23
+ it("extracts selectors", () => {
24
+ const result = parseOp("remove @type:db @recent:3");
25
+ if (isParseError(result)) return;
26
+ expect(result.verb).toBe("remove");
27
+ expect(result.selectors).toEqual(["@type:db", "@recent:3"]);
28
+ expect(result.positionals).toEqual([]);
29
+ });
30
+
31
+ it("handles mixed token types", () => {
32
+ const result = parseOp("style @type:svc fill:#ff0000 bold");
33
+ if (isParseError(result)) return;
34
+ expect(result.verb).toBe("style");
35
+ expect(result.selectors).toEqual(["@type:svc"]);
36
+ expect(result.params).toEqual({ fill: "#ff0000" });
37
+ expect(result.positionals).toEqual(["bold"]);
38
+ });
39
+
40
+ it("lowercases the verb", () => {
41
+ const result = parseOp("ADD svc Test");
42
+ if (isParseError(result)) return;
43
+ expect(result.verb).toBe("add");
44
+ });
45
+
46
+ it("preserves original input as raw", () => {
47
+ const result = parseOp(" add svc Test ");
48
+ if (isParseError(result)) return;
49
+ expect(result.raw).toBe("add svc Test");
50
+ });
51
+
52
+ it("handles quoted positionals", () => {
53
+ const result = parseOp('label Gateway "API Gateway v2"');
54
+ if (isParseError(result)) return;
55
+ expect(result.positionals).toEqual(["Gateway", "API Gateway v2"]);
56
+ });
57
+
58
+ it("returns error for empty input", () => {
59
+ const result = parseOp("");
60
+ expect(isParseError(result)).toBe(true);
61
+ if (!isParseError(result)) return;
62
+ expect(result.error).toBe("empty operation");
63
+ });
64
+
65
+ it("returns error for whitespace-only input", () => {
66
+ const result = parseOp(" ");
67
+ expect(isParseError(result)).toBe(true);
68
+ });
69
+
70
+ it("handles arrows as positionals", () => {
71
+ const result = parseOp("connect A -> B");
72
+ if (isParseError(result)) return;
73
+ expect(result.positionals).toEqual(["A", "->", "B"]);
74
+ });
75
+
76
+ it("handles verb-only input", () => {
77
+ const result = parseOp("undo");
78
+ if (isParseError(result)) return;
79
+ expect(result.verb).toBe("undo");
80
+ expect(result.positionals).toEqual([]);
81
+ expect(result.params).toEqual({});
82
+ expect(result.selectors).toEqual([]);
83
+ });
84
+ });
85
+
86
+ describe("isParseError", () => {
87
+ it("returns true for errors", () => {
88
+ expect(isParseError({ success: false, error: "test", raw: "" })).toBe(true);
89
+ });
90
+
91
+ it("returns false for valid ops", () => {
92
+ const result = parseOp("add test");
93
+ expect(isParseError(result)).toBe(false);
94
+ });
95
+ });