@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,253 @@
1
+ /**
2
+ * `predictBehaviour()` — the fourth
3
+ * observation method (#2357, contract #2356).
4
+ *
5
+ * The whole method is four moves, in this order, and the order is the contract:
6
+ *
7
+ * 1. **Screen the request.** `screenBehaviourRequest` and nothing else. It is
8
+ * the only entry point, and calling `assertNoCredentialInOptions` instead
9
+ * applies one rule of three — that was review finding F1 on #2365, where a
10
+ * `ghp_…` past the walk's depth budget and an `awsSecretAccessKey` in
11
+ * `props` both went out with the request.
12
+ * 2. **Resolve the engine.** `behaviourEngineFrom`, walking
13
+ * `CHANT_BEHAVIOUR_ENGINE_BEHAVIOUR_SCOPE` → `CHANT_BEHAVIOUR_ENGINE` →
14
+ * `BEHAVIOUR_ENGINE`. Nothing named means `noBehaviourEngineRefusal`, and
15
+ * it happens **before** anything is priced: a lexicon that prices first and
16
+ * checks the engine afterwards has already decided what zero means.
17
+ * 3. **Build the request.** `buildEngineRequest`, offline and pure
18
+ * (`./request.ts`).
19
+ * 4. **Ask, and translate the answer.** One {@link EngineOutcome} onto the
20
+ * contract's builders. A refusal comes back from the transport already
21
+ * built — the contract's transport (#2373) names the condition and the
22
+ * variable where the wire was seen — and is returned as it stands; this
23
+ * file holds no switch over causes. No figure is ever computed here either:
24
+ * this file has no arithmetic on money in it at all, and that is not an
25
+ * accident. The epic's first rule is that a missing engine produces a
26
+ * refusal and "never a locally faked number", and the surest way to keep
27
+ * that true is for the code that would have faked it not to exist.
28
+ *
29
+ * ## Every entity lands somewhere
30
+ *
31
+ * `behaviourReport` refuses a report that leaves a name in neither map, so the
32
+ * loop below has no `continue` that drops one. Four ways an entity can end up
33
+ * unpredicted, and each names a different thing:
34
+ *
35
+ * | Outcome | Reason | Means |
36
+ * |---|---|---|
37
+ * | the coverage table declares it unmapped | `unsupported-kind` | chant looked, and this kind has no rate |
38
+ * | the coverage table has never seen it | `unsupported-kind` | chant has not looked; the detail says so and where to add the row |
39
+ * | the engine declined it | `read-failed` | chant asked and the engine could not answer |
40
+ * | the engine answered about neither | `read-failed` | the engine lost it, and this says so rather than hiding it |
41
+ *
42
+ * The last row is the one that would otherwise be silent. An engine that
43
+ * returns figures for eleven of twelve nodes and mentions the twelfth nowhere
44
+ * is a bug in the engine, and an estate that quietly renders eleven nodes is
45
+ * how it stays a bug.
46
+ */
47
+
48
+ import {
49
+ behaviourReport,
50
+ behaviourEngineFrom,
51
+ noBehaviourEngineRefusal,
52
+ predictedRate,
53
+ screenBehaviourRequest,
54
+ unreachableBehaviourEngineRefusal,
55
+ type BehaviourResult,
56
+ type PredictBehaviourOptions,
57
+ type PredictedBehaviour,
58
+ type UnpredictedEntity,
59
+ } from "./behaviour";
60
+ import { buildEngineRequest } from "./behaviour-request";
61
+ import type { BehaviourKinds } from "./behaviour-kinds";
62
+ import { BEHAVIOUR_SCOPE, defaultConnect, type EngineConnect, type EngineFigure } from "./behaviour-engine";
63
+
64
+ export { BEHAVIOUR_SCOPE };
65
+
66
+ /** What {@link createBehaviourPredict} needs that the method's own options do not carry. */
67
+ export interface BehaviourPredictDeps {
68
+ /**
69
+ * The coverage rows to resolve entity types against — every configured
70
+ * lexicon's `behaviourKinds`, in the order the plugins were loaded.
71
+ *
72
+ * An empty list is legal and means every declared entity is withheld as
73
+ * `unknown-type`: nothing has said what any of them are. That is a report
74
+ * about an estate nobody has rows for, not a refusal, because the engine was
75
+ * reachable and answered; the distinction is the whole point of the refusal
76
+ * arm.
77
+ */
78
+ kinds?: readonly BehaviourKinds[];
79
+ /** The environment the engine address is resolved from. Defaults to the process's. */
80
+ env?: Record<string, string | undefined>;
81
+ /**
82
+ * How an address becomes an engine. Defaults to `./behaviour-engine.ts`'s chooser,
83
+ * which dials a URL through core's HTTP transport and a bare address as a
84
+ * command on PATH.
85
+ */
86
+ connect?: EngineConnect;
87
+ }
88
+
89
+ /**
90
+ * Build core's `predictBehaviour`, with the environment and the
91
+ * transport injected.
92
+ *
93
+ * Injected rather than read from module scope so a test can drive the whole
94
+ * method — screen, resolve, serialize, translate — against a fixture engine
95
+ * without touching `process.env`, which is what the shared conformance suite's
96
+ * probes need in order to ask the same request twice and compare.
97
+ */
98
+ export function createBehaviourPredict(
99
+ deps: BehaviourPredictDeps = {},
100
+ ): (options: PredictBehaviourOptions) => Promise<BehaviourResult> {
101
+ const env = deps.env ?? process.env;
102
+ const kinds = deps.kinds ?? [];
103
+ const connect = deps.connect ?? defaultConnect;
104
+
105
+ return async function predictBehaviour(options: PredictBehaviourOptions): Promise<BehaviourResult> {
106
+ const unsafe = screenBehaviourRequest(BEHAVIOUR_SCOPE, options);
107
+ if (unsafe) return unsafe;
108
+
109
+ const endpoint = behaviourEngineFrom(BEHAVIOUR_SCOPE, env);
110
+ if (!endpoint) return noBehaviourEngineRefusal(BEHAVIOUR_SCOPE);
111
+
112
+ const engine = connect(endpoint, env);
113
+ if (!engine) {
114
+ return unreachableBehaviourEngineRefusal(
115
+ BEHAVIOUR_SCOPE,
116
+ endpoint,
117
+ "no transport speaks that address — a http(s) URL is dialled with a bearer token, and a bare " +
118
+ "address is run as a command on PATH; any other scheme has no transport yet",
119
+ );
120
+ }
121
+
122
+ const request = buildEngineRequest(options, kinds);
123
+ const outcome = await engine.predict(request);
124
+
125
+ // Built where the wire was seen, and returned as it stands. The three
126
+ // engine-answered causes and the token cases are mapped once, in the
127
+ // contract's transport, rather than re-derived here from a cause.
128
+ if (!outcome.ok) return outcome.refusal;
129
+
130
+ const { answer } = outcome;
131
+ // `Object.create(null)`, not `{}`. An entity named `constructor` or
132
+ // `toString` writes onto `Object.prototype`'s members on a plain literal,
133
+ // and one named `__proto__` sets the prototype instead of adding a key, so
134
+ // the entry vanishes and `behaviourReport`'s totality check fires on an
135
+ // entity this function believed it had reported. `coverageFor` already
136
+ // guards its own lookups this way (`./mapping.ts`); these are the same
137
+ // hazard on the writing side.
138
+ const entities: Record<string, PredictedBehaviour> = Object.create(null) as Record<string, PredictedBehaviour>;
139
+ const unpredicted: Record<string, UnpredictedEntity> = Object.create(null) as Record<string, UnpredictedEntity>;
140
+
141
+ for (const held of request.withheld) {
142
+ unpredicted[held.name] = {
143
+ ...(held.entityType === "(undeclared)" ? {} : { type: held.entityType }),
144
+ reason: "unsupported-kind",
145
+ detail: held.detail,
146
+ };
147
+ }
148
+
149
+ for (const node of request.nodes) {
150
+ // `hasOwnProperty` for the same reason: `answer.declined` comes from
151
+ // `JSON.parse`, so `declined["constructor"]` is a function rather than
152
+ // `undefined` and the `!== undefined` guard below missed it.
153
+ const declined = has(answer.declined, node.name) ? answer.declined![node.name] : undefined;
154
+ if (declined !== undefined) {
155
+ unpredicted[node.name] = {
156
+ type: node.entityType,
157
+ reason: "read-failed",
158
+ detail: `${answer.engine} was sent ${node.name} as a ${node.kind} and declined it: ${declined}`,
159
+ };
160
+ continue;
161
+ }
162
+ const figure = has(answer.figures, node.name) ? answer.figures[node.name] : undefined;
163
+ if (figure === undefined) {
164
+ unpredicted[node.name] = {
165
+ type: node.entityType,
166
+ reason: "read-failed",
167
+ detail:
168
+ `${answer.engine} was sent ${node.name} and its answer names it in neither its figures nor ` +
169
+ "its declined list. An engine that loses a node is reported, not rendered as an estate one " +
170
+ "node smaller.",
171
+ };
172
+ continue;
173
+ }
174
+ entities[node.name] = block(options.traffic, figure, answer);
175
+ }
176
+
177
+ return behaviourReport(
178
+ options,
179
+ {
180
+ engine: answer.engine,
181
+ version: answer.version,
182
+ ...(answer.total
183
+ ? { total: predictedRate(answer.total.perHour, answer.total.currency) }
184
+ : {}),
185
+ },
186
+ entities,
187
+ unpredicted,
188
+ );
189
+ };
190
+ }
191
+
192
+ /** An own key, not a prototype member. See the accumulators above. */
193
+ function has(map: Record<string, unknown> | undefined, key: string): boolean {
194
+ return map !== undefined && Object.prototype.hasOwnProperty.call(map, key);
195
+ }
196
+
197
+ /**
198
+ * One engine figure as a contract block.
199
+ *
200
+ * `at` comes from the request rather than from the engine's answer. The engine
201
+ * was asked at one level and `behaviourReport` refuses a block priced at a
202
+ * level the run did not ask for, so echoing the engine's own idea of the level
203
+ * would turn an engine that quietly substituted a level it liked better into a
204
+ * report that agrees with itself and answers the wrong question.
205
+ *
206
+ * `headroom` is copied axis by axis. An axis the engine did not model is
207
+ * **absent**, never `0` — zero headroom means saturated, which is the opposite
208
+ * claim, and `BehaviourHeadroom` is a union requiring at least one axis so a
209
+ * figure with neither is a type error rather than a block behold drops.
210
+ */
211
+ function block(
212
+ traffic: string,
213
+ figure: EngineFigure,
214
+ answer: { engine: string; version: string; tolerance: string; basis: PredictedBehaviour["provenance"]["basis"] },
215
+ ): PredictedBehaviour {
216
+ const cpu = figure.headroom?.cpu;
217
+ const latency = figure.headroom?.latency;
218
+ // Built axis by axis, and the cast the first version used here was a lie:
219
+ // `EngineFigure.headroom` is optional, so a figure stating none produced
220
+ // `{ latency: undefined }` — an object satisfying `BehaviourHeadroom`
221
+ // structurally and carrying no axis at all, which behold drops. The doc
222
+ // claiming a type error caught this was false. `parseEngineAnswer` now
223
+ // refuses such a figure before it reaches here; this stays correct anyway,
224
+ // because a lexicon should not depend on its own validator having run.
225
+ const headroom =
226
+ cpu !== undefined
227
+ ? { cpu, ...(latency !== undefined ? { latency } : {}) }
228
+ : { latency: latency as number };
229
+ if (cpu === undefined && latency === undefined) {
230
+ throw new Error(
231
+ `predictBehaviour: the engine's figure for this entity carries neither a cpu nor a latency ` +
232
+ "headroom axis, and parseEngineAnswer should have refused it.",
233
+ );
234
+ }
235
+ return {
236
+ at: { traffic },
237
+ cost: predictedRate(figure.perHour, figure.currency),
238
+ headroom,
239
+ errorRate: figure.errorRate,
240
+ resilience: {
241
+ failure: figure.resilience.failure,
242
+ verdict: figure.resilience.verdict,
243
+ ...(figure.resilience.note ? { note: figure.resilience.note } : {}),
244
+ },
245
+ ...(figure.rightSize ? { rightSize: figure.rightSize } : {}),
246
+ provenance: {
247
+ engine: answer.engine,
248
+ version: answer.version,
249
+ tolerance: answer.tolerance,
250
+ basis: answer.basis,
251
+ },
252
+ };
253
+ }
@@ -0,0 +1,294 @@
1
+ /**
2
+ * The engine's request, built offline from chant's typed source (#2357).
3
+ *
4
+ * `chant build` reaches no network and is byte-identical on re-run
5
+ * (`packages/core/src/components/verbs/reproducibility.ts`, the
6
+ * `deterministic-synthesis` basis). The request an engine is handed is built
7
+ * the same way and holds itself to the same property, for a reason that is not
8
+ * tidiness: the epic wants the declared prediction and the live prediction
9
+ * shown as a delta, and a delta is only a statement about the estate if both
10
+ * sides were assembled the same way from the same source. A request carrying a
11
+ * timestamp, an unsorted map or a floating-point rendering that depends on the
12
+ * platform makes every re-run look like drift.
13
+ *
14
+ * So everything here is pure. No clock, no `Math.random`, no environment, no
15
+ * filesystem, no network. {@link renderEngineRequest} is the only place bytes
16
+ * are produced, and it produces them through a canonical writer that sorts
17
+ * every object key and every list.
18
+ *
19
+ * ## What is in the request, and why the withheld list is in it too
20
+ *
21
+ * Nodes, edges, the traffic level, the caller's edge-coverage claim — and
22
+ * `withheld`, the entities the caller asked about that this lexicon is not
23
+ * asking the engine about, each with the reason from the coverage table.
24
+ *
25
+ * That last one is the same argument `edgeCoverage` won on the contract
26
+ * (#2365, review finding 6). An engine handed twelve nodes cannot tell whether
27
+ * the estate has twelve or twenty, and a resilience verdict computed over a
28
+ * graph with eight nodes missing is a confident answer to a question nobody
29
+ * asked. Omitting them would make the request's own coverage invisible one
30
+ * level down from where the contract made it visible.
31
+ */
32
+
33
+ import type { IREdge } from "./graph-ir";
34
+ import type { BehaviourEdgeCoverage, PredictBehaviourOptions } from "./behaviour";
35
+ import {
36
+ byCodeUnit,
37
+ coverageFor,
38
+ coverageLabel,
39
+ ownerOf,
40
+ unmappedDetail,
41
+ type BehaviourKinds,
42
+ type EngineKind,
43
+ } from "./behaviour-kinds";
44
+
45
+ /** The wire version. Bumped when the shape changes, the way `behaviour: "v1"` is. */
46
+ export const BEHAVIOUR_REQUEST_VERSION = "behaviour/v1" as const;
47
+
48
+ /** One entity, in the engine's four words: a kind, a provider, a region and a size. */
49
+ export interface EngineNode {
50
+ /** The chant entity name, which is also the key `edges` uses. */
51
+ name: string;
52
+ /** The declared type it was translated from, so an engine can say what it choked on. */
53
+ entityType: string;
54
+ /**
55
+ * The provider's own type, where the declared type does not carry it: a
56
+ * terraform `resource` block is `Terraform::Resource` whatever it declares,
57
+ * and `aws_instance` is what an engine can say it choked on (#2360).
58
+ */
59
+ resourceType?: string;
60
+ kind: EngineKind;
61
+ provider: string;
62
+ /** Absent where neither the entity nor the caller states one. Never defaulted. */
63
+ region?: string;
64
+ /**
65
+ * The size in the provider's own words, verbatim. A string where the
66
+ * declaration holds one; absent where it holds none. Never parsed, never
67
+ * converted, never guessed — see the note in `./mapping.ts`.
68
+ */
69
+ size?: string;
70
+ }
71
+
72
+ /** One edge, flattened from `IREdge` to the two fields an engine reads. */
73
+ export interface EngineEdge {
74
+ from: string;
75
+ to: string;
76
+ /** The referring attribute, where the edge came from a declared reference. */
77
+ via?: string;
78
+ /** The referenced attribute on the far end. */
79
+ toAttr?: string;
80
+ }
81
+
82
+ /** One entity the caller asked about that is not on the wire, and why. */
83
+ export interface WithheldEntity {
84
+ name: string;
85
+ entityType: string;
86
+ /** The provider's own type, for a terraform block. See {@link EngineNode.resourceType}. */
87
+ resourceType?: string;
88
+ /**
89
+ * Which of the coverage table's three not-sent verdicts this is:
90
+ * `declared-unmapped` (looked at, and it carries no rate),
91
+ * `provider-not-modelled` (a substrate a lexicon states it does not cover), or
92
+ * `unknown-type` (a modelled provider's type with no row — the only one that
93
+ * is a defect). See `./mapping.ts` for why they are not one.
94
+ */
95
+ status: "declared-unmapped" | "provider-not-modelled" | "unknown-type";
96
+ detail: string;
97
+ }
98
+
99
+ /** What goes on the wire. */
100
+ export interface EngineRequest {
101
+ request: typeof BEHAVIOUR_REQUEST_VERSION;
102
+ /** The level to predict at, verbatim from the caller. chant parses nothing. */
103
+ traffic: string;
104
+ /** Present only when the caller named one; the engine's own default is not chant's to pick. */
105
+ region?: string;
106
+ nodes: EngineNode[];
107
+ edges: EngineEdge[];
108
+ /** The caller's claim about how complete `edges` is, passed through unchanged. */
109
+ coverage: BehaviourEdgeCoverage;
110
+ withheld: WithheldEntity[];
111
+ }
112
+
113
+ /** Read a dotted path out of a declared property bag. Returns `undefined` for any miss. */
114
+ function readPath(props: Record<string, unknown>, path: string): unknown {
115
+ let cursor: unknown = props;
116
+ for (const segment of path.split(".")) {
117
+ if (typeof cursor !== "object" || cursor === null) return undefined;
118
+ cursor = (cursor as Record<string, unknown>)[segment];
119
+ }
120
+ return cursor;
121
+ }
122
+
123
+ /**
124
+ * A declared size, as a string, or absent.
125
+ *
126
+ * `sizeType` says which type the row's property holds, and a value of the
127
+ * other type is **absent** rather than coerced. Without that, a
128
+ * `DBInstanceClass: 42` — a wrong-typed declaration, or a parameter that
129
+ * folded to a number — rendered as `"42"` and went to the engine to be matched
130
+ * against a price table of instance-class names it does not appear in. That is
131
+ * the outcome the paragraph below is written against, arriving through the
132
+ * door the paragraph left open.
133
+ *
134
+ * Anything else — an object, an array, an unresolved intrinsic, a `NaN` — is
135
+ * absent too, because a size an engine cannot read is worse than no size: it
136
+ * will either be ignored silently or matched against nothing.
137
+ *
138
+ * A terraform block's unresolved reference is a string, `"${var.size}"`, and
139
+ * is absent for the same reason a CloudFormation intrinsic object is: it is
140
+ * the name of a value, not the value (#2360).
141
+ */
142
+ export function sizeOf(
143
+ props: Record<string, unknown>,
144
+ sizeProp: string | undefined,
145
+ sizeType?: "string" | "number",
146
+ ): string | undefined {
147
+ if (!sizeProp) return undefined;
148
+ const raw = readPath(props, sizeProp);
149
+ if (sizeType === "number") {
150
+ return typeof raw === "number" && Number.isFinite(raw) ? String(raw) : undefined;
151
+ }
152
+ // `string`, and the default for a row naming a property and no type.
153
+ return isLiteral(raw) ? raw : undefined;
154
+ }
155
+
156
+ /** A non-empty string that is a value rather than a terraform `${…}` reference to one. */
157
+ function isLiteral(raw: unknown): raw is string {
158
+ return typeof raw === "string" && raw.length > 0 && !raw.includes("${");
159
+ }
160
+
161
+ /**
162
+ * Translate one request's worth of chant entities and edges into the engine's
163
+ * shape. Pure, and the same input gives the same output forever.
164
+ *
165
+ * Every name in `entityNames` comes out in exactly one of `nodes` or
166
+ * `withheld`. The caller in `./predict-behaviour.ts` depends on that to satisfy
167
+ * `behaviourReport`'s totality check, and a name the caller asked about that is
168
+ * missing from `options.entities` is `withheld` with an `unknown-type` detail
169
+ * rather than dropped — a `continue` in this loop is the exact silent hole the
170
+ * contract's totality refusal exists to catch.
171
+ */
172
+ export function buildEngineRequest(
173
+ options: Pick<
174
+ PredictBehaviourOptions,
175
+ "entityNames" | "entities" | "edges" | "edgeCoverage" | "traffic" | "region"
176
+ >,
177
+ kinds: readonly BehaviourKinds[],
178
+ ): EngineRequest {
179
+ const nodes: EngineNode[] = [];
180
+ const withheld: WithheldEntity[] = [];
181
+
182
+ for (const name of [...options.entityNames].sort(byCodeUnit)) {
183
+ const declared = options.entities.get(name);
184
+ if (!declared) {
185
+ withheld.push({
186
+ name,
187
+ entityType: "(undeclared)",
188
+ status: "unknown-type",
189
+ detail:
190
+ `${name} was named in entityNames and is not in the entities map, so this lexicon has ` +
191
+ "nothing to translate. It is reported rather than dropped: an entity that vanished " +
192
+ "between the build and the request is a defect in the caller, and a silent omission " +
193
+ "hides it behind an estate that looks smaller.",
194
+ });
195
+ continue;
196
+ }
197
+ const verdict = coverageFor(kinds, declared.entityType, declared.props);
198
+ // Where a lexicon keys its rows on something other than the entity type,
199
+ // that key rides beside the entity type on the wire and in the withheld
200
+ // list: `Terraform::Resource` alone names nothing an engine or a reader
201
+ // can act on (#2360), and its provider type is what does. Asked of the
202
+ // owning contributor rather than special-cased here (#2382).
203
+ const owner = ownerOf(kinds, declared.entityType);
204
+ const resolved = owner?.resolveType?.(declared.entityType, declared.props);
205
+ const resourceType = resolved && resolved !== declared.entityType ? resolved : undefined;
206
+ if (verdict.status !== "mapped") {
207
+ withheld.push({
208
+ name,
209
+ entityType: declared.entityType,
210
+ ...(resourceType ? { resourceType } : {}),
211
+ status: verdict.status,
212
+ detail: unmappedDetail(
213
+ coverageLabel(kinds, declared.entityType, declared.props),
214
+ verdict,
215
+ owner,
216
+ ),
217
+ });
218
+ continue;
219
+ }
220
+ const { mapping } = verdict;
221
+ const declaredRegion = mapping.regionProp
222
+ ? readPath(declared.props, mapping.regionProp)
223
+ : undefined;
224
+ const region = isLiteral(declaredRegion) ? declaredRegion : options.region;
225
+ const size = sizeOf(declared.props, mapping.sizeProp, mapping.sizeType);
226
+ nodes.push({
227
+ name,
228
+ entityType: declared.entityType,
229
+ ...(resourceType ? { resourceType } : {}),
230
+ kind: mapping.kind,
231
+ provider: mapping.provider,
232
+ ...(region ? { region } : {}),
233
+ ...(size ? { size } : {}),
234
+ });
235
+ }
236
+
237
+ const edges: EngineEdge[] = options.edges
238
+ .map((edge: IREdge) => ({
239
+ from: edge.from,
240
+ to: edge.to,
241
+ ...(edge.viaAttr ? { via: edge.viaAttr } : {}),
242
+ ...(edge.toAttr ? { toAttr: edge.toAttr } : {}),
243
+ }))
244
+ // By code unit, like the node order above. `localeCompare` reads the
245
+ // ambient locale, so two machines with different `LANG` values produced
246
+ // two different byte streams from one estate — in a module whose whole
247
+ // claim is that its bytes are a function of its content and nothing else.
248
+ .sort(
249
+ (a, b) =>
250
+ byCodeUnit(a.from, b.from) ||
251
+ byCodeUnit(a.to, b.to) ||
252
+ byCodeUnit(a.via ?? "", b.via ?? "") ||
253
+ byCodeUnit(a.toAttr ?? "", b.toAttr ?? ""),
254
+ );
255
+
256
+ return {
257
+ request: BEHAVIOUR_REQUEST_VERSION,
258
+ traffic: options.traffic,
259
+ ...(options.region ? { region: options.region } : {}),
260
+ nodes,
261
+ edges,
262
+ coverage: options.edgeCoverage,
263
+ withheld,
264
+ };
265
+ }
266
+
267
+ /**
268
+ * Canonical JSON: object keys sorted, arrays in the order they were built,
269
+ * two-space indent, one trailing newline.
270
+ *
271
+ * Key order is where a "deterministic" serializer usually is not. `JSON.stringify`
272
+ * preserves insertion order, and insertion order here follows the order a
273
+ * property bag was assembled in, which follows discovery order, which follows
274
+ * the filesystem. Sorting the keys makes the bytes a function of the content
275
+ * and nothing else — which is what `chant build`'s own reproducibility claim
276
+ * means, and what makes a re-run comparable rather than merely re-run.
277
+ */
278
+ export function renderEngineRequest(request: EngineRequest): string {
279
+ return `${JSON.stringify(canonical(request), null, 2)}\n`;
280
+ }
281
+
282
+ function canonical(value: unknown): unknown {
283
+ if (Array.isArray(value)) return value.map(canonical);
284
+ if (typeof value === "object" && value !== null) {
285
+ const out: Record<string, unknown> = {};
286
+ for (const key of Object.keys(value as Record<string, unknown>).sort()) {
287
+ const inner = (value as Record<string, unknown>)[key];
288
+ if (inner === undefined) continue;
289
+ out[key] = canonical(inner);
290
+ }
291
+ return out;
292
+ }
293
+ return value;
294
+ }
package/src/behaviour.ts CHANGED
@@ -146,8 +146,8 @@
146
146
  * ## The transport is part of the contract (#2373, decided in #2359)
147
147
  *
148
148
  * The first version of this module named the address chain, said the address
149
- * may be a URL, a socket path or a command on `PATH`, and stopped. augur
150
- * (#2357) then declared a private `BehaviourEngine` and its own mapping from
149
+ * may be a URL, a socket path or a command on `PATH`, and stopped. The first
150
+ * consumer (#2357) then declared a private `BehaviourEngine` and its own mapping from
151
151
  * what the wire said to which of the three engine-answered refusals to build.
152
152
  * With a second implementation to generalise from, the seam is here:
153
153
  * {@link BehaviourTransport} is one method, `send(body)`, taking the request
@@ -173,7 +173,7 @@
173
173
  * is that switch, written once.
174
174
  *
175
175
  * What the wire says maps to the causes above as follows, and
176
- * `./behaviour-http.ts` (the first adapter) and augur's command transport both
176
+ * `./behaviour-http.ts` (the first adapter) and `./behaviour-engine.ts`'s command transport both
177
177
  * hold to it: an HTTP 402 is `engine-out-of-credit`; a 429 is
178
178
  * `engine-over-quota`; a connection refused, a timeout, a 5xx, a redirect, or
179
179
  * an answer that is not JSON is `engine-unreachable`; an unset address is
@@ -1256,7 +1256,7 @@ export function overQuotaBehaviourEngineRefusal(
1256
1256
  * refusal ready to be returned from `predictBehaviour` as it stands.
1257
1257
  *
1258
1258
  * The answer is text rather than a parsed object because what the text means
1259
- * is the lexicon's wire version (`augur/v1`), and the lexicon is the one that
1259
+ * is the wire version (`behaviour/v1`), and `./behaviour-engine.ts` is what
1260
1260
  * validates it — an answer that fails that validation is
1261
1261
  * {@link unreachableBehaviourEngineRefusal} with a detail naming the field,
1262
1262
  * built by the lexicon, since the transport has nothing to say about it.
@@ -1268,7 +1268,7 @@ export type BehaviourTransportOutcome =
1268
1268
  /**
1269
1269
  * The seam between a lexicon and whatever answers its request (#2373).
1270
1270
  *
1271
- * One method. `body` is the request as the lexicon rendered it — augur's
1271
+ * One method. `body` is the request as it was rendered —
1272
1272
  * canonical JSON, say — and the transport carries it to the address it was
1273
1273
  * built for and brings back what came out, or a refusal naming why nothing
1274
1274
  * did. A transport is built knowing the lexicon and the endpoint, which is
@@ -1276,7 +1276,7 @@ export type BehaviourTransportOutcome =
1276
1276
  * better than returning a cause.
1277
1277
  *
1278
1278
  * Two ship today: `./behaviour-http.ts` dials a URL with a bearer token, and
1279
- * augur's `commandTransport` spawns a command on `PATH` with
1279
+ * `./behaviour-engine.ts`'s `commandTransport` spawns a command on `PATH` with
1280
1280
  * {@link behaviourEngineChildEnvironment}. A lexicon picks one by the shape of
1281
1281
  * the address and does not otherwise know which it got.
1282
1282
  */
@@ -1323,7 +1323,7 @@ export function behaviourWireRefusal(
1323
1323
  * and the child holds `AWS_SECRET_ACCESS_KEY` anyway. So a transport that
1324
1324
  * spawns builds the environment from this and nothing more — `PATH` because
1325
1325
  * the address is resolved against it, and no allowlist beyond that, because
1326
- * every name added is a name a credential could be sitting under. augur's
1326
+ * every name added is a name a credential could be sitting under. The
1327
1327
  * command transport pins this by test (#2372); this is the rule it pins.
1328
1328
  */
1329
1329
  export function behaviourEngineChildEnvironment(
@@ -450,7 +450,7 @@ describe("runScenarioCheck", () => {
450
450
 
451
451
  test("a fixture whose recorded prediction is a refusal fails with the refusal's own cause", async () => {
452
452
  const fixturePath = await writeFixture(
453
- snap({ resources: { bucket: meta() }, behaviour: noBehaviourEngineRefusal("augur") }),
453
+ snap({ resources: { bucket: meta() }, behaviour: noBehaviourEngineRefusal("chant") }),
454
454
  );
455
455
  const scenario = Scenario("stays under a dollar an hour", {
456
456
  given: snapshot(fixturePath),
@@ -166,7 +166,7 @@ interface GivenResolution {
166
166
 
167
167
  /**
168
168
  * Read the `behaviour` block off the fixture's snapshots. One block, not one
169
- * per lexicon: a prediction is over the whole estate (augur reads every
169
+ * per lexicon: a prediction is over the whole estate (it reads every
170
170
  * lexicon's entities and holds none of its own), so it belongs to no single
171
171
  * lexicon's file, and two files carrying two different blocks is a fixture
172
172
  * that answers twice. Validated on arrival — a hand-edited fixture with a