@suss/manifest-aws 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
@@ -31,10 +31,17 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
33
  CLOUDFORMATION_YAML_TAGS: () => CLOUDFORMATION_YAML_TAGS,
34
+ MAX_STACK_DEPTH: () => MAX_STACK_DEPTH,
35
+ inheritedEnvVars: () => inheritedEnvVars,
34
36
  loadCloudFormationTemplate: () => loadCloudFormationTemplate,
37
+ loadTemplateTree: () => loadTemplateTree,
35
38
  parseHandler: () => parseHandler,
39
+ qualifiedLogicalId: () => qualifiedLogicalId,
40
+ readAppSyncResolvers: () => readAppSyncResolvers,
36
41
  readServerlessFunctions: () => readServerlessFunctions,
37
- refTarget: () => refTarget
42
+ refTarget: () => refTarget,
43
+ resourcesWithGlobals: () => resourcesWithGlobals,
44
+ unfollowedStackMessage: () => unfollowedStackMessage
38
45
  });
39
46
  module.exports = __toCommonJS(index_exports);
40
47
 
@@ -42,17 +49,27 @@ module.exports = __toCommonJS(index_exports);
42
49
  var import_node_fs = __toESM(require("fs"), 1);
43
50
  var import_node_path = __toESM(require("path"), 1);
44
51
  var import_yaml = __toESM(require("yaml"), 1);
52
+ var everyNodeKind = (tag, resolve) => {
53
+ const forNode = (value) => resolve(plainly(value));
54
+ return [
55
+ { tag, resolve: forNode },
56
+ { tag, collection: "seq", resolve: forNode },
57
+ { tag, collection: "map", resolve: forNode }
58
+ ];
59
+ };
60
+ var plainly = (value) => typeof value === "object" && value !== null && "toJSON" in value && typeof value.toJSON === "function" ? value.toJSON() : value;
61
+ var getAttParts = (value) => {
62
+ if (Array.isArray(value)) {
63
+ return value.map(String);
64
+ }
65
+ const text = String(value);
66
+ return text.includes(".") ? text.split(".") : [text];
67
+ };
45
68
  var CLOUDFORMATION_YAML_TAGS = [
46
- {
47
- tag: "!Ref",
48
- resolve: (value) => ({ Ref: value })
49
- },
50
- {
51
- tag: "!GetAtt",
52
- resolve: (value) => ({
53
- "Fn::GetAtt": value.includes(".") ? value.split(".") : [value]
54
- })
55
- },
69
+ ...everyNodeKind("!Ref", (value) => ({ Ref: value })),
70
+ ...everyNodeKind("!GetAtt", (value) => ({
71
+ "Fn::GetAtt": getAttParts(value)
72
+ })),
56
73
  ...[
57
74
  "!Sub",
58
75
  "!Join",
@@ -67,7 +84,7 @@ var CLOUDFORMATION_YAML_TAGS = [
67
84
  "!And",
68
85
  "!Or",
69
86
  "!Equals"
70
- ].map((tag) => ({ tag, resolve: (value) => value }))
87
+ ].flatMap((tag) => everyNodeKind(tag, (value) => value))
71
88
  ];
72
89
  function loadCloudFormationTemplate(templatePath) {
73
90
  const resolved = import_node_path.default.resolve(templatePath);
@@ -97,10 +114,393 @@ function refTarget(value) {
97
114
  if (Array.isArray(getAtt) && typeof getAtt[0] === "string") {
98
115
  return getAtt[0];
99
116
  }
117
+ if (typeof getAtt === "string") {
118
+ const dot = getAtt.indexOf(".");
119
+ return dot === -1 ? getAtt : getAtt.slice(0, dot);
120
+ }
100
121
  return null;
101
122
  }
102
123
 
124
+ // src/appsyncResolvers.ts
125
+ var LAMBDA_CATEGORIES = ["Lambda", "Lambdas"];
126
+ function readAppSyncResolvers(template) {
127
+ const resources = template.Resources ?? {};
128
+ return [
129
+ ...fromServerlessShorthand(resources),
130
+ ...fromRawResources(resources)
131
+ ];
132
+ }
133
+ function fromServerlessShorthand(resources) {
134
+ const bindings = [];
135
+ for (const resource of Object.values(resources)) {
136
+ if (resource?.Type !== "AWS::Serverless::GraphQLApi") {
137
+ continue;
138
+ }
139
+ const props = asRecord(resource.Properties) ?? {};
140
+ const lambdaByDataSource = shorthandDataSources(props.DataSources);
141
+ const dataSourceByFunction = shorthandFunctions(props.Functions);
142
+ for (const [typeName, fields] of entriesOf(props.Resolvers)) {
143
+ for (const [fieldName, config] of entriesOf(fields)) {
144
+ const dataSources = dataSourcesReached(config, dataSourceByFunction);
145
+ bindings.push({
146
+ typeName,
147
+ fieldName,
148
+ lambdaFunctionLogicalIds: dataSources.map((name) => lambdaByDataSource.get(name) ?? null).filter((id) => id !== null)
149
+ });
150
+ }
151
+ }
152
+ }
153
+ return bindings;
154
+ }
155
+ function shorthandDataSources(block) {
156
+ const out = /* @__PURE__ */ new Map();
157
+ for (const [category, entries] of entriesOf(block)) {
158
+ if (!LAMBDA_CATEGORIES.includes(category)) {
159
+ continue;
160
+ }
161
+ for (const [name, config] of entriesOf(entries)) {
162
+ const props = asRecord(config);
163
+ out.set(name, props === null ? null : refTarget(props.FunctionArn));
164
+ }
165
+ }
166
+ return out;
167
+ }
168
+ function shorthandFunctions(block) {
169
+ const out = /* @__PURE__ */ new Map();
170
+ for (const [name, config] of entriesOf(block)) {
171
+ const props = asRecord(config);
172
+ const dataSource = props === null ? null : stringOf(props.DataSource);
173
+ if (dataSource !== null) {
174
+ out.set(name, dataSource);
175
+ }
176
+ }
177
+ return out;
178
+ }
179
+ function dataSourcesReached(config, dataSourceByFunction) {
180
+ const props = asRecord(config);
181
+ if (props === null) {
182
+ return [];
183
+ }
184
+ const direct = stringOf(props.DataSource);
185
+ if (direct !== null) {
186
+ return [direct];
187
+ }
188
+ const pipeline = Array.isArray(props.Pipeline) ? props.Pipeline : [];
189
+ return pipeline.map((name) => dataSourceByFunction.get(String(name))).filter((name) => name !== void 0);
190
+ }
191
+ function fromRawResources(resources) {
192
+ const lambdaByLogicalId = /* @__PURE__ */ new Map();
193
+ const lambdaByApiAndName = /* @__PURE__ */ new Map();
194
+ for (const [logicalId, resource] of Object.entries(resources)) {
195
+ if (resource?.Type !== "AWS::AppSync::DataSource") {
196
+ continue;
197
+ }
198
+ const props = asRecord(resource.Properties) ?? {};
199
+ const lambdaConfig = asRecord(props.LambdaConfig);
200
+ const lambda = lambdaConfig === null ? null : refTarget(lambdaConfig.LambdaFunctionArn);
201
+ if (lambda === null) {
202
+ continue;
203
+ }
204
+ lambdaByLogicalId.set(logicalId, lambda);
205
+ const name = stringOf(props.Name);
206
+ if (name !== null) {
207
+ lambdaByApiAndName.set(
208
+ apiScopedKey(refTarget(props.ApiId), name),
209
+ lambda
210
+ );
211
+ }
212
+ }
213
+ const dataSourceByLogicalId = /* @__PURE__ */ new Map();
214
+ const dataSourceByApiAndName = /* @__PURE__ */ new Map();
215
+ for (const [logicalId, resource] of Object.entries(resources)) {
216
+ if (resource?.Type !== "AWS::AppSync::FunctionConfiguration") {
217
+ continue;
218
+ }
219
+ const props = asRecord(resource.Properties) ?? {};
220
+ const dataSource = refTarget(props.DataSourceName);
221
+ if (dataSource === null) {
222
+ continue;
223
+ }
224
+ dataSourceByLogicalId.set(logicalId, dataSource);
225
+ const name = stringOf(props.Name);
226
+ if (name !== null) {
227
+ dataSourceByApiAndName.set(
228
+ apiScopedKey(refTarget(props.ApiId), name),
229
+ dataSource
230
+ );
231
+ }
232
+ }
233
+ const bindings = [];
234
+ for (const resource of Object.values(resources)) {
235
+ if (resource?.Type !== "AWS::AppSync::Resolver") {
236
+ continue;
237
+ }
238
+ const props = asRecord(resource.Properties) ?? {};
239
+ const typeName = stringOf(props.TypeName);
240
+ const fieldName = stringOf(props.FieldName);
241
+ if (typeName === null || fieldName === null) {
242
+ continue;
243
+ }
244
+ const apiId = refTarget(props.ApiId);
245
+ const lookup = (byLogicalId, byName) => (reference) => byLogicalId.get(reference) ?? byName.get(apiScopedKey(apiId, reference));
246
+ bindings.push({
247
+ typeName,
248
+ fieldName,
249
+ lambdaFunctionLogicalIds: rawDataSourcesReached(
250
+ props,
251
+ lookup(dataSourceByLogicalId, dataSourceByApiAndName)
252
+ ).map(lookup(lambdaByLogicalId, lambdaByApiAndName)).filter((id) => id !== void 0)
253
+ });
254
+ }
255
+ return bindings;
256
+ }
257
+ function apiScopedKey(apiId, name) {
258
+ return `${apiId ?? ""}::${name}`;
259
+ }
260
+ function rawDataSourcesReached(props, dataSourceOf) {
261
+ const direct = refTarget(props.DataSourceName);
262
+ if (direct !== null) {
263
+ return [direct];
264
+ }
265
+ const pipeline = asRecord(props.PipelineConfig);
266
+ const functions = Array.isArray(pipeline?.Functions) ? pipeline.Functions : [];
267
+ return functions.map((fn) => {
268
+ const reference = refTarget(fn);
269
+ return reference === null ? void 0 : dataSourceOf(reference);
270
+ }).filter((name) => name !== void 0);
271
+ }
272
+ function asRecord(value) {
273
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
274
+ }
275
+ function entriesOf(value) {
276
+ const record = asRecord(value);
277
+ return record === null ? [] : Object.entries(record);
278
+ }
279
+ function stringOf(value) {
280
+ return typeof value === "string" ? value : null;
281
+ }
282
+
283
+ // src/globals.ts
284
+ var GLOBALS_SECTION_TYPES = {
285
+ Api: "AWS::Serverless::Api",
286
+ Function: "AWS::Serverless::Function",
287
+ HttpApi: "AWS::Serverless::HttpApi",
288
+ LayerVersion: "AWS::Serverless::LayerVersion",
289
+ SimpleTable: "AWS::Serverless::SimpleTable",
290
+ StateMachine: "AWS::Serverless::StateMachine"
291
+ };
292
+ function resourcesWithGlobals(template) {
293
+ const resources = template.Resources ?? {};
294
+ const defaultsByType = defaultsPerResourceType(template.Globals);
295
+ if (defaultsByType.size === 0) {
296
+ return resources;
297
+ }
298
+ const out = {};
299
+ for (const [logicalId, resource] of Object.entries(resources)) {
300
+ const defaults = defaultsByType.get(resource.Type ?? "");
301
+ out[logicalId] = defaults === void 0 ? resource : {
302
+ ...resource,
303
+ Properties: mergedProperties(defaults, resource.Properties ?? {})
304
+ };
305
+ }
306
+ return out;
307
+ }
308
+ function inheritedEnvVars(template) {
309
+ const sectionVars = envVarNames(template.Globals?.Function);
310
+ if (sectionVars.length === 0) {
311
+ return {};
312
+ }
313
+ const out = {};
314
+ for (const [logicalId, resource] of Object.entries(
315
+ template.Resources ?? {}
316
+ )) {
317
+ if (resource.Type !== GLOBALS_SECTION_TYPES.Function) {
318
+ continue;
319
+ }
320
+ const own = new Set(envVarNames(resource.Properties));
321
+ out[logicalId] = sectionVars.filter((name) => !own.has(name));
322
+ }
323
+ return out;
324
+ }
325
+ function envVarNames(properties) {
326
+ const environment = properties?.Environment;
327
+ if (!isPlainObject(environment)) {
328
+ return [];
329
+ }
330
+ const variables = environment.Variables;
331
+ return isPlainObject(variables) ? Object.keys(variables) : [];
332
+ }
333
+ function defaultsPerResourceType(globals) {
334
+ const byType = /* @__PURE__ */ new Map();
335
+ if (globals === null || typeof globals !== "object") {
336
+ return byType;
337
+ }
338
+ for (const [section, properties] of Object.entries(globals)) {
339
+ const resourceType = GLOBALS_SECTION_TYPES[section];
340
+ if (resourceType === void 0 || !isPlainObject(properties)) {
341
+ continue;
342
+ }
343
+ byType.set(resourceType, properties);
344
+ }
345
+ return byType;
346
+ }
347
+ function mergedProperties(defaults, own) {
348
+ const merged = { ...own };
349
+ for (const [name, fallback] of Object.entries(defaults)) {
350
+ merged[name] = name in own ? combined(fallback, own[name]) : fallback;
351
+ }
352
+ return merged;
353
+ }
354
+ function combined(fallback, own) {
355
+ if (Array.isArray(fallback) && Array.isArray(own)) {
356
+ return [...fallback, ...own];
357
+ }
358
+ if (isMergeableMap(fallback) && isMergeableMap(own)) {
359
+ return mergedProperties(fallback, own);
360
+ }
361
+ return own;
362
+ }
363
+ function isMergeableMap(value) {
364
+ return isPlainObject(value) && !isIntrinsic(value);
365
+ }
366
+ function isPlainObject(value) {
367
+ return value !== null && typeof value === "object" && !Array.isArray(value);
368
+ }
369
+ function isIntrinsic(value) {
370
+ const keys = Object.keys(value);
371
+ return keys.length === 1 && (keys[0] === "Ref" || keys[0].startsWith("Fn::") || keys[0] === "Condition");
372
+ }
373
+
374
+ // src/nestedStacks.ts
375
+ var import_node_fs2 = __toESM(require("fs"), 1);
376
+ var import_node_path2 = __toESM(require("path"), 1);
377
+ var CHILD_TEMPLATE_PROPERTY = {
378
+ "AWS::CloudFormation::Stack": "TemplateURL",
379
+ "AWS::Serverless::Application": "Location"
380
+ };
381
+ var MAX_STACK_DEPTH = 10;
382
+ function loadTemplateTree(rootPath) {
383
+ const rootFile = import_node_path2.default.resolve(rootPath);
384
+ const root = {
385
+ path: rootFile,
386
+ stackPath: [],
387
+ template: loadCloudFormationTemplate(rootFile)
388
+ };
389
+ const documents = [root];
390
+ const unfollowed = [];
391
+ const queue = [
392
+ { document: root, ancestors: [rootFile] }
393
+ ];
394
+ for (let i = 0; i < queue.length; i += 1) {
395
+ const { document, ancestors } = queue[i];
396
+ for (const child of childStacksOf(document, ancestors)) {
397
+ if (child.type === "unfollowed") {
398
+ unfollowed.push(child.stack);
399
+ continue;
400
+ }
401
+ documents.push(child.document);
402
+ queue.push({
403
+ document: child.document,
404
+ ancestors: [...ancestors, child.document.path]
405
+ });
406
+ }
407
+ }
408
+ return { documents, unfollowed };
409
+ }
410
+ function qualifiedLogicalId(stackPath, logicalId) {
411
+ return [...stackPath, logicalId].join("/");
412
+ }
413
+ function unfollowedStackMessage(stack) {
414
+ const name = stack.stackPath.join("/");
415
+ return `could not follow nested stack ${name} declared in ${stack.declaredIn}: ${stack.detail}`;
416
+ }
417
+ function childStacksOf(document, ancestors) {
418
+ const out = [];
419
+ for (const [logicalId, resource] of Object.entries(
420
+ document.template.Resources ?? {}
421
+ )) {
422
+ const property = CHILD_TEMPLATE_PROPERTY[resource.Type ?? ""];
423
+ if (property === void 0) {
424
+ continue;
425
+ }
426
+ out.push(
427
+ childStack({
428
+ document,
429
+ ancestors,
430
+ logicalId,
431
+ location: resource.Properties?.[property]
432
+ })
433
+ );
434
+ }
435
+ return out;
436
+ }
437
+ function childStack(opts) {
438
+ const stackPath = [...opts.document.stackPath, opts.logicalId];
439
+ const report = (reason, detail, templateUrl) => ({
440
+ type: "unfollowed",
441
+ stack: {
442
+ declaredIn: opts.document.path,
443
+ stackPath,
444
+ templateUrl,
445
+ reason,
446
+ detail
447
+ }
448
+ });
449
+ const url = typeof opts.location === "string" ? opts.location.trim() : null;
450
+ if (url === null || url === "") {
451
+ return report(
452
+ "notALiteralPath",
453
+ "the template location is not a literal string",
454
+ null
455
+ );
456
+ }
457
+ if (url.includes("${")) {
458
+ return report(
459
+ "notALiteralPath",
460
+ `${url} holds a substitution nothing here can fill in`,
461
+ url
462
+ );
463
+ }
464
+ if (hasUrlScheme(url)) {
465
+ return report("remoteUrl", `${url} is not a path in this repository`, url);
466
+ }
467
+ if (stackPath.length > MAX_STACK_DEPTH) {
468
+ return report(
469
+ "depthLimit",
470
+ `nesting is deeper than ${MAX_STACK_DEPTH} stacks`,
471
+ url
472
+ );
473
+ }
474
+ const childPath = import_node_path2.default.resolve(import_node_path2.default.dirname(opts.document.path), url);
475
+ if (opts.ancestors.includes(childPath)) {
476
+ return report("cycle", `${url} is already open further up the tree`, url);
477
+ }
478
+ if (!import_node_fs2.default.existsSync(childPath)) {
479
+ return report("fileMissing", `${url} is not on disk`, url);
480
+ }
481
+ try {
482
+ return {
483
+ type: "document",
484
+ document: {
485
+ path: childPath,
486
+ stackPath,
487
+ template: loadCloudFormationTemplate(childPath)
488
+ }
489
+ };
490
+ } catch (err) {
491
+ return report(
492
+ "unreadable",
493
+ `${url} could not be parsed: ${err instanceof Error ? err.message : String(err)}`,
494
+ url
495
+ );
496
+ }
497
+ }
498
+ function hasUrlScheme(location) {
499
+ return /^[a-z][a-z0-9+.-]*:\/\//i.test(location);
500
+ }
501
+
103
502
  // src/serverlessFunctions.ts
503
+ var import_ir_core = require("@suss/ir-core");
104
504
  function parseHandler(handler) {
105
505
  const trimmed = handler.trim();
106
506
  const lastDot = trimmed.lastIndexOf(".");
@@ -112,24 +512,12 @@ function parseHandler(handler) {
112
512
  exportName: trimmed.slice(lastDot + 1)
113
513
  };
114
514
  }
115
- function normalizeCodeUri(raw) {
116
- const trimmed = raw.replace(/^\.\//, "").replace(/\/+$/, "");
117
- return trimmed === "" ? "." : trimmed;
118
- }
119
- function readGlobalsCodeUri(template) {
120
- const fnGlobals = template.Globals?.Function;
121
- if (fnGlobals === void 0) {
122
- return null;
123
- }
124
- const codeUri = fnGlobals.CodeUri;
125
- return typeof codeUri === "string" ? normalizeCodeUri(codeUri) : null;
126
- }
127
- function readCodeUri(resource, globalsCodeUri) {
515
+ function readCodeUri(resource) {
128
516
  const codeUri = resource.Properties?.CodeUri;
129
- if (typeof codeUri === "string") {
130
- return normalizeCodeUri(codeUri);
517
+ if (typeof codeUri !== "string") {
518
+ return ".";
131
519
  }
132
- return globalsCodeUri ?? ".";
520
+ return (0, import_ir_core.codeScopePath)(codeUri) || ".";
133
521
  }
134
522
  function classifyEvents(events) {
135
523
  const httpRoutes = [];
@@ -149,8 +537,8 @@ function classifyEvents(events) {
149
537
  }
150
538
  const props = event.Properties ?? {};
151
539
  const method = String(props.Method ?? "").toUpperCase();
152
- const path2 = String(props.Path ?? "");
153
- if (method === "" || path2 === "") {
540
+ const path3 = String(props.Path ?? "");
541
+ if (method === "" || path3 === "") {
154
542
  nonHttpEvents.push({ eventId, eventType: type });
155
543
  continue;
156
544
  }
@@ -159,15 +547,14 @@ function classifyEvents(events) {
159
547
  eventId,
160
548
  eventType: type,
161
549
  method,
162
- path: path2,
550
+ path: path3,
163
551
  apiId: apiRef
164
552
  });
165
553
  }
166
554
  return { httpRoutes, nonHttpEvents };
167
555
  }
168
556
  function readServerlessFunctions(template) {
169
- const resources = template.Resources ?? {};
170
- const globalsCodeUri = readGlobalsCodeUri(template);
557
+ const resources = resourcesWithGlobals(template);
171
558
  const out = [];
172
559
  for (const [logicalId, resource] of Object.entries(resources)) {
173
560
  if (resource.Type !== "AWS::Serverless::Function") {
@@ -189,7 +576,7 @@ function readServerlessFunctions(template) {
189
576
  handler: handlerRaw,
190
577
  modulePath: parsed.modulePath,
191
578
  exportName: parsed.exportName,
192
- codeUri: readCodeUri(resource, globalsCodeUri),
579
+ codeUri: readCodeUri(resource),
193
580
  httpRoutes,
194
581
  nonHttpEvents
195
582
  });
@@ -199,8 +586,15 @@ function readServerlessFunctions(template) {
199
586
  // Annotate the CommonJS export names for ESM import in node:
200
587
  0 && (module.exports = {
201
588
  CLOUDFORMATION_YAML_TAGS,
589
+ MAX_STACK_DEPTH,
590
+ inheritedEnvVars,
202
591
  loadCloudFormationTemplate,
592
+ loadTemplateTree,
203
593
  parseHandler,
594
+ qualifiedLogicalId,
595
+ readAppSyncResolvers,
204
596
  readServerlessFunctions,
205
- refTarget
597
+ refTarget,
598
+ resourcesWithGlobals,
599
+ unfollowedStackMessage
206
600
  });
package/dist/index.d.cts CHANGED
@@ -1,3 +1,5 @@
1
+ import YAML from 'yaml';
2
+
1
3
  interface CloudFormationResource {
2
4
  Type?: string;
3
5
  Properties?: Record<string, unknown>;
@@ -12,20 +14,7 @@ interface CloudFormationTemplate {
12
14
  Globals?: Record<string, Record<string, unknown>>;
13
15
  Resources?: Record<string, CloudFormationResource>;
14
16
  }
15
- declare const CLOUDFORMATION_YAML_TAGS: ({
16
- tag: string;
17
- resolve: (value: unknown) => unknown;
18
- } | {
19
- tag: string;
20
- resolve: (value: string) => {
21
- Ref: string;
22
- };
23
- } | {
24
- tag: string;
25
- resolve: (value: string) => {
26
- "Fn::GetAtt": string[];
27
- };
28
- })[];
17
+ declare const CLOUDFORMATION_YAML_TAGS: YAML.CollectionTag[];
29
18
  /**
30
19
  * Load a CloudFormation / SAM template from disk. Format is detected by
31
20
  * extension; `.json` parses as JSON, everything else (including
@@ -37,13 +26,113 @@ declare const CLOUDFORMATION_YAML_TAGS: ({
37
26
  */
38
27
  declare function loadCloudFormationTemplate(templatePath: string): CloudFormationTemplate;
39
28
  /**
40
- * CloudFormation references show up in three shapes after parsing:
29
+ * CloudFormation references show up in four shapes after parsing:
41
30
  * - { Ref: "LogicalId" }
42
31
  * - { "Fn::GetAtt": ["LogicalId", "Attr"] }
32
+ * - { "Fn::GetAtt": "LogicalId.Attr" }, the full-form spelling of the
33
+ * `!GetAtt` short form, which templates written by hand still use
43
34
  * - the bare logical id when the parser doesn't recognise the YAML tag
35
+ *
36
+ * Every CloudFormation property that names another resource accepts all
37
+ * of them, so anything reading such a property should come through here
38
+ * rather than matching one shape.
44
39
  */
45
40
  declare function refTarget(value: unknown): string | null;
46
41
 
42
+ /** One GraphQL field and the Lambdas behind it. */
43
+ interface AppSyncResolverBinding {
44
+ typeName: string;
45
+ fieldName: string;
46
+ /**
47
+ * Every Lambda a request for this field runs, in pipeline order.
48
+ * Usually one. A pipeline can chain several, and nothing in the
49
+ * template says which of them is the one that produces the value:
50
+ * the first is as often an auth step as it is the resolver, and the
51
+ * last is as often a formatter. So all of them are reported, and a
52
+ * reader that needs to pick says so itself.
53
+ *
54
+ * Empty when the field is served by something other than a Lambda.
55
+ */
56
+ lambdaFunctionLogicalIds: string[];
57
+ }
58
+ declare function readAppSyncResolvers(template: CloudFormationTemplate): AppSyncResolverBinding[];
59
+
60
+ /**
61
+ * The template's resources with each `Globals` section already applied
62
+ * to the resources it covers. A resource of a type no section names, or
63
+ * a template with no `Globals`, comes back untouched.
64
+ */
65
+ declare function resourcesWithGlobals(template: CloudFormationTemplate): Record<string, CloudFormationResource>;
66
+ /**
67
+ * Per function logical id, the environment variables the `Function`
68
+ * section supplies that the function does not declare for itself. A
69
+ * consumer that treats a function's environment as its own contract
70
+ * needs the distinction: a name every function in the document receives
71
+ * says something about the document, not about any one function.
72
+ */
73
+ declare function inheritedEnvVars(template: CloudFormationTemplate): Record<string, string[]>;
74
+
75
+ /**
76
+ * How deep the reader follows children. CloudFormation itself allows
77
+ * deeper nesting; a template chain this long in a repository is far
78
+ * more likely to be a mistake than a design, and the limit is reported
79
+ * rather than applied silently.
80
+ */
81
+ declare const MAX_STACK_DEPTH = 10;
82
+ /** One template file, and where it sits in the tree that reached it. */
83
+ interface TemplateDocument {
84
+ /** Absolute path of the file this document was read from. */
85
+ path: string;
86
+ /**
87
+ * Logical ids of the stack resources leading from the root document
88
+ * down to this one. Empty for the root.
89
+ */
90
+ stackPath: string[];
91
+ template: CloudFormationTemplate;
92
+ }
93
+ /**
94
+ * Why a child template was not read. Every value is a limit on what the
95
+ * reader could reach, so a consumer should report it as reading that
96
+ * stopped rather than treat the child as a template holding nothing.
97
+ */
98
+ type UnfollowedReason = "remoteUrl" | "notALiteralPath" | "fileMissing" | "unreadable" | "cycle" | "depthLimit";
99
+ /** A child template the reader could not open, and what stopped it. */
100
+ interface UnfollowedStack {
101
+ /** Absolute path of the document declaring the stack resource. */
102
+ declaredIn: string;
103
+ /** Stack path of the child, ending in this stack resource's logical id. */
104
+ stackPath: string[];
105
+ /** The template location as the document writes it, when it is a string. */
106
+ templateUrl: string | null;
107
+ reason: UnfollowedReason;
108
+ /** One phrase naming what stopped the read, for a message to a person. */
109
+ detail: string;
110
+ }
111
+ interface TemplateTree {
112
+ /** The root document first, then every child that could be read. */
113
+ documents: TemplateDocument[];
114
+ unfollowed: UnfollowedStack[];
115
+ }
116
+ /**
117
+ * The root template plus every template it embeds, directly or through
118
+ * another child.
119
+ *
120
+ * A missing or malformed ROOT throws, matching
121
+ * `loadCloudFormationTemplate`: the caller asked for that file by name.
122
+ * A child that cannot be read never throws; it lands in `unfollowed` so
123
+ * the caller can report which one and why.
124
+ */
125
+ declare function loadTemplateTree(rootPath: string): TemplateTree;
126
+ /**
127
+ * A logical id qualified by the stack path that reaches it, so two
128
+ * documents that both declare `HandlerFunction` name two different
129
+ * deployed things. A resource in the root document keeps its bare id,
130
+ * which is also what CloudFormation shows for it.
131
+ */
132
+ declare function qualifiedLogicalId(stackPath: string[], logicalId: string): string;
133
+ /** A line naming one child the reader could not open, and why. */
134
+ declare function unfollowedStackMessage(stack: UnfollowedStack): string;
135
+
47
136
  /** A route-bearing SAM Event (`Type: Api` or `Type: HttpApi`). */
48
137
  interface ServerlessHttpRoute {
49
138
  /** Key of the event in the function's `Events` map. */
@@ -104,4 +193,4 @@ declare function parseHandler(handler: string): ParsedHandler | null;
104
193
  */
105
194
  declare function readServerlessFunctions(template: CloudFormationTemplate): ServerlessFunctionInfo[];
106
195
 
107
- export { CLOUDFORMATION_YAML_TAGS, type CloudFormationResource, type CloudFormationTemplate, type ParsedHandler, type ServerlessFunctionInfo, type ServerlessHttpRoute, type ServerlessNonHttpEvent, loadCloudFormationTemplate, parseHandler, readServerlessFunctions, refTarget };
196
+ export { type AppSyncResolverBinding, CLOUDFORMATION_YAML_TAGS, type CloudFormationResource, type CloudFormationTemplate, MAX_STACK_DEPTH, type ParsedHandler, type ServerlessFunctionInfo, type ServerlessHttpRoute, type ServerlessNonHttpEvent, type TemplateDocument, type TemplateTree, type UnfollowedReason, type UnfollowedStack, inheritedEnvVars, loadCloudFormationTemplate, loadTemplateTree, parseHandler, qualifiedLogicalId, readAppSyncResolvers, readServerlessFunctions, refTarget, resourcesWithGlobals, unfollowedStackMessage };
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import YAML from 'yaml';
2
+
1
3
  interface CloudFormationResource {
2
4
  Type?: string;
3
5
  Properties?: Record<string, unknown>;
@@ -12,20 +14,7 @@ interface CloudFormationTemplate {
12
14
  Globals?: Record<string, Record<string, unknown>>;
13
15
  Resources?: Record<string, CloudFormationResource>;
14
16
  }
15
- declare const CLOUDFORMATION_YAML_TAGS: ({
16
- tag: string;
17
- resolve: (value: unknown) => unknown;
18
- } | {
19
- tag: string;
20
- resolve: (value: string) => {
21
- Ref: string;
22
- };
23
- } | {
24
- tag: string;
25
- resolve: (value: string) => {
26
- "Fn::GetAtt": string[];
27
- };
28
- })[];
17
+ declare const CLOUDFORMATION_YAML_TAGS: YAML.CollectionTag[];
29
18
  /**
30
19
  * Load a CloudFormation / SAM template from disk. Format is detected by
31
20
  * extension; `.json` parses as JSON, everything else (including
@@ -37,13 +26,113 @@ declare const CLOUDFORMATION_YAML_TAGS: ({
37
26
  */
38
27
  declare function loadCloudFormationTemplate(templatePath: string): CloudFormationTemplate;
39
28
  /**
40
- * CloudFormation references show up in three shapes after parsing:
29
+ * CloudFormation references show up in four shapes after parsing:
41
30
  * - { Ref: "LogicalId" }
42
31
  * - { "Fn::GetAtt": ["LogicalId", "Attr"] }
32
+ * - { "Fn::GetAtt": "LogicalId.Attr" }, the full-form spelling of the
33
+ * `!GetAtt` short form, which templates written by hand still use
43
34
  * - the bare logical id when the parser doesn't recognise the YAML tag
35
+ *
36
+ * Every CloudFormation property that names another resource accepts all
37
+ * of them, so anything reading such a property should come through here
38
+ * rather than matching one shape.
44
39
  */
45
40
  declare function refTarget(value: unknown): string | null;
46
41
 
42
+ /** One GraphQL field and the Lambdas behind it. */
43
+ interface AppSyncResolverBinding {
44
+ typeName: string;
45
+ fieldName: string;
46
+ /**
47
+ * Every Lambda a request for this field runs, in pipeline order.
48
+ * Usually one. A pipeline can chain several, and nothing in the
49
+ * template says which of them is the one that produces the value:
50
+ * the first is as often an auth step as it is the resolver, and the
51
+ * last is as often a formatter. So all of them are reported, and a
52
+ * reader that needs to pick says so itself.
53
+ *
54
+ * Empty when the field is served by something other than a Lambda.
55
+ */
56
+ lambdaFunctionLogicalIds: string[];
57
+ }
58
+ declare function readAppSyncResolvers(template: CloudFormationTemplate): AppSyncResolverBinding[];
59
+
60
+ /**
61
+ * The template's resources with each `Globals` section already applied
62
+ * to the resources it covers. A resource of a type no section names, or
63
+ * a template with no `Globals`, comes back untouched.
64
+ */
65
+ declare function resourcesWithGlobals(template: CloudFormationTemplate): Record<string, CloudFormationResource>;
66
+ /**
67
+ * Per function logical id, the environment variables the `Function`
68
+ * section supplies that the function does not declare for itself. A
69
+ * consumer that treats a function's environment as its own contract
70
+ * needs the distinction: a name every function in the document receives
71
+ * says something about the document, not about any one function.
72
+ */
73
+ declare function inheritedEnvVars(template: CloudFormationTemplate): Record<string, string[]>;
74
+
75
+ /**
76
+ * How deep the reader follows children. CloudFormation itself allows
77
+ * deeper nesting; a template chain this long in a repository is far
78
+ * more likely to be a mistake than a design, and the limit is reported
79
+ * rather than applied silently.
80
+ */
81
+ declare const MAX_STACK_DEPTH = 10;
82
+ /** One template file, and where it sits in the tree that reached it. */
83
+ interface TemplateDocument {
84
+ /** Absolute path of the file this document was read from. */
85
+ path: string;
86
+ /**
87
+ * Logical ids of the stack resources leading from the root document
88
+ * down to this one. Empty for the root.
89
+ */
90
+ stackPath: string[];
91
+ template: CloudFormationTemplate;
92
+ }
93
+ /**
94
+ * Why a child template was not read. Every value is a limit on what the
95
+ * reader could reach, so a consumer should report it as reading that
96
+ * stopped rather than treat the child as a template holding nothing.
97
+ */
98
+ type UnfollowedReason = "remoteUrl" | "notALiteralPath" | "fileMissing" | "unreadable" | "cycle" | "depthLimit";
99
+ /** A child template the reader could not open, and what stopped it. */
100
+ interface UnfollowedStack {
101
+ /** Absolute path of the document declaring the stack resource. */
102
+ declaredIn: string;
103
+ /** Stack path of the child, ending in this stack resource's logical id. */
104
+ stackPath: string[];
105
+ /** The template location as the document writes it, when it is a string. */
106
+ templateUrl: string | null;
107
+ reason: UnfollowedReason;
108
+ /** One phrase naming what stopped the read, for a message to a person. */
109
+ detail: string;
110
+ }
111
+ interface TemplateTree {
112
+ /** The root document first, then every child that could be read. */
113
+ documents: TemplateDocument[];
114
+ unfollowed: UnfollowedStack[];
115
+ }
116
+ /**
117
+ * The root template plus every template it embeds, directly or through
118
+ * another child.
119
+ *
120
+ * A missing or malformed ROOT throws, matching
121
+ * `loadCloudFormationTemplate`: the caller asked for that file by name.
122
+ * A child that cannot be read never throws; it lands in `unfollowed` so
123
+ * the caller can report which one and why.
124
+ */
125
+ declare function loadTemplateTree(rootPath: string): TemplateTree;
126
+ /**
127
+ * A logical id qualified by the stack path that reaches it, so two
128
+ * documents that both declare `HandlerFunction` name two different
129
+ * deployed things. A resource in the root document keeps its bare id,
130
+ * which is also what CloudFormation shows for it.
131
+ */
132
+ declare function qualifiedLogicalId(stackPath: string[], logicalId: string): string;
133
+ /** A line naming one child the reader could not open, and why. */
134
+ declare function unfollowedStackMessage(stack: UnfollowedStack): string;
135
+
47
136
  /** A route-bearing SAM Event (`Type: Api` or `Type: HttpApi`). */
48
137
  interface ServerlessHttpRoute {
49
138
  /** Key of the event in the function's `Events` map. */
@@ -104,4 +193,4 @@ declare function parseHandler(handler: string): ParsedHandler | null;
104
193
  */
105
194
  declare function readServerlessFunctions(template: CloudFormationTemplate): ServerlessFunctionInfo[];
106
195
 
107
- export { CLOUDFORMATION_YAML_TAGS, type CloudFormationResource, type CloudFormationTemplate, type ParsedHandler, type ServerlessFunctionInfo, type ServerlessHttpRoute, type ServerlessNonHttpEvent, loadCloudFormationTemplate, parseHandler, readServerlessFunctions, refTarget };
196
+ export { type AppSyncResolverBinding, CLOUDFORMATION_YAML_TAGS, type CloudFormationResource, type CloudFormationTemplate, MAX_STACK_DEPTH, type ParsedHandler, type ServerlessFunctionInfo, type ServerlessHttpRoute, type ServerlessNonHttpEvent, type TemplateDocument, type TemplateTree, type UnfollowedReason, type UnfollowedStack, inheritedEnvVars, loadCloudFormationTemplate, loadTemplateTree, parseHandler, qualifiedLogicalId, readAppSyncResolvers, readServerlessFunctions, refTarget, resourcesWithGlobals, unfollowedStackMessage };
package/dist/index.js CHANGED
@@ -2,17 +2,27 @@
2
2
  import fs from "fs";
3
3
  import path from "path";
4
4
  import YAML from "yaml";
5
+ var everyNodeKind = (tag, resolve) => {
6
+ const forNode = (value) => resolve(plainly(value));
7
+ return [
8
+ { tag, resolve: forNode },
9
+ { tag, collection: "seq", resolve: forNode },
10
+ { tag, collection: "map", resolve: forNode }
11
+ ];
12
+ };
13
+ var plainly = (value) => typeof value === "object" && value !== null && "toJSON" in value && typeof value.toJSON === "function" ? value.toJSON() : value;
14
+ var getAttParts = (value) => {
15
+ if (Array.isArray(value)) {
16
+ return value.map(String);
17
+ }
18
+ const text = String(value);
19
+ return text.includes(".") ? text.split(".") : [text];
20
+ };
5
21
  var CLOUDFORMATION_YAML_TAGS = [
6
- {
7
- tag: "!Ref",
8
- resolve: (value) => ({ Ref: value })
9
- },
10
- {
11
- tag: "!GetAtt",
12
- resolve: (value) => ({
13
- "Fn::GetAtt": value.includes(".") ? value.split(".") : [value]
14
- })
15
- },
22
+ ...everyNodeKind("!Ref", (value) => ({ Ref: value })),
23
+ ...everyNodeKind("!GetAtt", (value) => ({
24
+ "Fn::GetAtt": getAttParts(value)
25
+ })),
16
26
  ...[
17
27
  "!Sub",
18
28
  "!Join",
@@ -27,7 +37,7 @@ var CLOUDFORMATION_YAML_TAGS = [
27
37
  "!And",
28
38
  "!Or",
29
39
  "!Equals"
30
- ].map((tag) => ({ tag, resolve: (value) => value }))
40
+ ].flatMap((tag) => everyNodeKind(tag, (value) => value))
31
41
  ];
32
42
  function loadCloudFormationTemplate(templatePath) {
33
43
  const resolved = path.resolve(templatePath);
@@ -57,10 +67,393 @@ function refTarget(value) {
57
67
  if (Array.isArray(getAtt) && typeof getAtt[0] === "string") {
58
68
  return getAtt[0];
59
69
  }
70
+ if (typeof getAtt === "string") {
71
+ const dot = getAtt.indexOf(".");
72
+ return dot === -1 ? getAtt : getAtt.slice(0, dot);
73
+ }
60
74
  return null;
61
75
  }
62
76
 
77
+ // src/appsyncResolvers.ts
78
+ var LAMBDA_CATEGORIES = ["Lambda", "Lambdas"];
79
+ function readAppSyncResolvers(template) {
80
+ const resources = template.Resources ?? {};
81
+ return [
82
+ ...fromServerlessShorthand(resources),
83
+ ...fromRawResources(resources)
84
+ ];
85
+ }
86
+ function fromServerlessShorthand(resources) {
87
+ const bindings = [];
88
+ for (const resource of Object.values(resources)) {
89
+ if (resource?.Type !== "AWS::Serverless::GraphQLApi") {
90
+ continue;
91
+ }
92
+ const props = asRecord(resource.Properties) ?? {};
93
+ const lambdaByDataSource = shorthandDataSources(props.DataSources);
94
+ const dataSourceByFunction = shorthandFunctions(props.Functions);
95
+ for (const [typeName, fields] of entriesOf(props.Resolvers)) {
96
+ for (const [fieldName, config] of entriesOf(fields)) {
97
+ const dataSources = dataSourcesReached(config, dataSourceByFunction);
98
+ bindings.push({
99
+ typeName,
100
+ fieldName,
101
+ lambdaFunctionLogicalIds: dataSources.map((name) => lambdaByDataSource.get(name) ?? null).filter((id) => id !== null)
102
+ });
103
+ }
104
+ }
105
+ }
106
+ return bindings;
107
+ }
108
+ function shorthandDataSources(block) {
109
+ const out = /* @__PURE__ */ new Map();
110
+ for (const [category, entries] of entriesOf(block)) {
111
+ if (!LAMBDA_CATEGORIES.includes(category)) {
112
+ continue;
113
+ }
114
+ for (const [name, config] of entriesOf(entries)) {
115
+ const props = asRecord(config);
116
+ out.set(name, props === null ? null : refTarget(props.FunctionArn));
117
+ }
118
+ }
119
+ return out;
120
+ }
121
+ function shorthandFunctions(block) {
122
+ const out = /* @__PURE__ */ new Map();
123
+ for (const [name, config] of entriesOf(block)) {
124
+ const props = asRecord(config);
125
+ const dataSource = props === null ? null : stringOf(props.DataSource);
126
+ if (dataSource !== null) {
127
+ out.set(name, dataSource);
128
+ }
129
+ }
130
+ return out;
131
+ }
132
+ function dataSourcesReached(config, dataSourceByFunction) {
133
+ const props = asRecord(config);
134
+ if (props === null) {
135
+ return [];
136
+ }
137
+ const direct = stringOf(props.DataSource);
138
+ if (direct !== null) {
139
+ return [direct];
140
+ }
141
+ const pipeline = Array.isArray(props.Pipeline) ? props.Pipeline : [];
142
+ return pipeline.map((name) => dataSourceByFunction.get(String(name))).filter((name) => name !== void 0);
143
+ }
144
+ function fromRawResources(resources) {
145
+ const lambdaByLogicalId = /* @__PURE__ */ new Map();
146
+ const lambdaByApiAndName = /* @__PURE__ */ new Map();
147
+ for (const [logicalId, resource] of Object.entries(resources)) {
148
+ if (resource?.Type !== "AWS::AppSync::DataSource") {
149
+ continue;
150
+ }
151
+ const props = asRecord(resource.Properties) ?? {};
152
+ const lambdaConfig = asRecord(props.LambdaConfig);
153
+ const lambda = lambdaConfig === null ? null : refTarget(lambdaConfig.LambdaFunctionArn);
154
+ if (lambda === null) {
155
+ continue;
156
+ }
157
+ lambdaByLogicalId.set(logicalId, lambda);
158
+ const name = stringOf(props.Name);
159
+ if (name !== null) {
160
+ lambdaByApiAndName.set(
161
+ apiScopedKey(refTarget(props.ApiId), name),
162
+ lambda
163
+ );
164
+ }
165
+ }
166
+ const dataSourceByLogicalId = /* @__PURE__ */ new Map();
167
+ const dataSourceByApiAndName = /* @__PURE__ */ new Map();
168
+ for (const [logicalId, resource] of Object.entries(resources)) {
169
+ if (resource?.Type !== "AWS::AppSync::FunctionConfiguration") {
170
+ continue;
171
+ }
172
+ const props = asRecord(resource.Properties) ?? {};
173
+ const dataSource = refTarget(props.DataSourceName);
174
+ if (dataSource === null) {
175
+ continue;
176
+ }
177
+ dataSourceByLogicalId.set(logicalId, dataSource);
178
+ const name = stringOf(props.Name);
179
+ if (name !== null) {
180
+ dataSourceByApiAndName.set(
181
+ apiScopedKey(refTarget(props.ApiId), name),
182
+ dataSource
183
+ );
184
+ }
185
+ }
186
+ const bindings = [];
187
+ for (const resource of Object.values(resources)) {
188
+ if (resource?.Type !== "AWS::AppSync::Resolver") {
189
+ continue;
190
+ }
191
+ const props = asRecord(resource.Properties) ?? {};
192
+ const typeName = stringOf(props.TypeName);
193
+ const fieldName = stringOf(props.FieldName);
194
+ if (typeName === null || fieldName === null) {
195
+ continue;
196
+ }
197
+ const apiId = refTarget(props.ApiId);
198
+ const lookup = (byLogicalId, byName) => (reference) => byLogicalId.get(reference) ?? byName.get(apiScopedKey(apiId, reference));
199
+ bindings.push({
200
+ typeName,
201
+ fieldName,
202
+ lambdaFunctionLogicalIds: rawDataSourcesReached(
203
+ props,
204
+ lookup(dataSourceByLogicalId, dataSourceByApiAndName)
205
+ ).map(lookup(lambdaByLogicalId, lambdaByApiAndName)).filter((id) => id !== void 0)
206
+ });
207
+ }
208
+ return bindings;
209
+ }
210
+ function apiScopedKey(apiId, name) {
211
+ return `${apiId ?? ""}::${name}`;
212
+ }
213
+ function rawDataSourcesReached(props, dataSourceOf) {
214
+ const direct = refTarget(props.DataSourceName);
215
+ if (direct !== null) {
216
+ return [direct];
217
+ }
218
+ const pipeline = asRecord(props.PipelineConfig);
219
+ const functions = Array.isArray(pipeline?.Functions) ? pipeline.Functions : [];
220
+ return functions.map((fn) => {
221
+ const reference = refTarget(fn);
222
+ return reference === null ? void 0 : dataSourceOf(reference);
223
+ }).filter((name) => name !== void 0);
224
+ }
225
+ function asRecord(value) {
226
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
227
+ }
228
+ function entriesOf(value) {
229
+ const record = asRecord(value);
230
+ return record === null ? [] : Object.entries(record);
231
+ }
232
+ function stringOf(value) {
233
+ return typeof value === "string" ? value : null;
234
+ }
235
+
236
+ // src/globals.ts
237
+ var GLOBALS_SECTION_TYPES = {
238
+ Api: "AWS::Serverless::Api",
239
+ Function: "AWS::Serverless::Function",
240
+ HttpApi: "AWS::Serverless::HttpApi",
241
+ LayerVersion: "AWS::Serverless::LayerVersion",
242
+ SimpleTable: "AWS::Serverless::SimpleTable",
243
+ StateMachine: "AWS::Serverless::StateMachine"
244
+ };
245
+ function resourcesWithGlobals(template) {
246
+ const resources = template.Resources ?? {};
247
+ const defaultsByType = defaultsPerResourceType(template.Globals);
248
+ if (defaultsByType.size === 0) {
249
+ return resources;
250
+ }
251
+ const out = {};
252
+ for (const [logicalId, resource] of Object.entries(resources)) {
253
+ const defaults = defaultsByType.get(resource.Type ?? "");
254
+ out[logicalId] = defaults === void 0 ? resource : {
255
+ ...resource,
256
+ Properties: mergedProperties(defaults, resource.Properties ?? {})
257
+ };
258
+ }
259
+ return out;
260
+ }
261
+ function inheritedEnvVars(template) {
262
+ const sectionVars = envVarNames(template.Globals?.Function);
263
+ if (sectionVars.length === 0) {
264
+ return {};
265
+ }
266
+ const out = {};
267
+ for (const [logicalId, resource] of Object.entries(
268
+ template.Resources ?? {}
269
+ )) {
270
+ if (resource.Type !== GLOBALS_SECTION_TYPES.Function) {
271
+ continue;
272
+ }
273
+ const own = new Set(envVarNames(resource.Properties));
274
+ out[logicalId] = sectionVars.filter((name) => !own.has(name));
275
+ }
276
+ return out;
277
+ }
278
+ function envVarNames(properties) {
279
+ const environment = properties?.Environment;
280
+ if (!isPlainObject(environment)) {
281
+ return [];
282
+ }
283
+ const variables = environment.Variables;
284
+ return isPlainObject(variables) ? Object.keys(variables) : [];
285
+ }
286
+ function defaultsPerResourceType(globals) {
287
+ const byType = /* @__PURE__ */ new Map();
288
+ if (globals === null || typeof globals !== "object") {
289
+ return byType;
290
+ }
291
+ for (const [section, properties] of Object.entries(globals)) {
292
+ const resourceType = GLOBALS_SECTION_TYPES[section];
293
+ if (resourceType === void 0 || !isPlainObject(properties)) {
294
+ continue;
295
+ }
296
+ byType.set(resourceType, properties);
297
+ }
298
+ return byType;
299
+ }
300
+ function mergedProperties(defaults, own) {
301
+ const merged = { ...own };
302
+ for (const [name, fallback] of Object.entries(defaults)) {
303
+ merged[name] = name in own ? combined(fallback, own[name]) : fallback;
304
+ }
305
+ return merged;
306
+ }
307
+ function combined(fallback, own) {
308
+ if (Array.isArray(fallback) && Array.isArray(own)) {
309
+ return [...fallback, ...own];
310
+ }
311
+ if (isMergeableMap(fallback) && isMergeableMap(own)) {
312
+ return mergedProperties(fallback, own);
313
+ }
314
+ return own;
315
+ }
316
+ function isMergeableMap(value) {
317
+ return isPlainObject(value) && !isIntrinsic(value);
318
+ }
319
+ function isPlainObject(value) {
320
+ return value !== null && typeof value === "object" && !Array.isArray(value);
321
+ }
322
+ function isIntrinsic(value) {
323
+ const keys = Object.keys(value);
324
+ return keys.length === 1 && (keys[0] === "Ref" || keys[0].startsWith("Fn::") || keys[0] === "Condition");
325
+ }
326
+
327
+ // src/nestedStacks.ts
328
+ import fs2 from "fs";
329
+ import path2 from "path";
330
+ var CHILD_TEMPLATE_PROPERTY = {
331
+ "AWS::CloudFormation::Stack": "TemplateURL",
332
+ "AWS::Serverless::Application": "Location"
333
+ };
334
+ var MAX_STACK_DEPTH = 10;
335
+ function loadTemplateTree(rootPath) {
336
+ const rootFile = path2.resolve(rootPath);
337
+ const root = {
338
+ path: rootFile,
339
+ stackPath: [],
340
+ template: loadCloudFormationTemplate(rootFile)
341
+ };
342
+ const documents = [root];
343
+ const unfollowed = [];
344
+ const queue = [
345
+ { document: root, ancestors: [rootFile] }
346
+ ];
347
+ for (let i = 0; i < queue.length; i += 1) {
348
+ const { document, ancestors } = queue[i];
349
+ for (const child of childStacksOf(document, ancestors)) {
350
+ if (child.type === "unfollowed") {
351
+ unfollowed.push(child.stack);
352
+ continue;
353
+ }
354
+ documents.push(child.document);
355
+ queue.push({
356
+ document: child.document,
357
+ ancestors: [...ancestors, child.document.path]
358
+ });
359
+ }
360
+ }
361
+ return { documents, unfollowed };
362
+ }
363
+ function qualifiedLogicalId(stackPath, logicalId) {
364
+ return [...stackPath, logicalId].join("/");
365
+ }
366
+ function unfollowedStackMessage(stack) {
367
+ const name = stack.stackPath.join("/");
368
+ return `could not follow nested stack ${name} declared in ${stack.declaredIn}: ${stack.detail}`;
369
+ }
370
+ function childStacksOf(document, ancestors) {
371
+ const out = [];
372
+ for (const [logicalId, resource] of Object.entries(
373
+ document.template.Resources ?? {}
374
+ )) {
375
+ const property = CHILD_TEMPLATE_PROPERTY[resource.Type ?? ""];
376
+ if (property === void 0) {
377
+ continue;
378
+ }
379
+ out.push(
380
+ childStack({
381
+ document,
382
+ ancestors,
383
+ logicalId,
384
+ location: resource.Properties?.[property]
385
+ })
386
+ );
387
+ }
388
+ return out;
389
+ }
390
+ function childStack(opts) {
391
+ const stackPath = [...opts.document.stackPath, opts.logicalId];
392
+ const report = (reason, detail, templateUrl) => ({
393
+ type: "unfollowed",
394
+ stack: {
395
+ declaredIn: opts.document.path,
396
+ stackPath,
397
+ templateUrl,
398
+ reason,
399
+ detail
400
+ }
401
+ });
402
+ const url = typeof opts.location === "string" ? opts.location.trim() : null;
403
+ if (url === null || url === "") {
404
+ return report(
405
+ "notALiteralPath",
406
+ "the template location is not a literal string",
407
+ null
408
+ );
409
+ }
410
+ if (url.includes("${")) {
411
+ return report(
412
+ "notALiteralPath",
413
+ `${url} holds a substitution nothing here can fill in`,
414
+ url
415
+ );
416
+ }
417
+ if (hasUrlScheme(url)) {
418
+ return report("remoteUrl", `${url} is not a path in this repository`, url);
419
+ }
420
+ if (stackPath.length > MAX_STACK_DEPTH) {
421
+ return report(
422
+ "depthLimit",
423
+ `nesting is deeper than ${MAX_STACK_DEPTH} stacks`,
424
+ url
425
+ );
426
+ }
427
+ const childPath = path2.resolve(path2.dirname(opts.document.path), url);
428
+ if (opts.ancestors.includes(childPath)) {
429
+ return report("cycle", `${url} is already open further up the tree`, url);
430
+ }
431
+ if (!fs2.existsSync(childPath)) {
432
+ return report("fileMissing", `${url} is not on disk`, url);
433
+ }
434
+ try {
435
+ return {
436
+ type: "document",
437
+ document: {
438
+ path: childPath,
439
+ stackPath,
440
+ template: loadCloudFormationTemplate(childPath)
441
+ }
442
+ };
443
+ } catch (err) {
444
+ return report(
445
+ "unreadable",
446
+ `${url} could not be parsed: ${err instanceof Error ? err.message : String(err)}`,
447
+ url
448
+ );
449
+ }
450
+ }
451
+ function hasUrlScheme(location) {
452
+ return /^[a-z][a-z0-9+.-]*:\/\//i.test(location);
453
+ }
454
+
63
455
  // src/serverlessFunctions.ts
456
+ import { codeScopePath } from "@suss/ir-core";
64
457
  function parseHandler(handler) {
65
458
  const trimmed = handler.trim();
66
459
  const lastDot = trimmed.lastIndexOf(".");
@@ -72,24 +465,12 @@ function parseHandler(handler) {
72
465
  exportName: trimmed.slice(lastDot + 1)
73
466
  };
74
467
  }
75
- function normalizeCodeUri(raw) {
76
- const trimmed = raw.replace(/^\.\//, "").replace(/\/+$/, "");
77
- return trimmed === "" ? "." : trimmed;
78
- }
79
- function readGlobalsCodeUri(template) {
80
- const fnGlobals = template.Globals?.Function;
81
- if (fnGlobals === void 0) {
82
- return null;
83
- }
84
- const codeUri = fnGlobals.CodeUri;
85
- return typeof codeUri === "string" ? normalizeCodeUri(codeUri) : null;
86
- }
87
- function readCodeUri(resource, globalsCodeUri) {
468
+ function readCodeUri(resource) {
88
469
  const codeUri = resource.Properties?.CodeUri;
89
- if (typeof codeUri === "string") {
90
- return normalizeCodeUri(codeUri);
470
+ if (typeof codeUri !== "string") {
471
+ return ".";
91
472
  }
92
- return globalsCodeUri ?? ".";
473
+ return codeScopePath(codeUri) || ".";
93
474
  }
94
475
  function classifyEvents(events) {
95
476
  const httpRoutes = [];
@@ -109,8 +490,8 @@ function classifyEvents(events) {
109
490
  }
110
491
  const props = event.Properties ?? {};
111
492
  const method = String(props.Method ?? "").toUpperCase();
112
- const path2 = String(props.Path ?? "");
113
- if (method === "" || path2 === "") {
493
+ const path3 = String(props.Path ?? "");
494
+ if (method === "" || path3 === "") {
114
495
  nonHttpEvents.push({ eventId, eventType: type });
115
496
  continue;
116
497
  }
@@ -119,15 +500,14 @@ function classifyEvents(events) {
119
500
  eventId,
120
501
  eventType: type,
121
502
  method,
122
- path: path2,
503
+ path: path3,
123
504
  apiId: apiRef
124
505
  });
125
506
  }
126
507
  return { httpRoutes, nonHttpEvents };
127
508
  }
128
509
  function readServerlessFunctions(template) {
129
- const resources = template.Resources ?? {};
130
- const globalsCodeUri = readGlobalsCodeUri(template);
510
+ const resources = resourcesWithGlobals(template);
131
511
  const out = [];
132
512
  for (const [logicalId, resource] of Object.entries(resources)) {
133
513
  if (resource.Type !== "AWS::Serverless::Function") {
@@ -149,7 +529,7 @@ function readServerlessFunctions(template) {
149
529
  handler: handlerRaw,
150
530
  modulePath: parsed.modulePath,
151
531
  exportName: parsed.exportName,
152
- codeUri: readCodeUri(resource, globalsCodeUri),
532
+ codeUri: readCodeUri(resource),
153
533
  httpRoutes,
154
534
  nonHttpEvents
155
535
  });
@@ -158,8 +538,15 @@ function readServerlessFunctions(template) {
158
538
  }
159
539
  export {
160
540
  CLOUDFORMATION_YAML_TAGS,
541
+ MAX_STACK_DEPTH,
542
+ inheritedEnvVars,
161
543
  loadCloudFormationTemplate,
544
+ loadTemplateTree,
162
545
  parseHandler,
546
+ qualifiedLogicalId,
547
+ readAppSyncResolvers,
163
548
  readServerlessFunctions,
164
- refTarget
549
+ refTarget,
550
+ resourcesWithGlobals,
551
+ unfollowedStackMessage
165
552
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suss/manifest-aws",
3
- "version": "0.0.2",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "description": "Parse AWS CloudFormation / SAM templates into plain data — the shared facts layer contract readers and manifest-driven framework packs both consume.",
6
6
  "keywords": [
@@ -45,6 +45,7 @@
45
45
  "typecheck": "tsc --noEmit"
46
46
  },
47
47
  "dependencies": {
48
+ "@suss/ir-core": "0.1.1",
48
49
  "yaml": "^2.4.0"
49
50
  },
50
51
  "devDependencies": {