@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.
- package/.turbo/turbo-build.log +4 -0
- package/dist/cache.d.ts +24 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +97 -0
- package/dist/cache.js.map +1 -0
- package/dist/diff.d.ts +34 -0
- package/dist/diff.d.ts.map +1 -0
- package/dist/diff.js +319 -0
- package/dist/diff.js.map +1 -0
- package/dist/extract.d.ts +27 -0
- package/dist/extract.d.ts.map +1 -0
- package/dist/extract.js +162 -0
- package/dist/extract.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/plugin.d.ts +103 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +47 -0
- package/dist/plugin.js.map +1 -0
- package/dist/safety.d.ts +25 -0
- package/dist/safety.d.ts.map +1 -0
- package/dist/safety.js +139 -0
- package/dist/safety.js.map +1 -0
- package/dist/schema.d.ts +184 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +33 -0
- package/dist/schema.js.map +1 -0
- package/dist/serialize.d.ts +16 -0
- package/dist/serialize.d.ts.map +1 -0
- package/dist/serialize.js +75 -0
- package/dist/serialize.js.map +1 -0
- package/package.json +35 -0
- package/src/cache.ts +133 -0
- package/src/diff.ts +421 -0
- package/src/extract.ts +196 -0
- package/src/index.ts +94 -0
- package/src/plugin.ts +186 -0
- package/src/safety.ts +185 -0
- package/src/schema.ts +270 -0
- package/src/serialize.ts +97 -0
- package/tests/cache.test.ts +47 -0
- package/tests/diff.test.ts +222 -0
- package/tests/plugin.test.ts +107 -0
- package/tests/schema.test.ts +132 -0
- package/tests/serialize.test.ts +92 -0
- package/tsconfig.json +20 -0
package/src/serialize.ts
ADDED
|
@@ -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
|
+
});
|