@syncular/typegen 0.4.1 → 0.5.1

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.
@@ -0,0 +1,217 @@
1
+ /** Revision-1 SYQL import graph and predicate-library resolver (§4). */
2
+ import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
3
+ import {
4
+ SyqlFrontendError,
5
+ type SyqlSourcePosition,
6
+ type SyqlSourceSpan,
7
+ } from './syql-lexer';
8
+ import {
9
+ parseSyqlSyntaxFile,
10
+ type SyqlImportDeclaration,
11
+ type SyqlSyntaxFile,
12
+ } from './syql-parser';
13
+
14
+ export type SyqlModuleErrorCode =
15
+ | 'SYQL4001_IMPORT_OUTSIDE_ROOT'
16
+ | 'SYQL4002_MODULE_NOT_FOUND'
17
+ | 'SYQL4003_IMPORT_CYCLE'
18
+ | 'SYQL4004_UNKNOWN_PREDICATE'
19
+ | 'SYQL4005_DUPLICATE_IMPORT_TARGET'
20
+ | 'SYQL4006_DUPLICATE_QUERY';
21
+
22
+ export type SyqlSourceLoader = (canonicalPath: string) => string | undefined;
23
+
24
+ export interface SyqlImportEdge {
25
+ readonly from: string;
26
+ readonly to: string;
27
+ readonly declaration: SyqlImportDeclaration;
28
+ }
29
+
30
+ export interface SyqlModuleGraph {
31
+ readonly root: string;
32
+ readonly entries: readonly string[];
33
+ /** Dependency-first deterministic module order. */
34
+ readonly modules: readonly SyqlSyntaxFile[];
35
+ readonly moduleByPath: ReadonlyMap<string, SyqlSyntaxFile>;
36
+ readonly edges: readonly SyqlImportEdge[];
37
+ }
38
+
39
+ function startPosition(): SyqlSourcePosition {
40
+ return { offset: 0, line: 1, column: 1 };
41
+ }
42
+
43
+ function startSpan(file: string): SyqlSourceSpan {
44
+ const position = startPosition();
45
+ return { file, start: position, end: position };
46
+ }
47
+
48
+ function isWithinRoot(root: string, candidate: string): boolean {
49
+ const path = relative(root, candidate);
50
+ return (
51
+ path === '' ||
52
+ (!isAbsolute(path) && path !== '..' && !path.startsWith(`..${sep}`))
53
+ );
54
+ }
55
+
56
+ function displayPath(root: string, file: string): string {
57
+ const path = relative(root, file);
58
+ return path === '' ? '.' : path.split(sep).join('/');
59
+ }
60
+
61
+ class ModuleGraphBuilder {
62
+ readonly #root: string;
63
+ readonly #load: SyqlSourceLoader;
64
+ readonly #moduleByPath = new Map<string, SyqlSyntaxFile>();
65
+ readonly #edges: SyqlImportEdge[] = [];
66
+ readonly #order: SyqlSyntaxFile[] = [];
67
+ readonly #state = new Map<string, 'active' | 'complete'>();
68
+ readonly #stack: string[] = [];
69
+ readonly #queryNames = new Map<string, SyqlSourceSpan>();
70
+
71
+ constructor(root: string, load: SyqlSourceLoader) {
72
+ this.#root = resolve(root);
73
+ this.#load = load;
74
+ }
75
+
76
+ build(entries: readonly string[]): SyqlModuleGraph {
77
+ const canonicalEntries = entries.map((entry) => this.#entryPath(entry));
78
+ for (const entry of canonicalEntries) this.#visit(entry);
79
+ this.#validateImports();
80
+ this.#validateGlobalQueryNames();
81
+ return {
82
+ root: this.#root,
83
+ entries: canonicalEntries,
84
+ modules: this.#order,
85
+ moduleByPath: this.#moduleByPath,
86
+ edges: this.#edges,
87
+ };
88
+ }
89
+
90
+ #entryPath(entry: string): string {
91
+ const candidate = resolve(this.#root, entry);
92
+ if (!isWithinRoot(this.#root, candidate)) {
93
+ this.#fail(
94
+ 'SYQL4001_IMPORT_OUTSIDE_ROOT',
95
+ startSpan(candidate),
96
+ `entry ${JSON.stringify(entry)} resolves outside the configured queries root`,
97
+ );
98
+ }
99
+ return candidate;
100
+ }
101
+
102
+ #visit(file: string, incoming?: SyqlImportDeclaration): SyqlSyntaxFile {
103
+ const state = this.#state.get(file);
104
+ if (state === 'complete')
105
+ return this.#moduleByPath.get(file) as SyqlSyntaxFile;
106
+ if (state === 'active') {
107
+ const cycleStart = this.#stack.indexOf(file);
108
+ const cycle = [...this.#stack.slice(cycleStart), file]
109
+ .map((item) => displayPath(this.#root, item))
110
+ .join(' -> ');
111
+ this.#fail(
112
+ 'SYQL4003_IMPORT_CYCLE',
113
+ incoming?.span ?? startSpan(file),
114
+ `SYQL import cycle: ${cycle}`,
115
+ );
116
+ }
117
+
118
+ const source = this.#load(file);
119
+ if (source === undefined) {
120
+ this.#fail(
121
+ 'SYQL4002_MODULE_NOT_FOUND',
122
+ incoming?.span ?? startSpan(file),
123
+ `SYQL module not found: ${displayPath(this.#root, file)}`,
124
+ );
125
+ }
126
+ const module = parseSyqlSyntaxFile(file, source);
127
+ this.#moduleByPath.set(file, module);
128
+ this.#state.set(file, 'active');
129
+ this.#stack.push(file);
130
+
131
+ for (const declaration of module.imports) {
132
+ const target = resolve(dirname(file), declaration.path);
133
+ if (!isWithinRoot(this.#root, target)) {
134
+ this.#fail(
135
+ 'SYQL4001_IMPORT_OUTSIDE_ROOT',
136
+ declaration.span,
137
+ `import ${JSON.stringify(declaration.path)} escapes the configured queries root`,
138
+ );
139
+ }
140
+ this.#edges.push({ from: file, to: target, declaration });
141
+ this.#visit(target, declaration);
142
+ }
143
+
144
+ this.#stack.pop();
145
+ this.#state.set(file, 'complete');
146
+ this.#order.push(module);
147
+ return module;
148
+ }
149
+
150
+ #validateImports(): void {
151
+ const importedTargetsByModule = new Map<string, Set<string>>();
152
+ for (const edge of this.#edges) {
153
+ const target = this.#moduleByPath.get(edge.to) as SyqlSyntaxFile;
154
+ const predicates = new Set(
155
+ target.predicates.map((predicate) => predicate.name),
156
+ );
157
+ let targets = importedTargetsByModule.get(edge.from);
158
+ if (targets === undefined) {
159
+ targets = new Set();
160
+ importedTargetsByModule.set(edge.from, targets);
161
+ }
162
+ for (const item of edge.declaration.items) {
163
+ const key = `${edge.to}\0${item.imported}`;
164
+ if (targets.has(key)) {
165
+ this.#fail(
166
+ 'SYQL4005_DUPLICATE_IMPORT_TARGET',
167
+ item.span,
168
+ `predicate ${JSON.stringify(item.imported)} is imported more than once from ${displayPath(this.#root, edge.to)}`,
169
+ );
170
+ }
171
+ targets.add(key);
172
+ if (!predicates.has(item.imported)) {
173
+ this.#fail(
174
+ 'SYQL4004_UNKNOWN_PREDICATE',
175
+ item.span,
176
+ `${displayPath(this.#root, edge.to)} does not export predicate ${JSON.stringify(item.imported)}`,
177
+ );
178
+ }
179
+ }
180
+ }
181
+ }
182
+
183
+ #validateGlobalQueryNames(): void {
184
+ for (const module of this.#order) {
185
+ for (const query of module.queries) {
186
+ if (this.#queryNames.has(query.name)) {
187
+ this.#fail(
188
+ 'SYQL4006_DUPLICATE_QUERY',
189
+ query.nameSpan,
190
+ `duplicate query name ${JSON.stringify(query.name)} across the configured SYQL graph`,
191
+ );
192
+ }
193
+ this.#queryNames.set(query.name, query.nameSpan);
194
+ }
195
+ }
196
+ }
197
+
198
+ #fail(
199
+ code: SyqlModuleErrorCode,
200
+ span: SyqlSourceSpan,
201
+ message: string,
202
+ ): never {
203
+ throw new SyqlFrontendError(code, span, message);
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Parse and resolve every module reachable from the configured entry files.
209
+ * Paths passed to `load` are absolute, normalized paths under `root`.
210
+ */
211
+ export function buildSyqlModuleGraph(
212
+ root: string,
213
+ entries: readonly string[],
214
+ load: SyqlSourceLoader,
215
+ ): SyqlModuleGraph {
216
+ return new ModuleGraphBuilder(root, load).build(entries);
217
+ }