@suss/contract-graphql 0.0.2 → 0.1.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.
- package/dist/index.cjs +413 -33
- package/dist/index.d.cts +47 -1
- package/dist/index.d.ts +47 -1
- package/dist/index.js +415 -34
- package/package.json +3 -3
package/dist/index.cjs
CHANGED
|
@@ -30,19 +30,420 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
30
30
|
// src/index.ts
|
|
31
31
|
var index_exports = {};
|
|
32
32
|
__export(index_exports, {
|
|
33
|
+
graphqlDocumentFilesToSummaries: () => graphqlDocumentFilesToSummaries,
|
|
34
|
+
graphqlDocumentsPathToSummaries: () => graphqlDocumentsPathToSummaries,
|
|
35
|
+
graphqlDocumentsToSummaries: () => graphqlDocumentsToSummaries,
|
|
33
36
|
graphqlSdlFileToSummaries: () => graphqlSdlFileToSummaries,
|
|
34
37
|
graphqlSdlToSummaries: () => graphqlSdlToSummaries,
|
|
35
38
|
loadSdlFile: () => loadSdlFile
|
|
36
39
|
});
|
|
37
40
|
module.exports = __toCommonJS(index_exports);
|
|
38
|
-
var
|
|
41
|
+
var import_node_fs2 = __toESM(require("fs"), 1);
|
|
42
|
+
var import_graphql3 = require("graphql");
|
|
43
|
+
var import_behavioral_ir2 = require("@suss/behavioral-ir");
|
|
44
|
+
|
|
45
|
+
// src/typeShape.ts
|
|
39
46
|
var import_graphql = require("graphql");
|
|
47
|
+
var SCALAR_SHAPES = {
|
|
48
|
+
String: { type: "text" },
|
|
49
|
+
ID: { type: "text" },
|
|
50
|
+
Int: { type: "number" },
|
|
51
|
+
Float: { type: "number" },
|
|
52
|
+
Boolean: { type: "boolean" }
|
|
53
|
+
};
|
|
54
|
+
function typeNodeToShape(node) {
|
|
55
|
+
if (node.kind === import_graphql.Kind.NON_NULL_TYPE) {
|
|
56
|
+
return typeNodeToShape(node.type);
|
|
57
|
+
}
|
|
58
|
+
if (node.kind === import_graphql.Kind.LIST_TYPE) {
|
|
59
|
+
return { type: "array", items: typeNodeToShape(node.type) };
|
|
60
|
+
}
|
|
61
|
+
return scalarOrRef(node);
|
|
62
|
+
}
|
|
63
|
+
function scalarOrRef(node) {
|
|
64
|
+
const name = node.name.value;
|
|
65
|
+
return SCALAR_SHAPES[name] ?? { type: "ref", name };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// src/documents.ts
|
|
69
|
+
var import_node_fs = __toESM(require("fs"), 1);
|
|
70
|
+
var import_node_path = __toESM(require("path"), 1);
|
|
71
|
+
var import_graphql2 = require("graphql");
|
|
40
72
|
var import_behavioral_ir = require("@suss/behavioral-ir");
|
|
73
|
+
function graphqlDocumentsToSummaries(sources, options = {}) {
|
|
74
|
+
const recognition = options.recognition ?? "graphql-documents";
|
|
75
|
+
const transport = options.transport ?? "http-graphql";
|
|
76
|
+
const parsed = [];
|
|
77
|
+
for (const source of sources) {
|
|
78
|
+
const doc = safeParse(source.text);
|
|
79
|
+
if (doc === null) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
parsed.push({ source, doc });
|
|
83
|
+
}
|
|
84
|
+
const fragments = collectFragments(parsed);
|
|
85
|
+
const takenNames = /* @__PURE__ */ new Set();
|
|
86
|
+
const out = [];
|
|
87
|
+
for (const { source, doc } of parsed) {
|
|
88
|
+
for (const def of doc.definitions) {
|
|
89
|
+
if (def.kind !== import_graphql2.Kind.OPERATION_DEFINITION) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const name = distinctName(
|
|
93
|
+
operationName(def, source.path, options.rootDir),
|
|
94
|
+
takenNames
|
|
95
|
+
);
|
|
96
|
+
out.push(
|
|
97
|
+
buildOperationSummary(def, name, source.path, fragments, {
|
|
98
|
+
recognition,
|
|
99
|
+
transport
|
|
100
|
+
})
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
function operationName(op, file, rootDir) {
|
|
107
|
+
const declared = op.name?.value;
|
|
108
|
+
if (declared !== void 0) {
|
|
109
|
+
return declared;
|
|
110
|
+
}
|
|
111
|
+
return `${displayPath(file, rootDir)}:${op.operation}`;
|
|
112
|
+
}
|
|
113
|
+
function displayPath(file, rootDir) {
|
|
114
|
+
if (rootDir === void 0) {
|
|
115
|
+
return file;
|
|
116
|
+
}
|
|
117
|
+
const relative = import_node_path.default.relative(rootDir, file);
|
|
118
|
+
if (relative === "" || relative.startsWith("..")) {
|
|
119
|
+
return file;
|
|
120
|
+
}
|
|
121
|
+
return relative.split(import_node_path.default.sep).join("/");
|
|
122
|
+
}
|
|
123
|
+
function distinctName(candidate, taken) {
|
|
124
|
+
let name = candidate;
|
|
125
|
+
let suffix = 2;
|
|
126
|
+
while (taken.has(name)) {
|
|
127
|
+
name = `${candidate}#${suffix}`;
|
|
128
|
+
suffix += 1;
|
|
129
|
+
}
|
|
130
|
+
taken.add(name);
|
|
131
|
+
return name;
|
|
132
|
+
}
|
|
133
|
+
function graphqlDocumentFilesToSummaries(filepaths, options = {}) {
|
|
134
|
+
const sources = [];
|
|
135
|
+
for (const filepath of filepaths) {
|
|
136
|
+
let text;
|
|
137
|
+
try {
|
|
138
|
+
text = import_node_fs.default.readFileSync(filepath, "utf8");
|
|
139
|
+
} catch {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
sources.push({ path: filepath, text });
|
|
143
|
+
}
|
|
144
|
+
return graphqlDocumentsToSummaries(sources, options);
|
|
145
|
+
}
|
|
146
|
+
function graphqlDocumentsPathToSummaries(specPath, options = {}) {
|
|
147
|
+
const absolute = import_node_path.default.resolve(specPath);
|
|
148
|
+
if (!import_node_fs.default.existsSync(absolute)) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`No GraphQL documents found at "${specPath}". Pass a .graphql/.gql file or a directory containing them.`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
const stat = import_node_fs.default.statSync(absolute);
|
|
154
|
+
const isDirectory = stat.isDirectory();
|
|
155
|
+
const files = isDirectory ? walkForDocuments(absolute) : [absolute];
|
|
156
|
+
return graphqlDocumentFilesToSummaries(files, {
|
|
157
|
+
rootDir: isDirectory ? absolute : import_node_path.default.dirname(absolute),
|
|
158
|
+
...options
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
function walkForDocuments(dir) {
|
|
162
|
+
const out = [];
|
|
163
|
+
const entries = import_node_fs.default.readdirSync(dir, { withFileTypes: true });
|
|
164
|
+
for (const entry of entries) {
|
|
165
|
+
const full = import_node_path.default.join(dir, entry.name);
|
|
166
|
+
if (entry.isFile()) {
|
|
167
|
+
if (/\.(graphql|gql)$/.test(entry.name)) {
|
|
168
|
+
out.push(full);
|
|
169
|
+
}
|
|
170
|
+
} else if (entry.isDirectory() && entry.name !== "node_modules") {
|
|
171
|
+
out.push(...walkForDocuments(full));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return out.sort();
|
|
175
|
+
}
|
|
176
|
+
function collectFragments(parsed) {
|
|
177
|
+
const definitions = /* @__PURE__ */ new Map();
|
|
178
|
+
const definingFiles = /* @__PURE__ */ new Map();
|
|
179
|
+
for (const { source, doc } of parsed) {
|
|
180
|
+
for (const def of doc.definitions) {
|
|
181
|
+
if (def.kind !== import_graphql2.Kind.FRAGMENT_DEFINITION) {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const name = def.name.value;
|
|
185
|
+
if (!definitions.has(name)) {
|
|
186
|
+
definitions.set(name, def);
|
|
187
|
+
}
|
|
188
|
+
definingFiles.set(name, [
|
|
189
|
+
...definingFiles.get(name) ?? [],
|
|
190
|
+
source.path
|
|
191
|
+
]);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const competingFiles = /* @__PURE__ */ new Map();
|
|
195
|
+
for (const [name, files] of definingFiles) {
|
|
196
|
+
if (files.length > 1) {
|
|
197
|
+
competingFiles.set(name, files);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return { definitions, competingFiles };
|
|
201
|
+
}
|
|
202
|
+
function buildOperationSummary(op, name, file, fragments, config) {
|
|
203
|
+
const operationType = op.operation;
|
|
204
|
+
const operationName2 = op.name?.value;
|
|
205
|
+
const unexpanded = emptyUnexpandedSpreads();
|
|
206
|
+
const inlined = inlineSpreadsInOperation(op, fragments, unexpanded);
|
|
207
|
+
const documentText = (0, import_graphql2.print)(documentOf(inlined));
|
|
208
|
+
const responseShape = selectionSetToShape(inlined.selectionSet);
|
|
209
|
+
return {
|
|
210
|
+
kind: "client",
|
|
211
|
+
location: {
|
|
212
|
+
file,
|
|
213
|
+
range: { start: op.loc?.start ?? 0, end: op.loc?.end ?? 0 },
|
|
214
|
+
exportName: null
|
|
215
|
+
},
|
|
216
|
+
identity: {
|
|
217
|
+
name,
|
|
218
|
+
exportPath: null,
|
|
219
|
+
boundaryBinding: (0, import_behavioral_ir.graphqlOperationBinding)({
|
|
220
|
+
transport: config.transport,
|
|
221
|
+
recognition: config.recognition,
|
|
222
|
+
operationType,
|
|
223
|
+
...operationName2 !== void 0 ? { operationName: operationName2 } : {}
|
|
224
|
+
})
|
|
225
|
+
},
|
|
226
|
+
inputs: buildVariableInputs(op),
|
|
227
|
+
transitions: buildTransitions(name, responseShape),
|
|
228
|
+
gaps: unexpandedSpreadGaps(unexpanded, fragments),
|
|
229
|
+
confidence: { source: "declared", level: "high" },
|
|
230
|
+
metadata: {
|
|
231
|
+
graphql: {
|
|
232
|
+
document: documentText,
|
|
233
|
+
...unexpanded.missing.size > 0 ? { unresolvedFragments: [...unexpanded.missing].sort() } : {}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
function buildVariableInputs(op) {
|
|
239
|
+
const variables = op.variableDefinitions ?? [];
|
|
240
|
+
return variables.map((variable, index) => ({
|
|
241
|
+
type: "parameter",
|
|
242
|
+
name: variable.variable.name.value,
|
|
243
|
+
position: index,
|
|
244
|
+
// Same role the TypeScript adapter stamps on operation-header
|
|
245
|
+
// `$variables`, so downstream consumers treat both alike.
|
|
246
|
+
role: "variable",
|
|
247
|
+
shape: typeNodeToShape(variable.type)
|
|
248
|
+
}));
|
|
249
|
+
}
|
|
250
|
+
function buildTransitions(name, responseShape) {
|
|
251
|
+
return [
|
|
252
|
+
{
|
|
253
|
+
id: `${name}:return:success`,
|
|
254
|
+
conditions: [],
|
|
255
|
+
output: { type: "return", value: responseShape },
|
|
256
|
+
effects: [],
|
|
257
|
+
location: { start: 0, end: 0 },
|
|
258
|
+
isDefault: true,
|
|
259
|
+
confidence: { source: "declared", level: "high" },
|
|
260
|
+
metadata: {
|
|
261
|
+
source: "graphql:operation.success"
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
id: `${name}:throw:error`,
|
|
266
|
+
conditions: [
|
|
267
|
+
{
|
|
268
|
+
type: "opaque",
|
|
269
|
+
sourceText: "graphql:operation-error",
|
|
270
|
+
reason: "externalFunction"
|
|
271
|
+
}
|
|
272
|
+
],
|
|
273
|
+
output: {
|
|
274
|
+
type: "throw",
|
|
275
|
+
exceptionType: "GraphQLError",
|
|
276
|
+
message: null
|
|
277
|
+
},
|
|
278
|
+
effects: [],
|
|
279
|
+
location: { start: 0, end: 0 },
|
|
280
|
+
isDefault: false,
|
|
281
|
+
confidence: { source: "declared", level: "low" },
|
|
282
|
+
metadata: {
|
|
283
|
+
source: "graphql:operation.error"
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
];
|
|
287
|
+
}
|
|
288
|
+
function unexpandedSpreadGaps(unexpanded, fragments) {
|
|
289
|
+
const gaps = [];
|
|
290
|
+
for (const name of [...unexpanded.missing].sort()) {
|
|
291
|
+
gaps.push(
|
|
292
|
+
// A statement about the reading, not the code: the fragment
|
|
293
|
+
// likely exists somewhere outside the files handed to this
|
|
294
|
+
// reader.
|
|
295
|
+
readingGap(
|
|
296
|
+
`Fragment spread "...${name}" has no matching fragment definition in the read set; its selections are not part of this summary.`
|
|
297
|
+
)
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
for (const name of [...unexpanded.cyclic].sort()) {
|
|
301
|
+
gaps.push(
|
|
302
|
+
readingGap(
|
|
303
|
+
`Fragment spread "...${name}" is part of a fragment cycle, which no server would execute; the repeated spread was left unexpanded.`
|
|
304
|
+
)
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
for (const name of [...unexpanded.ambiguous].sort()) {
|
|
308
|
+
const files = fragments.competingFiles.get(name) ?? [];
|
|
309
|
+
gaps.push(
|
|
310
|
+
readingGap(
|
|
311
|
+
`Fragment "${name}" is defined in more than one file (${files.join(", ")}); the first definition was used, so the selections here may not be the ones the build resolves.`
|
|
312
|
+
)
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
return gaps;
|
|
316
|
+
}
|
|
317
|
+
function readingGap(description) {
|
|
318
|
+
return {
|
|
319
|
+
type: "unreadOutcome",
|
|
320
|
+
conditions: [],
|
|
321
|
+
consequence: "unknown",
|
|
322
|
+
description
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
function emptyUnexpandedSpreads() {
|
|
326
|
+
return { missing: /* @__PURE__ */ new Set(), cyclic: /* @__PURE__ */ new Set(), ambiguous: /* @__PURE__ */ new Set() };
|
|
327
|
+
}
|
|
328
|
+
function inlineSpreadsInOperation(op, fragments, unexpanded) {
|
|
329
|
+
return {
|
|
330
|
+
...op,
|
|
331
|
+
selectionSet: inlineSelectionSet(
|
|
332
|
+
op.selectionSet,
|
|
333
|
+
fragments,
|
|
334
|
+
[],
|
|
335
|
+
unexpanded
|
|
336
|
+
)
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
function inlineSelectionSet(selectionSet, fragments, stack, unexpanded) {
|
|
340
|
+
const out = [];
|
|
341
|
+
for (const selection of selectionSet.selections) {
|
|
342
|
+
out.push(...inlineOneSelection(selection, fragments, stack, unexpanded));
|
|
343
|
+
}
|
|
344
|
+
return { ...selectionSet, selections: out };
|
|
345
|
+
}
|
|
346
|
+
function inlineOneSelection(selection, fragments, stack, unexpanded) {
|
|
347
|
+
if (selection.kind === import_graphql2.Kind.FIELD) {
|
|
348
|
+
if (selection.selectionSet === void 0) {
|
|
349
|
+
return [selection];
|
|
350
|
+
}
|
|
351
|
+
const field = {
|
|
352
|
+
...selection,
|
|
353
|
+
selectionSet: inlineSelectionSet(
|
|
354
|
+
selection.selectionSet,
|
|
355
|
+
fragments,
|
|
356
|
+
stack,
|
|
357
|
+
unexpanded
|
|
358
|
+
)
|
|
359
|
+
};
|
|
360
|
+
return [field];
|
|
361
|
+
}
|
|
362
|
+
if (selection.kind === import_graphql2.Kind.FRAGMENT_SPREAD) {
|
|
363
|
+
const fragmentName = selection.name.value;
|
|
364
|
+
if (fragments.competingFiles.has(fragmentName)) {
|
|
365
|
+
unexpanded.ambiguous.add(fragmentName);
|
|
366
|
+
}
|
|
367
|
+
if (stack.includes(fragmentName)) {
|
|
368
|
+
unexpanded.cyclic.add(fragmentName);
|
|
369
|
+
return [selection];
|
|
370
|
+
}
|
|
371
|
+
const fragment = fragments.definitions.get(fragmentName);
|
|
372
|
+
if (fragment === void 0) {
|
|
373
|
+
unexpanded.missing.add(fragmentName);
|
|
374
|
+
return [selection];
|
|
375
|
+
}
|
|
376
|
+
const inlined = inlineSelectionSet(
|
|
377
|
+
fragment.selectionSet,
|
|
378
|
+
fragments,
|
|
379
|
+
[...stack, fragmentName],
|
|
380
|
+
unexpanded
|
|
381
|
+
);
|
|
382
|
+
return [...inlined.selections];
|
|
383
|
+
}
|
|
384
|
+
const inlineFragment = {
|
|
385
|
+
...selection,
|
|
386
|
+
selectionSet: inlineSelectionSet(
|
|
387
|
+
selection.selectionSet,
|
|
388
|
+
fragments,
|
|
389
|
+
stack,
|
|
390
|
+
unexpanded
|
|
391
|
+
)
|
|
392
|
+
};
|
|
393
|
+
return [inlineFragment];
|
|
394
|
+
}
|
|
395
|
+
function documentOf(op) {
|
|
396
|
+
const definitions = [op];
|
|
397
|
+
return { kind: import_graphql2.Kind.DOCUMENT, definitions };
|
|
398
|
+
}
|
|
399
|
+
function selectionSetToShape(selectionSet) {
|
|
400
|
+
const properties = {};
|
|
401
|
+
mergeSelectionsInto(properties, selectionSet.selections);
|
|
402
|
+
return { type: "record", properties };
|
|
403
|
+
}
|
|
404
|
+
function mergeSelectionsInto(properties, selections) {
|
|
405
|
+
for (const selection of selections) {
|
|
406
|
+
if (selection.kind === import_graphql2.Kind.FIELD) {
|
|
407
|
+
const key = selection.alias?.value ?? selection.name.value;
|
|
408
|
+
const selected = selection.selectionSet !== void 0 ? selectionSetToShape(selection.selectionSet) : { type: "unknown" };
|
|
409
|
+
properties[key] = mergeShapes(properties[key], selected);
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
if (selection.kind === import_graphql2.Kind.INLINE_FRAGMENT) {
|
|
413
|
+
mergeSelectionsInto(properties, selection.selectionSet.selections);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
function mergeShapes(existing, incoming) {
|
|
418
|
+
if (existing === void 0) {
|
|
419
|
+
return incoming;
|
|
420
|
+
}
|
|
421
|
+
if (existing.type === "record" && incoming.type === "record") {
|
|
422
|
+
const properties = { ...existing.properties };
|
|
423
|
+
for (const [key, shape] of Object.entries(incoming.properties)) {
|
|
424
|
+
properties[key] = mergeShapes(properties[key], shape);
|
|
425
|
+
}
|
|
426
|
+
return { type: "record", properties };
|
|
427
|
+
}
|
|
428
|
+
if (existing.type === "record") {
|
|
429
|
+
return existing;
|
|
430
|
+
}
|
|
431
|
+
return incoming;
|
|
432
|
+
}
|
|
433
|
+
function safeParse(text) {
|
|
434
|
+
try {
|
|
435
|
+
return (0, import_graphql2.parse)(text);
|
|
436
|
+
} catch {
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// src/index.ts
|
|
41
442
|
var ROOT_TYPES = ["Query", "Mutation", "Subscription"];
|
|
42
443
|
function graphqlSdlToSummaries(sdl, options = {}) {
|
|
43
444
|
let doc;
|
|
44
445
|
try {
|
|
45
|
-
doc = (0,
|
|
446
|
+
doc = (0, import_graphql3.parse)(sdl);
|
|
46
447
|
} catch (_err) {
|
|
47
448
|
return [];
|
|
48
449
|
}
|
|
@@ -59,7 +460,7 @@ function graphqlSdlToSummaries(sdl, options = {}) {
|
|
|
59
460
|
return out;
|
|
60
461
|
}
|
|
61
462
|
function graphqlSdlFileToSummaries(filepath, options = {}) {
|
|
62
|
-
const sdl =
|
|
463
|
+
const sdl = import_node_fs2.default.readFileSync(filepath, "utf8");
|
|
63
464
|
return graphqlSdlToSummaries(sdl, {
|
|
64
465
|
source: filepath,
|
|
65
466
|
...options
|
|
@@ -67,7 +468,7 @@ function graphqlSdlFileToSummaries(filepath, options = {}) {
|
|
|
67
468
|
}
|
|
68
469
|
function loadSdlFile(filepath) {
|
|
69
470
|
try {
|
|
70
|
-
return
|
|
471
|
+
return import_node_fs2.default.readFileSync(filepath, "utf8");
|
|
71
472
|
} catch {
|
|
72
473
|
return null;
|
|
73
474
|
}
|
|
@@ -75,7 +476,7 @@ function loadSdlFile(filepath) {
|
|
|
75
476
|
function collectRootFields(doc) {
|
|
76
477
|
const fieldsByRoot = /* @__PURE__ */ new Map();
|
|
77
478
|
for (const def of doc.definitions) {
|
|
78
|
-
const node = def.kind ===
|
|
479
|
+
const node = def.kind === import_graphql3.Kind.OBJECT_TYPE_DEFINITION || def.kind === import_graphql3.Kind.OBJECT_TYPE_EXTENSION ? def : null;
|
|
79
480
|
if (node === null) {
|
|
80
481
|
continue;
|
|
81
482
|
}
|
|
@@ -117,7 +518,7 @@ function buildResolverSummary(rootType, field, source, recognition, transport) {
|
|
|
117
518
|
identity: {
|
|
118
519
|
name: ownerKey,
|
|
119
520
|
exportPath: null,
|
|
120
|
-
boundaryBinding: (0,
|
|
521
|
+
boundaryBinding: (0, import_behavioral_ir2.graphqlResolverBinding)({
|
|
121
522
|
transport,
|
|
122
523
|
recognition,
|
|
123
524
|
typeName: rootType,
|
|
@@ -125,7 +526,7 @@ function buildResolverSummary(rootType, field, source, recognition, transport) {
|
|
|
125
526
|
})
|
|
126
527
|
},
|
|
127
528
|
inputs: buildInputs(field),
|
|
128
|
-
transitions:
|
|
529
|
+
transitions: buildTransitions2(ownerKey, field),
|
|
129
530
|
gaps: [],
|
|
130
531
|
confidence: { source: "derived", level: "high" },
|
|
131
532
|
metadata: {
|
|
@@ -148,7 +549,7 @@ function buildDeclaredContract(field, recognition) {
|
|
|
148
549
|
args: (field.arguments ?? []).map((arg) => ({
|
|
149
550
|
name: arg.name.value,
|
|
150
551
|
type: typeNodeToShape(arg.type),
|
|
151
|
-
required: arg.type.kind ===
|
|
552
|
+
required: arg.type.kind === import_graphql3.Kind.NON_NULL_TYPE
|
|
152
553
|
})),
|
|
153
554
|
provenance: "derived",
|
|
154
555
|
framework: recognition
|
|
@@ -164,7 +565,7 @@ function buildInputs(field) {
|
|
|
164
565
|
shape: typeNodeToShape(arg.type)
|
|
165
566
|
}));
|
|
166
567
|
}
|
|
167
|
-
function
|
|
568
|
+
function buildTransitions2(ownerKey, field) {
|
|
168
569
|
const returnShape = typeNodeToShape(field.type);
|
|
169
570
|
return [
|
|
170
571
|
{
|
|
@@ -203,32 +604,11 @@ function buildTransitions(ownerKey, field) {
|
|
|
203
604
|
}
|
|
204
605
|
];
|
|
205
606
|
}
|
|
206
|
-
function typeNodeToShape(node) {
|
|
207
|
-
if (node.kind === import_graphql.Kind.NON_NULL_TYPE) {
|
|
208
|
-
return typeNodeToShape(node.type);
|
|
209
|
-
}
|
|
210
|
-
if (node.kind === import_graphql.Kind.LIST_TYPE) {
|
|
211
|
-
return { type: "array", items: typeNodeToShape(node.type) };
|
|
212
|
-
}
|
|
213
|
-
return scalarOrRef(node);
|
|
214
|
-
}
|
|
215
|
-
function scalarOrRef(node) {
|
|
216
|
-
const name = node.name.value;
|
|
217
|
-
switch (name) {
|
|
218
|
-
case "String":
|
|
219
|
-
case "ID":
|
|
220
|
-
return { type: "text" };
|
|
221
|
-
case "Int":
|
|
222
|
-
case "Float":
|
|
223
|
-
return { type: "number" };
|
|
224
|
-
case "Boolean":
|
|
225
|
-
return { type: "boolean" };
|
|
226
|
-
default:
|
|
227
|
-
return { type: "ref", name };
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
607
|
// Annotate the CommonJS export names for ESM import in node:
|
|
231
608
|
0 && (module.exports = {
|
|
609
|
+
graphqlDocumentFilesToSummaries,
|
|
610
|
+
graphqlDocumentsPathToSummaries,
|
|
611
|
+
graphqlDocumentsToSummaries,
|
|
232
612
|
graphqlSdlFileToSummaries,
|
|
233
613
|
graphqlSdlToSummaries,
|
|
234
614
|
loadSdlFile
|
package/dist/index.d.cts
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
import { BehavioralSummary } from '@suss/behavioral-ir';
|
|
2
2
|
|
|
3
|
+
interface GraphqlDocumentsOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Recognition tag for the operation binding. Defaults to
|
|
6
|
+
* `"graphql-documents"` so findings distinguish document-derived
|
|
7
|
+
* operations from call-site-traced ones.
|
|
8
|
+
*/
|
|
9
|
+
recognition?: string;
|
|
10
|
+
/**
|
|
11
|
+
* Transport to record on the boundary binding. Defaults to
|
|
12
|
+
* `"http-graphql"` to match the schema reader's resolver side.
|
|
13
|
+
*/
|
|
14
|
+
transport?: string;
|
|
15
|
+
/**
|
|
16
|
+
* Directory the document paths hang off. An anonymous operation is
|
|
17
|
+
* named after its path relative to this root, so the name stays the
|
|
18
|
+
* same on every machine that checks the repo out. Without it the
|
|
19
|
+
* path is used as given.
|
|
20
|
+
*/
|
|
21
|
+
rootDir?: string;
|
|
22
|
+
}
|
|
23
|
+
/** One parsed source document, tagged with where it came from. */
|
|
24
|
+
interface DocumentSource {
|
|
25
|
+
/** Path recorded on each summary's `location.file`. */
|
|
26
|
+
path: string;
|
|
27
|
+
/** Raw document text. */
|
|
28
|
+
text: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Convert in-memory operation documents into summaries. Fragments are
|
|
32
|
+
* resolved across the whole set, so a fragment-only document
|
|
33
|
+
* contributes definitions without producing summaries of its own.
|
|
34
|
+
*/
|
|
35
|
+
declare function graphqlDocumentsToSummaries(sources: DocumentSource[], options?: GraphqlDocumentsOptions): BehavioralSummary[];
|
|
36
|
+
/**
|
|
37
|
+
* Read `.graphql` / `.gql` files from disk and convert them to
|
|
38
|
+
* summaries. Unreadable files are skipped, so one bad path does not
|
|
39
|
+
* lose the rest of the set.
|
|
40
|
+
*/
|
|
41
|
+
declare function graphqlDocumentFilesToSummaries(filepaths: string[], options?: GraphqlDocumentsOptions): BehavioralSummary[];
|
|
42
|
+
/**
|
|
43
|
+
* Accept either a single document file or a directory to walk
|
|
44
|
+
* recursively for `.graphql` / `.gql` files. Entry point for the
|
|
45
|
+
* CLI's `suss contract --from graphql-documents <path>`.
|
|
46
|
+
*/
|
|
47
|
+
declare function graphqlDocumentsPathToSummaries(specPath: string, options?: GraphqlDocumentsOptions): BehavioralSummary[];
|
|
48
|
+
|
|
3
49
|
interface GraphqlContractOptions {
|
|
4
50
|
/**
|
|
5
51
|
* Logical source path recorded on each summary's `location.file`.
|
|
@@ -42,4 +88,4 @@ declare function graphqlSdlFileToSummaries(filepath: string, options?: GraphqlCo
|
|
|
42
88
|
*/
|
|
43
89
|
declare function loadSdlFile(filepath: string): string | null;
|
|
44
90
|
|
|
45
|
-
export { type GraphqlContractOptions, graphqlSdlFileToSummaries, graphqlSdlToSummaries, loadSdlFile };
|
|
91
|
+
export { type GraphqlContractOptions, type GraphqlDocumentsOptions, graphqlDocumentFilesToSummaries, graphqlDocumentsPathToSummaries, graphqlDocumentsToSummaries, graphqlSdlFileToSummaries, graphqlSdlToSummaries, loadSdlFile };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
import { BehavioralSummary } from '@suss/behavioral-ir';
|
|
2
2
|
|
|
3
|
+
interface GraphqlDocumentsOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Recognition tag for the operation binding. Defaults to
|
|
6
|
+
* `"graphql-documents"` so findings distinguish document-derived
|
|
7
|
+
* operations from call-site-traced ones.
|
|
8
|
+
*/
|
|
9
|
+
recognition?: string;
|
|
10
|
+
/**
|
|
11
|
+
* Transport to record on the boundary binding. Defaults to
|
|
12
|
+
* `"http-graphql"` to match the schema reader's resolver side.
|
|
13
|
+
*/
|
|
14
|
+
transport?: string;
|
|
15
|
+
/**
|
|
16
|
+
* Directory the document paths hang off. An anonymous operation is
|
|
17
|
+
* named after its path relative to this root, so the name stays the
|
|
18
|
+
* same on every machine that checks the repo out. Without it the
|
|
19
|
+
* path is used as given.
|
|
20
|
+
*/
|
|
21
|
+
rootDir?: string;
|
|
22
|
+
}
|
|
23
|
+
/** One parsed source document, tagged with where it came from. */
|
|
24
|
+
interface DocumentSource {
|
|
25
|
+
/** Path recorded on each summary's `location.file`. */
|
|
26
|
+
path: string;
|
|
27
|
+
/** Raw document text. */
|
|
28
|
+
text: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Convert in-memory operation documents into summaries. Fragments are
|
|
32
|
+
* resolved across the whole set, so a fragment-only document
|
|
33
|
+
* contributes definitions without producing summaries of its own.
|
|
34
|
+
*/
|
|
35
|
+
declare function graphqlDocumentsToSummaries(sources: DocumentSource[], options?: GraphqlDocumentsOptions): BehavioralSummary[];
|
|
36
|
+
/**
|
|
37
|
+
* Read `.graphql` / `.gql` files from disk and convert them to
|
|
38
|
+
* summaries. Unreadable files are skipped, so one bad path does not
|
|
39
|
+
* lose the rest of the set.
|
|
40
|
+
*/
|
|
41
|
+
declare function graphqlDocumentFilesToSummaries(filepaths: string[], options?: GraphqlDocumentsOptions): BehavioralSummary[];
|
|
42
|
+
/**
|
|
43
|
+
* Accept either a single document file or a directory to walk
|
|
44
|
+
* recursively for `.graphql` / `.gql` files. Entry point for the
|
|
45
|
+
* CLI's `suss contract --from graphql-documents <path>`.
|
|
46
|
+
*/
|
|
47
|
+
declare function graphqlDocumentsPathToSummaries(specPath: string, options?: GraphqlDocumentsOptions): BehavioralSummary[];
|
|
48
|
+
|
|
3
49
|
interface GraphqlContractOptions {
|
|
4
50
|
/**
|
|
5
51
|
* Logical source path recorded on each summary's `location.file`.
|
|
@@ -42,4 +88,4 @@ declare function graphqlSdlFileToSummaries(filepath: string, options?: GraphqlCo
|
|
|
42
88
|
*/
|
|
43
89
|
declare function loadSdlFile(filepath: string): string | null;
|
|
44
90
|
|
|
45
|
-
export { type GraphqlContractOptions, graphqlSdlFileToSummaries, graphqlSdlToSummaries, loadSdlFile };
|
|
91
|
+
export { type GraphqlContractOptions, type GraphqlDocumentsOptions, graphqlDocumentFilesToSummaries, graphqlDocumentsPathToSummaries, graphqlDocumentsToSummaries, graphqlSdlFileToSummaries, graphqlSdlToSummaries, loadSdlFile };
|
package/dist/index.js
CHANGED
|
@@ -1,15 +1,417 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import
|
|
2
|
+
import fs2 from "fs";
|
|
3
3
|
import {
|
|
4
|
-
Kind,
|
|
5
|
-
parse
|
|
4
|
+
Kind as Kind3,
|
|
5
|
+
parse as parse2
|
|
6
6
|
} from "graphql";
|
|
7
7
|
import { graphqlResolverBinding } from "@suss/behavioral-ir";
|
|
8
|
+
|
|
9
|
+
// src/typeShape.ts
|
|
10
|
+
import { Kind } from "graphql";
|
|
11
|
+
var SCALAR_SHAPES = {
|
|
12
|
+
String: { type: "text" },
|
|
13
|
+
ID: { type: "text" },
|
|
14
|
+
Int: { type: "number" },
|
|
15
|
+
Float: { type: "number" },
|
|
16
|
+
Boolean: { type: "boolean" }
|
|
17
|
+
};
|
|
18
|
+
function typeNodeToShape(node) {
|
|
19
|
+
if (node.kind === Kind.NON_NULL_TYPE) {
|
|
20
|
+
return typeNodeToShape(node.type);
|
|
21
|
+
}
|
|
22
|
+
if (node.kind === Kind.LIST_TYPE) {
|
|
23
|
+
return { type: "array", items: typeNodeToShape(node.type) };
|
|
24
|
+
}
|
|
25
|
+
return scalarOrRef(node);
|
|
26
|
+
}
|
|
27
|
+
function scalarOrRef(node) {
|
|
28
|
+
const name = node.name.value;
|
|
29
|
+
return SCALAR_SHAPES[name] ?? { type: "ref", name };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// src/documents.ts
|
|
33
|
+
import fs from "fs";
|
|
34
|
+
import path from "path";
|
|
35
|
+
import {
|
|
36
|
+
Kind as Kind2,
|
|
37
|
+
parse,
|
|
38
|
+
print
|
|
39
|
+
} from "graphql";
|
|
40
|
+
import { graphqlOperationBinding } from "@suss/behavioral-ir";
|
|
41
|
+
function graphqlDocumentsToSummaries(sources, options = {}) {
|
|
42
|
+
const recognition = options.recognition ?? "graphql-documents";
|
|
43
|
+
const transport = options.transport ?? "http-graphql";
|
|
44
|
+
const parsed = [];
|
|
45
|
+
for (const source of sources) {
|
|
46
|
+
const doc = safeParse(source.text);
|
|
47
|
+
if (doc === null) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
parsed.push({ source, doc });
|
|
51
|
+
}
|
|
52
|
+
const fragments = collectFragments(parsed);
|
|
53
|
+
const takenNames = /* @__PURE__ */ new Set();
|
|
54
|
+
const out = [];
|
|
55
|
+
for (const { source, doc } of parsed) {
|
|
56
|
+
for (const def of doc.definitions) {
|
|
57
|
+
if (def.kind !== Kind2.OPERATION_DEFINITION) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const name = distinctName(
|
|
61
|
+
operationName(def, source.path, options.rootDir),
|
|
62
|
+
takenNames
|
|
63
|
+
);
|
|
64
|
+
out.push(
|
|
65
|
+
buildOperationSummary(def, name, source.path, fragments, {
|
|
66
|
+
recognition,
|
|
67
|
+
transport
|
|
68
|
+
})
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
function operationName(op, file, rootDir) {
|
|
75
|
+
const declared = op.name?.value;
|
|
76
|
+
if (declared !== void 0) {
|
|
77
|
+
return declared;
|
|
78
|
+
}
|
|
79
|
+
return `${displayPath(file, rootDir)}:${op.operation}`;
|
|
80
|
+
}
|
|
81
|
+
function displayPath(file, rootDir) {
|
|
82
|
+
if (rootDir === void 0) {
|
|
83
|
+
return file;
|
|
84
|
+
}
|
|
85
|
+
const relative = path.relative(rootDir, file);
|
|
86
|
+
if (relative === "" || relative.startsWith("..")) {
|
|
87
|
+
return file;
|
|
88
|
+
}
|
|
89
|
+
return relative.split(path.sep).join("/");
|
|
90
|
+
}
|
|
91
|
+
function distinctName(candidate, taken) {
|
|
92
|
+
let name = candidate;
|
|
93
|
+
let suffix = 2;
|
|
94
|
+
while (taken.has(name)) {
|
|
95
|
+
name = `${candidate}#${suffix}`;
|
|
96
|
+
suffix += 1;
|
|
97
|
+
}
|
|
98
|
+
taken.add(name);
|
|
99
|
+
return name;
|
|
100
|
+
}
|
|
101
|
+
function graphqlDocumentFilesToSummaries(filepaths, options = {}) {
|
|
102
|
+
const sources = [];
|
|
103
|
+
for (const filepath of filepaths) {
|
|
104
|
+
let text;
|
|
105
|
+
try {
|
|
106
|
+
text = fs.readFileSync(filepath, "utf8");
|
|
107
|
+
} catch {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
sources.push({ path: filepath, text });
|
|
111
|
+
}
|
|
112
|
+
return graphqlDocumentsToSummaries(sources, options);
|
|
113
|
+
}
|
|
114
|
+
function graphqlDocumentsPathToSummaries(specPath, options = {}) {
|
|
115
|
+
const absolute = path.resolve(specPath);
|
|
116
|
+
if (!fs.existsSync(absolute)) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`No GraphQL documents found at "${specPath}". Pass a .graphql/.gql file or a directory containing them.`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
const stat = fs.statSync(absolute);
|
|
122
|
+
const isDirectory = stat.isDirectory();
|
|
123
|
+
const files = isDirectory ? walkForDocuments(absolute) : [absolute];
|
|
124
|
+
return graphqlDocumentFilesToSummaries(files, {
|
|
125
|
+
rootDir: isDirectory ? absolute : path.dirname(absolute),
|
|
126
|
+
...options
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
function walkForDocuments(dir) {
|
|
130
|
+
const out = [];
|
|
131
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
132
|
+
for (const entry of entries) {
|
|
133
|
+
const full = path.join(dir, entry.name);
|
|
134
|
+
if (entry.isFile()) {
|
|
135
|
+
if (/\.(graphql|gql)$/.test(entry.name)) {
|
|
136
|
+
out.push(full);
|
|
137
|
+
}
|
|
138
|
+
} else if (entry.isDirectory() && entry.name !== "node_modules") {
|
|
139
|
+
out.push(...walkForDocuments(full));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return out.sort();
|
|
143
|
+
}
|
|
144
|
+
function collectFragments(parsed) {
|
|
145
|
+
const definitions = /* @__PURE__ */ new Map();
|
|
146
|
+
const definingFiles = /* @__PURE__ */ new Map();
|
|
147
|
+
for (const { source, doc } of parsed) {
|
|
148
|
+
for (const def of doc.definitions) {
|
|
149
|
+
if (def.kind !== Kind2.FRAGMENT_DEFINITION) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const name = def.name.value;
|
|
153
|
+
if (!definitions.has(name)) {
|
|
154
|
+
definitions.set(name, def);
|
|
155
|
+
}
|
|
156
|
+
definingFiles.set(name, [
|
|
157
|
+
...definingFiles.get(name) ?? [],
|
|
158
|
+
source.path
|
|
159
|
+
]);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const competingFiles = /* @__PURE__ */ new Map();
|
|
163
|
+
for (const [name, files] of definingFiles) {
|
|
164
|
+
if (files.length > 1) {
|
|
165
|
+
competingFiles.set(name, files);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return { definitions, competingFiles };
|
|
169
|
+
}
|
|
170
|
+
function buildOperationSummary(op, name, file, fragments, config) {
|
|
171
|
+
const operationType = op.operation;
|
|
172
|
+
const operationName2 = op.name?.value;
|
|
173
|
+
const unexpanded = emptyUnexpandedSpreads();
|
|
174
|
+
const inlined = inlineSpreadsInOperation(op, fragments, unexpanded);
|
|
175
|
+
const documentText = print(documentOf(inlined));
|
|
176
|
+
const responseShape = selectionSetToShape(inlined.selectionSet);
|
|
177
|
+
return {
|
|
178
|
+
kind: "client",
|
|
179
|
+
location: {
|
|
180
|
+
file,
|
|
181
|
+
range: { start: op.loc?.start ?? 0, end: op.loc?.end ?? 0 },
|
|
182
|
+
exportName: null
|
|
183
|
+
},
|
|
184
|
+
identity: {
|
|
185
|
+
name,
|
|
186
|
+
exportPath: null,
|
|
187
|
+
boundaryBinding: graphqlOperationBinding({
|
|
188
|
+
transport: config.transport,
|
|
189
|
+
recognition: config.recognition,
|
|
190
|
+
operationType,
|
|
191
|
+
...operationName2 !== void 0 ? { operationName: operationName2 } : {}
|
|
192
|
+
})
|
|
193
|
+
},
|
|
194
|
+
inputs: buildVariableInputs(op),
|
|
195
|
+
transitions: buildTransitions(name, responseShape),
|
|
196
|
+
gaps: unexpandedSpreadGaps(unexpanded, fragments),
|
|
197
|
+
confidence: { source: "declared", level: "high" },
|
|
198
|
+
metadata: {
|
|
199
|
+
graphql: {
|
|
200
|
+
document: documentText,
|
|
201
|
+
...unexpanded.missing.size > 0 ? { unresolvedFragments: [...unexpanded.missing].sort() } : {}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
function buildVariableInputs(op) {
|
|
207
|
+
const variables = op.variableDefinitions ?? [];
|
|
208
|
+
return variables.map((variable, index) => ({
|
|
209
|
+
type: "parameter",
|
|
210
|
+
name: variable.variable.name.value,
|
|
211
|
+
position: index,
|
|
212
|
+
// Same role the TypeScript adapter stamps on operation-header
|
|
213
|
+
// `$variables`, so downstream consumers treat both alike.
|
|
214
|
+
role: "variable",
|
|
215
|
+
shape: typeNodeToShape(variable.type)
|
|
216
|
+
}));
|
|
217
|
+
}
|
|
218
|
+
function buildTransitions(name, responseShape) {
|
|
219
|
+
return [
|
|
220
|
+
{
|
|
221
|
+
id: `${name}:return:success`,
|
|
222
|
+
conditions: [],
|
|
223
|
+
output: { type: "return", value: responseShape },
|
|
224
|
+
effects: [],
|
|
225
|
+
location: { start: 0, end: 0 },
|
|
226
|
+
isDefault: true,
|
|
227
|
+
confidence: { source: "declared", level: "high" },
|
|
228
|
+
metadata: {
|
|
229
|
+
source: "graphql:operation.success"
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
id: `${name}:throw:error`,
|
|
234
|
+
conditions: [
|
|
235
|
+
{
|
|
236
|
+
type: "opaque",
|
|
237
|
+
sourceText: "graphql:operation-error",
|
|
238
|
+
reason: "externalFunction"
|
|
239
|
+
}
|
|
240
|
+
],
|
|
241
|
+
output: {
|
|
242
|
+
type: "throw",
|
|
243
|
+
exceptionType: "GraphQLError",
|
|
244
|
+
message: null
|
|
245
|
+
},
|
|
246
|
+
effects: [],
|
|
247
|
+
location: { start: 0, end: 0 },
|
|
248
|
+
isDefault: false,
|
|
249
|
+
confidence: { source: "declared", level: "low" },
|
|
250
|
+
metadata: {
|
|
251
|
+
source: "graphql:operation.error"
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
];
|
|
255
|
+
}
|
|
256
|
+
function unexpandedSpreadGaps(unexpanded, fragments) {
|
|
257
|
+
const gaps = [];
|
|
258
|
+
for (const name of [...unexpanded.missing].sort()) {
|
|
259
|
+
gaps.push(
|
|
260
|
+
// A statement about the reading, not the code: the fragment
|
|
261
|
+
// likely exists somewhere outside the files handed to this
|
|
262
|
+
// reader.
|
|
263
|
+
readingGap(
|
|
264
|
+
`Fragment spread "...${name}" has no matching fragment definition in the read set; its selections are not part of this summary.`
|
|
265
|
+
)
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
for (const name of [...unexpanded.cyclic].sort()) {
|
|
269
|
+
gaps.push(
|
|
270
|
+
readingGap(
|
|
271
|
+
`Fragment spread "...${name}" is part of a fragment cycle, which no server would execute; the repeated spread was left unexpanded.`
|
|
272
|
+
)
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
for (const name of [...unexpanded.ambiguous].sort()) {
|
|
276
|
+
const files = fragments.competingFiles.get(name) ?? [];
|
|
277
|
+
gaps.push(
|
|
278
|
+
readingGap(
|
|
279
|
+
`Fragment "${name}" is defined in more than one file (${files.join(", ")}); the first definition was used, so the selections here may not be the ones the build resolves.`
|
|
280
|
+
)
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
return gaps;
|
|
284
|
+
}
|
|
285
|
+
function readingGap(description) {
|
|
286
|
+
return {
|
|
287
|
+
type: "unreadOutcome",
|
|
288
|
+
conditions: [],
|
|
289
|
+
consequence: "unknown",
|
|
290
|
+
description
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
function emptyUnexpandedSpreads() {
|
|
294
|
+
return { missing: /* @__PURE__ */ new Set(), cyclic: /* @__PURE__ */ new Set(), ambiguous: /* @__PURE__ */ new Set() };
|
|
295
|
+
}
|
|
296
|
+
function inlineSpreadsInOperation(op, fragments, unexpanded) {
|
|
297
|
+
return {
|
|
298
|
+
...op,
|
|
299
|
+
selectionSet: inlineSelectionSet(
|
|
300
|
+
op.selectionSet,
|
|
301
|
+
fragments,
|
|
302
|
+
[],
|
|
303
|
+
unexpanded
|
|
304
|
+
)
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
function inlineSelectionSet(selectionSet, fragments, stack, unexpanded) {
|
|
308
|
+
const out = [];
|
|
309
|
+
for (const selection of selectionSet.selections) {
|
|
310
|
+
out.push(...inlineOneSelection(selection, fragments, stack, unexpanded));
|
|
311
|
+
}
|
|
312
|
+
return { ...selectionSet, selections: out };
|
|
313
|
+
}
|
|
314
|
+
function inlineOneSelection(selection, fragments, stack, unexpanded) {
|
|
315
|
+
if (selection.kind === Kind2.FIELD) {
|
|
316
|
+
if (selection.selectionSet === void 0) {
|
|
317
|
+
return [selection];
|
|
318
|
+
}
|
|
319
|
+
const field = {
|
|
320
|
+
...selection,
|
|
321
|
+
selectionSet: inlineSelectionSet(
|
|
322
|
+
selection.selectionSet,
|
|
323
|
+
fragments,
|
|
324
|
+
stack,
|
|
325
|
+
unexpanded
|
|
326
|
+
)
|
|
327
|
+
};
|
|
328
|
+
return [field];
|
|
329
|
+
}
|
|
330
|
+
if (selection.kind === Kind2.FRAGMENT_SPREAD) {
|
|
331
|
+
const fragmentName = selection.name.value;
|
|
332
|
+
if (fragments.competingFiles.has(fragmentName)) {
|
|
333
|
+
unexpanded.ambiguous.add(fragmentName);
|
|
334
|
+
}
|
|
335
|
+
if (stack.includes(fragmentName)) {
|
|
336
|
+
unexpanded.cyclic.add(fragmentName);
|
|
337
|
+
return [selection];
|
|
338
|
+
}
|
|
339
|
+
const fragment = fragments.definitions.get(fragmentName);
|
|
340
|
+
if (fragment === void 0) {
|
|
341
|
+
unexpanded.missing.add(fragmentName);
|
|
342
|
+
return [selection];
|
|
343
|
+
}
|
|
344
|
+
const inlined = inlineSelectionSet(
|
|
345
|
+
fragment.selectionSet,
|
|
346
|
+
fragments,
|
|
347
|
+
[...stack, fragmentName],
|
|
348
|
+
unexpanded
|
|
349
|
+
);
|
|
350
|
+
return [...inlined.selections];
|
|
351
|
+
}
|
|
352
|
+
const inlineFragment = {
|
|
353
|
+
...selection,
|
|
354
|
+
selectionSet: inlineSelectionSet(
|
|
355
|
+
selection.selectionSet,
|
|
356
|
+
fragments,
|
|
357
|
+
stack,
|
|
358
|
+
unexpanded
|
|
359
|
+
)
|
|
360
|
+
};
|
|
361
|
+
return [inlineFragment];
|
|
362
|
+
}
|
|
363
|
+
function documentOf(op) {
|
|
364
|
+
const definitions = [op];
|
|
365
|
+
return { kind: Kind2.DOCUMENT, definitions };
|
|
366
|
+
}
|
|
367
|
+
function selectionSetToShape(selectionSet) {
|
|
368
|
+
const properties = {};
|
|
369
|
+
mergeSelectionsInto(properties, selectionSet.selections);
|
|
370
|
+
return { type: "record", properties };
|
|
371
|
+
}
|
|
372
|
+
function mergeSelectionsInto(properties, selections) {
|
|
373
|
+
for (const selection of selections) {
|
|
374
|
+
if (selection.kind === Kind2.FIELD) {
|
|
375
|
+
const key = selection.alias?.value ?? selection.name.value;
|
|
376
|
+
const selected = selection.selectionSet !== void 0 ? selectionSetToShape(selection.selectionSet) : { type: "unknown" };
|
|
377
|
+
properties[key] = mergeShapes(properties[key], selected);
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
if (selection.kind === Kind2.INLINE_FRAGMENT) {
|
|
381
|
+
mergeSelectionsInto(properties, selection.selectionSet.selections);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
function mergeShapes(existing, incoming) {
|
|
386
|
+
if (existing === void 0) {
|
|
387
|
+
return incoming;
|
|
388
|
+
}
|
|
389
|
+
if (existing.type === "record" && incoming.type === "record") {
|
|
390
|
+
const properties = { ...existing.properties };
|
|
391
|
+
for (const [key, shape] of Object.entries(incoming.properties)) {
|
|
392
|
+
properties[key] = mergeShapes(properties[key], shape);
|
|
393
|
+
}
|
|
394
|
+
return { type: "record", properties };
|
|
395
|
+
}
|
|
396
|
+
if (existing.type === "record") {
|
|
397
|
+
return existing;
|
|
398
|
+
}
|
|
399
|
+
return incoming;
|
|
400
|
+
}
|
|
401
|
+
function safeParse(text) {
|
|
402
|
+
try {
|
|
403
|
+
return parse(text);
|
|
404
|
+
} catch {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// src/index.ts
|
|
8
410
|
var ROOT_TYPES = ["Query", "Mutation", "Subscription"];
|
|
9
411
|
function graphqlSdlToSummaries(sdl, options = {}) {
|
|
10
412
|
let doc;
|
|
11
413
|
try {
|
|
12
|
-
doc =
|
|
414
|
+
doc = parse2(sdl);
|
|
13
415
|
} catch (_err) {
|
|
14
416
|
return [];
|
|
15
417
|
}
|
|
@@ -26,7 +428,7 @@ function graphqlSdlToSummaries(sdl, options = {}) {
|
|
|
26
428
|
return out;
|
|
27
429
|
}
|
|
28
430
|
function graphqlSdlFileToSummaries(filepath, options = {}) {
|
|
29
|
-
const sdl =
|
|
431
|
+
const sdl = fs2.readFileSync(filepath, "utf8");
|
|
30
432
|
return graphqlSdlToSummaries(sdl, {
|
|
31
433
|
source: filepath,
|
|
32
434
|
...options
|
|
@@ -34,7 +436,7 @@ function graphqlSdlFileToSummaries(filepath, options = {}) {
|
|
|
34
436
|
}
|
|
35
437
|
function loadSdlFile(filepath) {
|
|
36
438
|
try {
|
|
37
|
-
return
|
|
439
|
+
return fs2.readFileSync(filepath, "utf8");
|
|
38
440
|
} catch {
|
|
39
441
|
return null;
|
|
40
442
|
}
|
|
@@ -42,7 +444,7 @@ function loadSdlFile(filepath) {
|
|
|
42
444
|
function collectRootFields(doc) {
|
|
43
445
|
const fieldsByRoot = /* @__PURE__ */ new Map();
|
|
44
446
|
for (const def of doc.definitions) {
|
|
45
|
-
const node = def.kind ===
|
|
447
|
+
const node = def.kind === Kind3.OBJECT_TYPE_DEFINITION || def.kind === Kind3.OBJECT_TYPE_EXTENSION ? def : null;
|
|
46
448
|
if (node === null) {
|
|
47
449
|
continue;
|
|
48
450
|
}
|
|
@@ -92,7 +494,7 @@ function buildResolverSummary(rootType, field, source, recognition, transport) {
|
|
|
92
494
|
})
|
|
93
495
|
},
|
|
94
496
|
inputs: buildInputs(field),
|
|
95
|
-
transitions:
|
|
497
|
+
transitions: buildTransitions2(ownerKey, field),
|
|
96
498
|
gaps: [],
|
|
97
499
|
confidence: { source: "derived", level: "high" },
|
|
98
500
|
metadata: {
|
|
@@ -115,7 +517,7 @@ function buildDeclaredContract(field, recognition) {
|
|
|
115
517
|
args: (field.arguments ?? []).map((arg) => ({
|
|
116
518
|
name: arg.name.value,
|
|
117
519
|
type: typeNodeToShape(arg.type),
|
|
118
|
-
required: arg.type.kind ===
|
|
520
|
+
required: arg.type.kind === Kind3.NON_NULL_TYPE
|
|
119
521
|
})),
|
|
120
522
|
provenance: "derived",
|
|
121
523
|
framework: recognition
|
|
@@ -131,7 +533,7 @@ function buildInputs(field) {
|
|
|
131
533
|
shape: typeNodeToShape(arg.type)
|
|
132
534
|
}));
|
|
133
535
|
}
|
|
134
|
-
function
|
|
536
|
+
function buildTransitions2(ownerKey, field) {
|
|
135
537
|
const returnShape = typeNodeToShape(field.type);
|
|
136
538
|
return [
|
|
137
539
|
{
|
|
@@ -170,31 +572,10 @@ function buildTransitions(ownerKey, field) {
|
|
|
170
572
|
}
|
|
171
573
|
];
|
|
172
574
|
}
|
|
173
|
-
function typeNodeToShape(node) {
|
|
174
|
-
if (node.kind === Kind.NON_NULL_TYPE) {
|
|
175
|
-
return typeNodeToShape(node.type);
|
|
176
|
-
}
|
|
177
|
-
if (node.kind === Kind.LIST_TYPE) {
|
|
178
|
-
return { type: "array", items: typeNodeToShape(node.type) };
|
|
179
|
-
}
|
|
180
|
-
return scalarOrRef(node);
|
|
181
|
-
}
|
|
182
|
-
function scalarOrRef(node) {
|
|
183
|
-
const name = node.name.value;
|
|
184
|
-
switch (name) {
|
|
185
|
-
case "String":
|
|
186
|
-
case "ID":
|
|
187
|
-
return { type: "text" };
|
|
188
|
-
case "Int":
|
|
189
|
-
case "Float":
|
|
190
|
-
return { type: "number" };
|
|
191
|
-
case "Boolean":
|
|
192
|
-
return { type: "boolean" };
|
|
193
|
-
default:
|
|
194
|
-
return { type: "ref", name };
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
575
|
export {
|
|
576
|
+
graphqlDocumentFilesToSummaries,
|
|
577
|
+
graphqlDocumentsPathToSummaries,
|
|
578
|
+
graphqlDocumentsToSummaries,
|
|
198
579
|
graphqlSdlFileToSummaries,
|
|
199
580
|
graphqlSdlToSummaries,
|
|
200
581
|
loadSdlFile
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suss/contract-graphql",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Generate behavioral summaries from a plain
|
|
5
|
+
"description": "Generate behavioral summaries from GraphQL sources: a plain SDL file (root fields become resolver-kind summaries) or committed .graphql operation documents (each operation becomes a client-kind summary).",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"suss",
|
|
8
8
|
"behavioral-summary",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"typecheck": "tsc --noEmit"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@suss/behavioral-ir": "0.
|
|
48
|
+
"@suss/behavioral-ir": "0.1.1",
|
|
49
49
|
"graphql": "^16.8.0"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|