@suss/contract-serverless 0.3.2-0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,582 @@
1
+ // src/index.ts
2
+ import path2 from "path";
3
+ import { nestedDocumentLabel } from "@suss/behavioral-ir";
4
+ import {
5
+ cloudFormationToSummaries,
6
+ documentSourceLabel
7
+ } from "@suss/contract-cloudformation";
8
+
9
+ // src/document.ts
10
+ import fs from "fs";
11
+ import path from "path";
12
+ import YAML from "yaml";
13
+ import { CLOUDFORMATION_YAML_TAGS } from "@suss/manifest-aws";
14
+ var SERVICE_FILE_NAMES = [
15
+ "serverless.yml",
16
+ "serverless.yaml",
17
+ "serverless.json"
18
+ ];
19
+ var PROGRAM_SERVICE_FILE_NAMES = [
20
+ "serverless.ts",
21
+ "serverless.js"
22
+ ];
23
+ function locateServiceFile(candidate) {
24
+ const resolved = path.resolve(candidate);
25
+ if (!fs.existsSync(resolved)) {
26
+ return { kind: "missing" };
27
+ }
28
+ if (fs.statSync(resolved).isFile()) {
29
+ return isProgram(resolved) ? { kind: "program", file: resolved } : { kind: "readable", file: resolved };
30
+ }
31
+ for (const name of SERVICE_FILE_NAMES) {
32
+ const file = path.join(resolved, name);
33
+ if (fs.existsSync(file)) {
34
+ return { kind: "readable", file };
35
+ }
36
+ }
37
+ for (const name of PROGRAM_SERVICE_FILE_NAMES) {
38
+ const file = path.join(resolved, name);
39
+ if (fs.existsSync(file)) {
40
+ return { kind: "program", file };
41
+ }
42
+ }
43
+ return { kind: "missing" };
44
+ }
45
+ function isProgram(file) {
46
+ const name = path.basename(file);
47
+ return PROGRAM_SERVICE_FILE_NAMES.includes(name);
48
+ }
49
+ function findServiceFile(candidate) {
50
+ const located = locateServiceFile(candidate);
51
+ return located.kind === "readable" ? located.file : null;
52
+ }
53
+ function loadServerlessDocument(servicePath) {
54
+ const file = findServiceFile(servicePath);
55
+ if (file === null) {
56
+ throw new Error(`Serverless service file not found: ${servicePath}`);
57
+ }
58
+ const raw = fs.readFileSync(file, "utf-8");
59
+ const parsed = file.endsWith(".json") ? JSON.parse(raw) : YAML.parse(raw, { customTags: CLOUDFORMATION_YAML_TAGS });
60
+ if (parsed === null || typeof parsed !== "object") {
61
+ throw new Error(`Serverless service file is not an object: ${file}`);
62
+ }
63
+ return parsed;
64
+ }
65
+
66
+ // src/events.ts
67
+ function readString(raw, ctx) {
68
+ if (typeof raw !== "string") {
69
+ return null;
70
+ }
71
+ const resolved = ctx.resolver.resolveString(raw);
72
+ return resolved.kind === "resolved" ? { value: resolved.value, symbolic: false } : { value: resolved.token, symbolic: true };
73
+ }
74
+ function asMap(raw) {
75
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
76
+ return null;
77
+ }
78
+ return raw;
79
+ }
80
+ function readRoute(raw, ctx) {
81
+ const asString = readString(raw, ctx);
82
+ if (asString !== null) {
83
+ if (asString.symbolic) {
84
+ return {
85
+ abstain: `route is written as ${asString.value}, which the document does not state`
86
+ };
87
+ }
88
+ const space = asString.value.trim().indexOf(" ");
89
+ if (space === -1) {
90
+ return {
91
+ abstain: `route "${asString.value}" names no method and path`
92
+ };
93
+ }
94
+ return {
95
+ method: asString.value.slice(0, space).trim().toUpperCase(),
96
+ path: withLeadingSlash(asString.value.slice(space + 1).trim())
97
+ };
98
+ }
99
+ const map = asMap(raw);
100
+ if (map === null) {
101
+ return { abstain: "event is neither a route string nor a map" };
102
+ }
103
+ const method = readString(map.method, ctx);
104
+ const path3 = readString(map.path, ctx);
105
+ if (method === null || path3 === null) {
106
+ return { abstain: "event names no method or no path" };
107
+ }
108
+ if (method.symbolic || path3.symbolic) {
109
+ return {
110
+ abstain: `route method or path is written as ${method.symbolic ? method.value : path3.value}, which the document does not state`
111
+ };
112
+ }
113
+ return {
114
+ method: method.value.toUpperCase(),
115
+ path: withLeadingSlash(path3.value)
116
+ };
117
+ }
118
+ function withLeadingSlash(path3) {
119
+ return path3.startsWith("/") ? path3 : `/${path3}`;
120
+ }
121
+ function readReference(raw, ctx) {
122
+ if (typeof raw !== "string") {
123
+ return raw;
124
+ }
125
+ const resolved = ctx.resolver.resolveString(raw);
126
+ return resolved.kind === "resolved" ? resolved.value : resolved.token;
127
+ }
128
+ var httpApiEvent = (raw, ctx) => {
129
+ const route = readRoute(raw, ctx);
130
+ if ("abstain" in route) {
131
+ return { kind: "abstained", reason: route.abstain };
132
+ }
133
+ return {
134
+ kind: "event",
135
+ event: {
136
+ Type: "HttpApi",
137
+ Properties: { Method: route.method, Path: route.path }
138
+ }
139
+ };
140
+ };
141
+ var httpEvent = (raw, ctx) => {
142
+ const route = readRoute(raw, ctx);
143
+ if ("abstain" in route) {
144
+ return { kind: "abstained", reason: route.abstain };
145
+ }
146
+ return {
147
+ kind: "event",
148
+ event: {
149
+ Type: "Api",
150
+ Properties: { Method: route.method, Path: route.path }
151
+ }
152
+ };
153
+ };
154
+ var sqsEvent = (raw, ctx) => {
155
+ const map = asMap(raw);
156
+ const arn = map === null ? raw : map.arn;
157
+ const queue = readReference(arn, ctx);
158
+ if (queue === void 0 || queue === null || queue === "") {
159
+ return { kind: "abstained", reason: "sqs event names no queue" };
160
+ }
161
+ return {
162
+ kind: "event",
163
+ event: { Type: "SQS", Properties: { Queue: queue } }
164
+ };
165
+ };
166
+ var snsEvent = (raw, ctx) => {
167
+ const map = asMap(raw);
168
+ const named = map === null ? raw : map.arn ?? map.topicName;
169
+ const topic = readReference(named, ctx);
170
+ if (topic === void 0 || topic === null || topic === "") {
171
+ return { kind: "abstained", reason: "sns event names no topic" };
172
+ }
173
+ const filterPolicy = map?.filterPolicy;
174
+ return {
175
+ kind: "event",
176
+ event: {
177
+ Type: "SNS",
178
+ Properties: {
179
+ Topic: topic,
180
+ ...filterPolicy !== void 0 ? { FilterPolicy: filterPolicy } : {}
181
+ }
182
+ }
183
+ };
184
+ };
185
+ var scheduleEvent = (raw, ctx) => {
186
+ const map = asMap(raw);
187
+ const rate = map === null ? raw : map.rate ?? map.schedule;
188
+ const expression = Array.isArray(rate) ? rate[0] : rate;
189
+ const stated = readReference(expression, ctx);
190
+ const enabled = map?.enabled;
191
+ return {
192
+ kind: "event",
193
+ event: {
194
+ Type: "Schedule",
195
+ Properties: {
196
+ ...typeof stated === "string" ? { Schedule: stated } : {},
197
+ ...typeof enabled === "boolean" ? { Enabled: enabled } : {}
198
+ }
199
+ }
200
+ };
201
+ };
202
+ var eventBridgeEvent = (raw, ctx) => {
203
+ const map = asMap(raw);
204
+ if (map === null) {
205
+ return { kind: "abstained", reason: "eventBridge event is not a map" };
206
+ }
207
+ if (map.schedule !== void 0 && map.pattern === void 0) {
208
+ return scheduleEvent({ rate: map.schedule, enabled: map.enabled }, ctx);
209
+ }
210
+ if (map.pattern === void 0) {
211
+ return {
212
+ kind: "abstained",
213
+ reason: "eventBridge event states neither a pattern nor a schedule"
214
+ };
215
+ }
216
+ const eventBus = readReference(map.eventBus, ctx);
217
+ return {
218
+ kind: "event",
219
+ event: {
220
+ Type: "EventBridgeRule",
221
+ Properties: {
222
+ Pattern: map.pattern,
223
+ ...eventBus !== void 0 && eventBus !== null ? { EventBusName: eventBus } : {}
224
+ }
225
+ }
226
+ };
227
+ };
228
+ var EVENT_TRANSLATIONS = {
229
+ httpApi: httpApiEvent,
230
+ http: httpEvent,
231
+ sqs: sqsEvent,
232
+ sns: snsEvent,
233
+ schedule: scheduleEvent,
234
+ eventBridge: eventBridgeEvent
235
+ };
236
+
237
+ // src/variables.ts
238
+ var REFERENCE = /\$\{([^{}]*)\}/g;
239
+ var FRAMEWORK_SOURCES = [
240
+ "self",
241
+ "env",
242
+ "opt",
243
+ "cf",
244
+ "s3",
245
+ "ssm",
246
+ "aws",
247
+ "param",
248
+ "sls",
249
+ "file",
250
+ "git"
251
+ ];
252
+ function namesFrameworkSource(body) {
253
+ const source = body.trim().split(/[:(]/, 1)[0].trim();
254
+ return FRAMEWORK_SOURCES.includes(source);
255
+ }
256
+ function createVariableResolver(document) {
257
+ function resolveString(raw, inProgress) {
258
+ const value = resolveValue(raw, inProgress);
259
+ if (value.kind === "symbolic") {
260
+ return value;
261
+ }
262
+ if (typeof value.value === "string") {
263
+ return { kind: "resolved", value: value.value };
264
+ }
265
+ if (typeof value.value === "number" || typeof value.value === "boolean") {
266
+ return { kind: "resolved", value: String(value.value) };
267
+ }
268
+ return { kind: "symbolic", token: raw };
269
+ }
270
+ function resolveValue(raw, inProgress) {
271
+ if (typeof raw !== "string") {
272
+ return { kind: "resolved", value: raw };
273
+ }
274
+ if (!raw.includes("${")) {
275
+ return { kind: "resolved", value: raw };
276
+ }
277
+ const wholeReference = wholeReferenceBody(raw);
278
+ if (wholeReference !== null) {
279
+ return resolveReference(wholeReference, inProgress);
280
+ }
281
+ let anyUnresolved = false;
282
+ const substituted = raw.replace(REFERENCE, (whole, body) => {
283
+ const resolved = resolveReference(body, inProgress);
284
+ if (resolved.kind === "resolved" && (typeof resolved.value === "string" || typeof resolved.value === "number" || typeof resolved.value === "boolean")) {
285
+ return String(resolved.value);
286
+ }
287
+ anyUnresolved = true;
288
+ return whole;
289
+ });
290
+ return anyUnresolved || substituted.includes("${") ? { kind: "symbolic", token: substituted } : { kind: "resolved", value: substituted };
291
+ }
292
+ function resolveReference(body, inProgress) {
293
+ const { primary, fallback } = splitFallback(body);
294
+ if (!primary.startsWith("self:")) {
295
+ return { kind: "symbolic", token: primary };
296
+ }
297
+ const path3 = primary.slice("self:".length);
298
+ if (inProgress.has(path3)) {
299
+ return { kind: "symbolic", token: primary };
300
+ }
301
+ const target = documentPath(document, path3);
302
+ if (target !== void 0) {
303
+ inProgress.add(path3);
304
+ const resolved = resolveValue(target, inProgress);
305
+ inProgress.delete(path3);
306
+ return resolved;
307
+ }
308
+ if (fallback !== null) {
309
+ const literal = literalFallback(fallback);
310
+ if (literal !== null) {
311
+ return { kind: "resolved", value: literal };
312
+ }
313
+ }
314
+ return { kind: "symbolic", token: primary };
315
+ }
316
+ function resolveTemplateString(raw) {
317
+ const whole = wholeReferenceBody(raw);
318
+ if (whole !== null && !namesFrameworkSource(whole)) {
319
+ return raw;
320
+ }
321
+ const resolved = resolveValue(raw, /* @__PURE__ */ new Set());
322
+ return resolved.kind === "resolved" ? resolved.value : resolved.token;
323
+ }
324
+ function resolveTemplateTree(value) {
325
+ if (typeof value === "string") {
326
+ return resolveTemplateString(value);
327
+ }
328
+ if (Array.isArray(value)) {
329
+ return value.map(resolveTemplateTree);
330
+ }
331
+ if (value === null || typeof value !== "object") {
332
+ return value;
333
+ }
334
+ const out = {};
335
+ for (const [key, entry] of Object.entries(
336
+ value
337
+ )) {
338
+ out[key] = resolveTemplateTree(entry);
339
+ }
340
+ return out;
341
+ }
342
+ return {
343
+ resolveValue: (raw) => resolveValue(raw, /* @__PURE__ */ new Set()),
344
+ resolveString: (raw) => resolveString(raw, /* @__PURE__ */ new Set()),
345
+ resolveTemplateTree
346
+ };
347
+ }
348
+ function wholeReferenceBody(raw) {
349
+ const trimmed = raw.trim();
350
+ const match = /^\$\{([^{}]*)\}$/.exec(trimmed);
351
+ return match === null ? null : match[1];
352
+ }
353
+ function splitFallback(body) {
354
+ const comma = body.indexOf(",");
355
+ if (comma === -1) {
356
+ return { primary: body.trim(), fallback: null };
357
+ }
358
+ return {
359
+ primary: body.slice(0, comma).trim(),
360
+ fallback: body.slice(comma + 1).trim()
361
+ };
362
+ }
363
+ function literalFallback(fallback) {
364
+ const quoted = /^'([^']*)'$/.exec(fallback) ?? /^"([^"]*)"$/.exec(fallback);
365
+ if (quoted !== null) {
366
+ return quoted[1];
367
+ }
368
+ if (/^-?\d+(\.\d+)?$/.test(fallback)) {
369
+ return fallback;
370
+ }
371
+ return null;
372
+ }
373
+ function documentPath(document, path3) {
374
+ if (path3 === "") {
375
+ return void 0;
376
+ }
377
+ let current = document;
378
+ for (const segment of path3.split(".")) {
379
+ if (current === null || typeof current !== "object") {
380
+ return void 0;
381
+ }
382
+ current = current[segment];
383
+ }
384
+ return current;
385
+ }
386
+
387
+ // src/translate.ts
388
+ var IMPLICIT_HTTP_API = "HttpApi";
389
+ var IMPLICIT_REST_API = "RestApi";
390
+ var IMPLICIT_API_FOR_EVENT = {
391
+ HttpApi: IMPLICIT_HTTP_API,
392
+ Api: IMPLICIT_REST_API
393
+ };
394
+ var IMPLICIT_API_TYPE = {
395
+ [IMPLICIT_HTTP_API]: "AWS::Serverless::HttpApi",
396
+ [IMPLICIT_REST_API]: "AWS::Serverless::Api"
397
+ };
398
+ function translateService(document) {
399
+ const resolver = createVariableResolver(document);
400
+ const unread = [];
401
+ const resources = {};
402
+ const implicitApis = /* @__PURE__ */ new Set();
403
+ if (document.plugins !== void 0) {
404
+ unread.push({
405
+ functionName: null,
406
+ kind: "plugins",
407
+ reason: "the service loads plugins, which can add, rename or rewrite functions and events; what they declare is not in this document"
408
+ });
409
+ }
410
+ for (const [functionName, definition] of Object.entries(
411
+ document.functions ?? {}
412
+ )) {
413
+ if (definition === null || typeof definition !== "object") {
414
+ continue;
415
+ }
416
+ const handler = resolver.resolveValue(definition.handler);
417
+ if (handler.kind !== "resolved" || typeof handler.value !== "string") {
418
+ unread.push({
419
+ functionName,
420
+ kind: "handler",
421
+ reason: "the function's handler is not a string this document states, so nothing names the code behind it"
422
+ });
423
+ continue;
424
+ }
425
+ const events = {};
426
+ const declared = Array.isArray(definition.events) ? definition.events : [];
427
+ for (const [index, entry] of declared.entries()) {
428
+ if (entry === null || typeof entry !== "object") {
429
+ continue;
430
+ }
431
+ for (const [kind, raw] of Object.entries(
432
+ entry
433
+ )) {
434
+ const translate = EVENT_TRANSLATIONS[kind];
435
+ if (translate === void 0) {
436
+ unread.push({
437
+ functionName,
438
+ kind,
439
+ reason: "this reader does not translate that event kind yet"
440
+ });
441
+ continue;
442
+ }
443
+ const translated = translate(raw, { resolver });
444
+ if (translated.kind === "abstained") {
445
+ unread.push({ functionName, kind, reason: translated.reason });
446
+ continue;
447
+ }
448
+ events[`${kind}${index}`] = translated.event;
449
+ const implicitApi = IMPLICIT_API_FOR_EVENT[translated.event.Type];
450
+ if (implicitApi !== void 0) {
451
+ implicitApis.add(implicitApi);
452
+ }
453
+ }
454
+ }
455
+ const runtime = statedString(resolver.resolveValue(definition.runtime));
456
+ resources[functionName] = {
457
+ Type: "AWS::Serverless::Function",
458
+ Properties: {
459
+ Handler: handler.value,
460
+ ...runtime !== null ? { Runtime: runtime } : {},
461
+ ...Object.keys(events).length > 0 ? { Events: events } : {},
462
+ Environment: {
463
+ Variables: environmentVariables(definition.environment, resolver)
464
+ }
465
+ }
466
+ };
467
+ }
468
+ for (const apiId of implicitApis) {
469
+ resources[apiId] = { Type: IMPLICIT_API_TYPE[apiId], Properties: {} };
470
+ }
471
+ return {
472
+ functions: {
473
+ Globals: providerGlobals(document, resolver),
474
+ Resources: resources
475
+ },
476
+ resources: rawResources(document, resolver),
477
+ unread
478
+ };
479
+ }
480
+ function providerGlobals(document, resolver) {
481
+ const provider = document.provider ?? {};
482
+ const runtime = statedString(resolver.resolveValue(provider.runtime));
483
+ return {
484
+ Function: {
485
+ CodeUri: ".",
486
+ ...runtime !== null ? { Runtime: runtime } : {},
487
+ Environment: {
488
+ Variables: environmentVariables(provider.environment, resolver)
489
+ }
490
+ }
491
+ };
492
+ }
493
+ function statedString(resolved) {
494
+ return resolved.kind === "resolved" && typeof resolved.value === "string" ? resolved.value : null;
495
+ }
496
+ function environmentVariables(raw, resolver) {
497
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
498
+ return {};
499
+ }
500
+ const out = {};
501
+ for (const [name, value] of Object.entries(raw)) {
502
+ const resolved = resolver.resolveValue(value);
503
+ out[name] = resolved.kind === "resolved" ? resolved.value : resolved.token;
504
+ }
505
+ return out;
506
+ }
507
+ function rawResources(document, resolver) {
508
+ const block = document.resources;
509
+ if (block === null || typeof block !== "object" || Array.isArray(block)) {
510
+ return null;
511
+ }
512
+ const declared = block.Resources;
513
+ if (declared === null || typeof declared !== "object") {
514
+ return null;
515
+ }
516
+ return {
517
+ Resources: resolver.resolveTemplateTree(declared)
518
+ };
519
+ }
520
+
521
+ // src/index.ts
522
+ var RECOGNITION = "serverless";
523
+ var RESOURCES_DOCUMENT = "resources";
524
+ function serverlessToSummaries(document, options = {}) {
525
+ const translated = translateService(document);
526
+ const report = options.onUnread ?? reportUnread;
527
+ for (const wiring of translated.unread) {
528
+ report(wiring);
529
+ }
530
+ const rootLabel = options.source ?? "serverless";
531
+ const summaries = cloudFormationToSummaries(translated.functions, {
532
+ source: rootLabel,
533
+ recognition: RECOGNITION
534
+ });
535
+ if (translated.resources === null) {
536
+ return summaries;
537
+ }
538
+ return [
539
+ ...summaries,
540
+ ...cloudFormationToSummaries(translated.resources, {
541
+ source: nestedDocumentLabel(rootLabel, [RESOURCES_DOCUMENT]),
542
+ recognition: RECOGNITION
543
+ })
544
+ ];
545
+ }
546
+ function serverlessFileToSummaries(servicePath, options = {}) {
547
+ const located = locateServiceFile(servicePath);
548
+ if (located.kind === "missing") {
549
+ throw new Error(`Serverless service file not found: ${servicePath}`);
550
+ }
551
+ if (located.kind === "program") {
552
+ const report = options.onUnread ?? reportUnread;
553
+ report({
554
+ functionName: null,
555
+ kind: path2.basename(located.file),
556
+ reason: "a program declares this service, and a reader does not run one to find out what it says"
557
+ });
558
+ return [];
559
+ }
560
+ const document = loadServerlessDocument(located.file);
561
+ return serverlessToSummaries(document, {
562
+ ...options,
563
+ source: options.source ?? documentSourceLabel("serverless", located.file)
564
+ });
565
+ }
566
+ function reportUnread(wiring) {
567
+ const where = wiring.functionName === null ? wiring.kind : `${wiring.functionName}.${wiring.kind}`;
568
+ process.stderr.write(`[suss] serverless: ${where}: ${wiring.reason}
569
+ `);
570
+ }
571
+ export {
572
+ EVENT_TRANSLATIONS,
573
+ PROGRAM_SERVICE_FILE_NAMES,
574
+ SERVICE_FILE_NAMES,
575
+ createVariableResolver,
576
+ findServiceFile,
577
+ loadServerlessDocument,
578
+ locateServiceFile,
579
+ serverlessFileToSummaries,
580
+ serverlessToSummaries,
581
+ translateService
582
+ };
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@suss/contract-serverless",
3
+ "version": "0.3.2-0",
4
+ "type": "module",
5
+ "description": "Generate suss behavioral summaries from Serverless Framework serverless.yml service files.",
6
+ "keywords": [
7
+ "suss",
8
+ "behavioral-summary",
9
+ "aws",
10
+ "serverless",
11
+ "serverless-framework",
12
+ "lambda"
13
+ ],
14
+ "license": "Apache-2.0",
15
+ "author": "Nimbus Cloud AI LLC",
16
+ "homepage": "https://github.com/nimbuscloud-ai/suss/tree/main/packages/contract/serverless#readme",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/nimbuscloud-ai/suss.git",
20
+ "directory": "packages/contract/serverless"
21
+ },
22
+ "bugs": {
23
+ "url": "https://github.com/nimbuscloud-ai/suss/issues"
24
+ },
25
+ "main": "./dist/index.cjs",
26
+ "module": "./dist/index.js",
27
+ "types": "./dist/index.d.ts",
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/index.d.ts",
31
+ "import": "./dist/index.js",
32
+ "require": "./dist/index.cjs"
33
+ }
34
+ },
35
+ "files": [
36
+ "dist",
37
+ "README.md",
38
+ "LICENSE"
39
+ ],
40
+ "scripts": {
41
+ "build": "tsup",
42
+ "test": "vitest run",
43
+ "test:coverage": "vitest run --coverage",
44
+ "lint": "biome check .",
45
+ "lint:fix": "biome check --write .",
46
+ "typecheck": "tsc --noEmit"
47
+ },
48
+ "dependencies": {
49
+ "@suss/behavioral-ir": "0.3.2",
50
+ "@suss/contract-aws-apigateway": "0.3.2",
51
+ "@suss/contract-cloudformation": "0.3.2",
52
+ "@suss/ir-core": "0.3.2",
53
+ "@suss/manifest-aws": "0.3.2",
54
+ "yaml": "^2.4.0"
55
+ },
56
+ "devDependencies": {
57
+ "tsup": "*",
58
+ "typescript": "*",
59
+ "vitest": "*"
60
+ },
61
+ "publishConfig": {
62
+ "access": "public"
63
+ }
64
+ }