@sigil-engine/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 (48) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/dist/cache.d.ts +24 -0
  3. package/dist/cache.d.ts.map +1 -0
  4. package/dist/cache.js +97 -0
  5. package/dist/cache.js.map +1 -0
  6. package/dist/diff.d.ts +34 -0
  7. package/dist/diff.d.ts.map +1 -0
  8. package/dist/diff.js +319 -0
  9. package/dist/diff.js.map +1 -0
  10. package/dist/extract.d.ts +27 -0
  11. package/dist/extract.d.ts.map +1 -0
  12. package/dist/extract.js +162 -0
  13. package/dist/extract.js.map +1 -0
  14. package/dist/index.d.ts +26 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +21 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/plugin.d.ts +103 -0
  19. package/dist/plugin.d.ts.map +1 -0
  20. package/dist/plugin.js +47 -0
  21. package/dist/plugin.js.map +1 -0
  22. package/dist/safety.d.ts +25 -0
  23. package/dist/safety.d.ts.map +1 -0
  24. package/dist/safety.js +139 -0
  25. package/dist/safety.js.map +1 -0
  26. package/dist/schema.d.ts +184 -0
  27. package/dist/schema.d.ts.map +1 -0
  28. package/dist/schema.js +33 -0
  29. package/dist/schema.js.map +1 -0
  30. package/dist/serialize.d.ts +16 -0
  31. package/dist/serialize.d.ts.map +1 -0
  32. package/dist/serialize.js +75 -0
  33. package/dist/serialize.js.map +1 -0
  34. package/package.json +35 -0
  35. package/src/cache.ts +133 -0
  36. package/src/diff.ts +421 -0
  37. package/src/extract.ts +196 -0
  38. package/src/index.ts +94 -0
  39. package/src/plugin.ts +186 -0
  40. package/src/safety.ts +185 -0
  41. package/src/schema.ts +270 -0
  42. package/src/serialize.ts +97 -0
  43. package/tests/cache.test.ts +47 -0
  44. package/tests/diff.test.ts +222 -0
  45. package/tests/plugin.test.ts +107 -0
  46. package/tests/schema.test.ts +132 -0
  47. package/tests/serialize.test.ts +92 -0
  48. package/tsconfig.json +20 -0
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Context document serialization — YAML and JSON.
3
+ *
4
+ * YAML is the primary human-readable format.
5
+ * JSON is the machine-consumption format.
6
+ *
7
+ * _meta anchors are serialized as compact flow mappings for readability.
8
+ */
9
+
10
+ import { stringify, parse } from "yaml";
11
+ import { readFileSync, writeFileSync } from "fs";
12
+ import type { ContextDocument } from "./schema.js";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Serialize
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export function serializeYaml(doc: ContextDocument): string {
19
+ const header = `# context.yaml — generated by sigil extract\n# ${doc._extraction.extracted_at}\n\n`;
20
+
21
+ const yamlStr = stringify(doc, {
22
+ lineWidth: 120,
23
+ defaultKeyType: "PLAIN",
24
+ defaultStringType: "QUOTE_DOUBLE",
25
+ nullStr: "",
26
+ sortMapEntries: false,
27
+ flowCollectionPadding: true,
28
+ });
29
+
30
+ // Post-process: compact _meta blocks that are simple (no nested arrays/objects).
31
+ // _meta blocks with tags arrays are left in standard YAML format.
32
+ const processed = yamlStr.replace(
33
+ /^(\s+)_meta:\n((?:\1\s+[^\s].*\n)*)/gm,
34
+ (match: string, indent: string, body: string) => {
35
+ // If body contains array items (- "...") or nested objects, skip compaction
36
+ if (body.includes("\n" + indent + " - ") || body.includes("\n" + indent + " ")) {
37
+ return match; // Leave as-is
38
+ }
39
+
40
+ const lines = body.trim().split("\n").map((l: string) => l.trim()).filter((l: string) => l.length > 0);
41
+
42
+ // Only compact if all lines are simple key: value pairs
43
+ const pairs: string[] = [];
44
+ for (const line of lines) {
45
+ const colonIdx = line.indexOf(":");
46
+ if (colonIdx === -1) return match; // Not a simple pair, abort
47
+ const key = line.slice(0, colonIdx).trim();
48
+ let val = line.slice(colonIdx + 1).trim();
49
+ if (val.startsWith('"') && val.endsWith('"')) {
50
+ val = val.slice(1, -1);
51
+ }
52
+ pairs.push(`${key}: "${val}"`);
53
+ }
54
+
55
+ return `${indent}_meta: { ${pairs.join(", ")} }\n`;
56
+ },
57
+ );
58
+
59
+ return header + processed;
60
+ }
61
+
62
+ export function serializeJson(doc: ContextDocument): string {
63
+ return JSON.stringify(doc, null, 2);
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Deserialize
68
+ // ---------------------------------------------------------------------------
69
+
70
+ export function deserializeYaml(yamlStr: string): ContextDocument {
71
+ return parse(yamlStr) as ContextDocument;
72
+ }
73
+
74
+ export function deserializeJson(jsonStr: string): ContextDocument {
75
+ return JSON.parse(jsonStr) as ContextDocument;
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // File I/O helpers
80
+ // ---------------------------------------------------------------------------
81
+
82
+ export function writeContextFile(
83
+ doc: ContextDocument,
84
+ outputPath: string,
85
+ format: "yaml" | "json" = "yaml",
86
+ ): void {
87
+ const content = format === "json" ? serializeJson(doc) : serializeYaml(doc);
88
+ writeFileSync(outputPath, content, "utf-8");
89
+ }
90
+
91
+ export function readContextFile(inputPath: string): ContextDocument {
92
+ const content = readFileSync(inputPath, "utf-8");
93
+ if (inputPath.endsWith(".json")) {
94
+ return deserializeJson(content);
95
+ }
96
+ return deserializeYaml(content);
97
+ }
@@ -0,0 +1,47 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { getChangedFiles, type FileCache } from "../src/cache.js";
3
+
4
+ describe("Cache", () => {
5
+ describe("getChangedFiles", () => {
6
+ it("identifies added files", () => {
7
+ const cache: FileCache = {
8
+ files: {},
9
+ last_full_extract: "2026-01-01T00:00:00Z",
10
+ };
11
+
12
+ const result = getChangedFiles("/tmp", ["new-file.ts"], cache);
13
+
14
+ expect(result.added).toEqual(["new-file.ts"]);
15
+ expect(result.changed).toEqual([]);
16
+ expect(result.unchanged).toEqual([]);
17
+ expect(result.removed).toEqual([]);
18
+ });
19
+
20
+ it("identifies removed files", () => {
21
+ const cache: FileCache = {
22
+ files: {
23
+ "old-file.ts": { hash: "abc123", mtime: 1000 },
24
+ },
25
+ last_full_extract: "2026-01-01T00:00:00Z",
26
+ };
27
+
28
+ const result = getChangedFiles("/tmp", [], cache);
29
+
30
+ expect(result.removed).toEqual(["old-file.ts"]);
31
+ expect(result.added).toEqual([]);
32
+ });
33
+
34
+ it("categorizes files not in cache as added", () => {
35
+ const cache: FileCache = {
36
+ files: {
37
+ "existing.ts": { hash: "abc123", mtime: 1000 },
38
+ },
39
+ last_full_extract: "2026-01-01T00:00:00Z",
40
+ };
41
+
42
+ const result = getChangedFiles("/tmp", ["existing.ts", "brand-new.ts"], cache);
43
+
44
+ expect(result.added).toContain("brand-new.ts");
45
+ });
46
+ });
47
+ });
@@ -0,0 +1,222 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { createEmptyDocument } from "../src/schema.js";
3
+ import { diffContexts, formatDiff } from "../src/diff.js";
4
+ import type { Entity, Component, Route } from "../src/schema.js";
5
+
6
+ describe("Diff Engine", () => {
7
+ function makeDoc(name = "test") {
8
+ return createEmptyDocument(name);
9
+ }
10
+
11
+ describe("diffContexts", () => {
12
+ it("returns no changes for identical documents", () => {
13
+ const doc = makeDoc();
14
+ const result = diffContexts(doc, doc);
15
+
16
+ expect(result.summary.total_changes).toBe(0);
17
+ expect(result.changes).toEqual([]);
18
+ });
19
+
20
+ it("detects added entities", () => {
21
+ const oldDoc = makeDoc();
22
+ const newDoc = makeDoc();
23
+ newDoc.entities.push({
24
+ name: "User",
25
+ _meta: { file: "schema.ts", symbol: "users", type: "entity" },
26
+ fields: [{ name: "id", type: "uuid", primary: true }],
27
+ relations: [],
28
+ });
29
+
30
+ const result = diffContexts(oldDoc, newDoc);
31
+
32
+ expect(result.summary.added).toBe(1);
33
+ expect(result.changes[0].type).toBe("added");
34
+ expect(result.changes[0].name).toBe("User");
35
+ expect(result.changes[0].category).toBe("entity");
36
+ });
37
+
38
+ it("detects removed entities", () => {
39
+ const oldDoc = makeDoc();
40
+ oldDoc.entities.push({
41
+ name: "User",
42
+ _meta: { file: "schema.ts", symbol: "users", type: "entity" },
43
+ fields: [],
44
+ relations: [],
45
+ });
46
+ const newDoc = makeDoc();
47
+
48
+ const result = diffContexts(oldDoc, newDoc);
49
+
50
+ expect(result.summary.removed).toBe(1);
51
+ expect(result.changes[0].type).toBe("removed");
52
+ expect(result.changes[0].name).toBe("User");
53
+ });
54
+
55
+ it("detects modified entity fields", () => {
56
+ const entity: Entity = {
57
+ name: "User",
58
+ _meta: { file: "schema.ts", symbol: "users", type: "entity" },
59
+ fields: [
60
+ { name: "id", type: "uuid", primary: true },
61
+ { name: "name", type: "varchar(100)" },
62
+ ],
63
+ relations: [],
64
+ };
65
+
66
+ const oldDoc = makeDoc();
67
+ oldDoc.entities.push(entity);
68
+
69
+ const newDoc = makeDoc();
70
+ newDoc.entities.push({
71
+ ...entity,
72
+ fields: [
73
+ { name: "id", type: "uuid", primary: true },
74
+ { name: "name", type: "varchar(255)" }, // changed length
75
+ { name: "email", type: "varchar(255)" }, // new field
76
+ ],
77
+ });
78
+
79
+ const result = diffContexts(oldDoc, newDoc);
80
+
81
+ expect(result.summary.modified).toBe(1);
82
+ const change = result.changes[0];
83
+ expect(change.details).toContain("~ field name: type varchar(100) → varchar(255)");
84
+ expect(change.details).toContain("+ field: email (varchar(255))");
85
+ });
86
+
87
+ it("detects added components", () => {
88
+ const oldDoc = makeDoc();
89
+ const newDoc = makeDoc();
90
+ newDoc.components.push({
91
+ name: "Header",
92
+ _meta: { file: "Header.tsx", symbol: "Header", type: "component" },
93
+ props: [{ name: "title", type: "string" }],
94
+ dependencies: [],
95
+ hooks: [],
96
+ data_sources: [],
97
+ });
98
+
99
+ const result = diffContexts(oldDoc, newDoc);
100
+
101
+ expect(result.summary.added).toBe(1);
102
+ expect(result.changes[0].category).toBe("component");
103
+ });
104
+
105
+ it("detects modified component props", () => {
106
+ const makeComp = (props: Component["props"]): Component => ({
107
+ name: "Button",
108
+ _meta: { file: "Button.tsx", symbol: "Button", type: "component" },
109
+ props,
110
+ dependencies: [],
111
+ hooks: [],
112
+ data_sources: [],
113
+ });
114
+
115
+ const oldDoc = makeDoc();
116
+ oldDoc.components.push(makeComp([{ name: "label", type: "string" }]));
117
+
118
+ const newDoc = makeDoc();
119
+ newDoc.components.push(makeComp([
120
+ { name: "label", type: "string" },
121
+ { name: "variant", type: "'primary' | 'secondary'" },
122
+ ]));
123
+
124
+ const result = diffContexts(oldDoc, newDoc);
125
+
126
+ expect(result.summary.modified).toBe(1);
127
+ expect(result.changes[0].details).toContain("+ prop: variant: 'primary' | 'secondary'");
128
+ });
129
+
130
+ it("detects added/removed routes", () => {
131
+ const oldDoc = makeDoc();
132
+ oldDoc.routes.push({
133
+ path: "/api/users",
134
+ _meta: { file: "route.ts", symbol: "route", type: "route" },
135
+ methods: { GET: { auth: true } },
136
+ });
137
+
138
+ const newDoc = makeDoc();
139
+ newDoc.routes.push({
140
+ path: "/api/posts",
141
+ _meta: { file: "posts/route.ts", symbol: "route", type: "route" },
142
+ methods: { GET: {}, POST: { body: "JSON" } },
143
+ });
144
+
145
+ const result = diffContexts(oldDoc, newDoc);
146
+
147
+ expect(result.summary.added).toBe(1);
148
+ expect(result.summary.removed).toBe(1);
149
+ });
150
+
151
+ it("detects stack changes", () => {
152
+ const oldDoc = makeDoc();
153
+ oldDoc.stack = { framework: "next.js@14", language: "typescript" };
154
+
155
+ const newDoc = makeDoc();
156
+ newDoc.stack = { framework: "next.js@15", language: "typescript" };
157
+
158
+ const result = diffContexts(oldDoc, newDoc);
159
+
160
+ expect(result.stack_changes).toHaveLength(1);
161
+ expect(result.stack_changes[0].name).toBe("framework");
162
+ expect(result.stack_changes[0].type).toBe("modified");
163
+ });
164
+
165
+ it("detects dependency changes", () => {
166
+ const oldDoc = makeDoc();
167
+ oldDoc.dependencies = [
168
+ { name: "react", version: "^18.0.0" },
169
+ { name: "lodash", version: "^4.0.0" },
170
+ ];
171
+
172
+ const newDoc = makeDoc();
173
+ newDoc.dependencies = [
174
+ { name: "react", version: "^19.0.0" }, // upgraded
175
+ { name: "zustand", version: "^5.0.0" }, // added
176
+ // lodash removed
177
+ ];
178
+
179
+ const result = diffContexts(oldDoc, newDoc);
180
+
181
+ const depChanges = result.changes.filter((c) => c.category === "dependency");
182
+ expect(depChanges).toHaveLength(3);
183
+
184
+ const added = depChanges.find((c) => c.type === "added");
185
+ expect(added?.name).toBe("zustand");
186
+
187
+ const removed = depChanges.find((c) => c.type === "removed");
188
+ expect(removed?.name).toBe("lodash");
189
+
190
+ const modified = depChanges.find((c) => c.type === "modified");
191
+ expect(modified?.name).toBe("react");
192
+ });
193
+ });
194
+
195
+ describe("formatDiff", () => {
196
+ it("formats empty diff", () => {
197
+ const result = diffContexts(makeDoc(), makeDoc());
198
+ const output = formatDiff(result);
199
+
200
+ expect(output).toContain("0 changes");
201
+ expect(output).toContain("No changes detected");
202
+ });
203
+
204
+ it("formats changes with symbols", () => {
205
+ const oldDoc = makeDoc();
206
+ const newDoc = makeDoc();
207
+ newDoc.entities.push({
208
+ name: "User",
209
+ _meta: { file: "schema.ts", symbol: "users", type: "entity" },
210
+ fields: [],
211
+ relations: [],
212
+ });
213
+
214
+ const result = diffContexts(oldDoc, newDoc);
215
+ const output = formatDiff(result);
216
+
217
+ expect(output).toContain("1 changes");
218
+ expect(output).toContain("+ User");
219
+ expect(output).toContain("Entities");
220
+ });
221
+ });
222
+ });
@@ -0,0 +1,107 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import {
3
+ registerPlugin,
4
+ getPlugins,
5
+ detectPlugins,
6
+ clearPlugins,
7
+ type ExtractorPlugin,
8
+ type ParsedProject,
9
+ type ExtractResult,
10
+ } from "../src/plugin.js";
11
+
12
+ describe("Plugin System", () => {
13
+ beforeEach(() => {
14
+ clearPlugins();
15
+ });
16
+
17
+ function makeMockPlugin(name: string, detects = true): ExtractorPlugin {
18
+ return {
19
+ name,
20
+ version: "1.0.0",
21
+ languages: ["test"],
22
+ detect: () => detects,
23
+ init: (projectPath: string): ParsedProject => ({
24
+ projectPath,
25
+ sourceFiles: [],
26
+ }),
27
+ extract: (): ExtractResult => ({
28
+ entities: [],
29
+ components: [],
30
+ }),
31
+ };
32
+ }
33
+
34
+ describe("registerPlugin", () => {
35
+ it("registers a plugin", () => {
36
+ const plugin = makeMockPlugin("test");
37
+ registerPlugin(plugin);
38
+
39
+ expect(getPlugins()).toHaveLength(1);
40
+ expect(getPlugins()[0].name).toBe("test");
41
+ });
42
+
43
+ it("replaces plugin with same name", () => {
44
+ const plugin1 = makeMockPlugin("test");
45
+ const plugin2 = makeMockPlugin("test");
46
+ plugin2.version = "2.0.0";
47
+
48
+ registerPlugin(plugin1);
49
+ registerPlugin(plugin2);
50
+
51
+ expect(getPlugins()).toHaveLength(1);
52
+ expect(getPlugins()[0].version).toBe("2.0.0");
53
+ });
54
+
55
+ it("registers multiple plugins", () => {
56
+ registerPlugin(makeMockPlugin("typescript"));
57
+ registerPlugin(makeMockPlugin("python"));
58
+ registerPlugin(makeMockPlugin("go"));
59
+
60
+ expect(getPlugins()).toHaveLength(3);
61
+ });
62
+ });
63
+
64
+ describe("detectPlugins", () => {
65
+ it("returns plugins that detect the project", async () => {
66
+ registerPlugin(makeMockPlugin("typescript", true));
67
+ registerPlugin(makeMockPlugin("python", false));
68
+
69
+ const detected = await detectPlugins("/some/path");
70
+
71
+ expect(detected).toHaveLength(1);
72
+ expect(detected[0].name).toBe("typescript");
73
+ });
74
+
75
+ it("returns empty array when no plugins detect", async () => {
76
+ registerPlugin(makeMockPlugin("python", false));
77
+
78
+ const detected = await detectPlugins("/some/path");
79
+ expect(detected).toHaveLength(0);
80
+ });
81
+
82
+ it("supports async detect", async () => {
83
+ const asyncPlugin: ExtractorPlugin = {
84
+ ...makeMockPlugin("async-test"),
85
+ detect: async () => {
86
+ return true;
87
+ },
88
+ };
89
+
90
+ registerPlugin(asyncPlugin);
91
+ const detected = await detectPlugins("/some/path");
92
+ expect(detected).toHaveLength(1);
93
+ });
94
+ });
95
+
96
+ describe("clearPlugins", () => {
97
+ it("removes all plugins", () => {
98
+ registerPlugin(makeMockPlugin("a"));
99
+ registerPlugin(makeMockPlugin("b"));
100
+
101
+ expect(getPlugins()).toHaveLength(2);
102
+
103
+ clearPlugins();
104
+ expect(getPlugins()).toHaveLength(0);
105
+ });
106
+ });
107
+ });
@@ -0,0 +1,132 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { createEmptyDocument } from "../src/schema.js";
3
+ import type { ContextDocument, Meta, Entity, Component, Route } from "../src/schema.js";
4
+
5
+ describe("Schema", () => {
6
+ describe("createEmptyDocument", () => {
7
+ it("creates a valid empty document with project name", () => {
8
+ const doc = createEmptyDocument("test-project");
9
+
10
+ expect(doc.version).toBe("1.0");
11
+ expect(doc.project).toBe("test-project");
12
+ expect(doc._extraction.extracted_at).toBeTruthy();
13
+ expect(doc.entities).toEqual([]);
14
+ expect(doc.components).toEqual([]);
15
+ expect(doc.routes).toEqual([]);
16
+ expect(doc.exports).toEqual([]);
17
+ expect(doc.call_graph).toEqual([]);
18
+ expect(doc.component_graph).toEqual([]);
19
+ expect(doc.env_vars).toEqual([]);
20
+ expect(doc.patterns).toEqual({});
21
+ expect(doc.conventions).toEqual({});
22
+ expect(doc.dependencies).toEqual([]);
23
+ expect(doc.stack).toEqual({});
24
+ });
25
+
26
+ it("sets extraction timestamp to current time", () => {
27
+ const before = new Date().toISOString();
28
+ const doc = createEmptyDocument("test");
29
+ const after = new Date().toISOString();
30
+
31
+ expect(doc._extraction.extracted_at >= before).toBe(true);
32
+ expect(doc._extraction.extracted_at <= after).toBe(true);
33
+ });
34
+ });
35
+
36
+ describe("Type safety", () => {
37
+ it("Meta uses generic MetaType categories", () => {
38
+ const meta: Meta = {
39
+ file: "src/models/user.ts",
40
+ symbol: "User",
41
+ type: "entity",
42
+ tags: ["orm:drizzle", "db:postgresql"],
43
+ line: 10,
44
+ hash: "abc123def456",
45
+ };
46
+
47
+ expect(meta.type).toBe("entity");
48
+ expect(meta.tags).toContain("orm:drizzle");
49
+ });
50
+
51
+ it("Entity has proper structure", () => {
52
+ const entity: Entity = {
53
+ name: "User",
54
+ _meta: {
55
+ file: "src/db/schema.ts",
56
+ symbol: "users",
57
+ type: "entity",
58
+ tags: ["orm:drizzle"],
59
+ },
60
+ fields: [
61
+ { name: "id", type: "uuid", primary: true },
62
+ { name: "email", type: "varchar(255)", unique: true },
63
+ { name: "name", type: "varchar(100)", nullable: true },
64
+ ],
65
+ relations: [
66
+ { to: "Post", type: "one-to-many", via: "userId" },
67
+ ],
68
+ };
69
+
70
+ expect(entity.name).toBe("User");
71
+ expect(entity.fields).toHaveLength(3);
72
+ expect(entity.fields[0].primary).toBe(true);
73
+ expect(entity.relations[0].to).toBe("Post");
74
+ });
75
+
76
+ it("Component has proper structure", () => {
77
+ const comp: Component = {
78
+ name: "Dashboard",
79
+ _meta: {
80
+ file: "src/app/page.tsx",
81
+ symbol: "Dashboard",
82
+ type: "component",
83
+ tags: ["framework:react", "react:client"],
84
+ },
85
+ props: [
86
+ { name: "userId", type: "string", required: true },
87
+ ],
88
+ dependencies: ["MetricCard", "ChartPanel"],
89
+ hooks: ["useState", "useEffect", "useMetrics"],
90
+ data_sources: ["/api/metrics"],
91
+ route: "/dashboard",
92
+ };
93
+
94
+ expect(comp._meta.tags).toContain("react:client");
95
+ expect(comp.hooks).toContain("useMetrics");
96
+ });
97
+
98
+ it("Route has proper structure", () => {
99
+ const route: Route = {
100
+ path: "/api/users",
101
+ _meta: {
102
+ file: "src/app/api/users/route.ts",
103
+ symbol: "route",
104
+ type: "route",
105
+ tags: ["framework:nextjs"],
106
+ },
107
+ methods: {
108
+ GET: { auth: true, returns: "User[]" },
109
+ POST: { auth: true, body: "JSON" },
110
+ },
111
+ };
112
+
113
+ expect(route.methods.GET.auth).toBe(true);
114
+ expect(route.methods.POST.body).toBe("JSON");
115
+ });
116
+
117
+ it("StackInfo is extensible Record", () => {
118
+ const stack: ContextDocument["stack"] = {
119
+ framework: "next.js@15",
120
+ language: "typescript",
121
+ orm: "drizzle",
122
+ database: "postgresql",
123
+ ui: ["tailwind", "shadcn"],
124
+ // Any key works — not locked to predefined fields
125
+ custom_tool: "some-custom-thing",
126
+ };
127
+
128
+ expect(stack.framework).toBe("next.js@15");
129
+ expect(stack.custom_tool).toBe("some-custom-thing");
130
+ });
131
+ });
132
+ });
@@ -0,0 +1,92 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { createEmptyDocument } from "../src/schema.js";
3
+ import {
4
+ serializeYaml,
5
+ serializeJson,
6
+ deserializeYaml,
7
+ deserializeJson,
8
+ } from "../src/serialize.js";
9
+
10
+ describe("Serialization", () => {
11
+ function makeTestDoc() {
12
+ const doc = createEmptyDocument("test-project");
13
+ doc.stack = { framework: "next.js@15", language: "typescript" };
14
+ doc.entities.push({
15
+ name: "User",
16
+ _meta: { file: "src/db/schema.ts", symbol: "users", type: "entity", tags: ["orm:drizzle"] },
17
+ fields: [
18
+ { name: "id", type: "uuid", primary: true },
19
+ { name: "email", type: "varchar(255)", unique: true },
20
+ ],
21
+ relations: [],
22
+ });
23
+ doc.components.push({
24
+ name: "Dashboard",
25
+ _meta: { file: "src/app/page.tsx", symbol: "Dashboard", type: "component", tags: ["framework:react"] },
26
+ props: [{ name: "userId", type: "string" }],
27
+ dependencies: ["MetricCard"],
28
+ hooks: ["useState"],
29
+ data_sources: ["/api/metrics"],
30
+ route: "/",
31
+ });
32
+ doc.patterns = { auth: "next-auth with JWT", styling: "tailwind" };
33
+ return doc;
34
+ }
35
+
36
+ describe("YAML roundtrip", () => {
37
+ it("serializes and deserializes without data loss", () => {
38
+ const doc = makeTestDoc();
39
+ const yaml = serializeYaml(doc);
40
+ const restored = deserializeYaml(yaml);
41
+
42
+ expect(restored.project).toBe("test-project");
43
+ expect(restored.version).toBe("1.0");
44
+ expect(restored.entities).toHaveLength(1);
45
+ expect(restored.entities[0].name).toBe("User");
46
+ expect(restored.entities[0].fields).toHaveLength(2);
47
+ expect(restored.components).toHaveLength(1);
48
+ expect(restored.components[0].name).toBe("Dashboard");
49
+ expect(restored.patterns.auth).toBe("next-auth with JWT");
50
+ });
51
+
52
+ it("includes sigil header comment", () => {
53
+ const doc = makeTestDoc();
54
+ const yaml = serializeYaml(doc);
55
+
56
+ expect(yaml).toContain("# context.yaml — generated by sigil extract");
57
+ });
58
+ });
59
+
60
+ describe("JSON roundtrip", () => {
61
+ it("serializes and deserializes without data loss", () => {
62
+ const doc = makeTestDoc();
63
+ const json = serializeJson(doc);
64
+ const restored = deserializeJson(json);
65
+
66
+ expect(restored.project).toBe("test-project");
67
+ expect(restored.entities).toHaveLength(1);
68
+ expect(restored.components).toHaveLength(1);
69
+ });
70
+
71
+ it("produces valid JSON", () => {
72
+ const doc = makeTestDoc();
73
+ const json = serializeJson(doc);
74
+
75
+ expect(() => JSON.parse(json)).not.toThrow();
76
+ });
77
+ });
78
+
79
+ describe("Stack extensibility", () => {
80
+ it("preserves arbitrary stack keys through YAML roundtrip", () => {
81
+ const doc = makeTestDoc();
82
+ doc.stack.custom_field = "custom_value";
83
+ doc.stack.tools = ["eslint", "prettier"];
84
+
85
+ const yaml = serializeYaml(doc);
86
+ const restored = deserializeYaml(yaml);
87
+
88
+ expect(restored.stack.custom_field).toBe("custom_value");
89
+ expect(restored.stack.tools).toEqual(["eslint", "prettier"]);
90
+ });
91
+ });
92
+ });