@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.
@@ -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,CAwFpE;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,CAwB9G;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;;;;;;CAA8D,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,CAqF5D"}
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"}
@@ -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
@@ -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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"}
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.33.0",
3
+ "version": "0.33.1",
4
4
  "description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -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
- if (terms.some((t) => t.kind === "attr" && t.a === "internetFacing")) {
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)?.["internetFacingVia"];
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} internet-facing via ${via}`);
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
+ });
@@ -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
- return { ...ir, nodes };
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. */
@@ -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
+ }