@suss/manifest-aws 0.1.0 → 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,11 +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,
36
40
  readAppSyncResolvers: () => readAppSyncResolvers,
37
41
  readServerlessFunctions: () => readServerlessFunctions,
38
- refTarget: () => refTarget
42
+ refTarget: () => refTarget,
43
+ resourcesWithGlobals: () => resourcesWithGlobals,
44
+ unfollowedStackMessage: () => unfollowedStackMessage
39
45
  });
40
46
  module.exports = __toCommonJS(index_exports);
41
47
 
@@ -43,17 +49,27 @@ module.exports = __toCommonJS(index_exports);
43
49
  var import_node_fs = __toESM(require("fs"), 1);
44
50
  var import_node_path = __toESM(require("path"), 1);
45
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
+ };
46
68
  var CLOUDFORMATION_YAML_TAGS = [
47
- {
48
- tag: "!Ref",
49
- resolve: (value) => ({ Ref: value })
50
- },
51
- {
52
- tag: "!GetAtt",
53
- resolve: (value) => ({
54
- "Fn::GetAtt": value.includes(".") ? value.split(".") : [value]
55
- })
56
- },
69
+ ...everyNodeKind("!Ref", (value) => ({ Ref: value })),
70
+ ...everyNodeKind("!GetAtt", (value) => ({
71
+ "Fn::GetAtt": getAttParts(value)
72
+ })),
57
73
  ...[
58
74
  "!Sub",
59
75
  "!Join",
@@ -68,7 +84,7 @@ var CLOUDFORMATION_YAML_TAGS = [
68
84
  "!And",
69
85
  "!Or",
70
86
  "!Equals"
71
- ].map((tag) => ({ tag, resolve: (value) => value }))
87
+ ].flatMap((tag) => everyNodeKind(tag, (value) => value))
72
88
  ];
73
89
  function loadCloudFormationTemplate(templatePath) {
74
90
  const resolved = import_node_path.default.resolve(templatePath);
@@ -98,6 +114,10 @@ function refTarget(value) {
98
114
  if (Array.isArray(getAtt) && typeof getAtt[0] === "string") {
99
115
  return getAtt[0];
100
116
  }
117
+ if (typeof getAtt === "string") {
118
+ const dot = getAtt.indexOf(".");
119
+ return dot === -1 ? getAtt : getAtt.slice(0, dot);
120
+ }
101
121
  return null;
102
122
  }
103
123
 
@@ -260,7 +280,227 @@ function stringOf(value) {
260
280
  return typeof value === "string" ? value : null;
261
281
  }
262
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
+
263
502
  // src/serverlessFunctions.ts
503
+ var import_ir_core = require("@suss/ir-core");
264
504
  function parseHandler(handler) {
265
505
  const trimmed = handler.trim();
266
506
  const lastDot = trimmed.lastIndexOf(".");
@@ -272,24 +512,12 @@ function parseHandler(handler) {
272
512
  exportName: trimmed.slice(lastDot + 1)
273
513
  };
274
514
  }
275
- function normalizeCodeUri(raw) {
276
- const trimmed = raw.replace(/^\.\//, "").replace(/\/+$/, "");
277
- return trimmed === "" ? "." : trimmed;
278
- }
279
- function readGlobalsCodeUri(template) {
280
- const fnGlobals = template.Globals?.Function;
281
- if (fnGlobals === void 0) {
282
- return null;
283
- }
284
- const codeUri = fnGlobals.CodeUri;
285
- return typeof codeUri === "string" ? normalizeCodeUri(codeUri) : null;
286
- }
287
- function readCodeUri(resource, globalsCodeUri) {
515
+ function readCodeUri(resource) {
288
516
  const codeUri = resource.Properties?.CodeUri;
289
- if (typeof codeUri === "string") {
290
- return normalizeCodeUri(codeUri);
517
+ if (typeof codeUri !== "string") {
518
+ return ".";
291
519
  }
292
- return globalsCodeUri ?? ".";
520
+ return (0, import_ir_core.codeScopePath)(codeUri) || ".";
293
521
  }
294
522
  function classifyEvents(events) {
295
523
  const httpRoutes = [];
@@ -309,8 +537,8 @@ function classifyEvents(events) {
309
537
  }
310
538
  const props = event.Properties ?? {};
311
539
  const method = String(props.Method ?? "").toUpperCase();
312
- const path2 = String(props.Path ?? "");
313
- if (method === "" || path2 === "") {
540
+ const path3 = String(props.Path ?? "");
541
+ if (method === "" || path3 === "") {
314
542
  nonHttpEvents.push({ eventId, eventType: type });
315
543
  continue;
316
544
  }
@@ -319,15 +547,14 @@ function classifyEvents(events) {
319
547
  eventId,
320
548
  eventType: type,
321
549
  method,
322
- path: path2,
550
+ path: path3,
323
551
  apiId: apiRef
324
552
  });
325
553
  }
326
554
  return { httpRoutes, nonHttpEvents };
327
555
  }
328
556
  function readServerlessFunctions(template) {
329
- const resources = template.Resources ?? {};
330
- const globalsCodeUri = readGlobalsCodeUri(template);
557
+ const resources = resourcesWithGlobals(template);
331
558
  const out = [];
332
559
  for (const [logicalId, resource] of Object.entries(resources)) {
333
560
  if (resource.Type !== "AWS::Serverless::Function") {
@@ -349,7 +576,7 @@ function readServerlessFunctions(template) {
349
576
  handler: handlerRaw,
350
577
  modulePath: parsed.modulePath,
351
578
  exportName: parsed.exportName,
352
- codeUri: readCodeUri(resource, globalsCodeUri),
579
+ codeUri: readCodeUri(resource),
353
580
  httpRoutes,
354
581
  nonHttpEvents
355
582
  });
@@ -359,9 +586,15 @@ function readServerlessFunctions(template) {
359
586
  // Annotate the CommonJS export names for ESM import in node:
360
587
  0 && (module.exports = {
361
588
  CLOUDFORMATION_YAML_TAGS,
589
+ MAX_STACK_DEPTH,
590
+ inheritedEnvVars,
362
591
  loadCloudFormationTemplate,
592
+ loadTemplateTree,
363
593
  parseHandler,
594
+ qualifiedLogicalId,
364
595
  readAppSyncResolvers,
365
596
  readServerlessFunctions,
366
- refTarget
597
+ refTarget,
598
+ resourcesWithGlobals,
599
+ unfollowedStackMessage
367
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,10 +26,16 @@ 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
 
@@ -62,6 +57,82 @@ interface AppSyncResolverBinding {
62
57
  }
63
58
  declare function readAppSyncResolvers(template: CloudFormationTemplate): AppSyncResolverBinding[];
64
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
+
65
136
  /** A route-bearing SAM Event (`Type: Api` or `Type: HttpApi`). */
66
137
  interface ServerlessHttpRoute {
67
138
  /** Key of the event in the function's `Events` map. */
@@ -122,4 +193,4 @@ declare function parseHandler(handler: string): ParsedHandler | null;
122
193
  */
123
194
  declare function readServerlessFunctions(template: CloudFormationTemplate): ServerlessFunctionInfo[];
124
195
 
125
- export { type AppSyncResolverBinding, CLOUDFORMATION_YAML_TAGS, type CloudFormationResource, type CloudFormationTemplate, type ParsedHandler, type ServerlessFunctionInfo, type ServerlessHttpRoute, type ServerlessNonHttpEvent, loadCloudFormationTemplate, parseHandler, readAppSyncResolvers, 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,10 +26,16 @@ 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
 
@@ -62,6 +57,82 @@ interface AppSyncResolverBinding {
62
57
  }
63
58
  declare function readAppSyncResolvers(template: CloudFormationTemplate): AppSyncResolverBinding[];
64
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
+
65
136
  /** A route-bearing SAM Event (`Type: Api` or `Type: HttpApi`). */
66
137
  interface ServerlessHttpRoute {
67
138
  /** Key of the event in the function's `Events` map. */
@@ -122,4 +193,4 @@ declare function parseHandler(handler: string): ParsedHandler | null;
122
193
  */
123
194
  declare function readServerlessFunctions(template: CloudFormationTemplate): ServerlessFunctionInfo[];
124
195
 
125
- export { type AppSyncResolverBinding, CLOUDFORMATION_YAML_TAGS, type CloudFormationResource, type CloudFormationTemplate, type ParsedHandler, type ServerlessFunctionInfo, type ServerlessHttpRoute, type ServerlessNonHttpEvent, loadCloudFormationTemplate, parseHandler, readAppSyncResolvers, 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,6 +67,10 @@ 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
 
@@ -219,7 +233,227 @@ function stringOf(value) {
219
233
  return typeof value === "string" ? value : null;
220
234
  }
221
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
+
222
455
  // src/serverlessFunctions.ts
456
+ import { codeScopePath } from "@suss/ir-core";
223
457
  function parseHandler(handler) {
224
458
  const trimmed = handler.trim();
225
459
  const lastDot = trimmed.lastIndexOf(".");
@@ -231,24 +465,12 @@ function parseHandler(handler) {
231
465
  exportName: trimmed.slice(lastDot + 1)
232
466
  };
233
467
  }
234
- function normalizeCodeUri(raw) {
235
- const trimmed = raw.replace(/^\.\//, "").replace(/\/+$/, "");
236
- return trimmed === "" ? "." : trimmed;
237
- }
238
- function readGlobalsCodeUri(template) {
239
- const fnGlobals = template.Globals?.Function;
240
- if (fnGlobals === void 0) {
241
- return null;
242
- }
243
- const codeUri = fnGlobals.CodeUri;
244
- return typeof codeUri === "string" ? normalizeCodeUri(codeUri) : null;
245
- }
246
- function readCodeUri(resource, globalsCodeUri) {
468
+ function readCodeUri(resource) {
247
469
  const codeUri = resource.Properties?.CodeUri;
248
- if (typeof codeUri === "string") {
249
- return normalizeCodeUri(codeUri);
470
+ if (typeof codeUri !== "string") {
471
+ return ".";
250
472
  }
251
- return globalsCodeUri ?? ".";
473
+ return codeScopePath(codeUri) || ".";
252
474
  }
253
475
  function classifyEvents(events) {
254
476
  const httpRoutes = [];
@@ -268,8 +490,8 @@ function classifyEvents(events) {
268
490
  }
269
491
  const props = event.Properties ?? {};
270
492
  const method = String(props.Method ?? "").toUpperCase();
271
- const path2 = String(props.Path ?? "");
272
- if (method === "" || path2 === "") {
493
+ const path3 = String(props.Path ?? "");
494
+ if (method === "" || path3 === "") {
273
495
  nonHttpEvents.push({ eventId, eventType: type });
274
496
  continue;
275
497
  }
@@ -278,15 +500,14 @@ function classifyEvents(events) {
278
500
  eventId,
279
501
  eventType: type,
280
502
  method,
281
- path: path2,
503
+ path: path3,
282
504
  apiId: apiRef
283
505
  });
284
506
  }
285
507
  return { httpRoutes, nonHttpEvents };
286
508
  }
287
509
  function readServerlessFunctions(template) {
288
- const resources = template.Resources ?? {};
289
- const globalsCodeUri = readGlobalsCodeUri(template);
510
+ const resources = resourcesWithGlobals(template);
290
511
  const out = [];
291
512
  for (const [logicalId, resource] of Object.entries(resources)) {
292
513
  if (resource.Type !== "AWS::Serverless::Function") {
@@ -308,7 +529,7 @@ function readServerlessFunctions(template) {
308
529
  handler: handlerRaw,
309
530
  modulePath: parsed.modulePath,
310
531
  exportName: parsed.exportName,
311
- codeUri: readCodeUri(resource, globalsCodeUri),
532
+ codeUri: readCodeUri(resource),
312
533
  httpRoutes,
313
534
  nonHttpEvents
314
535
  });
@@ -317,9 +538,15 @@ function readServerlessFunctions(template) {
317
538
  }
318
539
  export {
319
540
  CLOUDFORMATION_YAML_TAGS,
541
+ MAX_STACK_DEPTH,
542
+ inheritedEnvVars,
320
543
  loadCloudFormationTemplate,
544
+ loadTemplateTree,
321
545
  parseHandler,
546
+ qualifiedLogicalId,
322
547
  readAppSyncResolvers,
323
548
  readServerlessFunctions,
324
- refTarget
549
+ refTarget,
550
+ resourcesWithGlobals,
551
+ unfollowedStackMessage
325
552
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suss/manifest-aws",
3
- "version": "0.1.0",
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": {