@intentius/chant 0.23.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts +8 -0
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +8 -0
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/config-import.d.ts +33 -0
- package/dist/config-import.d.ts.map +1 -0
- package/dist/config-sandbox.d.ts +47 -0
- package/dist/config-sandbox.d.ts.map +1 -0
- package/dist/config.d.ts +29 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/discovery/entity-wire-codec.d.ts +1 -1
- package/dist/discovery/entity-wire-codec.d.ts.map +1 -1
- package/dist/discovery/sandbox/config-run.d.ts +24 -0
- package/dist/discovery/sandbox/config-run.d.ts.map +1 -0
- package/dist/discovery/sandbox/config-wire.d.ts +68 -0
- package/dist/discovery/sandbox/config-wire.d.ts.map +1 -0
- package/dist/discovery/sandbox/driver.d.ts +20 -0
- package/dist/discovery/sandbox/driver.d.ts.map +1 -1
- package/dist/discovery/sandbox/fork.d.ts +52 -0
- package/dist/discovery/sandbox/fork.d.ts.map +1 -0
- package/dist/discovery/sandbox/run.d.ts +10 -20
- package/dist/discovery/sandbox/run.d.ts.map +1 -1
- package/dist/lexicon-output.d.ts +62 -6
- package/dist/lexicon-output.d.ts.map +1 -1
- package/dist/lint/config.d.ts +7 -12
- package/dist/lint/config.d.ts.map +1 -1
- package/dist/project-root.d.ts +51 -0
- package/dist/project-root.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/build.test.ts +19 -0
- package/src/build.ts +21 -10
- package/src/cli/commands/build.ts +42 -6
- package/src/cli/handlers/build.test.ts +11 -9
- package/src/cli/handlers/build.ts +7 -2
- package/src/cli/handlers/run.test.ts +31 -0
- package/src/cli/handlers/run.ts +15 -0
- package/src/cli/main.test.ts +23 -0
- package/src/cli/main.ts +27 -3
- package/src/cli/plugins.ts +10 -2
- package/src/config-import.ts +43 -0
- package/src/config-sandbox.ts +138 -0
- package/src/config.ts +37 -5
- package/src/discovery/entity-wire-codec.ts +21 -12
- package/src/discovery/entity-wire.test.ts +26 -0
- package/src/discovery/sandbox/config-boundary.test.ts +239 -0
- package/src/discovery/sandbox/config-run.ts +130 -0
- package/src/discovery/sandbox/config-wire.test.ts +110 -0
- package/src/discovery/sandbox/config-wire.ts +174 -0
- package/src/discovery/sandbox/driver.ts +68 -0
- package/src/discovery/sandbox/fork.ts +110 -0
- package/src/discovery/sandbox/run.ts +28 -85
- package/src/lexicon-output.test.ts +137 -1
- package/src/lexicon-output.ts +112 -13
- package/src/lint/config.test.ts +9 -4
- package/src/lint/config.ts +15 -27
- package/src/lint/policy.ts +5 -5
- package/src/project-root.test.ts +105 -0
- package/src/project-root.ts +78 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, test, expect } from "vitest";
|
|
1
|
+
import { describe, test, expect, vi } from "vitest";
|
|
2
2
|
import { LexiconOutput, output, isLexiconOutput } from "./lexicon-output";
|
|
3
3
|
import { AttrRef } from "./attrref";
|
|
4
4
|
import { INTRINSIC_MARKER } from "./intrinsic";
|
|
@@ -92,6 +92,60 @@ describe("LexiconOutput", () => {
|
|
|
92
92
|
const lo = new LexiconOutput(mockIntrinsic, "MyUrl");
|
|
93
93
|
expect(lo.getOutputValue()).toEqual({ "Fn::Sub": "http://${Param}/path" });
|
|
94
94
|
});
|
|
95
|
+
|
|
96
|
+
// chant #1121 — an already-resolved plain value (not a reference to
|
|
97
|
+
// anything) must be emitted verbatim as the Output's Value, never coerced
|
|
98
|
+
// into a bogus Fn::GetAtt pointing at the output's own logical id.
|
|
99
|
+
describe("literal-valued output (chant #1121)", () => {
|
|
100
|
+
test("accepts a string literal and sets no source entity/attribute", () => {
|
|
101
|
+
const lo = new LexiconOutput("us-east-1", "Region");
|
|
102
|
+
expect(lo.sourceLexicon).toBe("");
|
|
103
|
+
expect(lo.sourceEntity).toBe("");
|
|
104
|
+
expect(lo.sourceAttribute).toBeNull();
|
|
105
|
+
expect(lo.outputName).toBe("Region");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("getOutputValue() returns a string literal verbatim", () => {
|
|
109
|
+
const lo = new LexiconOutput("fold-output-repro", "oParamName");
|
|
110
|
+
expect(lo.getOutputValue()).toBe("fold-output-repro");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("getOutputValue() returns a number literal verbatim", () => {
|
|
114
|
+
const lo = new LexiconOutput(42, "oCount");
|
|
115
|
+
expect(lo.getOutputValue()).toBe(42);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("getOutputValue() returns a boolean literal verbatim (including false)", () => {
|
|
119
|
+
expect(new LexiconOutput(true, "oEnabled").getOutputValue()).toBe(true);
|
|
120
|
+
expect(new LexiconOutput(false, "oDisabled").getOutputValue()).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("getOutputValue() returns 0 and empty string verbatim (falsy but valid)", () => {
|
|
124
|
+
expect(new LexiconOutput(0, "oZero").getOutputValue()).toBe(0);
|
|
125
|
+
expect(new LexiconOutput("", "oEmpty").getOutputValue()).toBe("");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("_setSourceEntity has no effect on getOutputValue() for a literal", () => {
|
|
129
|
+
const lo = new LexiconOutput("v1", "oVersion");
|
|
130
|
+
lo._setSourceEntity("someUnrelatedEntity");
|
|
131
|
+
expect(lo.getOutputValue()).toBe("v1");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("throws for a ref that is neither an AttrRef, an Intrinsic, nor a string/number/boolean literal", () => {
|
|
135
|
+
// Mirrors what a resource member access that resolves to `undefined`
|
|
136
|
+
// looks like at runtime (e.g. a typo, or a non-attribute field a
|
|
137
|
+
// generated resource class never echoes onto the instance).
|
|
138
|
+
expect(() => new LexiconOutput(undefined as unknown as string, "oBroken")).toThrow(
|
|
139
|
+
/must be an AttrRef, an Intrinsic, or an already-resolved string\/number\/boolean/,
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("throws for null", () => {
|
|
144
|
+
expect(() => new LexiconOutput(null as unknown as string, "oBroken")).toThrow(
|
|
145
|
+
/must be an AttrRef, an Intrinsic, or an already-resolved string\/number\/boolean/,
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
95
149
|
});
|
|
96
150
|
|
|
97
151
|
describe("LexiconOutput.auto", () => {
|
|
@@ -164,6 +218,20 @@ describe("output() helper", () => {
|
|
|
164
218
|
expect(result.sourceAttribute).toBe("Arn");
|
|
165
219
|
expect(result.outputName).toBe("DataBucketArn");
|
|
166
220
|
});
|
|
221
|
+
|
|
222
|
+
// chant #1121
|
|
223
|
+
test.each([
|
|
224
|
+
["string", "v1"],
|
|
225
|
+
["number", 42],
|
|
226
|
+
["boolean", true],
|
|
227
|
+
] as const)("creates a literal-valued LexiconOutput from a %s and emits it verbatim", (_kind, value) => {
|
|
228
|
+
const result = output(value, "oLiteral");
|
|
229
|
+
|
|
230
|
+
expect(result).toBeInstanceOf(LexiconOutput);
|
|
231
|
+
expect(result.sourceEntity).toBe("");
|
|
232
|
+
expect(result.sourceAttribute).toBeNull();
|
|
233
|
+
expect(result.getOutputValue()).toBe(value);
|
|
234
|
+
});
|
|
167
235
|
});
|
|
168
236
|
|
|
169
237
|
describe("isLexiconOutput", () => {
|
|
@@ -188,6 +256,41 @@ describe("isLexiconOutput", () => {
|
|
|
188
256
|
|
|
189
257
|
expect(isLexiconOutput(ref)).toBe(false);
|
|
190
258
|
});
|
|
259
|
+
|
|
260
|
+
// chant #1122 — the guard used to be `value instanceof LexiconOutput`,
|
|
261
|
+
// which returns false when the value was built by a SEPARATELY-LOADED
|
|
262
|
+
// copy of this module (a plain npm-dedupe outcome: a lexicon pinned to a
|
|
263
|
+
// chant range that doesn't overlap the project's own gets its own nested
|
|
264
|
+
// `node_modules/@intentius/chant`). `vi.resetModules()` + a fresh dynamic
|
|
265
|
+
// import reproduces that split module graph exactly, without needing an
|
|
266
|
+
// actual second install on disk — the resulting instance is structurally
|
|
267
|
+
// and behaviorally identical to a real LexiconOutput, just built from a
|
|
268
|
+
// distinct `LexiconOutput` class object.
|
|
269
|
+
test("recognizes a LexiconOutput built by a second, separately-loaded copy of this module", async () => {
|
|
270
|
+
vi.resetModules();
|
|
271
|
+
const secondCopy = await import("./lexicon-output");
|
|
272
|
+
|
|
273
|
+
// Sanity check that this really is a distinct module instance — the
|
|
274
|
+
// premise the rest of the test depends on.
|
|
275
|
+
expect(secondCopy.LexiconOutput).not.toBe(LexiconOutput);
|
|
276
|
+
|
|
277
|
+
const second = new secondCopy.LexiconOutput("v2", "oFromSecondCopy");
|
|
278
|
+
|
|
279
|
+
// The historic bug: instanceof fails across separately-loaded copies of
|
|
280
|
+
// chant-core, even though the two classes are structurally identical.
|
|
281
|
+
expect(second instanceof LexiconOutput).toBe(false);
|
|
282
|
+
|
|
283
|
+
// The fix: a Symbol.for global marker holds across copies the way
|
|
284
|
+
// DECLARABLE_MARKER/INTRINSIC_MARKER/STACK_OUTPUT_MARKER already do —
|
|
285
|
+
// isLexiconOutput (from EITHER copy) recognizes the other copy's output.
|
|
286
|
+
expect(isLexiconOutput(second)).toBe(true);
|
|
287
|
+
expect(secondCopy.isLexiconOutput(second)).toBe(true);
|
|
288
|
+
|
|
289
|
+
// And the callers that gate on this guard only ever read own-prototype
|
|
290
|
+
// members that survive the cross-copy split.
|
|
291
|
+
expect(second.getOutputValue()).toBe("v2");
|
|
292
|
+
vi.resetModules();
|
|
293
|
+
});
|
|
191
294
|
});
|
|
192
295
|
|
|
193
296
|
describe("collectLexiconOutputs", () => {
|
|
@@ -231,4 +334,37 @@ describe("collectLexiconOutputs", () => {
|
|
|
231
334
|
expect(collected).toHaveLength(1);
|
|
232
335
|
expect(collected[0].outputName).toBe("BucketArn");
|
|
233
336
|
});
|
|
337
|
+
|
|
338
|
+
// chant #1121 — a literal-valued output has no source entity. Before this
|
|
339
|
+
// fix, a top-level `export const x = output("literal", "x")` fell back to
|
|
340
|
+
// naming the output's OWN map key as its "source entity" (there being no
|
|
341
|
+
// `_sourceParent` to resolve), which `getOutputValue()`'s `Fn::GetAtt`
|
|
342
|
+
// fallback then read back out as a self-referencing, invalid reference.
|
|
343
|
+
test("does NOT fall back to the output's own key as sourceEntity for a literal-valued output", () => {
|
|
344
|
+
const literalOutput = output("fold-output-repro", "oParamName");
|
|
345
|
+
|
|
346
|
+
const entities = new Map<string, Declarable>();
|
|
347
|
+
entities.set("oParamName", literalOutput as unknown as Declarable);
|
|
348
|
+
|
|
349
|
+
const collected = collectLexiconOutputs(entities);
|
|
350
|
+
|
|
351
|
+
expect(collected).toHaveLength(1);
|
|
352
|
+
expect(collected[0].sourceEntity).toBe("");
|
|
353
|
+
expect(collected[0].getOutputValue()).toBe("fold-output-repro");
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("does NOT fall back to the containing entity's name for a literal-valued output nested in props", () => {
|
|
357
|
+
const bucket = new MockResource();
|
|
358
|
+
const literalOutput = output(123, "oNested");
|
|
359
|
+
bucket.props.nested = literalOutput;
|
|
360
|
+
|
|
361
|
+
const entities = new Map<string, Declarable>();
|
|
362
|
+
entities.set("dataBucket", bucket as unknown as Declarable);
|
|
363
|
+
|
|
364
|
+
const collected = collectLexiconOutputs(entities);
|
|
365
|
+
|
|
366
|
+
expect(collected).toHaveLength(1);
|
|
367
|
+
expect(collected[0].sourceEntity).toBe("");
|
|
368
|
+
expect(collected[0].getOutputValue()).toBe(123);
|
|
369
|
+
});
|
|
234
370
|
});
|
package/src/lexicon-output.ts
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
|
-
import { INTRINSIC_MARKER, type Intrinsic } from "./intrinsic";
|
|
1
|
+
import { INTRINSIC_MARKER, isIntrinsic, type Intrinsic } from "./intrinsic";
|
|
2
2
|
import { AttrRef } from "./attrref";
|
|
3
3
|
|
|
4
|
+
/** A value `output()` accepts that is already fully resolved — not a
|
|
5
|
+
* reference to anything, just data the author computed (a literal, a prop,
|
|
6
|
+
* a template string). See chant #1121. */
|
|
7
|
+
export type LexiconOutputLiteral = string | number | boolean;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Marker symbol for LexiconOutput identification (chant #1122).
|
|
11
|
+
*
|
|
12
|
+
* A GLOBAL symbol (via `Symbol.for`), like every other chant-core brand
|
|
13
|
+
* check — `DECLARABLE_MARKER`, `STACK_OUTPUT_MARKER`, `INTRINSIC_MARKER` —
|
|
14
|
+
* holds across separately-loaded copies of chant-core the way `instanceof`
|
|
15
|
+
* does not. Two copies in one process is a plain npm-dedupe outcome: a
|
|
16
|
+
* lexicon pinned to a chant range that does not overlap the project's own
|
|
17
|
+
* gets a nested `node_modules/@intentius/chant`, and a project file
|
|
18
|
+
* importing `output` from that lexicon then holds a different `LexiconOutput`
|
|
19
|
+
* class than the CLI does. `instanceof LexiconOutput` returns false for a
|
|
20
|
+
* real output built by the other copy, and every `Outputs` entry vanishes
|
|
21
|
+
* silently.
|
|
22
|
+
*
|
|
23
|
+
* Installed non-enumerably in the constructor (not as a public class field)
|
|
24
|
+
* so a shallow spread/clone of a real instance — which would already lack
|
|
25
|
+
* its prototype methods (`getOutputValue()`, `_setSourceEntity()`, …) — does
|
|
26
|
+
* not silently pick up the marker and pass this guard too.
|
|
27
|
+
*/
|
|
28
|
+
export const LEXICON_OUTPUT_MARKER = Symbol.for("chant.lexiconOutput");
|
|
29
|
+
|
|
4
30
|
/**
|
|
5
31
|
* Sanitize auto-generated Output name parts into a valid CloudFormation
|
|
6
32
|
* logical id. Real CloudFormation logical ids (including `Outputs` keys)
|
|
@@ -34,11 +60,17 @@ export function sanitizeLogicalId(...parts: string[]): string {
|
|
|
34
60
|
*
|
|
35
61
|
* Implements Intrinsic so it can be used as Value<string> anywhere.
|
|
36
62
|
*
|
|
37
|
-
* Accepts
|
|
38
|
-
*
|
|
63
|
+
* Accepts an AttrRef (resource attribute reference), any Intrinsic (e.g. Sub,
|
|
64
|
+
* Join) for computed output values like constructed URLs, or an already-
|
|
65
|
+
* resolved literal (string/number/boolean) — a constant the author's code
|
|
66
|
+
* computed rather than a reference to anything (chant #1121).
|
|
39
67
|
*/
|
|
40
68
|
export class LexiconOutput implements Intrinsic {
|
|
41
69
|
readonly [INTRINSIC_MARKER] = true as const;
|
|
70
|
+
/** @internal Brand marker — see {@link LEXICON_OUTPUT_MARKER}. Declared
|
|
71
|
+
* here only for type purposes; the real, non-enumerable property is
|
|
72
|
+
* installed by the constructor. */
|
|
73
|
+
readonly [LEXICON_OUTPUT_MARKER]!: true;
|
|
42
74
|
readonly sourceLexicon: string;
|
|
43
75
|
readonly sourceEntity: string;
|
|
44
76
|
readonly sourceAttribute: string | null;
|
|
@@ -52,8 +84,22 @@ export class LexiconOutput implements Intrinsic {
|
|
|
52
84
|
* checking on every field it reads (#1047).
|
|
53
85
|
*/
|
|
54
86
|
readonly _intrinsic: Intrinsic | null;
|
|
87
|
+
/**
|
|
88
|
+
* @internal The already-resolved literal (string/number/boolean) when
|
|
89
|
+
* constructed from neither an AttrRef nor an Intrinsic — non-null exactly
|
|
90
|
+
* when `_intrinsic` is null AND `sourceAttribute` is null. There is no
|
|
91
|
+
* source entity or attribute to reference, so `getOutputValue()` returns
|
|
92
|
+
* this value verbatim rather than fabricating a `Fn::GetAtt` out of an
|
|
93
|
+
* unset attribute (chant #1121). Readable outside the class for the same
|
|
94
|
+
* reason as `_intrinsic`/`_sourceParent` above.
|
|
95
|
+
*/
|
|
96
|
+
readonly _literalValue: LexiconOutputLiteral | null;
|
|
55
97
|
|
|
56
|
-
constructor(ref: AttrRef | Intrinsic |
|
|
98
|
+
constructor(ref: AttrRef | Intrinsic | LexiconOutputLiteral, name: string) {
|
|
99
|
+
Object.defineProperty(this, LEXICON_OUTPUT_MARKER, {
|
|
100
|
+
value: true,
|
|
101
|
+
enumerable: false,
|
|
102
|
+
});
|
|
57
103
|
if (ref instanceof AttrRef) {
|
|
58
104
|
const parent = ref.parent.deref();
|
|
59
105
|
if (!parent) {
|
|
@@ -70,16 +116,45 @@ export class LexiconOutput implements Intrinsic {
|
|
|
70
116
|
this.outputName = name;
|
|
71
117
|
this._sourceParent = ref.parent;
|
|
72
118
|
this._intrinsic = null;
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
//
|
|
76
|
-
// TypeScript level (they are AttrRef at runtime, caught by instanceof above).
|
|
119
|
+
this._literalValue = null;
|
|
120
|
+
} else if (isIntrinsic(ref)) {
|
|
121
|
+
// Intrinsic (Sub, Join, Ref, etc.) — no parent entity tracking needed.
|
|
77
122
|
this.sourceLexicon = "";
|
|
78
123
|
this.sourceEntity = "";
|
|
79
124
|
this.sourceAttribute = null;
|
|
80
125
|
this.outputName = name;
|
|
81
126
|
this._sourceParent = null;
|
|
82
|
-
this._intrinsic =
|
|
127
|
+
this._intrinsic = ref;
|
|
128
|
+
this._literalValue = null;
|
|
129
|
+
} else if (typeof ref === "string" || typeof ref === "number" || typeof ref === "boolean") {
|
|
130
|
+
// An already-resolved literal — a real string/number/boolean the
|
|
131
|
+
// caller computed (a prop, a template string, a plain constant), not
|
|
132
|
+
// a reference to anything. The `string` arm of the exported type
|
|
133
|
+
// exists for a documented reason: a generated resource's attribute
|
|
134
|
+
// accessor is typed `string` at the TypeScript level but is a real
|
|
135
|
+
// `AttrRef` at runtime, caught by `instanceof AttrRef` above — so
|
|
136
|
+
// anything that reaches this branch genuinely has no source entity
|
|
137
|
+
// or attribute, and is recorded to be emitted as a plain `Value`
|
|
138
|
+
// rather than a fabricated `Fn::GetAtt` (chant #1121).
|
|
139
|
+
this.sourceLexicon = "";
|
|
140
|
+
this.sourceEntity = "";
|
|
141
|
+
this.sourceAttribute = null;
|
|
142
|
+
this.outputName = name;
|
|
143
|
+
this._sourceParent = null;
|
|
144
|
+
this._intrinsic = null;
|
|
145
|
+
this._literalValue = ref;
|
|
146
|
+
} else {
|
|
147
|
+
// Neither a reference NOR a resolved value — most commonly `undefined`
|
|
148
|
+
// from accessing a resource member that looks like an attribute but
|
|
149
|
+
// isn't one (a typo, or a genuine prop that was never wired onto the
|
|
150
|
+
// instance as either an AttrRef or an echoed literal). Silently
|
|
151
|
+
// treating this as a literal would trade one invalid Output (a
|
|
152
|
+
// fabricated `Fn::GetAtt`) for another (a `Value` that is missing or
|
|
153
|
+
// `null`) — fail loudly instead, the same call `stackOutput()` already
|
|
154
|
+
// makes for a ref it cannot anchor (chant #1121).
|
|
155
|
+
throw new Error(
|
|
156
|
+
`output(ref, "${name}"): ref must be an AttrRef, an Intrinsic, or an already-resolved string/number/boolean — got ${ref === null ? "null" : typeof ref} instead. If this came from a resource member access (e.g. "resource.SomeField"), that member is neither a generated attribute nor a real value here — check for a typo or a property that CloudFormation does not expose via Fn::GetAtt.`,
|
|
157
|
+
);
|
|
83
158
|
}
|
|
84
159
|
}
|
|
85
160
|
|
|
@@ -94,10 +169,14 @@ export class LexiconOutput implements Intrinsic {
|
|
|
94
169
|
|
|
95
170
|
/**
|
|
96
171
|
* Returns the CloudFormation Output Value for this output.
|
|
172
|
+
* For a literal output: the resolved value itself, verbatim.
|
|
97
173
|
* For AttrRef-based outputs: emits Fn::GetAtt.
|
|
98
174
|
* For Intrinsic-based outputs: delegates to the intrinsic's toJSON().
|
|
99
175
|
*/
|
|
100
176
|
getOutputValue(): unknown {
|
|
177
|
+
if (this._literalValue !== null) {
|
|
178
|
+
return this._literalValue;
|
|
179
|
+
}
|
|
101
180
|
if (this._intrinsic) {
|
|
102
181
|
return this._intrinsic.toJSON();
|
|
103
182
|
}
|
|
@@ -130,7 +209,8 @@ export class LexiconOutput implements Intrinsic {
|
|
|
130
209
|
}
|
|
131
210
|
|
|
132
211
|
/**
|
|
133
|
-
* Create a LexiconOutput from an AttrRef
|
|
212
|
+
* Create a LexiconOutput from an AttrRef, an Intrinsic, or an already-
|
|
213
|
+
* resolved literal, and a user-provided output name.
|
|
134
214
|
*
|
|
135
215
|
* Usage with AttrRef:
|
|
136
216
|
* ```ts
|
|
@@ -141,14 +221,33 @@ export class LexiconOutput implements Intrinsic {
|
|
|
141
221
|
* ```ts
|
|
142
222
|
* const solrUrl = output(Sub`http://${Ref(albDnsName)}/solr`, "solrUrl");
|
|
143
223
|
* ```
|
|
224
|
+
*
|
|
225
|
+
* Usage with a literal (chant #1121) — a real value the caller already
|
|
226
|
+
* computed, not a reference:
|
|
227
|
+
* ```ts
|
|
228
|
+
* const apiVersion = output("v1", "ApiVersion");
|
|
229
|
+
* ```
|
|
144
230
|
*/
|
|
145
|
-
export function output(ref: AttrRef | Intrinsic |
|
|
231
|
+
export function output(ref: AttrRef | Intrinsic | LexiconOutputLiteral, name: string): LexiconOutput {
|
|
146
232
|
return new LexiconOutput(ref, name);
|
|
147
233
|
}
|
|
148
234
|
|
|
149
235
|
/**
|
|
150
|
-
* Type guard to check if a value is a LexiconOutput
|
|
236
|
+
* Type guard to check if a value is a LexiconOutput.
|
|
237
|
+
*
|
|
238
|
+
* Structural, not `instanceof` (chant #1122) — keys off {@link
|
|
239
|
+
* LEXICON_OUTPUT_MARKER}, a global symbol, so it holds across separately-
|
|
240
|
+
* loaded copies of chant-core the way `instanceof` does not. Every caller
|
|
241
|
+
* (`collect.ts`, `build.ts`, `graph-ir.ts`, `entity-wire-codec.ts`) reads
|
|
242
|
+
* only own-prototype members off the result (`outputName`, `_sourceParent`,
|
|
243
|
+
* `_setSourceEntity()`, `getOutputValue()`), all of which work identically
|
|
244
|
+
* on a cross-copy instance.
|
|
151
245
|
*/
|
|
152
246
|
export function isLexiconOutput(value: unknown): value is LexiconOutput {
|
|
153
|
-
return
|
|
247
|
+
return (
|
|
248
|
+
typeof value === "object" &&
|
|
249
|
+
value !== null &&
|
|
250
|
+
LEXICON_OUTPUT_MARKER in value &&
|
|
251
|
+
(value as Record<symbol, unknown>)[LEXICON_OUTPUT_MARKER] === true
|
|
252
|
+
);
|
|
154
253
|
}
|
package/src/lint/config.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|
2
2
|
import { loadConfig, DEFAULT_CONFIG, findProjectRoot } from "./config";
|
|
3
3
|
import { writeFileSync, mkdirSync, rmSync } from "fs";
|
|
4
|
-
import { join } from "path";
|
|
4
|
+
import { join, resolve } from "path";
|
|
5
5
|
|
|
6
6
|
const TEST_DIR = join(import.meta.dirname, "__test_config__");
|
|
7
7
|
|
|
@@ -705,10 +705,15 @@ describe("findProjectRoot", () => {
|
|
|
705
705
|
expect(findProjectRoot(sub)).toBe(TEST_DIR);
|
|
706
706
|
});
|
|
707
707
|
|
|
708
|
-
test("
|
|
708
|
+
test("stops at the nearest .git/package.json boundary when no config is found (#1117)", () => {
|
|
709
709
|
const sub = join(TEST_DIR, "nowhere");
|
|
710
710
|
mkdirSync(sub, { recursive: true });
|
|
711
|
-
// No chant.config anywhere under TEST_DIR —
|
|
712
|
-
|
|
711
|
+
// No chant.config anywhere under TEST_DIR — walking up from this real
|
|
712
|
+
// repo location reaches `packages/core`'s own package.json before the
|
|
713
|
+
// filesystem root, so that's the returned boundary, not `sub` itself
|
|
714
|
+
// (chant #1117 — discovery must never wander past the project just
|
|
715
|
+
// because it declares no config).
|
|
716
|
+
const packageRoot = resolve(import.meta.dirname, "..", "..");
|
|
717
|
+
expect(findProjectRoot(sub)).toBe(packageRoot);
|
|
713
718
|
});
|
|
714
719
|
});
|
package/src/lint/config.ts
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { readFileSync, existsSync } from "fs";
|
|
2
2
|
import { join, dirname, resolve } from "path";
|
|
3
|
-
import { createRequire } from "module";
|
|
4
3
|
import { z } from "zod";
|
|
4
|
+
import { evaluateProjectConfigSync } from "../config-sandbox";
|
|
5
5
|
import type { Severity, RuleConfig } from "./rule";
|
|
6
6
|
import { moduleDir, getRuntime } from "../runtime-adapter";
|
|
7
7
|
import strictPreset from "./presets/strict.json";
|
|
8
8
|
|
|
9
|
+
// chant #1117 — the upward config-discovery walk moved to a shared module
|
|
10
|
+
// (`../project-root`) so `chant build`/`lint.policies` use the identical walk
|
|
11
|
+
// `chant lint`/`chant graph` already did. Re-exported here since this is
|
|
12
|
+
// still where every existing call site (`./config.test.ts`, `../cli/commands/lint.ts`)
|
|
13
|
+
// imports it from.
|
|
14
|
+
export { findProjectRoot } from "../project-root";
|
|
15
|
+
|
|
9
16
|
/** Mapping of built-in preset names to their file paths */
|
|
10
17
|
const BUILTIN_PRESETS: Record<string, string> = {
|
|
11
18
|
"@intentius/chant/lint/presets/strict": resolve(moduleDir(import.meta.url), "presets/strict.json"),
|
|
@@ -323,29 +330,6 @@ function loadConfigFile(configPath: string, visited: Set<string> = new Set()): L
|
|
|
323
330
|
return mergedConfig;
|
|
324
331
|
}
|
|
325
332
|
|
|
326
|
-
/**
|
|
327
|
-
* Walk up from `startDir` to the nearest ancestor holding a chant project
|
|
328
|
-
* config (`chant.config.ts` or `chant.config.json`). Returns that directory, or
|
|
329
|
-
* `startDir` unchanged when none is found before the filesystem root.
|
|
330
|
-
*
|
|
331
|
-
* Linting a subpath (`chant graph src --format ir`, `chant lint src/lib`) must
|
|
332
|
-
* still see the project-root config: its `lint.overrides` globs are written
|
|
333
|
-
* project-root-relative (`src/lib/**`), and a rule set scoped only to the lint
|
|
334
|
-
* arg would silently drop them. Config discovery therefore anchors on the
|
|
335
|
-
* project root, not the path being linted.
|
|
336
|
-
*/
|
|
337
|
-
export function findProjectRoot(startDir: string): string {
|
|
338
|
-
let dir = resolve(startDir);
|
|
339
|
-
for (;;) {
|
|
340
|
-
if (existsSync(join(dir, "chant.config.ts")) || existsSync(join(dir, "chant.config.json"))) {
|
|
341
|
-
return dir;
|
|
342
|
-
}
|
|
343
|
-
const parent = dirname(dir);
|
|
344
|
-
if (parent === dir) return resolve(startDir);
|
|
345
|
-
dir = parent;
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
|
|
349
333
|
/**
|
|
350
334
|
* Load lint configuration from a directory.
|
|
351
335
|
*
|
|
@@ -353,6 +337,12 @@ export function findProjectRoot(startDir: string): string {
|
|
|
353
337
|
* then falls back to `chant.config.json` (legacy LintConfig format).
|
|
354
338
|
* Returns default configuration if neither exists.
|
|
355
339
|
*
|
|
340
|
+
* chant #1113 — the `chant.config.ts` branch executes project-authored code,
|
|
341
|
+
* so it goes through `../config-sandbox.ts` like every other config load
|
|
342
|
+
* rather than `require`-ing the file itself. Unarmed (which is every `chant
|
|
343
|
+
* lint` invocation today — `lint` has no `--sandbox` flag) that is the
|
|
344
|
+
* identical `createRequire` path this used before, moved one module over.
|
|
345
|
+
*
|
|
356
346
|
* @param dir - Directory path to search for config file
|
|
357
347
|
* @returns Loaded and merged configuration, or default config if not found
|
|
358
348
|
*/
|
|
@@ -361,9 +351,7 @@ export function loadConfig(dir: string): LintConfig {
|
|
|
361
351
|
const tsConfigPath = join(dir, "chant.config.ts");
|
|
362
352
|
if (existsSync(tsConfigPath)) {
|
|
363
353
|
try {
|
|
364
|
-
const
|
|
365
|
-
const mod = _require(tsConfigPath);
|
|
366
|
-
const config = mod.default ?? mod.config ?? mod;
|
|
354
|
+
const config = evaluateProjectConfigSync(tsConfigPath, dir);
|
|
367
355
|
if (typeof config === "object" && config !== null) {
|
|
368
356
|
// ChantConfig format: extract lint property
|
|
369
357
|
if ("lint" in config && typeof config.lint === "object") {
|
package/src/lint/policy.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* `policyGate` Op step runs this to gate an apply on the same checks.
|
|
5
5
|
*/
|
|
6
6
|
import { resolve, dirname } from "node:path";
|
|
7
|
-
import {
|
|
7
|
+
import { loadChantConfigUpward } from "../config";
|
|
8
8
|
import { resolveProjectLexicons, loadPlugins } from "../cli/plugins";
|
|
9
9
|
import { build } from "../build";
|
|
10
10
|
import { runPostSynthChecks, isPostSynthCheck } from "./post-synth";
|
|
@@ -55,10 +55,10 @@ export async function evaluateProjectPolicies(opts: {
|
|
|
55
55
|
const plugins = await loadPlugins(lexiconNames);
|
|
56
56
|
const serializers = plugins.map((p) => p.serializer);
|
|
57
57
|
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
);
|
|
58
|
+
// chant #1117 — walks up from the build dir to the project root, same as
|
|
59
|
+
// `chant build` (`../cli/commands/build.ts`'s `loadChantConfigUpward`), not
|
|
60
|
+
// just the build dir's immediate parent.
|
|
61
|
+
const loaded = await loadChantConfigUpward(buildPath);
|
|
62
62
|
const config = loaded.config;
|
|
63
63
|
const configDir = loaded.configPath ? dirname(loaded.configPath) : buildPath;
|
|
64
64
|
const env = opts.env ?? config.ownership?.env;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
import { findProjectConfig, findProjectRoot } from "./project-root";
|
|
6
|
+
|
|
7
|
+
// Every test builds its own isolated directory tree under a fresh tmpdir —
|
|
8
|
+
// never reused across tests, and never anywhere near the real repo's own
|
|
9
|
+
// .git/package.json — so the boundary-stop behavior is exercised
|
|
10
|
+
// deterministically instead of depending on where this file happens to sit
|
|
11
|
+
// in the real chant checkout.
|
|
12
|
+
let root: string;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
root = mkdtempSync(join(tmpdir(), "chant-project-root-"));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
rmSync(root, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("findProjectConfig / findProjectRoot (chant #1117)", () => {
|
|
23
|
+
test("found at root — chant.config.ts in the start dir itself", () => {
|
|
24
|
+
writeFileSync(join(root, "chant.config.ts"), "export default {};");
|
|
25
|
+
|
|
26
|
+
const result = findProjectConfig(root);
|
|
27
|
+
|
|
28
|
+
expect(result.dir).toBe(root);
|
|
29
|
+
expect(result.configPath).toBe(join(root, "chant.config.ts"));
|
|
30
|
+
expect(findProjectRoot(root)).toBe(root);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("found at an intermediate ancestor — several levels below the config", () => {
|
|
34
|
+
writeFileSync(join(root, "chant.config.json"), "{}");
|
|
35
|
+
const stackDir = join(root, "src", "stacks", "shared-foundation");
|
|
36
|
+
mkdirSync(stackDir, { recursive: true });
|
|
37
|
+
|
|
38
|
+
const result = findProjectConfig(stackDir);
|
|
39
|
+
|
|
40
|
+
expect(result.dir).toBe(root);
|
|
41
|
+
expect(result.configPath).toBe(join(root, "chant.config.json"));
|
|
42
|
+
expect(findProjectRoot(stackDir)).toBe(root);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("not found — no chant.config, no .git, no package.json anywhere above start dir: falls back to start dir, not the filesystem root", () => {
|
|
46
|
+
const deep = join(root, "a", "b", "c");
|
|
47
|
+
mkdirSync(deep, { recursive: true });
|
|
48
|
+
|
|
49
|
+
const result = findProjectConfig(deep);
|
|
50
|
+
|
|
51
|
+
// Nothing between `deep` and the real filesystem root declares a chant
|
|
52
|
+
// config or a boundary marker (this mkdtemp tree carries none). Rather
|
|
53
|
+
// than adopting "/" as the project root — which would make a downstream
|
|
54
|
+
// caller that scopes a directory walk off this result (e.g.
|
|
55
|
+
// `resolveProjectLexicons` -> `findInfraFiles`) scan the entire disk —
|
|
56
|
+
// the walk gives up and returns `deep` itself unchanged.
|
|
57
|
+
expect(result.configPath).toBeUndefined();
|
|
58
|
+
expect(result.dir).toBe(deep);
|
|
59
|
+
expect(findProjectRoot(deep)).toBe(deep);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("boundary stop — a .git/package.json ancestor halts the walk before an outer chant.config.ts", () => {
|
|
63
|
+
// An unrelated chant.config.ts two levels above this project's own git
|
|
64
|
+
// root must never be picked up — the boundary marker (.git here) stops
|
|
65
|
+
// the walk at the project's own root first.
|
|
66
|
+
writeFileSync(join(root, "chant.config.ts"), "export default { unrelated: true };");
|
|
67
|
+
const projectRoot = join(root, "project");
|
|
68
|
+
mkdirSync(join(projectRoot, ".git"), { recursive: true });
|
|
69
|
+
const stackDir = join(projectRoot, "src", "stack");
|
|
70
|
+
mkdirSync(stackDir, { recursive: true });
|
|
71
|
+
|
|
72
|
+
const result = findProjectConfig(stackDir);
|
|
73
|
+
|
|
74
|
+
expect(result.dir).toBe(projectRoot);
|
|
75
|
+
expect(result.configPath).toBeUndefined();
|
|
76
|
+
expect(findProjectRoot(stackDir)).toBe(projectRoot);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("boundary stop — package.json (no .git) halts the walk the same way", () => {
|
|
80
|
+
writeFileSync(join(root, "chant.config.json"), "{}");
|
|
81
|
+
const projectRoot = join(root, "project");
|
|
82
|
+
mkdirSync(projectRoot, { recursive: true });
|
|
83
|
+
writeFileSync(join(projectRoot, "package.json"), "{}");
|
|
84
|
+
const stackDir = join(projectRoot, "src", "stack");
|
|
85
|
+
mkdirSync(stackDir, { recursive: true });
|
|
86
|
+
|
|
87
|
+
const result = findProjectConfig(stackDir);
|
|
88
|
+
|
|
89
|
+
expect(result.dir).toBe(projectRoot);
|
|
90
|
+
expect(result.configPath).toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("a config living exactly at the boundary dir is still found — boundary check never wins over a real config", () => {
|
|
94
|
+
const projectRoot = join(root, "project");
|
|
95
|
+
mkdirSync(join(projectRoot, ".git"), { recursive: true });
|
|
96
|
+
writeFileSync(join(projectRoot, "chant.config.ts"), "export default {};");
|
|
97
|
+
const stackDir = join(projectRoot, "src", "stack");
|
|
98
|
+
mkdirSync(stackDir, { recursive: true });
|
|
99
|
+
|
|
100
|
+
const result = findProjectConfig(stackDir);
|
|
101
|
+
|
|
102
|
+
expect(result.dir).toBe(projectRoot);
|
|
103
|
+
expect(result.configPath).toBe(join(projectRoot, "chant.config.ts"));
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { existsSync } from "fs";
|
|
2
|
+
import { dirname, join, resolve } from "path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared upward config-discovery walk (chant #1117).
|
|
6
|
+
*
|
|
7
|
+
* Before this, `chant build <subdir>` and `lint.policies`
|
|
8
|
+
* (`./lint/policy.ts`'s `evaluateProjectPolicies`) each searched only the
|
|
9
|
+
* build directory and its immediate parent for `chant.config.ts`/`.json`,
|
|
10
|
+
* while `chant lint`/`chant graph` (`./lint/config.ts`'s old, file-local
|
|
11
|
+
* `findProjectRoot`) already walked all the way up. A project with a deeper
|
|
12
|
+
* `src/<stack>` layout — `chant build src/<stack>` two or more levels below
|
|
13
|
+
* the project root — silently never found the root config: `buildParams`'
|
|
14
|
+
* declared `env:` mappings went inert, `ownership`/`lint.policies`/etc quietly
|
|
15
|
+
* fell back to defaults, and nothing warned (loomster#162: `LOOM_TIER`/
|
|
16
|
+
* `LOOM_ENV` inert under every `npm run synth:*` for two releases).
|
|
17
|
+
*
|
|
18
|
+
* `findProjectConfig` is the one walk every config-discovery call site now
|
|
19
|
+
* shares. It stops at the first of:
|
|
20
|
+
*
|
|
21
|
+
* 1. A directory holding `chant.config.ts` or `chant.config.json` — found.
|
|
22
|
+
* 2. A directory holding `.git` or `package.json` with no chant config of its
|
|
23
|
+
* own — the project boundary. Discovery must never wander past the actual
|
|
24
|
+
* project into an unrelated ancestor directory just because this project
|
|
25
|
+
* happens not to declare a config (a stray `chant.config.ts` two levels
|
|
26
|
+
* above an unrelated git repo must never be picked up).
|
|
27
|
+
* 3. `startDir` itself, unchanged, if the walk reaches the filesystem root
|
|
28
|
+
* without ever finding a config OR a boundary marker. This is not just a
|
|
29
|
+
* "give up gracefully" nicety — several callers (`resolveProjectLexicons`
|
|
30
|
+
* -> `findInfraFiles`) scope a real directory walk off this function's
|
|
31
|
+
* result; if a rootless/marker-less start dir (a bare tmpdir, as chant's
|
|
32
|
+
* own test suites use) resolved all the way to `/`, that downstream walk
|
|
33
|
+
* would scan the entire filesystem instead of failing fast. Falling back
|
|
34
|
+
* to `startDir` keeps every caller's blast radius local no matter how far
|
|
35
|
+
* up the walk had to look.
|
|
36
|
+
*/
|
|
37
|
+
export interface ProjectConfigSearch {
|
|
38
|
+
/** The resolved project root: the config's directory, the boundary directory, or the (resolved) `startDir` when neither was found. */
|
|
39
|
+
dir: string;
|
|
40
|
+
/** Absolute path to the discovered `chant.config.ts`/`.json`, if any. */
|
|
41
|
+
configPath?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Walk up from `startDir` (inclusive) to the nearest chant config or project boundary. See {@link ProjectConfigSearch}. */
|
|
45
|
+
export function findProjectConfig(startDir: string): ProjectConfigSearch {
|
|
46
|
+
const resolvedStart = resolve(startDir);
|
|
47
|
+
let dir = resolvedStart;
|
|
48
|
+
for (;;) {
|
|
49
|
+
const tsPath = join(dir, "chant.config.ts");
|
|
50
|
+
if (existsSync(tsPath)) return { dir, configPath: tsPath };
|
|
51
|
+
const jsonPath = join(dir, "chant.config.json");
|
|
52
|
+
if (existsSync(jsonPath)) return { dir, configPath: jsonPath };
|
|
53
|
+
|
|
54
|
+
if (existsSync(join(dir, ".git")) || existsSync(join(dir, "package.json"))) {
|
|
55
|
+
return { dir };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const parent = dirname(dir);
|
|
59
|
+
if (parent === dir) {
|
|
60
|
+
// Filesystem root, no config, no boundary ever seen — give up rather
|
|
61
|
+
// than adopting "/" as the project root (see the module doc above).
|
|
62
|
+
return { dir: resolvedStart };
|
|
63
|
+
}
|
|
64
|
+
dir = parent;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Walk up from `startDir` to the nearest ancestor holding a chant project
|
|
70
|
+
* config (`chant.config.ts` or `chant.config.json`), the `.git`/`package.json`
|
|
71
|
+
* project boundary, or `startDir` itself when neither is found. Thin wrapper
|
|
72
|
+
* over {@link findProjectConfig} for callers that only need the directory
|
|
73
|
+
* (e.g. `chant lint`'s rule/plugin resolution, which just needs *a* stable
|
|
74
|
+
* root to resolve relative paths against).
|
|
75
|
+
*/
|
|
76
|
+
export function findProjectRoot(startDir: string): string {
|
|
77
|
+
return findProjectConfig(startDir).dir;
|
|
78
|
+
}
|