@intentius/chant 0.1.0 → 0.1.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.1.0",
3
+ "version": "0.1.4",
4
4
  "description": "Declarative infrastructure-as-code toolkit — TypeScript on Bun",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
package/src/build.test.ts CHANGED
@@ -2,6 +2,7 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2
2
  import { build, partitionByLexicon, detectCrossLexiconRefs } from "./build";
3
3
  import { output } from "./lexicon-output";
4
4
  import { AttrRef } from "./attrref";
5
+ import { INTRINSIC_MARKER } from "./intrinsic";
5
6
  import type { Serializer } from "./serializer";
6
7
  import type { Declarable } from "./declarable";
7
8
  import { DECLARABLE_MARKER } from "./declarable";
@@ -359,14 +360,14 @@ describe("detectCrossLexiconRefs", () => {
359
360
  expect(explicitOutputs[0].outputName).toBe("MyCustomArnName");
360
361
 
361
362
  // Merge logic: explicit wins (same parent object + attribute)
362
- const explicitRefs = explicitOutputs.map((o: { _sourceParent: WeakRef<object>; sourceAttribute: string }) => ({
363
- parent: o._sourceParent.deref(),
363
+ const explicitRefs = explicitOutputs.map((o: { _sourceParent: WeakRef<object> | null; sourceAttribute: string | null }) => ({
364
+ parent: o._sourceParent?.deref(),
364
365
  attribute: o.sourceAttribute,
365
366
  }));
366
367
  const merged = [
367
368
  ...explicitOutputs,
368
369
  ...autoDetected.filter((auto) => {
369
- const autoParent = auto._sourceParent.deref();
370
+ const autoParent = auto._sourceParent?.deref();
370
371
  return !explicitRefs.some(
371
372
  (e: { parent: object | undefined; attribute: string }) =>
372
373
  e.parent === autoParent && e.attribute === auto.sourceAttribute
@@ -403,6 +404,32 @@ describe("detectCrossLexiconRefs", () => {
403
404
  expect(detected).toHaveLength(0);
404
405
  });
405
406
 
407
+ test("intrinsic-based output() is passed to every lexicon's serializer", () => {
408
+ const alphaEntity = {
409
+ lexicon: "alpha",
410
+ entityType: "Alpha::Resource",
411
+ [DECLARABLE_MARKER]: true,
412
+ } as Declarable;
413
+
414
+ const mockIntrinsic = {
415
+ [INTRINSIC_MARKER]: true as const,
416
+ toJSON: () => ({ "Fn::Sub": "http://example.com/path" }),
417
+ };
418
+ const intrinsicOutput = output(mockIntrinsic, "MyUrl");
419
+
420
+ const entities = new Map<string, Declarable>([
421
+ ["alphaEntity", alphaEntity],
422
+ ["myUrl", intrinsicOutput as unknown as Declarable],
423
+ ]);
424
+
425
+ const { collectLexiconOutputs } = require("./build");
426
+ const collected = collectLexiconOutputs(entities);
427
+ expect(collected).toHaveLength(1);
428
+ expect(collected[0].outputName).toBe("MyUrl");
429
+ expect(collected[0].sourceLexicon).toBe("");
430
+ expect(collected[0].getOutputValue()).toEqual({ "Fn::Sub": "http://example.com/path" });
431
+ });
432
+
406
433
  test("deduplicates when same cross-lexicon ref appears in multiple entities", () => {
407
434
  const alphaBucket = {
408
435
  lexicon: "alpha",
package/src/build.ts CHANGED
@@ -105,7 +105,7 @@ export function collectLexiconOutputs(
105
105
  if (isLexiconOutput(entity as unknown)) {
106
106
  const lexiconOutput = entity as unknown as LexiconOutput;
107
107
  // Resolve source entity name from the WeakRef parent identity
108
- const parent = lexiconOutput._sourceParent.deref();
108
+ const parent = lexiconOutput._sourceParent?.deref();
109
109
  let sourceName = name;
110
110
  if (parent) {
111
111
  for (const [entityName, e] of entities) {
@@ -287,7 +287,7 @@ function generateManifest(
287
287
  outputsRecord[output.outputName] = {
288
288
  source: output.sourceLexicon,
289
289
  entity: output.sourceEntity,
290
- attribute: output.sourceAttribute,
290
+ attribute: output.sourceAttribute ?? "",
291
291
  };
292
292
  }
293
293
 
@@ -391,22 +391,29 @@ export async function build(
391
391
  // Merge: explicit outputs take precedence over auto-detected ones.
392
392
  // Match by parent object identity + attribute to detect collisions.
393
393
  const explicitRefs = explicitOutputs.map((o) => ({
394
- parent: o._sourceParent.deref(),
394
+ parent: o._sourceParent?.deref(),
395
395
  attribute: o.sourceAttribute,
396
396
  }));
397
397
  const lexiconOutputs = [
398
398
  ...explicitOutputs,
399
399
  ...autoOutputs.filter((auto) => {
400
- const autoParent = auto._sourceParent.deref();
400
+ const autoParent = auto._sourceParent?.deref();
401
401
  return !explicitRefs.some(
402
402
  (e) => e.parent === autoParent && e.attribute === auto.sourceAttribute
403
403
  );
404
404
  }),
405
405
  ];
406
406
 
407
- // Group outputs by source lexicon
407
+ // Group outputs by source lexicon.
408
+ // Intrinsic-based outputs (sourceLexicon === "") have no source entity to derive a lexicon from,
409
+ // so they are included in every lexicon's output list.
408
410
  const outputsByLexicon = new Map<string, LexiconOutput[]>();
411
+ const unassignedOutputs: LexiconOutput[] = [];
409
412
  for (const output of lexiconOutputs) {
413
+ if (!output.sourceLexicon) {
414
+ unassignedOutputs.push(output);
415
+ continue;
416
+ }
410
417
  if (!outputsByLexicon.has(output.sourceLexicon)) {
411
418
  outputsByLexicon.set(output.sourceLexicon, []);
412
419
  }
@@ -418,7 +425,10 @@ export async function build(
418
425
  for (const [lexiconName, lexiconEntities] of partitions) {
419
426
  const serializer = serializersByName.get(lexiconName);
420
427
  if (serializer) {
421
- const lexiconLexiconOutputs = outputsByLexicon.get(lexiconName) ?? [];
428
+ const lexiconLexiconOutputs = [
429
+ ...(outputsByLexicon.get(lexiconName) ?? []),
430
+ ...unassignedOutputs,
431
+ ];
422
432
  outputs.set(lexiconName, serializer.serialize(lexiconEntities, lexiconLexiconOutputs));
423
433
  } else {
424
434
  warnings.push(`No serializer found for lexicon "${lexiconName}"`);
@@ -71,6 +71,27 @@ describe("LexiconOutput", () => {
71
71
 
72
72
  expect(() => new LexiconOutput(ref, "Test")).toThrow("no lexicon field");
73
73
  });
74
+
75
+ test("accepts an Intrinsic and sets sourceAttribute to null", () => {
76
+ const mockIntrinsic = { [INTRINSIC_MARKER]: true as const, toJSON: () => ({ "Fn::Sub": "hello" }) };
77
+ const lo = new LexiconOutput(mockIntrinsic, "MyOutput");
78
+ expect(lo.sourceAttribute).toBeNull();
79
+ expect(lo.sourceLexicon).toBe("");
80
+ expect(lo.outputName).toBe("MyOutput");
81
+ });
82
+
83
+ test("getOutputValue() returns Fn::GetAtt for AttrRef-based output", () => {
84
+ const bucket = new MockResource();
85
+ const lo = new LexiconOutput(bucket.arn, "BucketArn");
86
+ lo._setSourceEntity("myBucket");
87
+ expect(lo.getOutputValue()).toEqual({ "Fn::GetAtt": ["myBucket", "Arn"] });
88
+ });
89
+
90
+ test("getOutputValue() returns intrinsic toJSON for Intrinsic-based output", () => {
91
+ const mockIntrinsic = { [INTRINSIC_MARKER]: true as const, toJSON: () => ({ "Fn::Sub": "http://${Param}/path" }) };
92
+ const lo = new LexiconOutput(mockIntrinsic, "MyUrl");
93
+ expect(lo.getOutputValue()).toEqual({ "Fn::Sub": "http://${Param}/path" });
94
+ });
74
95
  });
75
96
 
76
97
  describe("LexiconOutput.auto", () => {
@@ -6,31 +6,49 @@ import { AttrRef } from "./attrref";
6
6
  * with any consuming lexicon (e.g. GitHub, Cloudflare).
7
7
  *
8
8
  * Implements Intrinsic so it can be used as Value<string> anywhere.
9
+ *
10
+ * Accepts either an AttrRef (resource attribute reference) or any Intrinsic
11
+ * (e.g. Sub, Join) for computed output values like constructed URLs.
9
12
  */
10
13
  export class LexiconOutput implements Intrinsic {
11
14
  readonly [INTRINSIC_MARKER] = true as const;
12
15
  readonly sourceLexicon: string;
13
16
  readonly sourceEntity: string;
14
- readonly sourceAttribute: string;
17
+ readonly sourceAttribute: string | null;
15
18
  readonly outputName: string;
16
19
  /** @internal WeakRef to the source entity object for identity-based matching */
17
- readonly _sourceParent: WeakRef<object>;
20
+ readonly _sourceParent: WeakRef<object> | null;
21
+ /** @internal Intrinsic value when constructed from an Intrinsic rather than AttrRef */
22
+ private readonly _intrinsic: Intrinsic | null;
18
23
 
19
- constructor(ref: AttrRef, name: string) {
20
- const parent = ref.parent.deref();
21
- if (!parent) {
22
- throw new Error("Cannot create LexiconOutput: parent entity has been garbage collected");
23
- }
24
+ constructor(ref: AttrRef | Intrinsic | string, name: string) {
25
+ if (ref instanceof AttrRef) {
26
+ const parent = ref.parent.deref();
27
+ if (!parent) {
28
+ throw new Error("Cannot create LexiconOutput: parent entity has been garbage collected");
29
+ }
24
30
 
25
- if (!("lexicon" in parent) || typeof (parent as Record<string, unknown>).lexicon !== "string") {
26
- throw new Error("Cannot create LexiconOutput: parent entity has no lexicon field");
27
- }
31
+ if (!("lexicon" in parent) || typeof (parent as Record<string, unknown>).lexicon !== "string") {
32
+ throw new Error("Cannot create LexiconOutput: parent entity has no lexicon field");
33
+ }
28
34
 
29
- this.sourceLexicon = (parent as Record<string, unknown>).lexicon as string;
30
- this.sourceEntity = "";
31
- this.sourceAttribute = ref.attribute;
32
- this.outputName = name;
33
- this._sourceParent = ref.parent;
35
+ this.sourceLexicon = (parent as Record<string, unknown>).lexicon as string;
36
+ this.sourceEntity = "";
37
+ this.sourceAttribute = ref.attribute;
38
+ this.outputName = name;
39
+ this._sourceParent = ref.parent;
40
+ this._intrinsic = null;
41
+ } else {
42
+ // Intrinsic (Sub, Join, Ref, etc.) — no parent entity tracking needed
43
+ // Note: `string` in the union is for attribute accessors typed as string at the
44
+ // TypeScript level (they are AttrRef at runtime, caught by instanceof above).
45
+ this.sourceLexicon = "";
46
+ this.sourceEntity = "";
47
+ this.sourceAttribute = null;
48
+ this.outputName = name;
49
+ this._sourceParent = null;
50
+ this._intrinsic = typeof ref !== "string" ? ref : null;
51
+ }
34
52
  }
35
53
 
36
54
  /**
@@ -42,6 +60,18 @@ export class LexiconOutput implements Intrinsic {
42
60
  (this as { sourceEntity: string }).sourceEntity = name;
43
61
  }
44
62
 
63
+ /**
64
+ * Returns the CloudFormation Output Value for this output.
65
+ * For AttrRef-based outputs: emits Fn::GetAtt.
66
+ * For Intrinsic-based outputs: delegates to the intrinsic's toJSON().
67
+ */
68
+ getOutputValue(): unknown {
69
+ if (this._intrinsic) {
70
+ return this._intrinsic.toJSON();
71
+ }
72
+ return { "Fn::GetAtt": [this.sourceEntity, this.sourceAttribute] };
73
+ }
74
+
45
75
  /**
46
76
  * Create a LexiconOutput with an auto-generated name from entity name and attribute.
47
77
  * Used during cross-lexicon ref auto-detection.
@@ -63,14 +93,19 @@ export class LexiconOutput implements Intrinsic {
63
93
  }
64
94
 
65
95
  /**
66
- * Create a LexiconOutput from an AttrRef and a user-provided output name.
96
+ * Create a LexiconOutput from an AttrRef or Intrinsic and a user-provided output name.
67
97
  *
68
- * Usage:
98
+ * Usage with AttrRef:
69
99
  * ```ts
70
100
  * const bucketArn = output(dataBucket.arn, "DataBucketArn");
71
101
  * ```
102
+ *
103
+ * Usage with an intrinsic (e.g. a constructed URL):
104
+ * ```ts
105
+ * const solrUrl = output(Sub`http://${Ref(albDnsName)}/solr`, "solrUrl");
106
+ * ```
72
107
  */
73
- export function output(ref: AttrRef, name: string): LexiconOutput {
108
+ export function output(ref: AttrRef | Intrinsic | string, name: string): LexiconOutput {
74
109
  return new LexiconOutput(ref, name);
75
110
  }
76
111
 
package/src/runtime.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * lexicon, entityType, kind, and attribute references.
6
6
  */
7
7
 
8
- import { DECLARABLE_MARKER } from "./declarable";
8
+ import { DECLARABLE_MARKER, type Declarable } from "./declarable";
9
9
  import { AttrRef } from "./attrref";
10
10
 
11
11
  /**
@@ -22,7 +22,7 @@ export function createResource(
22
22
  type: string,
23
23
  lexicon: string,
24
24
  attrMap: Record<string, string>,
25
- ): new (props: Record<string, unknown>, attributes?: Record<string, unknown>) => Record<string, unknown> {
25
+ ): new (props: Record<string, unknown>, attributes?: Record<string, unknown>) => Declarable & Record<string, string> {
26
26
  const ResourceClass = function (this: Record<string, unknown>, props: Record<string, unknown>, attributes?: Record<string, unknown>) {
27
27
  Object.defineProperty(this, DECLARABLE_MARKER, { value: true, enumerable: false });
28
28
  Object.defineProperty(this, "lexicon", { value: lexicon, enumerable: false });
@@ -44,7 +44,7 @@ export function createResource(
44
44
  writable: false,
45
45
  });
46
46
  }
47
- } as unknown as new (props: Record<string, unknown>, attributes?: Record<string, unknown>) => Record<string, unknown>;
47
+ } as unknown as new (props: Record<string, unknown>, attributes?: Record<string, unknown>) => Declarable & Record<string, string>;
48
48
 
49
49
  // Set the constructor name for debugging
50
50
  Object.defineProperty(ResourceClass, "name", { value: type.split("::").pop() ?? type });
package/src/yaml.ts CHANGED
@@ -188,7 +188,10 @@ export function parseYAMLLines(
188
188
  if (i + 1 < lines.length) {
189
189
  const nextLine = lines[i + 1];
190
190
  const nextIndent = nextLine.search(/\S/);
191
- if (nextIndent > indent && nextLine.trimStart().startsWith("- ")) {
191
+ if (nextLine.trimStart().startsWith("- ") && nextIndent >= indent) {
192
+ // Same-indent arrays are valid YAML (e.g. controller-gen output):
193
+ // versions:
194
+ // - name: v1
192
195
  const arr = parseYAMLArray(lines, i + 1, nextIndent);
193
196
  result[key] = arr.value;
194
197
  i = arr.endIndex;