@intentius/chant 0.33.0 → 0.33.1
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/cli/handlers/search.d.ts +24 -0
- package/dist/cli/handlers/search.d.ts.map +1 -1
- package/dist/graph-effective.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +7 -0
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/observation.d.ts +71 -0
- package/dist/observation.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/search.test.ts +47 -1
- package/src/cli/handlers/search.ts +55 -4
- package/src/codegen/release-wiring.test.ts +82 -0
- package/src/graph-effective.ts +7 -1
- package/src/graph-ir.ts +7 -0
- package/src/observation.test.ts +135 -0
- package/src/observation.ts +151 -0
|
@@ -24,6 +24,19 @@ import type { CommandContext } from "../registry.js";
|
|
|
24
24
|
* thousands.
|
|
25
25
|
*/
|
|
26
26
|
export declare function runSearch(ctx: CommandContext): Promise<number>;
|
|
27
|
+
/**
|
|
28
|
+
* Name the facts chant computed for the kinds in this result that the query did not use.
|
|
29
|
+
*
|
|
30
|
+
* A provider API can only return what it stores; chant additionally folds multi-hop topology
|
|
31
|
+
* onto a node, and a caller has no way to know that surface exists. Reporting it turns a
|
|
32
|
+
* one-shot query into a conversation with the graph — ask something, learn what else is
|
|
33
|
+
* knowable about the same resources, refine.
|
|
34
|
+
*
|
|
35
|
+
* The names come from {@link GraphIR.derivedAttrs}, recorded by whichever enrichment pass
|
|
36
|
+
* produced them. Nothing here knows what any attribute means or which question it answers;
|
|
37
|
+
* add a pass and its facts appear, remove one and they stop.
|
|
38
|
+
*/
|
|
39
|
+
declare function derivedSurface(terms: Term[], matches: IRNode[], ir: GraphIR): void;
|
|
27
40
|
/**
|
|
28
41
|
* `--explain` footer (#1139): a compact, model-DERIVED summary that gives a
|
|
29
42
|
* small model a reason to trust the result instead of re-deriving it with a
|
|
@@ -34,6 +47,15 @@ export declare function runSearch(ctx: CommandContext): Promise<number>;
|
|
|
34
47
|
* any expected answer, so it stays a fair, question-agnostic capability.
|
|
35
48
|
*/
|
|
36
49
|
declare function explain(terms: Term[], matches: IRNode[], ir: GraphIR, byId: Map<string, IRNode>, query: string): void;
|
|
50
|
+
/**
|
|
51
|
+
* On a miss, name the attributes the queried kind actually carries. A graph knows
|
|
52
|
+
* its own schema, so a caller who guessed an attribute name — or did not know a
|
|
53
|
+
* derived one existed — can see what is queryable instead of falling back to a
|
|
54
|
+
* lossy CLI sweep. Read off the nodes present, so it stays a property of the
|
|
55
|
+
* graph rather than of any expected answer: whatever the estate holds is what
|
|
56
|
+
* this lists, and it says nothing about which attribute answers a question.
|
|
57
|
+
*/
|
|
58
|
+
declare function availableAttrs(terms: Term[], ir: GraphIR): void;
|
|
37
59
|
declare function describeTerm(t: Term): string;
|
|
38
60
|
interface Term {
|
|
39
61
|
kind: "word" | "kind" | "tag" | "attr" | "edge";
|
|
@@ -53,6 +75,8 @@ export declare const __searchInternals: {
|
|
|
53
75
|
formatRow: typeof formatRow;
|
|
54
76
|
explain: typeof explain;
|
|
55
77
|
describeTerm: typeof describeTerm;
|
|
78
|
+
derivedSurface: typeof derivedSurface;
|
|
79
|
+
availableAttrs: typeof availableAttrs;
|
|
56
80
|
};
|
|
57
81
|
export {};
|
|
58
82
|
//# sourceMappingURL=search.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../src/cli/handlers/search.ts"],"names":[],"mappings":"AAEA,OAAO,EAAuD,KAAK,OAAO,EAAE,KAAK,MAAM,EAAE,MAAM,gBAAgB,CAAC;AAShH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAElD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAsB,SAAS,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,
|
|
1
|
+
{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../src/cli/handlers/search.ts"],"names":[],"mappings":"AAEA,OAAO,EAAuD,KAAK,OAAO,EAAE,KAAK,MAAM,EAAE,MAAM,gBAAgB,CAAC;AAShH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAElD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAsB,SAAS,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CA0FpE;AAED;;;;;;;;;;;GAWG;AACH,iBAAS,cAAc,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,OAAO,GAAG,IAAI,CAa3E;AAED;;;;;;;;GAQG;AACH,iBAAS,OAAO,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAyB9G;AAED;;;;;;;GAOG;AACH,iBAAS,cAAc,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE,EAAE,EAAE,OAAO,GAAG,IAAI,CAWxD;AAED,iBAAS,YAAY,CAAC,CAAC,EAAE,IAAI,GAAG,MAAM,CAMrC;AAED,UAAU,IAAI;IACZ,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,KAAK,GAAG,MAAM,GAAG,MAAM,CAAC;IAChD,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,CAAC,EAAE,MAAM,CAAC;IACX,kFAAkF;IAClF,GAAG,CAAC,EAAE,KAAK,GAAG,IAAI,CAAC;IACnB,GAAG,CAAC,EAAE,IAAI,CAAC;CACZ;AAcD,iBAAS,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,EAAE,CASzC;AAeD,iBAAS,SAAS,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,IAAI,EAAE,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,OAAO,CA6BxF;AAED,iBAAS,SAAS,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,MAAM,CAcpD;AAED,wCAAwC;AACxC,eAAO,MAAM,iBAAiB;;;;;;;;CAA8F,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"graph-effective.d.ts","sourceRoot":"","sources":["../src/graph-effective.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAU,MAAM,YAAY,CAAC;AAElD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,uBAAuB,CAAC,EAAE,EAAE,OAAO,GAAG,OAAO,
|
|
1
|
+
{"version":3,"file":"graph-effective.d.ts","sourceRoot":"","sources":["../src/graph-effective.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAU,MAAM,YAAY,CAAC;AAElD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,uBAAuB,CAAC,EAAE,EAAE,OAAO,GAAG,OAAO,CA2F5D"}
|
package/dist/graph-ir.d.ts
CHANGED
|
@@ -173,6 +173,13 @@ export interface GraphIR {
|
|
|
173
173
|
imports?: IRImport[];
|
|
174
174
|
/** The CI/pipeline projection alongside the component graph (#989) — see {@link IRPipeline}. */
|
|
175
175
|
pipeline?: IRPipeline;
|
|
176
|
+
/**
|
|
177
|
+
* Attributes chant computed rather than read back from the provider, keyed by the kind
|
|
178
|
+
* they were folded onto. An enrichment pass records what it derived here so callers can
|
|
179
|
+
* report the graph's own surface without knowing any attribute name — the set is whatever
|
|
180
|
+
* the passes produced, not a list maintained by hand.
|
|
181
|
+
*/
|
|
182
|
+
derivedAttrs?: Record<string, string[]>;
|
|
176
183
|
}
|
|
177
184
|
/**
|
|
178
185
|
* Build the graph IR from resolved entities (the output of `discover`). Pure and
|
package/dist/graph-ir.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"graph-ir.d.ts","sourceRoot":"","sources":["../src/graph-ir.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"graph-ir.d.ts","sourceRoot":"","sources":["../src/graph-ir.ts"],"names":[],"mappings":"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"}
|
package/dist/observation.d.ts
CHANGED
|
@@ -120,4 +120,75 @@ export declare function mergeObservations(parts: Iterable<NormalizedObservation>
|
|
|
120
120
|
export declare function unobservedReasonText(reason: UnobservedReason): string;
|
|
121
121
|
/** `name — reason (detail)`, the shared rendering for CLI and plan output. */
|
|
122
122
|
export declare function formatUnobserved(name: string, entry: UnobservedEntity): string;
|
|
123
|
+
/** One declared entity handed to the harness. */
|
|
124
|
+
export interface DeclaredEntity {
|
|
125
|
+
/** chant entity name — the key every outcome is filed under. */
|
|
126
|
+
name: string;
|
|
127
|
+
/** Declared entity type (e.g. `AWS::EC2::VPC`). */
|
|
128
|
+
type: string;
|
|
129
|
+
/** Declared properties, for the adapter to derive a physical address from. */
|
|
130
|
+
props: Record<string, unknown>;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The outcome of reading one entity, mapped onto the tri-state:
|
|
134
|
+
* - `present` — a key in `resources`.
|
|
135
|
+
* - `absent` — in neither map (the provider was asked and reported it missing).
|
|
136
|
+
* - `unobserved` — a typed NOT-OBSERVED (unsupported kind, filtered, read error).
|
|
137
|
+
*/
|
|
138
|
+
export type EntityObservation = {
|
|
139
|
+
present: ResourceMetadata;
|
|
140
|
+
} | {
|
|
141
|
+
absent: true;
|
|
142
|
+
} | {
|
|
143
|
+
unobserved: {
|
|
144
|
+
reason: UnobservedReason;
|
|
145
|
+
detail?: string;
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
/** What a lexicon supplies to drive the harness. `Client` is its transport handle. */
|
|
149
|
+
export interface ObserverAdapter<Client> {
|
|
150
|
+
/**
|
|
151
|
+
* Reach the provider on the applier's transport. Throw for a whole-lexicon
|
|
152
|
+
* failure; {@link classifyBindFailure} decides what the throw means.
|
|
153
|
+
*/
|
|
154
|
+
bind(): Promise<Client>;
|
|
155
|
+
/**
|
|
156
|
+
* Map a `bind()` throw to a typed whole-lexicon reason (every entity becomes
|
|
157
|
+
* NOT-OBSERVED with it), or `"rethrow"` for a loud refusal that must not be
|
|
158
|
+
* swallowed — a context/subscription mismatch, which core turns into an
|
|
159
|
+
* honest hole per entity at a higher layer.
|
|
160
|
+
*/
|
|
161
|
+
classifyBindFailure(err: unknown): {
|
|
162
|
+
reason: UnobservedReason;
|
|
163
|
+
detail?: string;
|
|
164
|
+
} | "rethrow";
|
|
165
|
+
/** Read one declared entity. A throw here is caught and recorded `read-failed`. */
|
|
166
|
+
read(client: Client, entity: DeclaredEntity): Promise<EntityObservation>;
|
|
167
|
+
/**
|
|
168
|
+
* Run `fn` over `items` concurrently. Supply the transport's own bounded pool
|
|
169
|
+
* (the k8s client's `concurrently`, say); when omitted the harness uses
|
|
170
|
+
* {@link boundedConcurrently}, so "N entities is not N serial spawns" holds
|
|
171
|
+
* for every lexicon whether or not its transport ships a pool.
|
|
172
|
+
*/
|
|
173
|
+
concurrently?<T>(items: readonly T[], fn: (item: T) => Promise<void>): Promise<void>;
|
|
174
|
+
}
|
|
175
|
+
/** Default concurrency for {@link boundedConcurrently} when a transport ships no pool. */
|
|
176
|
+
export declare const DEFAULT_OBSERVE_CONCURRENCY = 16;
|
|
177
|
+
/**
|
|
178
|
+
* Run `fn` over `items` with at most `limit` in flight. A rejected `fn` rejects
|
|
179
|
+
* the whole run (the harness wraps per-entity reads so this stays for genuinely
|
|
180
|
+
* unexpected faults).
|
|
181
|
+
*/
|
|
182
|
+
export declare function boundedConcurrently<T>(items: readonly T[], fn: (item: T) => Promise<void>, limit?: number): Promise<void>;
|
|
183
|
+
/**
|
|
184
|
+
* The shared observer control flow (#1201). Binds via the adapter, reads every
|
|
185
|
+
* declared entity concurrently, and assembles the tri-state {@link ObservationResult}.
|
|
186
|
+
*
|
|
187
|
+
* A `bind()` throw becomes NOT-OBSERVED for every entity (typed by
|
|
188
|
+
* {@link ObserverAdapter.classifyBindFailure}) unless the adapter asks to
|
|
189
|
+
* rethrow. A per-entity `read()` throw the adapter did not itself map becomes
|
|
190
|
+
* `read-failed` for that one entity — never a silent absence, which would
|
|
191
|
+
* classify as a spurious `create`.
|
|
192
|
+
*/
|
|
193
|
+
export declare function observeEntities<Client>(declared: readonly DeclaredEntity[], adapter: ObserverAdapter<Client>): Promise<ObservationResult>;
|
|
123
194
|
//# sourceMappingURL=observation.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"observation.d.ts","sourceRoot":"","sources":["../src/observation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAElD;;;;;;;;;;;;;;;GAeG;AACH,MAAM,MAAM,gBAAgB,GACxB,aAAa,GACb,gBAAgB,GAChB,YAAY,GACZ,kBAAkB,GAClB,UAAU,CAAC;AAEf,mFAAmF;AACnF,eAAO,MAAM,kBAAkB,EAAE,SAAS,gBAAgB,EAMzD,CAAC;AAEF,6DAA6D;AAC7D,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,gBAAgB,CAE5E;AAED,kEAAkE;AAClE,MAAM,WAAW,gBAAgB;IAC/B,kGAAkG;IAClG,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,qBAAqB;IACrB,MAAM,EAAE,gBAAgB,CAAC;IACzB,qGAAqG;IACrG,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;GAGG;AACH,MAAM,WAAW,iBAAiB;IAChC,2GAA2G;IAC3G,QAAQ,CAAC,WAAW,EAAE,IAAI,CAAC;IAC3B,oDAAoD;IACpD,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;IAC5C,+GAA+G;IAC/G,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;CAC/C;AAED;;;GAGG;AACH,MAAM,MAAM,uBAAuB,GAAG,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,GAAG,iBAAiB,CAAC;AAE3F,2EAA2E;AAC3E,MAAM,WAAW,qBAAqB;IACpC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;IAC5C,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;CAC9C;AAED,6EAA6E;AAC7E,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,iBAAiB,CAM9E;AAED;;;GAGG;AACH,wBAAgB,WAAW,CACzB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,EAC3C,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,GAC5C,iBAAiB,CAMnB;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,uBAAuB,GAAG,SAAS,GAAG,qBAAqB,CAMtG;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAC3B,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,EACvB,MAAM,EAAE,gBAAgB,EACxB,MAAM,CAAC,EAAE,MAAM,EACf,KAAK,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE;IAAE,UAAU,EAAE,MAAM,CAAA;CAAE,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GACnE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAYlC;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,QAAQ,CAAC,qBAAqB,CAAC,GAAG,qBAAqB,CAW/F;AAED,2DAA2D;AAC3D,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,gBAAgB,GAAG,MAAM,CAarE;AAED,8EAA8E;AAC9E,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,gBAAgB,GAAG,MAAM,CAG9E"}
|
|
1
|
+
{"version":3,"file":"observation.d.ts","sourceRoot":"","sources":["../src/observation.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, test, expect, vi } from "vitest";
|
|
2
2
|
import { __searchInternals } from "./search";
|
|
3
3
|
|
|
4
|
-
const { parseQuery, matchTerm, formatRow, explain, describeTerm } = __searchInternals;
|
|
4
|
+
const { parseQuery, matchTerm, formatRow, explain, describeTerm, derivedSurface, availableAttrs } = __searchInternals;
|
|
5
5
|
|
|
6
6
|
function node(id: string, kind: string, attrs: Record<string, unknown> = {}) {
|
|
7
7
|
return { id, kind, lexicon: "aws", attrs } as never;
|
|
@@ -111,3 +111,49 @@ describe("search edge traversal", () => {
|
|
|
111
111
|
.toBe("→attr:MapPublicIpOnLaunch=true (no such edge)");
|
|
112
112
|
});
|
|
113
113
|
});
|
|
114
|
+
|
|
115
|
+
describe("search surfaces what the graph derived", () => {
|
|
116
|
+
const insts = [
|
|
117
|
+
node("webServer", "AWS::EC2::Instance", { internetFacing: true, internetFacingVia: "rtb-1 → igw-1", effectiveIngress: ["tcp:22:0.0.0.0/0"] }),
|
|
118
|
+
node("privServer", "AWS::EC2::Instance", { internetFacing: false, effectiveIngress: [] }),
|
|
119
|
+
];
|
|
120
|
+
const derivedIr = { nodes: insts, edges: [], groups: {}, derivedAttrs: { Instance: ["internetFacing", "effectiveIngress"] } } as never;
|
|
121
|
+
|
|
122
|
+
function capture(fn: () => void): string {
|
|
123
|
+
const lines: string[] = [];
|
|
124
|
+
const spy = vi.spyOn(console, "log").mockImplementation((s: string) => { lines.push(s); });
|
|
125
|
+
fn();
|
|
126
|
+
spy.mockRestore();
|
|
127
|
+
return lines.join("\n");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
test("names derived facts the query did not use, and omits the ones it did", () => {
|
|
131
|
+
const out = capture(() => derivedSurface(parseQuery("kind:EC2::Instance attr:internetFacing=true") as never, insts as never, derivedIr));
|
|
132
|
+
expect(out).toContain("effectiveIngress");
|
|
133
|
+
expect(out).not.toContain("internetFacing");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("says nothing when the query already used every derived fact", () => {
|
|
137
|
+
const q = "kind:EC2::Instance attr:internetFacing=true attr:effectiveIngress=tcp:22:0.0.0.0/0";
|
|
138
|
+
expect(capture(() => derivedSurface(parseQuery(q) as never, insts as never, derivedIr))).toBe("");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("says nothing for a graph with no derived facts recorded", () => {
|
|
142
|
+
const plain = { nodes: insts, edges: [], groups: {} } as never;
|
|
143
|
+
expect(capture(() => derivedSurface(parseQuery("kind:EC2::Instance") as never, insts as never, plain))).toBe("");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("a miss lists the attributes the queried kind actually carries", () => {
|
|
147
|
+
const out = capture(() => availableAttrs(parseQuery("kind:EC2::Instance attr:nosuchattr=1") as never, derivedIr));
|
|
148
|
+
expect(out).toContain("effectiveIngress");
|
|
149
|
+
expect(out).toContain("internetFacing");
|
|
150
|
+
expect(out).not.toContain("nosuchattr");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("inclusion evidence is keyed off <attr>Via provenance, not a fixed attribute name", () => {
|
|
154
|
+
const byId = new Map(insts.map((n: { id: string }) => [n.id, n]));
|
|
155
|
+
const q = "attr:internetFacing=true";
|
|
156
|
+
const out = capture(() => explain(parseQuery(q) as never, [insts[0]] as never, derivedIr, byId as never, q));
|
|
157
|
+
expect(out).toContain("webServer internetFacing via rtb-1 → igw-1");
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -114,16 +114,45 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
|
|
|
114
114
|
const matches = ir.nodes.filter((n) => terms.every((t) => matchTerm(n, t, ir, nodeById)));
|
|
115
115
|
if (matches.length === 0) {
|
|
116
116
|
console.log("(no matches)");
|
|
117
|
+
availableAttrs(terms, ir);
|
|
117
118
|
if (args.explain) explain(terms, matches, ir, nodeById, query);
|
|
118
119
|
return 0;
|
|
119
120
|
}
|
|
120
121
|
for (const n of matches) {
|
|
121
122
|
console.log(formatRow(n, show));
|
|
122
123
|
}
|
|
124
|
+
derivedSurface(terms, matches, ir);
|
|
123
125
|
if (args.explain) explain(terms, matches, ir, nodeById, query);
|
|
124
126
|
return 0;
|
|
125
127
|
}
|
|
126
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Name the facts chant computed for the kinds in this result that the query did not use.
|
|
131
|
+
*
|
|
132
|
+
* A provider API can only return what it stores; chant additionally folds multi-hop topology
|
|
133
|
+
* onto a node, and a caller has no way to know that surface exists. Reporting it turns a
|
|
134
|
+
* one-shot query into a conversation with the graph — ask something, learn what else is
|
|
135
|
+
* knowable about the same resources, refine.
|
|
136
|
+
*
|
|
137
|
+
* The names come from {@link GraphIR.derivedAttrs}, recorded by whichever enrichment pass
|
|
138
|
+
* produced them. Nothing here knows what any attribute means or which question it answers;
|
|
139
|
+
* add a pass and its facts appear, remove one and they stop.
|
|
140
|
+
*/
|
|
141
|
+
function derivedSurface(terms: Term[], matches: IRNode[], ir: GraphIR): void {
|
|
142
|
+
const derived = ir.derivedAttrs;
|
|
143
|
+
if (!derived || matches.length === 0) return;
|
|
144
|
+
const used = new Set(terms.filter((t) => t.kind === "attr").map((t) => t.a));
|
|
145
|
+
const unused = new Set<string>();
|
|
146
|
+
for (const n of matches) {
|
|
147
|
+
for (const [kind, names] of Object.entries(derived)) {
|
|
148
|
+
if (!n.kind?.includes(kind)) continue;
|
|
149
|
+
for (const name of names) if (!used.has(name)) unused.add(name);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (unused.size === 0) return;
|
|
153
|
+
console.log(`— also derived for these resources: ${[...unused].sort().join(", ")}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
127
156
|
/**
|
|
128
157
|
* `--explain` footer (#1139): a compact, model-DERIVED summary that gives a
|
|
129
158
|
* small model a reason to trust the result instead of re-deriving it with a
|
|
@@ -143,11 +172,12 @@ function explain(terms: Term[], matches: IRNode[], ir: GraphIR, byId: Map<string
|
|
|
143
172
|
// Inclusion evidence: for a derived fact a CLI can't easily re-verify
|
|
144
173
|
// (internetFacing, resolved across the default VPC's routing), name WHY each
|
|
145
174
|
// match qualifies, so the agent trusts the result instead of dropping it.
|
|
146
|
-
|
|
175
|
+
for (const t of terms) {
|
|
176
|
+
if (t.kind !== "attr") continue;
|
|
147
177
|
for (const n of matches) {
|
|
148
|
-
const via = (n.attrs as Record<string, unknown> | undefined)?.[
|
|
178
|
+
const via = (n.attrs as Record<string, unknown> | undefined)?.[`${t.a}Via`];
|
|
149
179
|
const id = n.id.includes("::") ? n.id.slice(n.id.lastIndexOf("::") + 2) : n.id;
|
|
150
|
-
if (typeof via === "string") console.log(` ✓ ${id}
|
|
180
|
+
if (typeof via === "string") console.log(` ✓ ${id} ${t.a} via ${via}`);
|
|
151
181
|
}
|
|
152
182
|
}
|
|
153
183
|
const shown = excluded.slice(0, 8);
|
|
@@ -159,6 +189,27 @@ function explain(terms: Term[], matches: IRNode[], ir: GraphIR, byId: Map<string
|
|
|
159
189
|
if (excluded.length > shown.length) console.log(` · …and ${excluded.length - shown.length} more excluded`);
|
|
160
190
|
}
|
|
161
191
|
|
|
192
|
+
/**
|
|
193
|
+
* On a miss, name the attributes the queried kind actually carries. A graph knows
|
|
194
|
+
* its own schema, so a caller who guessed an attribute name — or did not know a
|
|
195
|
+
* derived one existed — can see what is queryable instead of falling back to a
|
|
196
|
+
* lossy CLI sweep. Read off the nodes present, so it stays a property of the
|
|
197
|
+
* graph rather than of any expected answer: whatever the estate holds is what
|
|
198
|
+
* this lists, and it says nothing about which attribute answers a question.
|
|
199
|
+
*/
|
|
200
|
+
function availableAttrs(terms: Term[], ir: GraphIR): void {
|
|
201
|
+
const kindTerm = terms.find((t) => t.kind === "kind");
|
|
202
|
+
if (!kindTerm) return;
|
|
203
|
+
const of = ir.nodes.filter((n) => n.kind?.includes(kindTerm.a));
|
|
204
|
+
if (of.length === 0) return;
|
|
205
|
+
const names = new Set<string>();
|
|
206
|
+
for (const n of of) for (const k of Object.keys((n.attrs as Record<string, unknown>) ?? {})) names.add(k);
|
|
207
|
+
const queried = new Set(terms.filter((t) => t.kind === "attr").map((t) => t.a));
|
|
208
|
+
const unused = [...names].filter((k) => !queried.has(k)).sort();
|
|
209
|
+
if (unused.length === 0) return;
|
|
210
|
+
console.log(` · ${of.length} ${kindTerm.a} node(s) carry: ${unused.join(", ")}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
162
213
|
function describeTerm(t: Term): string {
|
|
163
214
|
const leaf = (x: Term): string =>
|
|
164
215
|
x.kind === "kind" ? `kind:${x.a}` : x.kind === "attr" ? `attr:${x.a}${x.b !== undefined ? "=" + x.b : ""}`
|
|
@@ -260,4 +311,4 @@ function formatRow(n: IRNode, show: string[]): string {
|
|
|
260
311
|
}
|
|
261
312
|
|
|
262
313
|
/** Internals exposed for unit tests. */
|
|
263
|
-
export const __searchInternals = { parseQuery, matchTerm, formatRow, explain, describeTerm };
|
|
314
|
+
export const __searchInternals = { parseQuery, matchTerm, formatRow, explain, describeTerm, derivedSurface, availableAttrs };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The release path is two hand-maintained halves that must agree: the
|
|
3
|
+
* justfile recipes that create and push a tag, and publish.yml's tag
|
|
4
|
+
* trigger that decides whether pushing it does anything.
|
|
5
|
+
*
|
|
6
|
+
* They disagreed. `just release-lexicon <name>` has always tagged
|
|
7
|
+
* `lexicon-<name>-v<version>`, pushed it, and echoed "publish workflow
|
|
8
|
+
* triggered" — while publish.yml matched only `chant-v*`. The tag landed,
|
|
9
|
+
* no workflow ran, and the recipe reported success. fly's 0.33.0 shipped
|
|
10
|
+
* with zero rules and zero skills and could not be patched by the one
|
|
11
|
+
* recipe built for patching a single lexicon.
|
|
12
|
+
*
|
|
13
|
+
* A release that silently no-ops is worse than one that fails, so this
|
|
14
|
+
* turns the next divergence into a PR-time failure.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, expect, it } from "vitest";
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
19
|
+
import { join, dirname } from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
|
|
22
|
+
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "..");
|
|
23
|
+
|
|
24
|
+
function publishTagPatterns(): string[] {
|
|
25
|
+
const workflow = readFileSync(join(repoRoot, ".github", "workflows", "publish.yml"), "utf-8");
|
|
26
|
+
const match = workflow.match(/^\s*tags:\s*\[([^\]]+)\]/m);
|
|
27
|
+
if (!match) throw new Error("publish.yml: `tags: [...]` trigger not found");
|
|
28
|
+
return match[1].split(",").map((s) => s.trim().replace(/^['"]|['"]$/g, ""));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Tags the justfile actually creates, as literal-prefix + wildcard shapes. */
|
|
32
|
+
function releaseTagShapes(): Array<{ recipe: string; example: string }> {
|
|
33
|
+
const justfile = readFileSync(join(repoRoot, "justfile"), "utf-8");
|
|
34
|
+
const shapes: Array<{ recipe: string; example: string }> = [];
|
|
35
|
+
|
|
36
|
+
// `git tag "chant-v$next"` / `git tag "lexicon-{{name}}-v$next"`
|
|
37
|
+
for (const m of justfile.matchAll(/git tag "([^"]+)"/g)) {
|
|
38
|
+
const raw = m[1];
|
|
39
|
+
const example = raw
|
|
40
|
+
.replace(/\{\{name\}\}/g, "docker")
|
|
41
|
+
.replace(/\$\{?next\}?/g, "9.9.9");
|
|
42
|
+
shapes.push({ recipe: raw, example });
|
|
43
|
+
}
|
|
44
|
+
return shapes;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Minimal glob match for the `prefix*` shapes these patterns use. */
|
|
48
|
+
function matchesGlob(pattern: string, value: string): boolean {
|
|
49
|
+
const rx = new RegExp(
|
|
50
|
+
"^" + pattern.split("*").map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$",
|
|
51
|
+
);
|
|
52
|
+
return rx.test(value);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe("release wiring: justfile tags vs publish.yml trigger", () => {
|
|
56
|
+
it("finds both halves", () => {
|
|
57
|
+
expect(publishTagPatterns().length).toBeGreaterThan(0);
|
|
58
|
+
expect(releaseTagShapes().length).toBeGreaterThan(0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("every tag a release recipe pushes triggers the publish workflow", () => {
|
|
62
|
+
const patterns = publishTagPatterns();
|
|
63
|
+
|
|
64
|
+
for (const { recipe, example } of releaseTagShapes()) {
|
|
65
|
+
const hit = patterns.some((p) => matchesGlob(p, example));
|
|
66
|
+
expect(
|
|
67
|
+
hit,
|
|
68
|
+
`justfile creates tag "${recipe}" (e.g. ${example}) but publish.yml triggers on ` +
|
|
69
|
+
`[${patterns.join(", ")}] — pushing it would publish nothing while the recipe ` +
|
|
70
|
+
`reports success`,
|
|
71
|
+
).toBe(true);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("covers the two shapes the repo releases by", () => {
|
|
76
|
+
const examples = releaseTagShapes().map((s) => s.example);
|
|
77
|
+
// Whole-repo release and single-lexicon patch. If a recipe stops
|
|
78
|
+
// producing one of these, the assertion above would pass vacuously.
|
|
79
|
+
expect(examples).toContain("chant-v9.9.9");
|
|
80
|
+
expect(examples).toContain("lexicon-docker-v9.9.9");
|
|
81
|
+
});
|
|
82
|
+
});
|
package/src/graph-effective.ts
CHANGED
|
@@ -106,5 +106,11 @@ export function enrichEffectiveTopology(ir: GraphIR): GraphIR {
|
|
|
106
106
|
attrs: { ...attrs, effectiveIngress, internetFacing: liveFacing || !!declaredVia, ...(via ? { internetFacingVia: via } : {}) },
|
|
107
107
|
};
|
|
108
108
|
});
|
|
109
|
-
|
|
109
|
+
// Record what this pass computed, so a caller can report the graph's derived surface
|
|
110
|
+
// without hardcoding attribute names. Only kinds actually enriched are listed.
|
|
111
|
+
const enriched = nodes.some((n) => isKind(n, "Instance"));
|
|
112
|
+
const derivedAttrs = enriched
|
|
113
|
+
? { ...(ir.derivedAttrs ?? {}), Instance: ["internetFacing", "effectiveIngress"] }
|
|
114
|
+
: ir.derivedAttrs;
|
|
115
|
+
return { ...ir, nodes, ...(derivedAttrs ? { derivedAttrs } : {}) };
|
|
110
116
|
}
|
package/src/graph-ir.ts
CHANGED
|
@@ -191,6 +191,13 @@ export interface GraphIR {
|
|
|
191
191
|
imports?: IRImport[];
|
|
192
192
|
/** The CI/pipeline projection alongside the component graph (#989) — see {@link IRPipeline}. */
|
|
193
193
|
pipeline?: IRPipeline;
|
|
194
|
+
/**
|
|
195
|
+
* Attributes chant computed rather than read back from the provider, keyed by the kind
|
|
196
|
+
* they were folded onto. An enrichment pass records what it derived here so callers can
|
|
197
|
+
* report the graph's own surface without knowing any attribute name — the set is whatever
|
|
198
|
+
* the passes produced, not a list maintained by hand.
|
|
199
|
+
*/
|
|
200
|
+
derivedAttrs?: Record<string, string[]>;
|
|
194
201
|
}
|
|
195
202
|
|
|
196
203
|
/** A node is anything that serializes to a resource — not a property or output. */
|
package/src/observation.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/observation.ts
CHANGED
|
@@ -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
|
+
}
|