@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 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 import_node_fs = __toESM(require("fs"), 1);
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, import_graphql.parse)(sdl);
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 = import_node_fs.default.readFileSync(filepath, "utf8");
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 import_node_fs.default.readFileSync(filepath, "utf8");
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 === import_graphql.Kind.OBJECT_TYPE_DEFINITION || def.kind === import_graphql.Kind.OBJECT_TYPE_EXTENSION ? def : null;
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, import_behavioral_ir.graphqlResolverBinding)({
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: buildTransitions(ownerKey, field),
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 === import_graphql.Kind.NON_NULL_TYPE
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 buildTransitions(ownerKey, field) {
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 fs from "fs";
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 = parse(sdl);
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 = fs.readFileSync(filepath, "utf8");
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 fs.readFileSync(filepath, "utf8");
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 === Kind.OBJECT_TYPE_DEFINITION || def.kind === Kind.OBJECT_TYPE_EXTENSION ? def : null;
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: buildTransitions(ownerKey, field),
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 === Kind.NON_NULL_TYPE
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 buildTransitions(ownerKey, field) {
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.0.2",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
- "description": "Generate behavioral summaries from a plain GraphQL SDL file (Query/Mutation/Subscription field definitions become resolver-kind summaries).",
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.0.2",
48
+ "@suss/behavioral-ir": "0.1.1",
49
49
  "graphql": "^16.8.0"
50
50
  },
51
51
  "devDependencies": {