@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.
Files changed (54) hide show
  1. package/dist/attrref.d.ts +21 -0
  2. package/dist/attrref.d.ts.map +1 -1
  3. package/dist/behaviour-engine.d.ts +212 -0
  4. package/dist/behaviour-engine.d.ts.map +1 -0
  5. package/dist/behaviour-http.d.ts +1 -1
  6. package/dist/behaviour-http.d.ts.map +1 -1
  7. package/dist/behaviour-kinds.d.ts +220 -0
  8. package/dist/behaviour-kinds.d.ts.map +1 -0
  9. package/dist/behaviour-predict.d.ts +83 -0
  10. package/dist/behaviour-predict.d.ts.map +1 -0
  11. package/dist/behaviour-request.d.ts +141 -0
  12. package/dist/behaviour-request.d.ts.map +1 -0
  13. package/dist/behaviour.d.ts +7 -7
  14. package/dist/discovery/fold-import.d.ts.map +1 -1
  15. package/dist/fold/fold.d.ts +10 -0
  16. package/dist/fold/fold.d.ts.map +1 -1
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/lexicon.d.ts +19 -0
  20. package/dist/lexicon.d.ts.map +1 -1
  21. package/dist/lint/rules/evl011-symbolic-in-template.d.ts +3 -0
  22. package/dist/lint/rules/evl011-symbolic-in-template.d.ts.map +1 -0
  23. package/dist/lint/rules/index.d.ts +1 -0
  24. package/dist/lint/rules/index.d.ts.map +1 -1
  25. package/dist/op/activities/index.d.ts +1 -1
  26. package/dist/op/activities/index.d.ts.map +1 -1
  27. package/dist/op/activities/predict-behaviour.d.ts +12 -5
  28. package/dist/op/activities/predict-behaviour.d.ts.map +1 -1
  29. package/package.json +1 -1
  30. package/src/attrref.test.ts +31 -0
  31. package/src/attrref.ts +33 -0
  32. package/src/behaviour-delta.test.ts +8 -8
  33. package/src/behaviour-engine.ts +478 -0
  34. package/src/behaviour-http.ts +1 -1
  35. package/src/behaviour-kinds.test.ts +235 -0
  36. package/src/behaviour-kinds.ts +324 -0
  37. package/src/behaviour-predict.ts +253 -0
  38. package/src/behaviour-request.ts +294 -0
  39. package/src/behaviour.ts +7 -7
  40. package/src/cli/handlers/scenario.test.ts +1 -1
  41. package/src/cli/handlers/scenario.ts +1 -1
  42. package/src/discovery/fold-composite.test.ts +70 -0
  43. package/src/discovery/fold-import.ts +48 -2
  44. package/src/fold/fold.test.ts +53 -0
  45. package/src/fold/fold.ts +43 -1
  46. package/src/index.ts +1 -0
  47. package/src/lexicon.ts +20 -0
  48. package/src/lifecycle/scenario-cost.test.ts +2 -2
  49. package/src/lint/rules/evl011-symbolic-in-template.test.ts +88 -0
  50. package/src/lint/rules/evl011-symbolic-in-template.ts +100 -0
  51. package/src/lint/rules/index.ts +3 -0
  52. package/src/op/activities/index.ts +1 -2
  53. package/src/op/activities/predict-behaviour.test.ts +20 -7
  54. 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);