@intentius/chant 0.33.0 → 0.34.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 (56) hide show
  1. package/dist/cli/commands/onboard.d.ts.map +1 -1
  2. package/dist/cli/handlers/graph.d.ts.map +1 -1
  3. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  4. package/dist/cli/handlers/search.d.ts +72 -0
  5. package/dist/cli/handlers/search.d.ts.map +1 -1
  6. package/dist/cli/main.d.ts.map +1 -1
  7. package/dist/cli/registry.d.ts +26 -0
  8. package/dist/cli/registry.d.ts.map +1 -1
  9. package/dist/graph-effective.d.ts.map +1 -1
  10. package/dist/graph-ir.d.ts +21 -0
  11. package/dist/graph-ir.d.ts.map +1 -1
  12. package/dist/graph-refs.d.ts +19 -0
  13. package/dist/graph-refs.d.ts.map +1 -1
  14. package/dist/lexicon.d.ts +141 -0
  15. package/dist/lexicon.d.ts.map +1 -1
  16. package/dist/lifecycle/deep-observe.d.ts +4 -0
  17. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  18. package/dist/lifecycle/live-diff.d.ts.map +1 -1
  19. package/dist/lifecycle/observe.d.ts +55 -1
  20. package/dist/lifecycle/observe.d.ts.map +1 -1
  21. package/dist/lifecycle/replay.d.ts +47 -0
  22. package/dist/lifecycle/replay.d.ts.map +1 -0
  23. package/dist/lifecycle/snapshot.d.ts +6 -0
  24. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  25. package/dist/lifecycle/types.d.ts +46 -0
  26. package/dist/lifecycle/types.d.ts.map +1 -1
  27. package/dist/observation.d.ts +71 -0
  28. package/dist/observation.d.ts.map +1 -1
  29. package/package.json +1 -1
  30. package/src/cli/commands/onboard.ts +10 -25
  31. package/src/cli/handlers/graph.test.ts +74 -0
  32. package/src/cli/handlers/graph.ts +77 -36
  33. package/src/cli/handlers/lifecycle.test.ts +86 -0
  34. package/src/cli/handlers/lifecycle.ts +43 -10
  35. package/src/cli/handlers/search.test.ts +246 -4
  36. package/src/cli/handlers/search.ts +432 -27
  37. package/src/cli/main.ts +9 -0
  38. package/src/cli/registry.ts +27 -0
  39. package/src/codegen/lexicon-wiring.test.ts +53 -0
  40. package/src/codegen/release-wiring.test.ts +174 -0
  41. package/src/graph-effective.ts +7 -1
  42. package/src/graph-ir-live.test.ts +83 -0
  43. package/src/graph-ir.ts +58 -1
  44. package/src/graph-refs.test.ts +59 -0
  45. package/src/graph-refs.ts +39 -8
  46. package/src/lexicon.ts +145 -0
  47. package/src/lifecycle/deep-observe.ts +5 -0
  48. package/src/lifecycle/live-diff.test.ts +38 -0
  49. package/src/lifecycle/live-diff.ts +45 -2
  50. package/src/lifecycle/observe.ts +186 -4
  51. package/src/lifecycle/replay.ts +141 -0
  52. package/src/lifecycle/snapshot.test.ts +179 -0
  53. package/src/lifecycle/snapshot.ts +88 -3
  54. package/src/lifecycle/types.ts +47 -0
  55. package/src/observation.test.ts +135 -0
  56. package/src/observation.ts +151 -0
@@ -5,14 +5,19 @@
5
5
  import { describe, test, expect } from "vitest";
6
6
  import {
7
7
  UNOBSERVED_REASONS,
8
+ boundedConcurrently,
8
9
  formatUnobserved,
9
10
  isObservationResult,
10
11
  isUnobservedReason,
11
12
  mergeObservations,
12
13
  normalizeObservation,
13
14
  observation,
15
+ observeEntities,
14
16
  unobservedAll,
15
17
  unobservedReasonText,
18
+ type DeclaredEntity,
19
+ type EntityObservation,
20
+ type ObserverAdapter,
16
21
  } from "./observation";
17
22
  import type { ResourceMetadata } from "./lexicon";
18
23
 
@@ -94,3 +99,133 @@ describe("reason totality", () => {
94
99
  ).toBe("widget (K8s::X::Widget) — no reader for this resource kind: no mapping");
95
100
  });
96
101
  });
102
+
103
+ describe("boundedConcurrently", () => {
104
+ test("processes every item", async () => {
105
+ const seen: number[] = [];
106
+ await boundedConcurrently([1, 2, 3, 4, 5], async (n) => {
107
+ seen.push(n);
108
+ }, 2);
109
+ expect([...seen].sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5]);
110
+ });
111
+
112
+ test("never exceeds the limit in flight", async () => {
113
+ let inFlight = 0;
114
+ let peak = 0;
115
+ await boundedConcurrently(Array.from({ length: 20 }, (_, i) => i), async () => {
116
+ inFlight += 1;
117
+ peak = Math.max(peak, inFlight);
118
+ await new Promise((r) => setTimeout(r, 1));
119
+ inFlight -= 1;
120
+ }, 4);
121
+ expect(peak).toBeLessThanOrEqual(4);
122
+ expect(peak).toBeGreaterThan(1); // it did run concurrently, not serially
123
+ });
124
+
125
+ test("an empty list is a no-op", async () => {
126
+ await expect(boundedConcurrently([], async () => {})).resolves.toBeUndefined();
127
+ });
128
+ });
129
+
130
+ describe("observeEntities harness (#1201)", () => {
131
+ const entity = (name: string, type = "Fake::Resource"): DeclaredEntity => ({ name, type, props: {} });
132
+
133
+ /** A fake adapter whose `read` is table-driven by entity name. */
134
+ const adapterOf = (
135
+ reads: Record<string, EntityObservation | (() => Promise<EntityObservation>)>,
136
+ over: Partial<ObserverAdapter<{ ok: true }>> = {},
137
+ ): ObserverAdapter<{ ok: true }> => ({
138
+ bind: async () => ({ ok: true }),
139
+ classifyBindFailure: () => ({ reason: "read-failed" }),
140
+ read: async (_client, e) => {
141
+ const r = reads[e.name];
142
+ if (typeof r === "function") return r();
143
+ if (!r) throw new Error(`no fake read for ${e.name}`);
144
+ return r;
145
+ },
146
+ ...over,
147
+ });
148
+
149
+ test("routes the tri-state: present -> resources, absent -> neither, unobserved -> unobserved", async () => {
150
+ const result = await observeEntities(
151
+ [entity("a"), entity("b"), entity("c", "Fake::Odd")],
152
+ adapterOf({
153
+ a: { present: meta({ physicalId: "id-a" }) },
154
+ b: { absent: true },
155
+ c: { unobserved: { reason: "unsupported-kind", detail: "no reader" } },
156
+ }),
157
+ );
158
+ expect(Object.keys(result.resources)).toEqual(["a"]);
159
+ expect(result.resources.a.physicalId).toBe("id-a");
160
+ // absent 'b' is in neither map
161
+ expect(result.unobserved).toEqual({
162
+ c: { type: "Fake::Odd", reason: "unsupported-kind", detail: "no reader" },
163
+ });
164
+ });
165
+
166
+ test("a bind failure marks every entity NOT-OBSERVED with the typed reason and declared type", async () => {
167
+ const result = await observeEntities(
168
+ [entity("a", "AWS::S3::Bucket"), entity("b", "AWS::S3::Bucket")],
169
+ adapterOf(
170
+ {},
171
+ {
172
+ bind: async () => {
173
+ throw new Error("no creds");
174
+ },
175
+ classifyBindFailure: () => ({ reason: "no-credentials", detail: "token expired" }),
176
+ },
177
+ ),
178
+ );
179
+ expect(result.resources).toEqual({});
180
+ expect(result.unobserved).toEqual({
181
+ a: { reason: "no-credentials", type: "AWS::S3::Bucket", detail: "token expired" },
182
+ b: { reason: "no-credentials", type: "AWS::S3::Bucket", detail: "token expired" },
183
+ });
184
+ });
185
+
186
+ test("a loud refusal rethrows instead of degrading to a hole", async () => {
187
+ await expect(
188
+ observeEntities(
189
+ [entity("a")],
190
+ adapterOf(
191
+ {},
192
+ {
193
+ bind: async () => {
194
+ throw new Error("context mismatch");
195
+ },
196
+ classifyBindFailure: () => "rethrow",
197
+ },
198
+ ),
199
+ ),
200
+ ).rejects.toThrow("context mismatch");
201
+ });
202
+
203
+ test("a per-entity read throw degrades to read-failed for that one entity, not an absence", async () => {
204
+ const result = await observeEntities(
205
+ [entity("a"), entity("b")],
206
+ adapterOf({
207
+ a: () => Promise.reject(new Error("boom")),
208
+ b: { present: meta() },
209
+ }),
210
+ );
211
+ expect(Object.keys(result.resources)).toEqual(["b"]);
212
+ expect(result.unobserved?.a).toEqual({ type: "Fake::Resource", reason: "read-failed", detail: "boom" });
213
+ });
214
+
215
+ test("uses the adapter's own concurrency pool when it supplies one", async () => {
216
+ let usedPool = false;
217
+ await observeEntities(
218
+ [entity("a")],
219
+ adapterOf(
220
+ { a: { present: meta() } },
221
+ {
222
+ concurrently: async (items, fn) => {
223
+ usedPool = true;
224
+ for (const it of items) await fn(it);
225
+ },
226
+ },
227
+ ),
228
+ );
229
+ expect(usedPool).toBe(true);
230
+ });
231
+ });
@@ -211,3 +211,154 @@ export function formatUnobserved(name: string, entry: UnobservedEntity): string
211
211
  const base = `${name}${entry.type ? ` (${entry.type})` : ""} — ${unobservedReasonText(entry.reason)}`;
212
212
  return entry.detail ? `${base}: ${entry.detail}` : base;
213
213
  }
214
+
215
+ /* ------------------------------------------------------------------------- *
216
+ * The observer harness (#1201).
217
+ *
218
+ * Every native observer runs the same control flow: bind to the provider on
219
+ * the applier's own transport, read the declared entities concurrently, and
220
+ * turn each read into one of the tri-state outcomes above. The k8s observer
221
+ * (#1074) and Fly (#767) already embody it. Rather than have aws/gcp/azure each
222
+ * re-derive it — and re-derive it inconsistently, which is how the shell-out
223
+ * observers drifted apart — a lexicon supplies an {@link ObserverAdapter} and
224
+ * the harness owns the shape: bind-or-not-observe-all with a typed reason,
225
+ * bounded concurrency, per-entity tri-state routing, and a per-entity throw
226
+ * degrading to `read-failed` rather than a silent absence.
227
+ *
228
+ * The adapter owns transport and endpoint resolution (an emulator override is
229
+ * resolved inside `bind()` via the shared live-endpoint helper), so the
230
+ * emulator override behaves identically across lexicons by construction — the
231
+ * harness never touches an endpoint itself.
232
+ * ------------------------------------------------------------------------- */
233
+
234
+ /** One declared entity handed to the harness. */
235
+ export interface DeclaredEntity {
236
+ /** chant entity name — the key every outcome is filed under. */
237
+ name: string;
238
+ /** Declared entity type (e.g. `AWS::EC2::VPC`). */
239
+ type: string;
240
+ /** Declared properties, for the adapter to derive a physical address from. */
241
+ props: Record<string, unknown>;
242
+ }
243
+
244
+ /**
245
+ * The outcome of reading one entity, mapped onto the tri-state:
246
+ * - `present` — a key in `resources`.
247
+ * - `absent` — in neither map (the provider was asked and reported it missing).
248
+ * - `unobserved` — a typed NOT-OBSERVED (unsupported kind, filtered, read error).
249
+ */
250
+ export type EntityObservation =
251
+ | { present: ResourceMetadata }
252
+ | { absent: true }
253
+ | { unobserved: { reason: UnobservedReason; detail?: string } };
254
+
255
+ /** What a lexicon supplies to drive the harness. `Client` is its transport handle. */
256
+ export interface ObserverAdapter<Client> {
257
+ /**
258
+ * Reach the provider on the applier's transport. Throw for a whole-lexicon
259
+ * failure; {@link classifyBindFailure} decides what the throw means.
260
+ */
261
+ bind(): Promise<Client>;
262
+ /**
263
+ * Map a `bind()` throw to a typed whole-lexicon reason (every entity becomes
264
+ * NOT-OBSERVED with it), or `"rethrow"` for a loud refusal that must not be
265
+ * swallowed — a context/subscription mismatch, which core turns into an
266
+ * honest hole per entity at a higher layer.
267
+ */
268
+ classifyBindFailure(err: unknown): { reason: UnobservedReason; detail?: string } | "rethrow";
269
+ /** Read one declared entity. A throw here is caught and recorded `read-failed`. */
270
+ read(client: Client, entity: DeclaredEntity): Promise<EntityObservation>;
271
+ /**
272
+ * Run `fn` over `items` concurrently. Supply the transport's own bounded pool
273
+ * (the k8s client's `concurrently`, say); when omitted the harness uses
274
+ * {@link boundedConcurrently}, so "N entities is not N serial spawns" holds
275
+ * for every lexicon whether or not its transport ships a pool.
276
+ */
277
+ concurrently?<T>(items: readonly T[], fn: (item: T) => Promise<void>): Promise<void>;
278
+ }
279
+
280
+ /** Default concurrency for {@link boundedConcurrently} when a transport ships no pool. */
281
+ export const DEFAULT_OBSERVE_CONCURRENCY = 16;
282
+
283
+ /**
284
+ * Run `fn` over `items` with at most `limit` in flight. A rejected `fn` rejects
285
+ * the whole run (the harness wraps per-entity reads so this stays for genuinely
286
+ * unexpected faults).
287
+ */
288
+ export async function boundedConcurrently<T>(
289
+ items: readonly T[],
290
+ fn: (item: T) => Promise<void>,
291
+ limit: number = DEFAULT_OBSERVE_CONCURRENCY,
292
+ ): Promise<void> {
293
+ const queue = [...items];
294
+ const size = Math.max(1, Math.min(limit, queue.length || 1));
295
+ const workers = Array.from({ length: size }, async () => {
296
+ for (;;) {
297
+ const next = queue.shift();
298
+ if (next === undefined) return;
299
+ await fn(next);
300
+ }
301
+ });
302
+ await Promise.all(workers);
303
+ }
304
+
305
+ /**
306
+ * The shared observer control flow (#1201). Binds via the adapter, reads every
307
+ * declared entity concurrently, and assembles the tri-state {@link ObservationResult}.
308
+ *
309
+ * A `bind()` throw becomes NOT-OBSERVED for every entity (typed by
310
+ * {@link ObserverAdapter.classifyBindFailure}) unless the adapter asks to
311
+ * rethrow. A per-entity `read()` throw the adapter did not itself map becomes
312
+ * `read-failed` for that one entity — never a silent absence, which would
313
+ * classify as a spurious `create`.
314
+ */
315
+ export async function observeEntities<Client>(
316
+ declared: readonly DeclaredEntity[],
317
+ adapter: ObserverAdapter<Client>,
318
+ ): Promise<ObservationResult> {
319
+ const typesByName: Record<string, string> = {};
320
+ for (const d of declared) typesByName[d.name] = d.type;
321
+
322
+ let client: Client;
323
+ try {
324
+ client = await adapter.bind();
325
+ } catch (err) {
326
+ const verdict = adapter.classifyBindFailure(err);
327
+ if (verdict === "rethrow") throw err;
328
+ return observation(
329
+ {},
330
+ unobservedAll(
331
+ declared.map((d) => d.name),
332
+ verdict.reason,
333
+ verdict.detail,
334
+ typesByName,
335
+ ),
336
+ );
337
+ }
338
+
339
+ const resources: Record<string, ResourceMetadata> = {};
340
+ const unobserved: Record<string, UnobservedEntity> = {};
341
+ const run = adapter.concurrently ?? ((items, fn) => boundedConcurrently(items, fn));
342
+
343
+ await run(declared, async (entity) => {
344
+ let result: EntityObservation;
345
+ try {
346
+ result = await adapter.read(client, entity);
347
+ } catch (err) {
348
+ result = {
349
+ unobserved: {
350
+ reason: "read-failed",
351
+ detail: err instanceof Error ? err.message : String(err),
352
+ },
353
+ };
354
+ }
355
+ if ("present" in result) {
356
+ resources[entity.name] = result.present;
357
+ } else if ("unobserved" in result) {
358
+ unobserved[entity.name] = { type: entity.type, ...result.unobserved };
359
+ }
360
+ // `absent`: record nothing — in neither map is how the contract spells absence.
361
+ });
362
+
363
+ return observation(resources, unobserved);
364
+ }