@intentius/chant 0.66.1 → 0.68.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/attrref.d.ts +21 -0
- package/dist/attrref.d.ts.map +1 -1
- package/dist/behaviour-engine.d.ts +212 -0
- package/dist/behaviour-engine.d.ts.map +1 -0
- package/dist/behaviour-http.d.ts +1 -1
- package/dist/behaviour-http.d.ts.map +1 -1
- package/dist/behaviour-kinds.d.ts +220 -0
- package/dist/behaviour-kinds.d.ts.map +1 -0
- package/dist/behaviour-predict.d.ts +83 -0
- package/dist/behaviour-predict.d.ts.map +1 -0
- package/dist/behaviour-request.d.ts +141 -0
- package/dist/behaviour-request.d.ts.map +1 -0
- package/dist/behaviour.d.ts +7 -7
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/fold/fold.d.ts +10 -0
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lexicon.d.ts +19 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lint/rules/evl011-symbolic-in-template.d.ts +3 -0
- package/dist/lint/rules/evl011-symbolic-in-template.d.ts.map +1 -0
- package/dist/lint/rules/index.d.ts +1 -0
- package/dist/lint/rules/index.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +1 -1
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/predict-behaviour.d.ts +12 -5
- package/dist/op/activities/predict-behaviour.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/attrref.test.ts +31 -0
- package/src/attrref.ts +33 -0
- package/src/behaviour-delta.test.ts +8 -8
- package/src/behaviour-engine.ts +478 -0
- package/src/behaviour-http.ts +1 -1
- package/src/behaviour-kinds.test.ts +235 -0
- package/src/behaviour-kinds.ts +324 -0
- package/src/behaviour-predict.ts +253 -0
- package/src/behaviour-request.ts +294 -0
- package/src/behaviour.ts +7 -7
- package/src/cli/handlers/scenario.test.ts +1 -1
- package/src/cli/handlers/scenario.ts +1 -1
- package/src/discovery/fold-composite.test.ts +70 -0
- package/src/discovery/fold-import.ts +48 -2
- package/src/fold/fold.test.ts +53 -0
- package/src/fold/fold.ts +43 -1
- package/src/index.ts +1 -0
- package/src/lexicon.ts +20 -0
- package/src/lifecycle/scenario-cost.test.ts +2 -2
- package/src/lint/rules/evl011-symbolic-in-template.test.ts +88 -0
- package/src/lint/rules/evl011-symbolic-in-template.ts +100 -0
- package/src/lint/rules/index.ts +3 -0
- package/src/op/activities/index.ts +1 -2
- package/src/op/activities/predict-behaviour.test.ts +20 -7
- package/src/op/activities/predict-behaviour.ts +32 -30
|
@@ -197,6 +197,76 @@ describe("composite factory interpretation (chant #1023)", () => {
|
|
|
197
197
|
expect([...result.entities.keys()].sort()).toEqual(["webBucket", "webRole"]);
|
|
198
198
|
});
|
|
199
199
|
|
|
200
|
+
|
|
201
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
202
|
+
// The interpretation depth bound (#2370).
|
|
203
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* A chain of composites each calling the next, `depth` links long, across
|
|
207
|
+
* separate modules so every link is a fresh `resolveInterpretableFactory`.
|
|
208
|
+
* Link 0 is the one `main.ts` calls; the last link builds a real resource.
|
|
209
|
+
*/
|
|
210
|
+
async function writeChain(depth: number): Promise<void> {
|
|
211
|
+
for (let i = 0; i < depth; i++) {
|
|
212
|
+
const isLast = i === depth - 1;
|
|
213
|
+
const body = isLast
|
|
214
|
+
? `(props) => ({ bucket: new Bucket({ BucketName: props.name }) })`
|
|
215
|
+
: `(props) => ({ inner: Link${i + 1}({ name: props.name }) })`;
|
|
216
|
+
await writeFile(
|
|
217
|
+
join(testDir, `link${i}.ts`),
|
|
218
|
+
`
|
|
219
|
+
import { Bucket } from ${JSON.stringify(LEXICON)};
|
|
220
|
+
import { Composite } from ${JSON.stringify(compositePath)};
|
|
221
|
+
${isLast ? "" : `import { Link${i + 1} } from "./link${i + 1}";`}
|
|
222
|
+
export const Link${i} = Composite(${body}, "Link${i}");
|
|
223
|
+
`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
await writeFile(
|
|
227
|
+
join(srcDir, "main.ts"),
|
|
228
|
+
`
|
|
229
|
+
import { Link0 } from ${JSON.stringify(join(testDir, "link0"))};
|
|
230
|
+
export const top = Link0({ name: "deep" });
|
|
231
|
+
`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
test("a chain within the bound interprets all the way down", async () => {
|
|
236
|
+
// The control. Without this, a test asserting the deep chain falls back
|
|
237
|
+
// proves only that something went wrong somewhere.
|
|
238
|
+
await writeChain(4);
|
|
239
|
+
|
|
240
|
+
const result = await discover(srcDir, { fold: true, lexicons: [LEXICON_NAME] });
|
|
241
|
+
|
|
242
|
+
expect(result.errors).toEqual([]);
|
|
243
|
+
expect(result.foldDecisions.find((d) => d.file.endsWith("main.ts"))?.mode).toBe("fold");
|
|
244
|
+
expect(foldExecutionCounts()).toMatchObject({ factoryInterpretations: 4, projectFactoryInvocations: 0 });
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("exhausting the bound falls the file back to run, naming the bound", async () => {
|
|
248
|
+
// 17 links against a bound of 16. Before #2370 this returned the "not
|
|
249
|
+
// interpretable" answer, so the factory was invoked and the file was
|
|
250
|
+
// still reported folded — a different evaluation than the one reported,
|
|
251
|
+
// with nothing in the output saying so.
|
|
252
|
+
await writeChain(17);
|
|
253
|
+
|
|
254
|
+
const result = await discover(srcDir, { fold: true, lexicons: [LEXICON_NAME] });
|
|
255
|
+
|
|
256
|
+
const decision = result.foldDecisions.find((d) => d.file.endsWith("main.ts"));
|
|
257
|
+
expect(decision?.mode).toBe("run");
|
|
258
|
+
expect(decision?.reason).toMatch(/interpretation depth exceeded 16/);
|
|
259
|
+
// The remedy half of the message: what a reader should suspect.
|
|
260
|
+
expect(decision?.reason).toMatch(/recursive/);
|
|
261
|
+
// Falling back means the estate is still built, by the other path. F-Depth
|
|
262
|
+
// asks for a fallback, not a failure.
|
|
263
|
+
expect(result.errors).toEqual([]);
|
|
264
|
+
// And it is a fallback rather than a degradation: the factory was not
|
|
265
|
+
// invoked instead. Before #2370 this read `projectFactoryInvocations: 1`
|
|
266
|
+
// with the file still reported folded.
|
|
267
|
+
expect(foldExecutionCounts()).toMatchObject({ projectFactoryInvocations: 0, factoryInvocations: 0 });
|
|
268
|
+
});
|
|
269
|
+
|
|
200
270
|
test("a sibling reference inside the body is a LIVE AttrRef on the real instance", async () => {
|
|
201
271
|
await writeComposite(ADMISSIBLE_BODY);
|
|
202
272
|
await writeMain(CALL_WEBAPP);
|
|
@@ -1769,9 +1769,39 @@ async function resolveCallArguments(
|
|
|
1769
1769
|
* inside one module's source, crossing no file boundary — so this is what
|
|
1770
1770
|
* terminates it. Deliberately small: real composite nesting is 2-3 deep, and a
|
|
1771
1771
|
* chain past this is a bug, not a design.
|
|
1772
|
+
*
|
|
1773
|
+
* Exhausting it falls the file back to run, naming this bound (#2370). That is
|
|
1774
|
+
* the second half of the specification's `F-Depth`: an implementation states
|
|
1775
|
+
* its bounds and, on exhaustion, falls back rather than fails. The other two —
|
|
1776
|
+
* {@link MAX_RESOLUTION_DEPTH} and `fold()`'s `MAX_FUNCTION_CALL_DEPTH` —
|
|
1777
|
+
* already did.
|
|
1778
|
+
*
|
|
1779
|
+
* It previously returned `undefined` here, which
|
|
1780
|
+
* {@link resolveInterpretableFactory}'s contract defines as "not
|
|
1781
|
+
* interpretable", so the caller invoked the factory instead and the file was
|
|
1782
|
+
* still reported as folded. That conflated two different facts: a factory
|
|
1783
|
+
* whose shape is outside the subset (invoking it is correct) and a factory we
|
|
1784
|
+
* stopped looking at (invoking it is a different evaluation than the one being
|
|
1785
|
+
* reported). The first is a verdict; the second was a silent degradation, and
|
|
1786
|
+
* a reader comparing chant's output against the reference implementation's
|
|
1787
|
+
* "fell back at depth 16" had nothing to explain the difference.
|
|
1772
1788
|
*/
|
|
1773
1789
|
const MAX_INTERPRETATION_DEPTH = 16;
|
|
1774
1790
|
|
|
1791
|
+
/**
|
|
1792
|
+
* Exhaustion of {@link MAX_INTERPRETATION_DEPTH}, as its own type so it can
|
|
1793
|
+
* pass through {@link interpretCompositeFactory}'s decline.
|
|
1794
|
+
*
|
|
1795
|
+
* That decline — a blanket `catch { return undefined }` — is right for every
|
|
1796
|
+
* other failure inside a factory body: the body turned out not to interpret,
|
|
1797
|
+
* the caller invokes it instead, and the file still folds. It is wrong for
|
|
1798
|
+
* this one, because giving up at a depth bound is not a statement about the
|
|
1799
|
+
* body. Without a type to test, the depth error was caught by the *enclosing*
|
|
1800
|
+
* interpretation, declined, and the factory invoked — which is how the bound
|
|
1801
|
+
* came to be silent (#2370).
|
|
1802
|
+
*/
|
|
1803
|
+
class InterpretationDepthError extends FoldError {}
|
|
1804
|
+
|
|
1775
1805
|
/**
|
|
1776
1806
|
* The static scope of a module that DEFINES composites — everything
|
|
1777
1807
|
* interpreting one of its factory bodies needs, computed once per module per
|
|
@@ -1969,7 +1999,18 @@ async function resolveInterpretableFactory(
|
|
|
1969
1999
|
// Rule 1 — project files only. A text check; no resolution performed for a
|
|
1970
2000
|
// bare specifier, so this costs nothing for the (common) lexicon case.
|
|
1971
2001
|
if (!isProjectFileSpecifier(binding.specifier)) return undefined;
|
|
1972
|
-
|
|
2002
|
+
// Not `undefined`: that answer means "not interpretable" and would invoke
|
|
2003
|
+
// the factory while still reporting the file folded. Giving up is a
|
|
2004
|
+
// different fact and falls the file back to run, naming the bound (#2370).
|
|
2005
|
+
if (ctx.interpretDepth >= MAX_INTERPRETATION_DEPTH) {
|
|
2006
|
+
throw new InterpretationDepthError(
|
|
2007
|
+
`interpretation depth exceeded ${MAX_INTERPRETATION_DEPTH} at "${binding.imported}" — a composite ` +
|
|
2008
|
+
"chain this deep is almost certainly recursive. Falling back to run rather than invoking the " +
|
|
2009
|
+
"factory, which would have reported this file as folded while evaluating it a different way.",
|
|
2010
|
+
0,
|
|
2011
|
+
0,
|
|
2012
|
+
);
|
|
2013
|
+
}
|
|
1973
2014
|
|
|
1974
2015
|
let modulePath: string;
|
|
1975
2016
|
try {
|
|
@@ -2346,7 +2387,12 @@ async function interpretCompositeFactory(
|
|
|
2346
2387
|
const instance = definition();
|
|
2347
2388
|
executionCounts.factoryInterpretations += 1;
|
|
2348
2389
|
return { value: instance };
|
|
2349
|
-
} catch {
|
|
2390
|
+
} catch (err) {
|
|
2391
|
+
// Every other failure here means "this body did not interpret" and the
|
|
2392
|
+
// caller invokes instead. Exhausting the depth bound means "we stopped
|
|
2393
|
+
// looking", which has to reach the file's fold verdict rather than be
|
|
2394
|
+
// answered with an invocation (#2370).
|
|
2395
|
+
if (err instanceof InterpretationDepthError) throw err;
|
|
2350
2396
|
return undefined;
|
|
2351
2397
|
}
|
|
2352
2398
|
}
|
package/src/fold/fold.test.ts
CHANGED
|
@@ -66,6 +66,59 @@ describe("fold — template interpolation", () => {
|
|
|
66
66
|
});
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
+
describe("fold — a symbolic value in a plain template (#2349)", () => {
|
|
70
|
+
// The bug this replaces: both paths produced "[object Object]" and agreed,
|
|
71
|
+
// so the differential harness — the thing that catches everything else —
|
|
72
|
+
// saw agreement and said nothing.
|
|
73
|
+
test("an attribute reference is refused, located, and names the entity", () => {
|
|
74
|
+
const src = `
|
|
75
|
+
const bucket = new S3Bucket({ name: "my-bucket" });
|
|
76
|
+
const x = \`\${bucket.arn}-suffix\`;
|
|
77
|
+
`;
|
|
78
|
+
expect(() => foldConst(src, "x")).toThrow(FoldError);
|
|
79
|
+
try {
|
|
80
|
+
foldConst(src, "x");
|
|
81
|
+
} catch (e) {
|
|
82
|
+
const err = e as FoldError;
|
|
83
|
+
expect(err.message).toContain("bucket.arn");
|
|
84
|
+
expect(err.message).toContain("[object Object]");
|
|
85
|
+
// The remedy, not just the complaint.
|
|
86
|
+
expect(err.message).toMatch(/Sub|intrinsic/);
|
|
87
|
+
// Located on the span, so an editor can point at it.
|
|
88
|
+
expect(err.line).toBeGreaterThan(0);
|
|
89
|
+
expect(err.column).toBeGreaterThan(0);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("the run path refuses the same program rather than producing the wrong string", () => {
|
|
94
|
+
// The other half of #2349: a file that falls back to run must not quietly
|
|
95
|
+
// succeed where fold refused. `AttrRef.toString` throws, so the two paths
|
|
96
|
+
// now refuse together instead of agreeing on a wrong value.
|
|
97
|
+
const Bucket = createResource("S3Bucket", "aws", { arn: "arn" });
|
|
98
|
+
const bucket = new Bucket({ name: "my-bucket" }) as unknown as Declarable & { arn: AttrRef };
|
|
99
|
+
expect(() => `${bucket.arn}-suffix`).toThrow(/no string form/);
|
|
100
|
+
expect(() => "prefix" + (bucket.arn as unknown as string)).toThrow(/no string form/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("an ordinary value in a template still interpolates", () => {
|
|
104
|
+
const src = `
|
|
105
|
+
const region = "us-east-1";
|
|
106
|
+
const x = \`bucket-\${region}\`;
|
|
107
|
+
`;
|
|
108
|
+
expect(foldConst(src, "x")).toBe("bucket-us-east-1");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("a tagged template is untouched — that is the documented idiom", () => {
|
|
112
|
+
// The refusal is about *plain* templates. The intrinsic form handles
|
|
113
|
+
// envelopes in its interior and must keep doing so.
|
|
114
|
+
const src = `
|
|
115
|
+
const bucket = new S3Bucket({ name: "my-bucket" });
|
|
116
|
+
const x = Sub\`\${bucket.arn}-suffix\`;
|
|
117
|
+
`;
|
|
118
|
+
expect(() => foldConst(src, "x")).not.toThrow(/no string form/);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
69
122
|
describe("fold — objects and spread", () => {
|
|
70
123
|
test("object literal with property and shorthand assignment", () => {
|
|
71
124
|
const src = `
|
package/src/fold/fold.ts
CHANGED
|
@@ -151,6 +151,31 @@ export type FoldedValue =
|
|
|
151
151
|
* invented shape: it's the existing envelope, produced without running the
|
|
152
152
|
* module that would otherwise construct the real `AttrRef`.
|
|
153
153
|
*/
|
|
154
|
+
/**
|
|
155
|
+
* Which symbolic envelope a folded value is, in words a message can use — or
|
|
156
|
+
* `undefined` for a value that has an honest string form.
|
|
157
|
+
*
|
|
158
|
+
* These are the shapes `fold()` produces for things the build resolves later:
|
|
159
|
+
* a resource attribute, a lexicon intrinsic in either of its two forms, and a
|
|
160
|
+
* registered authoring helper. None of them can be concatenated into a string
|
|
161
|
+
* here, because what they stand for is not known until the build runs.
|
|
162
|
+
*/
|
|
163
|
+
export function symbolicEnvelopeKind(value: unknown): string | undefined {
|
|
164
|
+
if (typeof value !== "object" || value === null) return undefined;
|
|
165
|
+
const v = value as Record<string, unknown>;
|
|
166
|
+
if ("__attrRef" in v) {
|
|
167
|
+
const ref = v.__attrRef as { entity?: unknown; attribute?: unknown } | undefined;
|
|
168
|
+
const named =
|
|
169
|
+
typeof ref?.entity === "string" && typeof ref?.attribute === "string"
|
|
170
|
+
? ` (${ref.entity}.${ref.attribute})`
|
|
171
|
+
: "";
|
|
172
|
+
return `a resource attribute reference${named}`;
|
|
173
|
+
}
|
|
174
|
+
if ("__intrinsic" in v && typeof v.__intrinsic === "string") return `the intrinsic \`${v.__intrinsic}\``;
|
|
175
|
+
if ("__helper" in v && typeof v.__helper === "string") return `the helper \`${v.__helper}\``;
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
154
179
|
export interface AttrRefValue {
|
|
155
180
|
__attrRef: { entity: string; attribute: string };
|
|
156
181
|
}
|
|
@@ -1028,7 +1053,24 @@ export function fold(
|
|
|
1028
1053
|
if (ts.isTemplateExpression(node)) {
|
|
1029
1054
|
let out = node.head.text;
|
|
1030
1055
|
for (const span of node.templateSpans) {
|
|
1031
|
-
|
|
1056
|
+
const value = fold(span.expression, consts, intrinsics, externals);
|
|
1057
|
+
// A symbolic envelope has no string form. `String({__attrRef: …})` is
|
|
1058
|
+
// `"[object Object]"`, and so is `String(attrRef)` on the run path,
|
|
1059
|
+
// because `../attrref.ts` defines no `toString` — so the two paths
|
|
1060
|
+
// agreed on a wrong answer and the differential saw nothing (#2349).
|
|
1061
|
+
// Refused here rather than stringified, for the same reason the
|
|
1062
|
+
// eager-intrinsic branch refuses a deferred envelope (#1966).
|
|
1063
|
+
const symbolic = symbolicEnvelopeKind(value);
|
|
1064
|
+
if (symbolic) {
|
|
1065
|
+
throw foldError(
|
|
1066
|
+
span.expression,
|
|
1067
|
+
`${symbolic} interpolated into a plain template literal, which would stringify it as ` +
|
|
1068
|
+
'"[object Object]". A symbolic value has no string form until the build resolves it, so a ' +
|
|
1069
|
+
"plain template cannot carry one. Use the lexicon's own intrinsic, whose interior handles " +
|
|
1070
|
+
"envelopes — `Sub`${…}`` for CloudFormation — or move the reference out of the template.",
|
|
1071
|
+
);
|
|
1072
|
+
}
|
|
1073
|
+
out += String(value) + span.literal.text;
|
|
1032
1074
|
}
|
|
1033
1075
|
return out;
|
|
1034
1076
|
}
|
package/src/index.ts
CHANGED
|
@@ -64,6 +64,7 @@ export * from "./apply";
|
|
|
64
64
|
export * from "./deep-observation";
|
|
65
65
|
export * from "./behaviour";
|
|
66
66
|
export * from "./behaviour-http";
|
|
67
|
+
export * from "./behaviour-kinds";
|
|
67
68
|
export * from "./behaviour-delta";
|
|
68
69
|
export * from "./claimed-fields";
|
|
69
70
|
export * from "./fold-provenance";
|
package/src/lexicon.ts
CHANGED
|
@@ -24,6 +24,7 @@ import type { DescribeResourcesResult, UnobservedReason } from "./observation";
|
|
|
24
24
|
import type { DescribeIdentityOptions, DescribeIdentityResult } from "./identity";
|
|
25
25
|
import type { DeepNormalizationHooks, DeepObservationResult } from "./deep-observation";
|
|
26
26
|
import type { BehaviourResult, PredictBehaviourOptions } from "./behaviour";
|
|
27
|
+
import type { BehaviourKinds } from "./behaviour-kinds";
|
|
27
28
|
import type { DisruptionQuery, DisruptionVerdict } from "./lifecycle/disruption";
|
|
28
29
|
import type { OwnerChainVerdict } from "./owner-chain";
|
|
29
30
|
import type { CommandGroup } from "./cli/command-group";
|
|
@@ -1459,6 +1460,25 @@ export interface LexiconPlugin {
|
|
|
1459
1460
|
*/
|
|
1460
1461
|
predictBehaviour?(options: PredictBehaviourOptions): Promise<BehaviourResult>;
|
|
1461
1462
|
|
|
1463
|
+
/**
|
|
1464
|
+
* What this lexicon's own entity types are, to an engine that prices them
|
|
1465
|
+
* (#2382). Rows, not a capability: core resolves and predicts, and a lexicon
|
|
1466
|
+
* says only what its types mean.
|
|
1467
|
+
*
|
|
1468
|
+
* Optional in the way rows can safely be and a capability cannot. A lexicon
|
|
1469
|
+
* that is not installed declared no entities of its types, so its absent
|
|
1470
|
+
* rows describe an absent part of the estate; whereas a *capability* behind
|
|
1471
|
+
* an optional install leaves a consumer with entities nobody will say
|
|
1472
|
+
* anything about, and no refusal naming why — an outcome
|
|
1473
|
+
* `packages/core/src/behaviour.ts` never defined.
|
|
1474
|
+
*
|
|
1475
|
+
* Three things can be said, and saying none is itself a statement that this
|
|
1476
|
+
* lexicon has not been considered: `mapped` rows price a type, `unmapped`
|
|
1477
|
+
* rows state that a real type carries no rate and why, and `nothingPriced`
|
|
1478
|
+
* says it once for the whole substrate — a CI workflow is not an estate.
|
|
1479
|
+
*/
|
|
1480
|
+
behaviourKinds?: BehaviourKinds;
|
|
1481
|
+
|
|
1462
1482
|
/**
|
|
1463
1483
|
* Report the live status of one deploy unit by its deployed name. Opt-in.
|
|
1464
1484
|
*
|
|
@@ -105,12 +105,12 @@ describe("evaluateScenario — cost against the fixture's recorded prediction",
|
|
|
105
105
|
});
|
|
106
106
|
|
|
107
107
|
test("a fixture whose prediction is a refusal fails with the refusal's reason, never a pass on nothing", () => {
|
|
108
|
-
const verdict = evaluateScenario(EMPTY, { cost: { maxPerHour: 1e9, currency: "USD" } }, { result: noBehaviourEngineRefusal("
|
|
108
|
+
const verdict = evaluateScenario(EMPTY, { cost: { maxPerHour: 1e9, currency: "USD" } }, { result: noBehaviourEngineRefusal("chant") });
|
|
109
109
|
expect(verdict.pass).toBe(false);
|
|
110
110
|
expect(cost(verdict).detail).toContain("the fixture's prediction is a refusal (no-engine)");
|
|
111
111
|
expect(cost(verdict).detail).toContain("Set CHANT_BEHAVIOUR_ENGINE to the engine's address.");
|
|
112
112
|
|
|
113
|
-
const broke = outOfCreditBehaviourEngineRefusal("
|
|
113
|
+
const broke = outOfCreditBehaviourEngineRefusal("chant", { value: "engine", source: "CHANT_BEHAVIOUR_ENGINE" }, "balance 0");
|
|
114
114
|
expect(cost(evaluateScenario(EMPTY, { cost: { maxPerHour: 1e9, currency: "USD" } }, { result: broke })).detail).toContain("engine-out-of-credit");
|
|
115
115
|
});
|
|
116
116
|
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import * as ts from "typescript";
|
|
3
|
+
import { evl011SymbolicInTemplateRule } from "./evl011-symbolic-in-template";
|
|
4
|
+
import type { LintContext } from "../rule";
|
|
5
|
+
|
|
6
|
+
function createContext(code: string, filePath = "test.ts"): LintContext {
|
|
7
|
+
const sourceFile = ts.createSourceFile(filePath, code, ts.ScriptTarget.Latest, true);
|
|
8
|
+
return { sourceFile, entities: [], filePath, lexicon: undefined };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("EVL011: a symbolic reference in a plain template", () => {
|
|
12
|
+
test("rule metadata", () => {
|
|
13
|
+
expect(evl011SymbolicInTemplateRule.id).toBe("EVL011");
|
|
14
|
+
expect(evl011SymbolicInTemplateRule.severity).toBe("error");
|
|
15
|
+
expect(evl011SymbolicInTemplateRule.category).toBe("correctness");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("flags a resource attribute in a plain template, at the reference", () => {
|
|
19
|
+
const ctx = createContext(`
|
|
20
|
+
const bucket = new S3Bucket({ name: "b" });
|
|
21
|
+
const x = \`\${bucket.arn}-suffix\`;
|
|
22
|
+
`);
|
|
23
|
+
const found = evl011SymbolicInTemplateRule.check(ctx);
|
|
24
|
+
expect(found).toHaveLength(1);
|
|
25
|
+
expect(found[0].ruleId).toBe("EVL011");
|
|
26
|
+
expect(found[0].message).toContain("bucket.arn");
|
|
27
|
+
// The remedy, not just the complaint — the whole reason this rule exists
|
|
28
|
+
// alongside a refusal that already fails the build.
|
|
29
|
+
expect(found[0].message).toMatch(/Sub|intrinsic/);
|
|
30
|
+
expect(found[0].line).toBe(3);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("flags every offending span, not just the first", () => {
|
|
34
|
+
const ctx = createContext(`
|
|
35
|
+
const bucket = new S3Bucket({ name: "b" });
|
|
36
|
+
const queue = new Queue({ name: "q" });
|
|
37
|
+
const x = \`\${bucket.arn}/\${queue.url}\`;
|
|
38
|
+
`);
|
|
39
|
+
expect(evl011SymbolicInTemplateRule.check(ctx)).toHaveLength(2);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("allows a tagged template — that is the documented remedy", () => {
|
|
43
|
+
const ctx = createContext(`
|
|
44
|
+
const bucket = new S3Bucket({ name: "b" });
|
|
45
|
+
const x = Sub\`\${bucket.arn}-suffix\`;
|
|
46
|
+
`);
|
|
47
|
+
expect(evl011SymbolicInTemplateRule.check(ctx)).toHaveLength(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("allows an ordinary const in a template", () => {
|
|
51
|
+
const ctx = createContext(`
|
|
52
|
+
const region = "us-east-1";
|
|
53
|
+
const x = \`bucket-\${region}\`;
|
|
54
|
+
`);
|
|
55
|
+
expect(evl011SymbolicInTemplateRule.check(ctx)).toHaveLength(0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("allows a property access on something that is not a resource", () => {
|
|
59
|
+
// The false positive worth avoiding: this code works, and flagging it
|
|
60
|
+
// would train people to suppress the rule.
|
|
61
|
+
const ctx = createContext(`
|
|
62
|
+
const config = { region: "us-east-1" };
|
|
63
|
+
const x = \`bucket-\${config.region}\`;
|
|
64
|
+
`);
|
|
65
|
+
expect(evl011SymbolicInTemplateRule.check(ctx)).toHaveLength(0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("says nothing about a reference outside a template", () => {
|
|
69
|
+
const ctx = createContext(`
|
|
70
|
+
const bucket = new S3Bucket({ name: "b" });
|
|
71
|
+
const x = bucket.arn;
|
|
72
|
+
`);
|
|
73
|
+
expect(evl011SymbolicInTemplateRule.check(ctx)).toHaveLength(0);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("a missed shape is a cost the refusal already covers", () => {
|
|
77
|
+
// Reached through an array rather than a plain identifier: lint sees
|
|
78
|
+
// syntax, not values, so this is not flagged — and `fold()` still refuses
|
|
79
|
+
// it (#2349). Documented as a deliberate limit rather than left to be
|
|
80
|
+
// discovered as a gap.
|
|
81
|
+
const ctx = createContext(`
|
|
82
|
+
const bucket = new S3Bucket({ name: "b" });
|
|
83
|
+
const all = [bucket];
|
|
84
|
+
const x = \`\${all[0].arn}-suffix\`;
|
|
85
|
+
`);
|
|
86
|
+
expect(evl011SymbolicInTemplateRule.check(ctx)).toHaveLength(0);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import * as ts from "typescript";
|
|
2
|
+
import type { LintRule, LintContext, LintDiagnostic } from "../rule";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* EVL011: a symbolic reference inside a plain template literal
|
|
6
|
+
*
|
|
7
|
+
* `` `${bucket.arn}-suffix` `` cannot be evaluated to a string. A resource
|
|
8
|
+
* attribute stands for a value the build resolves later, so there is nothing
|
|
9
|
+
* to interpolate yet, and JavaScript's answer — `"[object Object]"` — is
|
|
10
|
+
* wrong on both of chant's paths.
|
|
11
|
+
*
|
|
12
|
+
* This rule is the friendly half of #2349. The unfriendly half already landed:
|
|
13
|
+
* `fold()` refuses the span with a located error and `AttrRef.toString` throws,
|
|
14
|
+
* so the program fails either way. What a rule adds is finding it at lint time
|
|
15
|
+
* with the remedy attached, rather than at build time with a stack.
|
|
16
|
+
*
|
|
17
|
+
* ## Why it is a heuristic, and why that is acceptable here
|
|
18
|
+
*
|
|
19
|
+
* Lint sees syntax, not values. `foo.bar` inside a template is a resource
|
|
20
|
+
* attribute only if `foo` is a resource, which is a fact `fold()` has and this
|
|
21
|
+
* rule does not — so the check is: a property access whose object is a
|
|
22
|
+
* plain identifier bound in this file by `new`. That is the shape #2349 was
|
|
23
|
+
* reported against and the shape a lexicon's own docs show.
|
|
24
|
+
*
|
|
25
|
+
* It therefore misses a reference reached some other way (through a parameter,
|
|
26
|
+
* an array, a helper's return) and does not flag an access on a plain object.
|
|
27
|
+
* Missing a case costs nothing, because `fold()` still refuses it; flagging a
|
|
28
|
+
* plain object would be a false positive on code that works, which is the
|
|
29
|
+
* error worth avoiding. This is the same asymmetry `../../fold/subset.ts`
|
|
30
|
+
* documents for EVL generally, pointed the other way: there EVL may be
|
|
31
|
+
* stricter than fold, here it is deliberately looser.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** Identifiers bound by `const x = new T(...)` at any depth in this file. */
|
|
35
|
+
function resourceBindings(sourceFile: ts.SourceFile): Set<string> {
|
|
36
|
+
const names = new Set<string>();
|
|
37
|
+
const visit = (node: ts.Node): void => {
|
|
38
|
+
if (
|
|
39
|
+
ts.isVariableDeclaration(node) &&
|
|
40
|
+
ts.isIdentifier(node.name) &&
|
|
41
|
+
node.initializer &&
|
|
42
|
+
ts.isNewExpression(node.initializer)
|
|
43
|
+
) {
|
|
44
|
+
names.add(node.name.text);
|
|
45
|
+
}
|
|
46
|
+
ts.forEachChild(node, visit);
|
|
47
|
+
};
|
|
48
|
+
visit(sourceFile);
|
|
49
|
+
return names;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function checkNode(
|
|
53
|
+
node: ts.Node,
|
|
54
|
+
context: LintContext,
|
|
55
|
+
bound: Set<string>,
|
|
56
|
+
diagnostics: LintDiagnostic[],
|
|
57
|
+
): void {
|
|
58
|
+
// Untagged only: a tagged template is the documented remedy, and its
|
|
59
|
+
// interior handles envelopes.
|
|
60
|
+
if (ts.isTemplateExpression(node) && !ts.isTaggedTemplateExpression(node.parent)) {
|
|
61
|
+
for (const span of node.templateSpans) {
|
|
62
|
+
const expr = span.expression;
|
|
63
|
+
if (
|
|
64
|
+
ts.isPropertyAccessExpression(expr) &&
|
|
65
|
+
ts.isIdentifier(expr.expression) &&
|
|
66
|
+
bound.has(expr.expression.text)
|
|
67
|
+
) {
|
|
68
|
+
const { line, character } = context.sourceFile.getLineAndCharacterOfPosition(
|
|
69
|
+
expr.getStart(context.sourceFile),
|
|
70
|
+
);
|
|
71
|
+
diagnostics.push({
|
|
72
|
+
file: context.filePath,
|
|
73
|
+
line: line + 1,
|
|
74
|
+
column: character + 1,
|
|
75
|
+
ruleId: "EVL011",
|
|
76
|
+
severity: "error",
|
|
77
|
+
message:
|
|
78
|
+
`\`${expr.expression.text}.${expr.name.text}\` is a resource attribute and has no string ` +
|
|
79
|
+
"form until the build resolves it — a plain template would stringify it as " +
|
|
80
|
+
"\"[object Object]\". Use the lexicon's own intrinsic, whose interior handles references " +
|
|
81
|
+
"(`Sub`${…}`` for CloudFormation), or move the reference out of the template.",
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
ts.forEachChild(node, (child) => checkNode(child, context, bound, diagnostics));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const evl011SymbolicInTemplateRule: LintRule = {
|
|
91
|
+
id: "EVL011",
|
|
92
|
+
severity: "error",
|
|
93
|
+
category: "correctness",
|
|
94
|
+
description: "A resource attribute cannot be interpolated into a plain template literal",
|
|
95
|
+
check(context: LintContext): LintDiagnostic[] {
|
|
96
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
97
|
+
checkNode(context.sourceFile, context, resourceBindings(context.sourceFile), diagnostics);
|
|
98
|
+
return diagnostics;
|
|
99
|
+
},
|
|
100
|
+
};
|
package/src/lint/rules/index.ts
CHANGED
|
@@ -23,6 +23,7 @@ export { evl005ResourceBlockBodyRule } from "./evl005-resource-block-body";
|
|
|
23
23
|
export { evl007InvalidSiblingsRule } from "./evl007-invalid-siblings";
|
|
24
24
|
export { evl009CompositeNoConstantRule } from "./evl009-composite-no-constant";
|
|
25
25
|
export { evl010CompositeNoTransformRule } from "./evl010-composite-no-transform";
|
|
26
|
+
export { evl011SymbolicInTemplateRule } from "./evl011-symbolic-in-template";
|
|
26
27
|
export { cor017CompositeNameMatchRule } from "./cor017-composite-name-match";
|
|
27
28
|
export { cor018CompositePreferLexiconTypeRule } from "./cor018-composite-prefer-lexicon-type";
|
|
28
29
|
export { cor021EnvLiteralNameRule } from "./cor021-env-literal-name";
|
|
@@ -49,6 +50,7 @@ import { evl005ResourceBlockBodyRule } from "./evl005-resource-block-body";
|
|
|
49
50
|
import { evl007InvalidSiblingsRule } from "./evl007-invalid-siblings";
|
|
50
51
|
import { evl009CompositeNoConstantRule } from "./evl009-composite-no-constant";
|
|
51
52
|
import { evl010CompositeNoTransformRule } from "./evl010-composite-no-transform";
|
|
53
|
+
import { evl011SymbolicInTemplateRule } from "./evl011-symbolic-in-template";
|
|
52
54
|
import { cor017CompositeNameMatchRule } from "./cor017-composite-name-match";
|
|
53
55
|
import { cor018CompositePreferLexiconTypeRule } from "./cor018-composite-prefer-lexicon-type";
|
|
54
56
|
import { cor021EnvLiteralNameRule } from "./cor021-env-literal-name";
|
|
@@ -79,6 +81,7 @@ export function loadCoreRules(): LintRule[] {
|
|
|
79
81
|
evl007InvalidSiblingsRule,
|
|
80
82
|
evl009CompositeNoConstantRule,
|
|
81
83
|
evl010CompositeNoTransformRule,
|
|
84
|
+
evl011SymbolicInTemplateRule,
|
|
82
85
|
cor017CompositeNameMatchRule,
|
|
83
86
|
cor018CompositePreferLexiconTypeRule,
|
|
84
87
|
cor021EnvLiteralNameRule,
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { describe, expect, test, vi } from "vitest";
|
|
8
|
+
import { coverageFor, type BehaviourKinds } from "../../behaviour-kinds";
|
|
8
9
|
import {
|
|
9
10
|
behaviourReport,
|
|
10
11
|
noBehaviourEngineRefusal,
|
|
@@ -15,14 +16,13 @@ import {
|
|
|
15
16
|
} from "../../behaviour";
|
|
16
17
|
import { commentMarker, issueMarker, type ReconcilePrArgs, type ReconcileResult } from "./reconcile";
|
|
17
18
|
import {
|
|
18
|
-
ambiguousPredictorMessage,
|
|
19
19
|
baseRefFrom,
|
|
20
20
|
behaviourFindingMarker,
|
|
21
21
|
createBehaviourFinding,
|
|
22
22
|
declaredEdgeCoverage,
|
|
23
23
|
headRefFrom,
|
|
24
24
|
noBaseRefMessage,
|
|
25
|
-
|
|
25
|
+
noContributedRowsMessage,
|
|
26
26
|
type BaseCheckout,
|
|
27
27
|
} from "./predict-behaviour";
|
|
28
28
|
|
|
@@ -97,12 +97,25 @@ describe("declaredEdgeCoverage — never complete on the declared path", () => {
|
|
|
97
97
|
|
|
98
98
|
describe("the refusals for a project that cannot predict", () => {
|
|
99
99
|
test("no predicting lexicon names the configured ones and what to add", () => {
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
// Not an error any more: with rows contributed per lexicon there is
|
|
101
|
+
// nothing to choose between, and an estate whose lexicons contribute no
|
|
102
|
+
// rows is a real state with a real answer rather than a broken command
|
|
103
|
+
// (#2382).
|
|
104
|
+
expect(noContributedRowsMessage(["aws", "k8s"])).toMatch(/\(aws, k8s\).*behaviourKinds.*CHANT_BEHAVIOUR_ENGINE/s);
|
|
105
|
+
expect(noContributedRowsMessage([])).toContain("(none)");
|
|
106
|
+
expect(noContributedRowsMessage(["aws"])).toContain("unknown-type");
|
|
102
107
|
});
|
|
103
108
|
|
|
104
|
-
test("two
|
|
105
|
-
|
|
109
|
+
test("two lexicons contributing rows is ordinary, not a conflict", () => {
|
|
110
|
+
// The old shape refused here, because two lexicons implementing
|
|
111
|
+
// predictBehaviour() meant two engines pricing one estate. Rows do not
|
|
112
|
+
// have that problem: each lexicon speaks only about its own entity types,
|
|
113
|
+
// so contributions are additive and there is nothing to merge.
|
|
114
|
+
const aws: BehaviourKinds = { provider: "aws", prefixes: ["AWS::"], mapped: { "AWS::S3::Bucket": { kind: "object-store" } } };
|
|
115
|
+
const k8s: BehaviourKinds = { provider: "kubernetes", prefixes: ["K8s::"], mapped: { "K8s::Core::Pod": { kind: "compute" } } };
|
|
116
|
+
expect(coverageFor([aws, k8s], "AWS::S3::Bucket").status).toBe("mapped");
|
|
117
|
+
expect(coverageFor([aws, k8s], "K8s::Core::Pod").status).toBe("mapped");
|
|
118
|
+
expect(coverageFor([k8s, aws], "AWS::S3::Bucket").status).toBe("mapped");
|
|
106
119
|
});
|
|
107
120
|
});
|
|
108
121
|
|
|
@@ -198,7 +211,7 @@ describe("behaviourFinding — predicts both sides, differences them, and posts
|
|
|
198
211
|
});
|
|
199
212
|
|
|
200
213
|
test("a refusal on the base side posts a finding that says no prediction, and flags the run", async () => {
|
|
201
|
-
const { run, posted } = harness({ base: noBehaviourEngineRefusal("
|
|
214
|
+
const { run, posted } = harness({ base: noBehaviourEngineRefusal("chant"), head: report({ db: figure(0.1) }) });
|
|
202
215
|
const result = await run({ environment: "prod", traffic: TRAFFIC, op: "pr-behaviour" });
|
|
203
216
|
expect(result.refused).toBe(true);
|
|
204
217
|
expect(result.finding.kind).toBe("no-prediction");
|