@intentius/chant 0.66.0 → 0.67.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/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/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/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/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/index.ts +1 -0
- package/src/lexicon.ts +20 -0
- package/src/lifecycle/scenario-cost.test.ts +2 -2
- 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
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The coverage resolution, now that rows are contributed rather than held
|
|
3
|
+
* centrally (#2382).
|
|
4
|
+
*
|
|
5
|
+
* Two properties matter more than any individual row, and both are here
|
|
6
|
+
* because #2357's version could not have them: resolution is **total** (every
|
|
7
|
+
* type reaches exactly one verdict, so the request builder can promise every
|
|
8
|
+
* entity lands in `entities` or in `unpredicted`), and an absent contributor
|
|
9
|
+
* is **silence about an absent substrate**, never silence about entities that
|
|
10
|
+
* are present.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, expect, test } from "vitest";
|
|
14
|
+
import {
|
|
15
|
+
ENGINE_KINDS,
|
|
16
|
+
coverageFor,
|
|
17
|
+
isEngineKind,
|
|
18
|
+
type BehaviourKinds,
|
|
19
|
+
} from "./behaviour-kinds";
|
|
20
|
+
|
|
21
|
+
const aws: BehaviourKinds = {
|
|
22
|
+
provider: "aws",
|
|
23
|
+
prefixes: ["AWS::"],
|
|
24
|
+
mapped: {
|
|
25
|
+
"AWS::EC2::Instance": { kind: "compute", sizeProp: "InstanceType", sizeType: "string" },
|
|
26
|
+
"AWS::S3::Bucket": { kind: "object-store" },
|
|
27
|
+
},
|
|
28
|
+
unmapped: {
|
|
29
|
+
"AWS::IAM::Role": "a grant is not a resource: it carries no rate of its own",
|
|
30
|
+
},
|
|
31
|
+
// CloudFormation property types — hundreds of nested blocks that became
|
|
32
|
+
// entities of their own, never separately priced.
|
|
33
|
+
unmappedWhen: (type) =>
|
|
34
|
+
type.includes(".")
|
|
35
|
+
? "a CloudFormation property type: the resource above it carries the figures"
|
|
36
|
+
: undefined,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const cedar: BehaviourKinds = {
|
|
40
|
+
provider: "cedar",
|
|
41
|
+
prefixes: ["Cedar::"],
|
|
42
|
+
nothingPriced: "Cedar (a policy language, with nothing to saturate)",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const terraform: BehaviourKinds = {
|
|
46
|
+
provider: "aws",
|
|
47
|
+
prefixes: ["Terraform::"],
|
|
48
|
+
resolveType: (_entityType, props) => {
|
|
49
|
+
const address = props?.address;
|
|
50
|
+
if (typeof address !== "string") return undefined;
|
|
51
|
+
const dot = address.indexOf(".");
|
|
52
|
+
return dot > 0 ? address.slice(0, dot) : undefined;
|
|
53
|
+
},
|
|
54
|
+
mapped: {
|
|
55
|
+
aws_instance: { kind: "compute", sizeProp: "body.instance_type", sizeType: "string" },
|
|
56
|
+
},
|
|
57
|
+
unmapped: { aws_iam_role: "a grant is not a resource" },
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** A lexicon that models part of what it declares, which is terraform's shape. */
|
|
61
|
+
const partial: BehaviourKinds = {
|
|
62
|
+
provider: "aws",
|
|
63
|
+
prefixes: ["Partial::"],
|
|
64
|
+
mapped: { "Partial::aws_instance": { kind: "compute" } },
|
|
65
|
+
unmapped: { "Partial::aws_iam_role": "a grant is not a resource" },
|
|
66
|
+
notModelledWhen: (type) =>
|
|
67
|
+
type.startsWith("Partial::aws_") ? undefined : `the ${type.split("::")[1]?.split("_")[0]} provider, which nothing here models`,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const ALL = [aws, cedar, terraform, partial];
|
|
71
|
+
|
|
72
|
+
describe("the kind enum", () => {
|
|
73
|
+
test("is derived from a total witness, so a kind cannot be added and forgotten", () => {
|
|
74
|
+
expect(ENGINE_KINDS).toContain("control-plane");
|
|
75
|
+
expect(ENGINE_KINDS.length).toBe(new Set(ENGINE_KINDS).size);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("isEngineKind rejects a plausible near-miss", () => {
|
|
79
|
+
expect(isEngineKind("compute")).toBe(true);
|
|
80
|
+
expect(isEngineKind("storage")).toBe(false);
|
|
81
|
+
expect(isEngineKind(undefined)).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("a lexicon's rows decide its own types", () => {
|
|
86
|
+
test("a mapped type carries the kind, the size property and the contributor's provider", () => {
|
|
87
|
+
const v = coverageFor(ALL, "AWS::EC2::Instance");
|
|
88
|
+
expect(v.status).toBe("mapped");
|
|
89
|
+
if (v.status !== "mapped") return;
|
|
90
|
+
expect(v.mapping.kind).toBe("compute");
|
|
91
|
+
expect(v.mapping.sizeProp).toBe("InstanceType");
|
|
92
|
+
// The row did not state a provider; it inherits the contributor's, which
|
|
93
|
+
// is what keeps `provider` off every row.
|
|
94
|
+
expect(v.mapping.provider).toBe("aws");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("a declared-unmapped type keeps the sentence saying why", () => {
|
|
98
|
+
const v = coverageFor(ALL, "AWS::IAM::Role");
|
|
99
|
+
expect(v.status).toBe("declared-unmapped");
|
|
100
|
+
if (v.status !== "declared-unmapped") return;
|
|
101
|
+
expect(v.reason).toContain("a grant is not a resource");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("a whole substrate can say nothing is priced, once, without rows", () => {
|
|
105
|
+
const v = coverageFor(ALL, "Cedar::Policy");
|
|
106
|
+
expect(v.status).toBe("provider-not-modelled");
|
|
107
|
+
if (v.status !== "provider-not-modelled") return;
|
|
108
|
+
expect(v.substrate).toContain("nothing to saturate");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("a type its own lexicon claims and cannot classify is the one defect verdict", () => {
|
|
112
|
+
// Not `declared-unmapped`: nobody decided this, somebody forgot it.
|
|
113
|
+
expect(coverageFor(ALL, "AWS::Kinesis::Stream").status).toBe("unknown-type");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("unmappedWhen is the last word, for a family too large to enumerate", () => {
|
|
117
|
+
const v = coverageFor(ALL, "AWS::S3::Bucket.VersioningConfiguration");
|
|
118
|
+
expect(v.status).toBe("declared-unmapped");
|
|
119
|
+
if (v.status !== "declared-unmapped") return;
|
|
120
|
+
expect(v.reason).toContain("carries the figures");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("a lexicon whose entities share one entity type", () => {
|
|
125
|
+
test("rows are looked up on the type its props carry, not on the entity type", () => {
|
|
126
|
+
const v = coverageFor(ALL, "Terraform::Resource", { address: "aws_instance.web" });
|
|
127
|
+
expect(v.status).toBe("mapped");
|
|
128
|
+
if (v.status !== "mapped") return;
|
|
129
|
+
expect(v.mapping.sizeProp).toBe("body.instance_type");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("nothing to resolve is no opinion, not a defect claim about the wrong type", () => {
|
|
133
|
+
expect(coverageFor(ALL, "Terraform::Resource", {}).status).toBe("unknown-type");
|
|
134
|
+
expect(coverageFor(ALL, "Terraform::Resource").status).toBe("unknown-type");
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("a contributor that redirects only some of its types", () => {
|
|
139
|
+
// terraform's shape: `Terraform::Resource` keys off its address, and the
|
|
140
|
+
// root's own blocks key by entity type like everyone else. The bug this
|
|
141
|
+
// pins: with `resolveType` blind to the entity type, every root block
|
|
142
|
+
// resolved through the address path and came back a defect.
|
|
143
|
+
const mixed: BehaviourKinds = {
|
|
144
|
+
provider: "aws",
|
|
145
|
+
prefixes: ["Tf::"],
|
|
146
|
+
resolveType: (entityType, props) =>
|
|
147
|
+
entityType === "Tf::Resource"
|
|
148
|
+
? typeof props?.address === "string"
|
|
149
|
+
? props.address.split(".")[0]
|
|
150
|
+
: undefined
|
|
151
|
+
: entityType,
|
|
152
|
+
mapped: { aws_instance: { kind: "compute" } },
|
|
153
|
+
unmapped: { "Tf::Variable": "a root module input; it shapes what is created and is never created itself" },
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
test("the redirected type reads its key out of the props", () => {
|
|
157
|
+
expect(coverageFor([mixed], "Tf::Resource", { address: "aws_instance.web" }).status).toBe("mapped");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("an ordinary type of the same lexicon still keys by entity type", () => {
|
|
161
|
+
const v = coverageFor([mixed], "Tf::Variable");
|
|
162
|
+
expect(v.status).toBe("declared-unmapped");
|
|
163
|
+
if (v.status !== "declared-unmapped") return;
|
|
164
|
+
expect(v.reason).toContain("never created itself");
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("what an absent contributor means", () => {
|
|
169
|
+
test("a type nobody claims is nobody's mistake", () => {
|
|
170
|
+
// The gcp lexicon is not installed, so no GCP:: entity was declared. The
|
|
171
|
+
// verdict exists for a type reached some other way, and it is silence.
|
|
172
|
+
expect(coverageFor(ALL, "GCP::Compute::Instance").status).toBe("unknown-type");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("removing a contributor changes only its own types", () => {
|
|
176
|
+
const withoutAws = coverageFor([cedar, terraform], "AWS::EC2::Instance");
|
|
177
|
+
expect(withoutAws.status).toBe("unknown-type");
|
|
178
|
+
// Everyone else's verdicts are untouched — rows are additive, which is why
|
|
179
|
+
// they may be optional when the capability may not be.
|
|
180
|
+
expect(coverageFor([cedar, terraform], "Cedar::Policy").status).toBe("provider-not-modelled");
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe("a lexicon that models only part of what it declares", () => {
|
|
185
|
+
test("a family outside the modelled substrate is named, not called a defect", () => {
|
|
186
|
+
const v = coverageFor(ALL, "Partial::google_compute_instance");
|
|
187
|
+
expect(v.status).toBe("provider-not-modelled");
|
|
188
|
+
if (v.status !== "provider-not-modelled") return;
|
|
189
|
+
expect(v.substrate).toContain("google");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("a type inside the modelled substrate with no row is still the defect", () => {
|
|
193
|
+
// The ordering that matters: a substrate boundary is a statement about
|
|
194
|
+
// types this lexicon never models, and it must not swallow a row somebody
|
|
195
|
+
// forgot to write for one it does.
|
|
196
|
+
expect(coverageFor(ALL, "Partial::aws_kinesis_stream").status).toBe("unknown-type");
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe("chant's own build-time entities", () => {
|
|
201
|
+
test("are declared-unmapped before any contributor is consulted", () => {
|
|
202
|
+
const v = coverageFor([], "chant:output:apiUrl");
|
|
203
|
+
expect(v.status).toBe("declared-unmapped");
|
|
204
|
+
if (v.status !== "declared-unmapped") return;
|
|
205
|
+
expect(v.reason).toContain("never billed");
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe("resolution is total", () => {
|
|
210
|
+
test("every shape of input reaches exactly one verdict, including hostile keys", () => {
|
|
211
|
+
const cases: Array<[string, Record<string, unknown> | undefined]> = [
|
|
212
|
+
["AWS::EC2::Instance", undefined],
|
|
213
|
+
["AWS::IAM::Role", undefined],
|
|
214
|
+
["Cedar::Policy", undefined],
|
|
215
|
+
["Terraform::Resource", { address: "aws_instance.web" }],
|
|
216
|
+
["Terraform::Resource", undefined],
|
|
217
|
+
["GCP::Compute::Instance", undefined],
|
|
218
|
+
["chant:output:x", undefined],
|
|
219
|
+
// A prototype member as an entity type: `hasOwnProperty` rather than a
|
|
220
|
+
// truthiness check is what stops this resolving to a mapping that does
|
|
221
|
+
// not exist.
|
|
222
|
+
["AWS::constructor", undefined],
|
|
223
|
+
["AWS::__proto__", undefined],
|
|
224
|
+
["", undefined],
|
|
225
|
+
];
|
|
226
|
+
const seen = new Set<string>();
|
|
227
|
+
for (const [type, props] of cases) {
|
|
228
|
+
const v = coverageFor(ALL, type, props);
|
|
229
|
+
expect(["mapped", "declared-unmapped", "provider-not-modelled", "unknown-type"]).toContain(v.status);
|
|
230
|
+
seen.add(v.status);
|
|
231
|
+
}
|
|
232
|
+
// Not just legal: the cases above actually exercise all four arms.
|
|
233
|
+
expect(seen.size).toBe(4);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an engine is asked to price, and who gets to say so (#2382).
|
|
3
|
+
*
|
|
4
|
+
* `./behaviour.ts` says what a prediction may mean and `./behaviour-http.ts`
|
|
5
|
+
* carries one to an engine. Between them sits a question neither answers: for
|
|
6
|
+
* a given entity in a project's graph, what *kind* of thing is it, and is it
|
|
7
|
+
* something an engine prices at all? #2357 answered it with one table in one
|
|
8
|
+
* lexicon, keyed by every other lexicon's entity types. That table described
|
|
9
|
+
* 117 types it did not own, and the capability rode on whether an optional
|
|
10
|
+
* package happened to be installed — which gave a consumer a fourth
|
|
11
|
+
* outcome this contract never named: no overlay, no refusal, and no reason.
|
|
12
|
+
*
|
|
13
|
+
* So the resolution lives here, where every other part of the feature already
|
|
14
|
+
* does, and the *rows* are contributed by the lexicon that owns the substrate.
|
|
15
|
+
* Core learns that an entity has an engine kind. It never learns which.
|
|
16
|
+
*
|
|
17
|
+
* ## Why rows are safe to make optional when the capability is not
|
|
18
|
+
*
|
|
19
|
+
* A lexicon that is not installed declared no entities of its types, so it has
|
|
20
|
+
* no rows to contribute and nothing goes missing: the estate simply contains
|
|
21
|
+
* nothing of that substrate. That is the opposite of the capability itself
|
|
22
|
+
* being optional, where an uninstalled package means an estate full of
|
|
23
|
+
* entities nobody will say anything about.
|
|
24
|
+
*
|
|
25
|
+
* ## The three things a lexicon can say about its own types
|
|
26
|
+
*
|
|
27
|
+
* - **mapped** — this type is a `compute`/`database`/`queue`, and its size
|
|
28
|
+
* is read from this declared property, in the provider's own vocabulary.
|
|
29
|
+
* - **declared unmapped** — this type is real and carries no rate an engine
|
|
30
|
+
* can quote, with the sentence saying why. A grant is not a resource; a
|
|
31
|
+
* boundary is not a node; a meter chant cannot read is not a figure.
|
|
32
|
+
* - **nothing priced** — nothing this lexicon declares is an estate an
|
|
33
|
+
* engine prices, once, for the whole substrate. A CI workflow is not an
|
|
34
|
+
* estate, and a policy language has nothing to saturate.
|
|
35
|
+
*
|
|
36
|
+
* A type a contributor claims and has no row for is `unknown-type`: the one
|
|
37
|
+
* verdict that is a defect rather than a decision, and the reason the three
|
|
38
|
+
* above are distinct states instead of one absent row.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The categories a cost-and-saturation model actually distinguishes, not a
|
|
43
|
+
* taxonomy of every product a cloud sells.
|
|
44
|
+
*
|
|
45
|
+
* A kind is here when an engine would price it differently or when its
|
|
46
|
+
* saturation axis differs. `cache` is separate from `database` because a cache
|
|
47
|
+
* saturates on memory and a database on IO; `serverless` is separate from
|
|
48
|
+
* `compute` because one is priced per invocation and the other per hour of
|
|
49
|
+
* existence, and this contract's output shape is per hour. `control-plane` is
|
|
50
|
+
* separate from everything because a managed control plane is a flat hourly
|
|
51
|
+
* fee that does not move with the estate's traffic at all — pricing one as
|
|
52
|
+
* `compute` would make it look like something a right-size suggestion could
|
|
53
|
+
* shrink.
|
|
54
|
+
*/
|
|
55
|
+
export type EngineKind =
|
|
56
|
+
| "compute"
|
|
57
|
+
| "serverless"
|
|
58
|
+
| "control-plane"
|
|
59
|
+
| "database"
|
|
60
|
+
| "cache"
|
|
61
|
+
| "queue"
|
|
62
|
+
| "object-store"
|
|
63
|
+
| "block-store"
|
|
64
|
+
| "load-balancer"
|
|
65
|
+
| "cdn";
|
|
66
|
+
|
|
67
|
+
const ENGINE_KIND_WITNESS: Record<EngineKind, true> = {
|
|
68
|
+
compute: true,
|
|
69
|
+
serverless: true,
|
|
70
|
+
"control-plane": true,
|
|
71
|
+
database: true,
|
|
72
|
+
cache: true,
|
|
73
|
+
queue: true,
|
|
74
|
+
"object-store": true,
|
|
75
|
+
"block-store": true,
|
|
76
|
+
"load-balancer": true,
|
|
77
|
+
cdn: true,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Every legal {@link EngineKind}, derived from a total witness rather than
|
|
82
|
+
* written out by hand — the construction `behaviour.ts` uses for
|
|
83
|
+
* `BEHAVIOUR_BASES`, for the same reason: a hand-written array is checked for
|
|
84
|
+
* having legal members and never for having all of them.
|
|
85
|
+
*/
|
|
86
|
+
export const ENGINE_KINDS: readonly EngineKind[] = Object.keys(ENGINE_KIND_WITNESS) as EngineKind[];
|
|
87
|
+
|
|
88
|
+
/** True when `value` is a legal {@link EngineKind}. */
|
|
89
|
+
export function isEngineKind(value: unknown): value is EngineKind {
|
|
90
|
+
return typeof value === "string" && (ENGINE_KINDS as readonly string[]).includes(value);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** One row of a lexicon's coverage: what one of its entity types becomes on the wire. */
|
|
94
|
+
export interface EngineKindMapping {
|
|
95
|
+
/** The engine-side kind. */
|
|
96
|
+
kind: EngineKind;
|
|
97
|
+
/**
|
|
98
|
+
* The substrate, as the engine names it — `aws`, `kubernetes`. A plain
|
|
99
|
+
* string rather than a closed union: the contributing lexicon names its own
|
|
100
|
+
* substrate, and core adding a member here for every lexicon that ships
|
|
101
|
+
* would be core holding the list it exists to stop holding.
|
|
102
|
+
*/
|
|
103
|
+
provider: string;
|
|
104
|
+
/**
|
|
105
|
+
* The declared property whose value is the entity's size, in the provider's
|
|
106
|
+
* own vocabulary — `InstanceType`, not a parsed vCPU count. Absent where the
|
|
107
|
+
* type has no size a single `size` string can carry.
|
|
108
|
+
*/
|
|
109
|
+
sizeProp?: string;
|
|
110
|
+
/**
|
|
111
|
+
* Which type {@link sizeProp} holds. A value of the other type is absent
|
|
112
|
+
* rather than coerced: a number rendered into a size field an engine matches
|
|
113
|
+
* against a price table of strings is worse than no size at all.
|
|
114
|
+
*/
|
|
115
|
+
sizeType?: "string" | "number";
|
|
116
|
+
/**
|
|
117
|
+
* The declared property naming the region or zone this entity sits in, where
|
|
118
|
+
* the type states one of its own. Most do not, and inherit the caller's.
|
|
119
|
+
*/
|
|
120
|
+
regionProp?: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* One lexicon's answer for its own entity types, contributed through the
|
|
125
|
+
* plugin's `behaviourKinds` field.
|
|
126
|
+
*
|
|
127
|
+
* Every field except {@link prefixes} is optional, and a lexicon that supplies
|
|
128
|
+
* only {@link nothingPriced} has said something complete: that none of what it
|
|
129
|
+
* declares is an estate an engine prices.
|
|
130
|
+
*/
|
|
131
|
+
export interface BehaviourKinds {
|
|
132
|
+
/** The substrate, as the engine names it. Every mapped row inherits it. */
|
|
133
|
+
provider: string;
|
|
134
|
+
/**
|
|
135
|
+
* The entity-type prefixes this lexicon owns — `["AWS::"]`, `["K8s::"]`.
|
|
136
|
+
* Ownership is what makes a missing row a defect rather than silence: a type
|
|
137
|
+
* nobody claims is nobody's mistake, and a type its own lexicon claims and
|
|
138
|
+
* cannot classify is a row somebody forgot to write.
|
|
139
|
+
*/
|
|
140
|
+
prefixes: readonly string[];
|
|
141
|
+
/** Types this lexicon prices, keyed by entity type — or by resolved type where {@link resolveType} is given. */
|
|
142
|
+
mapped?: Readonly<Record<string, Omit<EngineKindMapping, "provider"> & { provider?: string }>>;
|
|
143
|
+
/** Types that are real and carry no rate, keyed the same way, with the sentence saying why. */
|
|
144
|
+
unmapped?: Readonly<Record<string, string>>;
|
|
145
|
+
/**
|
|
146
|
+
* Nothing this lexicon declares is priced, and this says why — checked
|
|
147
|
+
* before the tables, so a lexicon that states it needs no rows at all.
|
|
148
|
+
*/
|
|
149
|
+
nothingPriced?: string;
|
|
150
|
+
/**
|
|
151
|
+
* For a lexicon whose entities do not key rows by their entity type, the
|
|
152
|
+
* key to look rows up on instead. terraform's every `resource` block arrives
|
|
153
|
+
* as `Terraform::Resource`, and what an engine would price is the type its
|
|
154
|
+
* address carries.
|
|
155
|
+
*
|
|
156
|
+
* It is handed the entity type as well as the props, because a lexicon
|
|
157
|
+
* usually redirects only *some* of its types: terraform's root blocks
|
|
158
|
+
* (`Terraform::Variable`, `Terraform::Output`) key by entity type like
|
|
159
|
+
* everyone else, and only `Terraform::Resource` reads its key out of the
|
|
160
|
+
* props. Returning the entity type unchanged is how a contributor says "this
|
|
161
|
+
* one is ordinary". Returning `undefined` is "nothing to look up", the same
|
|
162
|
+
* no-opinion an unseen type gets.
|
|
163
|
+
*/
|
|
164
|
+
resolveType?: (entityType: string, props: Record<string, unknown> | undefined) => string | undefined;
|
|
165
|
+
/**
|
|
166
|
+
* A family of this lexicon's types that belongs to a substrate nothing here
|
|
167
|
+
* models, named as the substrate — or `undefined` to let the type carry on
|
|
168
|
+
* to {@link unmappedWhen}. terraform is the case this exists for: one
|
|
169
|
+
* lexicon's `resource` blocks span every provider there is, so `google_`
|
|
170
|
+
* and `azurerm_` and `null_` are each a boundary of their own, while an
|
|
171
|
+
* `aws_` type with no row stays the defect it is. A contributor whose whole
|
|
172
|
+
* substrate is unpriced uses {@link nothingPriced} instead; this is for the
|
|
173
|
+
* lexicon that models part of what it declares.
|
|
174
|
+
*/
|
|
175
|
+
notModelledWhen?: (type: string) => string | undefined;
|
|
176
|
+
/**
|
|
177
|
+
* A last word on a type this lexicon claims and has no row for: the reason
|
|
178
|
+
* it is unmapped, or `undefined` to leave it a defect. CloudFormation's
|
|
179
|
+
* property types are the case this exists for — hundreds of nested blocks
|
|
180
|
+
* that became entities of their own, never separately priced, and
|
|
181
|
+
* enumerating them one row at a time would bury the table's real decisions.
|
|
182
|
+
*/
|
|
183
|
+
unmappedWhen?: (type: string) => string | undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** What one entity type resolves to. Total: every type reaches exactly one of these. */
|
|
187
|
+
export type CoverageVerdict =
|
|
188
|
+
| { status: "mapped"; mapping: EngineKindMapping }
|
|
189
|
+
| { status: "declared-unmapped"; reason: string }
|
|
190
|
+
| { status: "provider-not-modelled"; substrate: string }
|
|
191
|
+
| { status: "unknown-type" };
|
|
192
|
+
|
|
193
|
+
/** chant's own build-time entities, which no lexicon owns and nothing bills. */
|
|
194
|
+
const CHANT_PSEUDO_PREFIX = "chant:";
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Resolve one entity type against the contributed rows.
|
|
198
|
+
*
|
|
199
|
+
* Total by construction: every type reaches one of the four states and there
|
|
200
|
+
* is no fifth arm for "dropped". That is the property the request builder
|
|
201
|
+
* relies on to guarantee every entity it was asked about lands in `entities`
|
|
202
|
+
* or in `unpredicted`.
|
|
203
|
+
*
|
|
204
|
+
* `hasOwnProperty` rather than a truthiness check, because these are plain
|
|
205
|
+
* object literals and an entity named after a prototype member would otherwise
|
|
206
|
+
* resolve to a mapping that does not exist.
|
|
207
|
+
*/
|
|
208
|
+
export function coverageFor(
|
|
209
|
+
contributors: readonly BehaviourKinds[],
|
|
210
|
+
entityType: string,
|
|
211
|
+
props?: Record<string, unknown>,
|
|
212
|
+
): CoverageVerdict {
|
|
213
|
+
if (entityType.startsWith(CHANT_PSEUDO_PREFIX)) {
|
|
214
|
+
return {
|
|
215
|
+
status: "declared-unmapped",
|
|
216
|
+
reason:
|
|
217
|
+
"one of chant's own build-time entities rather than something an account holds — a declared " +
|
|
218
|
+
"output, or a default that rides onto the resources beside it. It is never created and never " +
|
|
219
|
+
"billed",
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const owner = contributors.find((c) => c.prefixes.some((p) => entityType.startsWith(p)));
|
|
224
|
+
if (!owner) return { status: "unknown-type" };
|
|
225
|
+
if (owner.nothingPriced) return { status: "provider-not-modelled", substrate: owner.nothingPriced };
|
|
226
|
+
|
|
227
|
+
const type = owner.resolveType ? owner.resolveType(entityType, props) : entityType;
|
|
228
|
+
if (type === undefined) return { status: "unknown-type" };
|
|
229
|
+
|
|
230
|
+
if (owner.mapped && Object.prototype.hasOwnProperty.call(owner.mapped, type)) {
|
|
231
|
+
const row = owner.mapped[type];
|
|
232
|
+
return { status: "mapped", mapping: { ...row, provider: row.provider ?? owner.provider } };
|
|
233
|
+
}
|
|
234
|
+
if (owner.unmapped && Object.prototype.hasOwnProperty.call(owner.unmapped, type)) {
|
|
235
|
+
return { status: "declared-unmapped", reason: owner.unmapped[type] };
|
|
236
|
+
}
|
|
237
|
+
// Before `unmappedWhen`, and after both tables: a row is this lexicon's
|
|
238
|
+
// decision about a type it models, and a substrate boundary is a statement
|
|
239
|
+
// about types it never will. A type inside the modelled substrate with no
|
|
240
|
+
// row falls past both and stays the defect it is.
|
|
241
|
+
const elsewhere = owner.notModelledWhen?.(type);
|
|
242
|
+
if (elsewhere !== undefined) return { status: "provider-not-modelled", substrate: elsewhere };
|
|
243
|
+
const late = owner.unmappedWhen?.(type);
|
|
244
|
+
if (late !== undefined) return { status: "declared-unmapped", reason: late };
|
|
245
|
+
return { status: "unknown-type" };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The contributor that owns an entity type, or `undefined` when nobody claims
|
|
250
|
+
* it. Exported because a caller that has already resolved a verdict often
|
|
251
|
+
* needs the owner too — to name it in a detail, or to label the entity the way
|
|
252
|
+
* its own lexicon would.
|
|
253
|
+
*/
|
|
254
|
+
export function ownerOf(
|
|
255
|
+
contributors: readonly BehaviourKinds[],
|
|
256
|
+
entityType: string,
|
|
257
|
+
): BehaviourKinds | undefined {
|
|
258
|
+
return contributors.find((c) => c.prefixes.some((p) => entityType.startsWith(p)));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* How a detail names an entity's kind. The chant entity type, except where the
|
|
263
|
+
* owning lexicon keys its rows on something else: `Terraform::Resource` names
|
|
264
|
+
* nothing a reader can act on, and the provider type is the kind, so the label
|
|
265
|
+
* is `aws_vpc (Terraform::Resource)`.
|
|
266
|
+
*/
|
|
267
|
+
export function coverageLabel(
|
|
268
|
+
contributors: readonly BehaviourKinds[],
|
|
269
|
+
entityType: string,
|
|
270
|
+
props?: Record<string, unknown>,
|
|
271
|
+
): string {
|
|
272
|
+
const owner = ownerOf(contributors, entityType);
|
|
273
|
+
if (!owner?.resolveType) return entityType;
|
|
274
|
+
const type = owner.resolveType(entityType, props);
|
|
275
|
+
return type === undefined || type === entityType ? entityType : `${type} (${entityType})`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* The `detail` an `unpredicted` entry carries, naming the kind in every case.
|
|
280
|
+
*
|
|
281
|
+
* The kind is named here, in the per-entity decline, because that is the only
|
|
282
|
+
* place in this contract with a per-entity axis: a `BehaviourRefusalReport` is
|
|
283
|
+
* a statement about the whole run and has no `entities` key by design, so a
|
|
284
|
+
* report-level refusal for one unmapped bucket would take the other nineteen
|
|
285
|
+
* entities' figures down with it.
|
|
286
|
+
*
|
|
287
|
+
* The `unknown-type` arm names the lexicon that owns the type rather than a
|
|
288
|
+
* file path, because since #2382 the row belongs to whichever lexicon defines
|
|
289
|
+
* the type, and that is the thing a reader needs to be told.
|
|
290
|
+
*/
|
|
291
|
+
export function unmappedDetail(
|
|
292
|
+
label: string,
|
|
293
|
+
verdict: CoverageVerdict,
|
|
294
|
+
owner?: BehaviourKinds,
|
|
295
|
+
): string {
|
|
296
|
+
if (verdict.status === "declared-unmapped") {
|
|
297
|
+
return `${label} is declared unmapped by the ${owner?.provider ?? "declaring"} coverage rows: ${verdict.reason}.`;
|
|
298
|
+
}
|
|
299
|
+
if (verdict.status === "provider-not-modelled") {
|
|
300
|
+
return (
|
|
301
|
+
`${label} belongs to ${verdict.substrate}, which nothing here models. A substrate is added by ` +
|
|
302
|
+
"the lexicon that owns its types contributing rows for them, not by adding a row elsewhere. " +
|
|
303
|
+
"This is a stated boundary rather than a gap — nothing needs filing."
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
const where = owner
|
|
307
|
+
? `the lexicon that owns ${owner.prefixes.join(", ")}`
|
|
308
|
+
: "the lexicon that owns this entity type";
|
|
309
|
+
return (
|
|
310
|
+
`${label} has no row in any contributed coverage table — it is a type from a substrate that is ` +
|
|
311
|
+
"modelled, and is neither mapped to an engine kind nor declared unmapped, so nothing has an " +
|
|
312
|
+
`opinion about it rather than a stated one. Add a row in ${where}.`
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Compare two strings by UTF-16 code unit.
|
|
318
|
+
*
|
|
319
|
+
* Not `localeCompare`, which reads the ambient locale: under `sv-SE` and
|
|
320
|
+
* `et-EE` it orders these type names differently from `en-US`, which would
|
|
321
|
+
* make the request's bytes — and therefore any golden fixture, and therefore
|
|
322
|
+
* any declared-versus-live delta — a function of the machine that built them.
|
|
323
|
+
*/
|
|
324
|
+
export const byCodeUnit = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
|