@intentius/chant 0.44.14 → 0.45.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 (170) hide show
  1. package/dist/audit/discover.d.ts +26 -2
  2. package/dist/audit/discover.d.ts.map +1 -1
  3. package/dist/audit/report-model.d.ts +10 -0
  4. package/dist/audit/report-model.d.ts.map +1 -1
  5. package/dist/audit/rules-doc.d.ts.map +1 -1
  6. package/dist/cli/build-params-cli.d.ts +16 -7
  7. package/dist/cli/build-params-cli.d.ts.map +1 -1
  8. package/dist/cli/commands/audit.d.ts +20 -0
  9. package/dist/cli/commands/audit.d.ts.map +1 -1
  10. package/dist/cli/commands/build.d.ts +9 -0
  11. package/dist/cli/commands/build.d.ts.map +1 -1
  12. package/dist/cli/commands/check-lexicon-docs.d.ts +13 -0
  13. package/dist/cli/commands/check-lexicon-docs.d.ts.map +1 -1
  14. package/dist/cli/commands/check-lexicon-examples.d.ts +24 -14
  15. package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -1
  16. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  17. package/dist/cli/commands/init-lexicon/templates/codegen.d.ts.map +1 -1
  18. package/dist/cli/commands/init-lexicon/templates/docs.d.ts +5 -0
  19. package/dist/cli/commands/init-lexicon/templates/docs.d.ts.map +1 -1
  20. package/dist/cli/commands/init-lexicon.d.ts.map +1 -1
  21. package/dist/cli/commands/onboard.d.ts +28 -0
  22. package/dist/cli/commands/onboard.d.ts.map +1 -1
  23. package/dist/cli/handlers/build.d.ts.map +1 -1
  24. package/dist/cli/handlers/graph.d.ts.map +1 -1
  25. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  26. package/dist/cli/handlers/run.d.ts.map +1 -1
  27. package/dist/cli/handlers/search.d.ts +27 -0
  28. package/dist/cli/handlers/search.d.ts.map +1 -1
  29. package/dist/cli/main.d.ts.map +1 -1
  30. package/dist/codegen/docs-pages.d.ts +39 -0
  31. package/dist/codegen/docs-pages.d.ts.map +1 -0
  32. package/dist/codegen/docs-sections.d.ts.map +1 -1
  33. package/dist/codegen/docs-sidebar.d.ts +13 -2
  34. package/dist/codegen/docs-sidebar.d.ts.map +1 -1
  35. package/dist/codegen/docs-types.d.ts +30 -10
  36. package/dist/codegen/docs-types.d.ts.map +1 -1
  37. package/dist/codegen/docs.d.ts +14 -2
  38. package/dist/codegen/docs.d.ts.map +1 -1
  39. package/dist/codegen/surface-snapshot.d.ts +27 -3
  40. package/dist/codegen/surface-snapshot.d.ts.map +1 -1
  41. package/dist/codegen/validate.d.ts +11 -3
  42. package/dist/codegen/validate.d.ts.map +1 -1
  43. package/dist/components/capability-plugin.d.ts +13 -2
  44. package/dist/components/capability-plugin.d.ts.map +1 -1
  45. package/dist/components/driver.d.ts +38 -0
  46. package/dist/components/driver.d.ts.map +1 -1
  47. package/dist/components/index.d.ts +1 -1
  48. package/dist/components/index.d.ts.map +1 -1
  49. package/dist/components/starter-plugin.d.ts.map +1 -1
  50. package/dist/config.d.ts +57 -5
  51. package/dist/config.d.ts.map +1 -1
  52. package/dist/discovery/fold-import.d.ts.map +1 -1
  53. package/dist/fold/fold.d.ts +100 -1
  54. package/dist/fold/fold.d.ts.map +1 -1
  55. package/dist/graph-ir.d.ts +12 -0
  56. package/dist/graph-ir.d.ts.map +1 -1
  57. package/dist/graph-ops.d.ts +17 -0
  58. package/dist/graph-ops.d.ts.map +1 -0
  59. package/dist/lifecycle/change-set.d.ts +23 -2
  60. package/dist/lifecycle/change-set.d.ts.map +1 -1
  61. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  62. package/dist/lifecycle/observe.d.ts +8 -0
  63. package/dist/lifecycle/observe.d.ts.map +1 -1
  64. package/dist/lifecycle/replay.d.ts.map +1 -1
  65. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  66. package/dist/lifecycle/types.d.ts +7 -0
  67. package/dist/lifecycle/types.d.ts.map +1 -1
  68. package/dist/lint/policy.d.ts.map +1 -1
  69. package/dist/managed-fields.d.ts +33 -21
  70. package/dist/managed-fields.d.ts.map +1 -1
  71. package/dist/observation.d.ts +23 -1
  72. package/dist/observation.d.ts.map +1 -1
  73. package/dist/yaml.d.ts +0 -8
  74. package/dist/yaml.d.ts.map +1 -1
  75. package/package.json +1 -1
  76. package/src/__snapshots__/okf.test.ts.snap +20 -0
  77. package/src/audit/discover.test.ts +55 -1
  78. package/src/audit/discover.ts +70 -2
  79. package/src/audit/edge-init-safety.test.ts +149 -18
  80. package/src/audit/report-model.ts +12 -1
  81. package/src/audit/rules-doc.ts +1 -0
  82. package/src/build-params.test.ts +52 -0
  83. package/src/cli/build-params-cli.test.ts +25 -8
  84. package/src/cli/build-params-cli.ts +37 -23
  85. package/src/cli/commands/__fixtures__/audit-coverage/.github/workflows/ci.yml +5 -0
  86. package/src/cli/commands/__fixtures__/audit-coverage/Dockerfile +2 -0
  87. package/src/cli/commands/__fixtures__/audit-coverage/infra/main.tf +3 -0
  88. package/src/cli/commands/__fixtures__/audit-coverage/infra/stack.json +6 -0
  89. package/src/cli/commands/__fixtures__/audit-coverage/k8s/deploy.yaml +12 -0
  90. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/pages/getting-started.mdx +16 -0
  91. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content.config.ts +10 -2
  92. package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/generate-cli.ts +3 -1
  93. package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/generate.ts +2 -1
  94. package/src/cli/commands/__snapshots__/init-lexicon.test.ts.snap +2 -1
  95. package/src/cli/commands/audit.test.ts +83 -1
  96. package/src/cli/commands/audit.ts +121 -28
  97. package/src/cli/commands/build.test.ts +144 -5
  98. package/src/cli/commands/build.ts +66 -14
  99. package/src/cli/commands/check-lexicon-docs.ts +30 -0
  100. package/src/cli/commands/check-lexicon-examples.test.ts +73 -7
  101. package/src/cli/commands/check-lexicon-examples.ts +66 -17
  102. package/src/cli/commands/check-lexicon.test.ts +1 -1
  103. package/src/cli/commands/check-lexicon.ts +19 -4
  104. package/src/cli/commands/init-lexicon/templates/codegen.ts +5 -2
  105. package/src/cli/commands/init-lexicon/templates/docs.ts +35 -2
  106. package/src/cli/commands/init-lexicon.test.ts +42 -1
  107. package/src/cli/commands/init-lexicon.ts +3 -1
  108. package/src/cli/commands/lexicon-rolling-upgrade.ts +1 -1
  109. package/src/cli/commands/lexicon-surface-diff.test.ts +1 -1
  110. package/src/cli/commands/lexicon-surface-diff.ts +1 -1
  111. package/src/cli/commands/onboard.test.ts +319 -225
  112. package/src/cli/commands/onboard.ts +144 -58
  113. package/src/cli/handlers/build.test.ts +3 -3
  114. package/src/cli/handlers/build.ts +2 -0
  115. package/src/cli/handlers/components.ts +2 -2
  116. package/src/cli/handlers/graph.test.ts +44 -0
  117. package/src/cli/handlers/graph.ts +22 -2
  118. package/src/cli/handlers/lifecycle.test.ts +117 -0
  119. package/src/cli/handlers/lifecycle.ts +16 -0
  120. package/src/cli/handlers/run.test.ts +5 -5
  121. package/src/cli/handlers/run.ts +1 -0
  122. package/src/cli/handlers/search-live.test.ts +217 -0
  123. package/src/cli/handlers/search.test.ts +41 -1
  124. package/src/cli/handlers/search.ts +55 -6
  125. package/src/cli/main.ts +5 -3
  126. package/src/cli/security-boundary.test.ts +2 -1
  127. package/src/codegen/docs-pages.test.ts +194 -0
  128. package/src/codegen/docs-pages.ts +138 -0
  129. package/src/codegen/docs-sections.ts +0 -8
  130. package/src/codegen/docs-sidebar.ts +50 -37
  131. package/src/codegen/docs-types.ts +32 -4
  132. package/src/codegen/docs.ts +78 -40
  133. package/src/codegen/lexicon-regen.ts +3 -1
  134. package/src/codegen/rolling-upgrade.test.ts +1 -1
  135. package/src/codegen/rolling-upgrade.ts +1 -1
  136. package/src/codegen/surface-snapshot.test.ts +139 -0
  137. package/src/codegen/surface-snapshot.ts +297 -122
  138. package/src/codegen/validate.test.ts +16 -3
  139. package/src/codegen/validate.ts +17 -7
  140. package/src/components/auto-release.ts +1 -1
  141. package/src/components/capability-plugin.ts +23 -2
  142. package/src/components/driver.test.ts +45 -0
  143. package/src/components/driver.ts +64 -27
  144. package/src/components/index.ts +2 -0
  145. package/src/components/starter-plugin.ts +5 -2
  146. package/src/config.ts +101 -7
  147. package/src/discovery/fold-import.test.ts +427 -2
  148. package/src/discovery/fold-import.ts +163 -7
  149. package/src/discovery/sandbox/driver.test.ts +11 -0
  150. package/src/fold/fold.test.ts +41 -0
  151. package/src/fold/fold.ts +372 -2
  152. package/src/graph-ir-live.test.ts +42 -0
  153. package/src/graph-ir.ts +42 -2
  154. package/src/graph-ops.test.ts +69 -0
  155. package/src/graph-ops.ts +42 -0
  156. package/src/lifecycle/change-set.test.ts +41 -0
  157. package/src/lifecycle/change-set.ts +30 -2
  158. package/src/lifecycle/deep-observe.ts +4 -0
  159. package/src/lifecycle/observe.ts +29 -2
  160. package/src/lifecycle/replay.ts +3 -0
  161. package/src/lifecycle/snapshot.ts +3 -0
  162. package/src/lifecycle/types.ts +7 -0
  163. package/src/lint/policy.ts +14 -3
  164. package/src/managed-fields.test.ts +15 -42
  165. package/src/managed-fields.ts +43 -26
  166. package/src/observation.test.ts +24 -8
  167. package/src/observation.ts +48 -4
  168. package/src/ownership.test.ts +63 -1
  169. package/src/yaml.test.ts +15 -0
  170. package/src/yaml.ts +19 -2
@@ -416,6 +416,7 @@ export async function runLifecycleDiff(ctx: CommandContext): Promise<number> {
416
416
  buildResult,
417
417
  json,
418
418
  stack: target.stack,
419
+ region: target.region,
419
420
  componentStacks,
420
421
  baseline,
421
422
  updateBaseline: args.updateBaseline,
@@ -650,6 +651,11 @@ interface LiveDiffArgs {
650
651
  /** Deployed stack name for a multi-stack project (#932); scopes the live
651
652
  * observation (which CloudFormation stack to query) and the snapshot read. */
652
653
  stack?: string;
654
+ /** Region that stack is deployed in, from `stacks[].region` (#1264). Same
655
+ * contract as snapshot (#1261): without it every stack is read against the
656
+ * ambient region, so a multi-region estate reports its out-of-region stacks
657
+ * as absent rather than unobserved. */
658
+ region?: string;
653
659
  /** Component projects deploy one CFN stack per component; observe them all and
654
660
  * union (the same fix graph/plan use), else every deployed resource reads as
655
661
  * "missing". Empty → the single-stack observe path. */
@@ -710,6 +716,9 @@ async function observeLexicon(
710
716
  declared: Set<string>;
711
717
  entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
712
718
  stack?: string;
719
+ /** Region the stack is deployed in (#1264); the read targets it instead of
720
+ * the ambient region. */
721
+ region?: string;
713
722
  componentStacks?: string[];
714
723
  owned?: boolean;
715
724
  /** `--namespace <ns>` (#1629): where to read an entity that declares no
@@ -723,6 +732,7 @@ async function observeLexicon(
723
732
  buildOutput: opts.buildOutput,
724
733
  entityNames,
725
734
  entities: opts.entities,
735
+ ...(opts.region ? { region: opts.region } : {}),
726
736
  ...(opts.owned !== undefined ? { owned: opts.owned } : {}),
727
737
  ...(opts.namespace ? { namespace: opts.namespace } : {}),
728
738
  };
@@ -746,6 +756,7 @@ async function observeLexicon(
746
756
  resources: {},
747
757
  unobserved: unobservedAll(entityNames, "read-failed", message, opts.entities),
748
758
  queried: {},
759
+ notes: [],
749
760
  };
750
761
  }
751
762
  }
@@ -811,6 +822,7 @@ async function runLifecycleDiffLive(args: LiveDiffArgs): Promise<LiveDiffOutcome
811
822
  declared,
812
823
  entities,
813
824
  stack: args.stack,
825
+ region: args.region,
814
826
  componentStacks: args.componentStacks,
815
827
  ...(args.namespace ? { namespace: args.namespace } : {}),
816
828
  });
@@ -839,6 +851,7 @@ async function runLifecycleDiffLive(args: LiveDiffArgs): Promise<LiveDiffOutcome
839
851
  buildOutput,
840
852
  entities,
841
853
  stack: args.stack,
854
+ region: args.region,
842
855
  componentStacks: args.componentStacks,
843
856
  baseline: baselineForLexicon(args.baseline, lexiconName),
844
857
  });
@@ -1181,6 +1194,9 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
1181
1194
  // The resolved read address rides every entry, not just unobserved
1182
1195
  // ones — the diff path already passes it (#1620); plan lost it.
1183
1196
  queried: observed.queried,
1197
+ }, {
1198
+ // Attribution survives the flat merge below (#1674).
1199
+ lexicon: lexiconName,
1184
1200
  });
1185
1201
  merged.entries.push(...cs.entries);
1186
1202
  checked++;
@@ -101,7 +101,7 @@ function makeOp(name: string, depends: string[] = []): [string, { config: { name
101
101
  function setupTemporalClient(mock: ReturnType<typeof createMockTemporalClient>) {
102
102
  loadTemporalClientMock.mockResolvedValue({
103
103
  Connection: { connect: vi.fn(async () => ({})) },
104
- Client: vi.fn(() => mock.client) as unknown as new () => unknown,
104
+ Client: vi.fn(function () { return mock.client; }) as unknown as new () => unknown,
105
105
  });
106
106
  loadChantConfigMock.mockResolvedValue({ config: {} });
107
107
  resolveProfileMock.mockReturnValue({ address: "localhost:7233", namespace: "default", taskQueue: "q" });
@@ -951,9 +951,9 @@ describe("runOpComponents", () => {
951
951
  expect(runComponentsMock).toHaveBeenCalledWith(expect.any(String), "svc", expect.objectContaining({
952
952
  buildParams: [{ name: "tier", value: "light", source: "default" }],
953
953
  }));
954
- // A parameter at its default collapses into the count line now; the
955
- // provenance forwarded above is where its value is asserted.
956
- expect(stderr.join("\n")).toContain("at their defaults");
954
+ // The echo is a one-line count (#1424); the provenance forwarded above
955
+ // is where the value is asserted.
956
+ expect(stderr.join("\n")).toContain("1 build parameter resolved (1 default)");
957
957
  });
958
958
 
959
959
  test("--param overrides a declared default and is threaded through to runComponents", async () => {
@@ -978,7 +978,7 @@ describe("runOpComponents", () => {
978
978
  expect(runComponentsMock).toHaveBeenCalledWith(expect.any(String), "svc", expect.objectContaining({
979
979
  buildParams: [{ name: "tier", value: "production", source: "cli" }],
980
980
  }));
981
- expect(stderr.join("\n")).toContain("[param] tier");
981
+ expect(stderr.join("\n")).toContain("1 build parameter resolved (1 from cli)");
982
982
  vi.restoreAllMocks();
983
983
  });
984
984
 
@@ -714,6 +714,7 @@ export async function runOpComponents(ctx: CommandContext): Promise<number> {
714
714
  const paramsResolution = resolveCliBuildParams(config.buildParams, {
715
715
  cli: parseParamFlags(ctx.args.param),
716
716
  paramsFile: ctx.args.paramsFile,
717
+ verbose: ctx.args.verbose,
717
718
  });
718
719
  if (!paramsResolution.success) {
719
720
  for (const message of paramsResolution.errors) console.error(message);
@@ -0,0 +1,217 @@
1
+ import { describe, test, expect, vi, beforeEach } from "vitest";
2
+ import type { ParsedArgs } from "../registry";
3
+ import { DECLARABLE_MARKER, type Declarable } from "../../declarable";
4
+ import { createMockPlugin } from "../../../../test-utils/src/mock-plugin";
5
+
6
+ // #1263 — `chant search --live` against an unreachable endpoint returned the
7
+ // declared rows, exit 0, and a footer naming derived facts it never computed.
8
+ // The observe layer already records a thrown read as NOT-OBSERVED
9
+ // (`read-failed`, #1089); these tests pin that the handler carries it through
10
+ // to the rows, the footer, stderr, and the exit code. `observeResources` is
11
+ // real here so the plumbing from a throwing `describeResources` is exercised.
12
+
13
+ const discoverMock = vi.fn();
14
+ vi.mock("../../discovery/index", () => ({
15
+ discover: (...a: unknown[]) => discoverMock(...a),
16
+ }));
17
+ const loadPluginsMock = vi.fn();
18
+ const resolveLexMock = vi.fn();
19
+ vi.mock("../plugins", async () => {
20
+ const actual = await vi.importActual<typeof import("../plugins")>("../plugins");
21
+ return {
22
+ ...actual,
23
+ loadPlugins: (...a: unknown[]) => loadPluginsMock(...a),
24
+ resolveProjectLexicons: (...a: unknown[]) => resolveLexMock(...a),
25
+ };
26
+ });
27
+ const hasSnapshotMock = vi.fn((..._a: unknown[]) => Promise.resolve(false));
28
+ vi.mock("../../lifecycle/replay", () => ({
29
+ replaySnapshots: vi.fn(),
30
+ hasSnapshot: (...a: unknown[]) => hasSnapshotMock(...a),
31
+ }));
32
+ const loadChantConfigMock = vi.fn();
33
+ vi.mock("../../config", async () => {
34
+ const actual = await vi.importActual<typeof import("../../config")>("../../config");
35
+ return { ...actual, loadChantConfig: (...a: unknown[]) => loadChantConfigMock(...a) };
36
+ });
37
+ const buildResultMock = vi.fn();
38
+ vi.mock("../../build", async () => {
39
+ const actual = await vi.importActual<typeof import("../../build")>("../../build");
40
+ return {
41
+ ...actual,
42
+ build: (...a: unknown[]) => Promise.resolve(buildResultMock(...a)),
43
+ buildProject: (...a: unknown[]) => Promise.resolve(buildResultMock(...a)),
44
+ };
45
+ });
46
+
47
+ const { runSearch } = await import("./search");
48
+
49
+ function decl<T extends object>(base: T): Declarable & T {
50
+ return { [DECLARABLE_MARKER]: true, ...base } as Declarable & T;
51
+ }
52
+
53
+ function makeArgs(overrides: Partial<ParsedArgs> = {}): ParsedArgs {
54
+ return {
55
+ command: "search", path: "kind:Instance",
56
+ format: "", fix: false, watch: false, verbose: false, help: false, live: true, env: "dev",
57
+ ...overrides,
58
+ };
59
+ }
60
+
61
+ describe("search --live when the live read fails (#1263)", () => {
62
+ let out: string[];
63
+ let err: string[];
64
+ const entities = new Map<string, Declarable>([
65
+ ["webServer", decl({ lexicon: "aws", entityType: "AWS::EC2::Instance", props: {} })],
66
+ ["privateServer", decl({ lexicon: "aws", entityType: "AWS::EC2::Instance", props: {} })],
67
+ ]);
68
+
69
+ beforeEach(() => {
70
+ out = [];
71
+ err = [];
72
+ vi.spyOn(console, "log").mockImplementation((s: string) => { out.push(s); });
73
+ vi.spyOn(console, "error").mockImplementation((s: string) => { err.push(s); });
74
+ discoverMock.mockReset();
75
+ discoverMock.mockResolvedValue({ entities, errors: [], sourceFiles: [] });
76
+ buildResultMock.mockReset();
77
+ buildResultMock.mockReturnValue({ errors: [], entities, outputs: new Map([["aws", ""]]), warnings: [] });
78
+ loadChantConfigMock.mockReset();
79
+ loadChantConfigMock.mockResolvedValue({ config: {} });
80
+ resolveLexMock.mockReset();
81
+ resolveLexMock.mockResolvedValue(["aws"]);
82
+ hasSnapshotMock.mockReset();
83
+ hasSnapshotMock.mockResolvedValue(false);
84
+ });
85
+
86
+ const deadEndpoint = createMockPlugin({
87
+ name: "aws",
88
+ describeResources: async () => {
89
+ throw new Error("connect ECONNREFUSED 127.0.0.1:9999");
90
+ },
91
+ });
92
+
93
+ test("exits non-zero, names the lexicon and cause, and marks every row unobserved", async () => {
94
+ loadPluginsMock.mockResolvedValue([deadEndpoint]);
95
+ const exit = await runSearch({ args: makeArgs(), plugins: [], serializers: [] });
96
+ expect(exit).toBe(1);
97
+ const stderr = err.join("\n");
98
+ expect(stderr).toContain("aws");
99
+ expect(stderr).toContain("ECONNREFUSED 127.0.0.1:9999");
100
+ expect(stderr).toContain("declared-only");
101
+ const stdout = out.join("\n");
102
+ expect(stdout).toContain("webServer AWS::EC2::Instance (unobserved: read-failed)");
103
+ expect(stdout).toContain("privateServer AWS::EC2::Instance (unobserved: read-failed)");
104
+ expect(stdout).toContain("live read failed (aws) · 2/2 rows unobserved");
105
+ // Nothing was read, so no fold over live topology could have run.
106
+ expect(stdout).not.toContain("also derived");
107
+ });
108
+
109
+ test("points at a recorded snapshot when one exists", async () => {
110
+ loadPluginsMock.mockResolvedValue([deadEndpoint]);
111
+ hasSnapshotMock.mockResolvedValue(true);
112
+ const exit = await runSearch({ args: makeArgs(), plugins: [], serializers: [] });
113
+ expect(exit).toBe(1);
114
+ expect(out.join("\n")).toContain("--at latest");
115
+ expect(err.join("\n")).toContain("--at latest");
116
+ });
117
+
118
+ test("a working live read still exits 0 with bound rows", async () => {
119
+ loadPluginsMock.mockResolvedValue([
120
+ createMockPlugin({
121
+ name: "aws",
122
+ describeResources: async () => ({
123
+ webServer: { type: "AWS::EC2::Instance", physicalId: "i-1", status: "OK", owned: true },
124
+ privateServer: { type: "AWS::EC2::Instance", physicalId: "i-2", status: "OK", owned: true },
125
+ }),
126
+ }),
127
+ ]);
128
+ const exit = await runSearch({ args: makeArgs(), plugins: [], serializers: [] });
129
+ expect(exit).toBe(0);
130
+ const stdout = out.join("\n");
131
+ expect(stdout).toContain("webServer AWS::EC2::Instance i-1");
132
+ expect(stdout).toContain("observed live · bound 2/2");
133
+ expect(err.join("\n")).not.toContain("live read failed");
134
+ });
135
+ });
136
+
137
+ // #1265 — a lexicon's run-level note ("ownership filter unavailable on this
138
+ // path") printed once per stack, ahead of the rows. It is one fact about the
139
+ // read, so it is said once, and it qualifies the answer, so it follows it.
140
+ describe("search --live run-level notes (#1265)", () => {
141
+ const NOTE =
142
+ "ownership filter unavailable on describeResources (no tags from describe-stack-resources) — returning all, each with an explicit `unknown` verdict; use `chant import --from <env> --owned` for ownership-filtered export";
143
+ let out: string[];
144
+ let err: string[];
145
+ // One log of everything, in the order it was written, so the test can see
146
+ // whether the note came before or after the rows.
147
+ let all: string[];
148
+ const entities = new Map<string, Declarable>([
149
+ ["webServer", decl({ lexicon: "aws", entityType: "AWS::EC2::Instance", props: {} })],
150
+ ["privateServer", decl({ lexicon: "aws", entityType: "AWS::EC2::Instance", props: {} })],
151
+ ]);
152
+ const calls: string[] = [];
153
+
154
+ beforeEach(() => {
155
+ out = [];
156
+ err = [];
157
+ all = [];
158
+ calls.length = 0;
159
+ vi.spyOn(console, "log").mockImplementation((s: string) => { out.push(s); all.push(s); });
160
+ vi.spyOn(console, "error").mockImplementation((s: string) => { err.push(s); all.push(s); });
161
+ vi.spyOn(console, "warn").mockImplementation((s: string) => { all.push(s); });
162
+ discoverMock.mockReset();
163
+ discoverMock.mockResolvedValue({ entities, errors: [], sourceFiles: [] });
164
+ buildResultMock.mockReset();
165
+ buildResultMock.mockReturnValue({ errors: [], entities, outputs: new Map([["aws", ""]]), warnings: [] });
166
+ loadChantConfigMock.mockReset();
167
+ loadChantConfigMock.mockResolvedValue({
168
+ config: { stacks: [{ name: "net" }, { name: "data" }, { name: "app" }, { name: "edge" }] },
169
+ });
170
+ resolveLexMock.mockReset();
171
+ resolveLexMock.mockResolvedValue(["aws"]);
172
+ hasSnapshotMock.mockReset();
173
+ hasSnapshotMock.mockResolvedValue(false);
174
+ loadPluginsMock.mockResolvedValue([
175
+ createMockPlugin({
176
+ name: "aws",
177
+ describeResources: async (opts: { stack?: string }) => {
178
+ calls.push(opts.stack ?? "");
179
+ return {
180
+ observation: "v1" as const,
181
+ resources: {
182
+ webServer: { type: "AWS::EC2::Instance", physicalId: "i-1", status: "OK", ownership: "unknown" as const },
183
+ privateServer: { type: "AWS::EC2::Instance", physicalId: "i-2", status: "OK", ownership: "unknown" as const },
184
+ },
185
+ notes: [NOTE],
186
+ };
187
+ },
188
+ }),
189
+ ]);
190
+ });
191
+
192
+ test("four stacks, one note, after the rows and the provenance line", async () => {
193
+ const exit = await runSearch({ args: makeArgs(), plugins: [], serializers: [] });
194
+ expect(exit).toBe(0);
195
+ expect(calls).toEqual(["net", "data", "app", "edge"]);
196
+ const notes = all.filter((line) => line.includes("ownership filter unavailable"));
197
+ expect(notes).toHaveLength(1);
198
+ expect(notes[0]).toContain(`[aws] ${NOTE}`);
199
+ const noteAt = all.findIndex((line) => line.includes("ownership filter unavailable"));
200
+ const lastRow = all.findIndex((line) => line.startsWith("privateServer AWS::EC2::Instance i-2"));
201
+ const footer = all.findIndex((line) => line.includes("observed live · bound 2/2"));
202
+ expect(lastRow).toBeGreaterThanOrEqual(0);
203
+ expect(footer).toBeGreaterThan(lastRow);
204
+ expect(noteAt).toBeGreaterThan(footer);
205
+ // stderr, like every other warning; the rows stay clean on stdout.
206
+ expect(out.join("\n")).not.toContain("ownership filter unavailable");
207
+ expect(err.join("\n")).toContain("ownership filter unavailable");
208
+ });
209
+
210
+ test("a miss still says the note, after the miss", async () => {
211
+ const exit = await runSearch({ args: makeArgs({ path: "kind:Nope" }), plugins: [], serializers: [] });
212
+ expect(exit).toBe(0);
213
+ const notes = all.filter((line) => line.includes("ownership filter unavailable"));
214
+ expect(notes).toHaveLength(1);
215
+ expect(all.indexOf("(no matches)")).toBeLessThan(all.findIndex((l) => l.includes("ownership filter unavailable")));
216
+ });
217
+ });
@@ -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, derivedSurface, availableAttrs, ambientHint, regionSpread, showMiss } = __searchInternals;
4
+ const { parseQuery, matchTerm, formatRow, explain, describeTerm, derivedSurface, availableAttrs, ambientHint, regionSpread, showMiss, provenance } = __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;
@@ -53,6 +53,13 @@ describe("search formatting", () => {
53
53
  test("compact row with live physical id, skipping object placeholders", () => {
54
54
  const live = node("webServer", "AWS::EC2::Instance", { physicalId: "i-abc" });
55
55
  expect(formatRow(live, [])).toBe("webServer AWS::EC2::Instance i-abc");
56
+ });
57
+
58
+ // #1263 — a row nobody could read printed blank where the physical id goes,
59
+ // which reads as "declared, not provisioned".
60
+ test("a row the live read could not observe says so in place of the physical id", () => {
61
+ const n = node("webServer", "AWS::EC2::Instance", { _status: "neutral", _unobserved: "read-failed" });
62
+ expect(formatRow(n, [])).toBe("webServer AWS::EC2::Instance (unobserved: read-failed)");
56
63
  const src = node("webServer", "AWS::EC2::Instance", { InstanceId: { $ref: "webServer.InstanceId" } });
57
64
  expect(formatRow(src, [])).toBe("webServer AWS::EC2::Instance");
58
65
  });
@@ -353,3 +360,36 @@ describe("--show name matching", () => {
353
360
  expect(capture(() => showMiss([n] as never, ["Region"]))).toBe("");
354
361
  });
355
362
  });
363
+
364
+ // #1263 — a `--live` read that failed entirely returned the declared rows with
365
+ // the same footer as a working answer and exit 0.
366
+ describe("provenance of a failed live read", () => {
367
+ const capture = (fn: () => void): string => {
368
+ const lines: string[] = [];
369
+ const spy = vi.spyOn(console, "log").mockImplementation((s: string) => { lines.push(s); });
370
+ fn();
371
+ spy.mockRestore();
372
+ return lines.join("\n");
373
+ };
374
+ const rows = [
375
+ node("a", "AWS::EC2::Instance", { _unobserved: "read-failed" }),
376
+ node("b", "AWS::EC2::Instance", { _unobserved: "read-failed" }),
377
+ ] as never;
378
+
379
+ test("names the lexicon and counts the unobserved rows", () => {
380
+ const out = capture(() => provenance(rows, { kind: "live" }, undefined, ["aws: connect ECONNREFUSED 127.0.0.1:9999"]));
381
+ expect(out).toContain("live read failed (aws)");
382
+ expect(out).toContain("2/2 rows unobserved");
383
+ expect(out).toContain("physical ids unavailable");
384
+ });
385
+
386
+ test("points at a recorded snapshot when one exists", () => {
387
+ const out = capture(() => provenance(rows, { kind: "live" }, "yes", ["aws: connect ECONNREFUSED"]));
388
+ expect(out).toContain("--at latest");
389
+ });
390
+
391
+ test("a working live read is unchanged", () => {
392
+ const bound = [{ id: "a", kind: "AWS::EC2::Instance", lexicon: "aws", attrs: {}, physicalId: "i-1" }] as never;
393
+ expect(capture(() => provenance(bound, { kind: "live" }))).toBe("— observed live · bound 1/1");
394
+ });
395
+ });
@@ -1,6 +1,6 @@
1
1
  import { resolve } from "node:path";
2
2
  import { build } from "../../build";
3
- import { buildGraphIr, buildLiveGraphIr, sourceOverlayGraphs, type GraphIR, type IRNode, type IREdge } from "../../graph-ir";
3
+ import { buildGraphIr, buildLiveGraphIr, collectUnobserved, sourceOverlayGraphs, type GraphIR, type IRNode, type IREdge } from "../../graph-ir";
4
4
  import { buildDeclaredPerStack } from "../../graph-declared";
5
5
  import { enrichEffectiveTopology } from "../../graph-effective";
6
6
  import { reconstructEdges, mergeCatalogs, type ReferenceCatalog } from "../../graph-refs";
@@ -61,6 +61,13 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
61
61
 
62
62
  let ir: GraphIR;
63
63
  let source: AnswerSource = { kind: "declared" };
64
+ // Lexicons whose live read threw (#1263). "Nothing observed" and "could not
65
+ // observe" are different claims; this is what carries the second one.
66
+ const liveFailures: string[] = [];
67
+ // Run-level notes from the read (#1265) — "ownership could not be filtered
68
+ // on this path" — printed with the provenance footer, after the rows, so the
69
+ // answer is not preceded by what qualifies it.
70
+ let liveNotes: string[] = [];
64
71
  // Kinds that can exist in the account without being declared (#1278). Known
65
72
  // without a scan, so it costs nothing to mention.
66
73
  let ambientKinds: string[] = [];
@@ -122,8 +129,15 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
122
129
  stacks,
123
130
  ambient: args.ambient === true,
124
131
  });
125
- for (const e of observed.errors) console.error(formatWarning({ message: e }));
132
+ // A thrown read is not a warning. Printed as one, it sat between the
133
+ // ownership-filter notices that also print on a working run and carried
134
+ // no signal (#1263). Name the lexicon and the cause, as an error.
135
+ for (const e of observed.errors) {
136
+ liveFailures.push(e);
137
+ console.error(formatError({ message: `live read failed — ${e}` }));
138
+ }
126
139
  observations = observed.observations;
140
+ liveNotes = observed.notes ?? [];
127
141
  source = { kind: "live" };
128
142
  }
129
143
  let live = buildLiveGraphIr(observations);
@@ -189,7 +203,10 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
189
203
  stacks.length > 0
190
204
  ? await buildDeclaredPerStack(stacks, projectPath)
191
205
  : buildGraphIr((await discover(resolve(args.src ?? config.sourceDir ?? "."))).entities, projectPath);
192
- ir = sourceOverlayGraphs(declared, live);
206
+ // Carry the NOT-OBSERVED half of the tri-state (#1089) onto the rows, so a
207
+ // declared entity nobody could read is painted `_unobserved` and a row can
208
+ // say so instead of printing blank where a physical id would go (#1263).
209
+ ir = sourceOverlayGraphs(declared, live, { unobserved: collectUnobserved(observations) });
193
210
  // Containment goes on AFTER the overlay, not through it.
194
211
  //
195
212
  // `sourceOverlayGraphs` admits a live edge only when one end is foreign,
@@ -242,6 +259,7 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
242
259
  console.log("(no matches)");
243
260
  availableAttrs(terms, ir);
244
261
  if (args.explain) explain(terms, matches, ir, nodeById, query);
262
+ for (const n of liveNotes) console.error(formatWarning({ message: n }));
245
263
  return 0;
246
264
  }
247
265
  for (const n of matches) {
@@ -254,12 +272,28 @@ export async function runSearch(ctx: CommandContext): Promise<number> {
254
272
  source.kind === "live" && !matches.some((n) => n.physicalId) && args.env
255
273
  ? (await hasSnapshot(String(args.env))) ? "yes" : undefined
256
274
  : undefined;
257
- provenance(matches, source, recorded);
275
+ provenance(matches, source, recorded, liveFailures);
276
+ // Qualifies the provenance line, so it sits with it: one line per distinct
277
+ // note for the whole run, not one per stack, and after the rows (#1265).
278
+ for (const n of liveNotes) console.error(formatWarning({ message: n }));
258
279
  ambientHint(matches, ambientKinds, args.ambient === true, replayAmbient);
259
280
  showMiss(matches, show);
260
281
  regionSpread(terms, matches, show);
261
282
  derivedSurface(terms, matches, ir, backed);
262
283
  if (args.explain) explain(terms, matches, ir, nodeById, query);
284
+ // The caller asked for a live answer and at least one lexicon could not be
285
+ // read. The rows above are still printed — they are what the source declares,
286
+ // labelled as such — but the command did not do what it was asked, and a
287
+ // script or agent needs to see that without parsing the footer (#1263).
288
+ if (liveFailures.length > 0) {
289
+ console.error(formatError({
290
+ message: `live read failed for ${liveFailures.length} lexicon${liveFailures.length === 1 ? "" : "s"} — answer is declared-only`,
291
+ hint: recorded
292
+ ? "answer from the recorded snapshot with --at latest, or drop --live for a declared-only query"
293
+ : "drop --live for a declared-only query, or record a snapshot with chant lifecycle snapshot",
294
+ }));
295
+ return 1;
296
+ }
263
297
  return 0;
264
298
  }
265
299
 
@@ -379,13 +413,24 @@ function ambientHint(
379
413
  * already done. That is a fact about the query, printed for every query, and it
380
414
  * encodes no expected answer.
381
415
  */
382
- function provenance(matches: IRNode[], source: AnswerSource, recorded?: string): void {
416
+ function provenance(matches: IRNode[], source: AnswerSource, recorded?: string, liveFailures: string[] = []): void {
383
417
  if (source.kind === "declared") {
384
418
  console.log("— declared only · no observation · physical ids unavailable");
385
419
  return;
386
420
  }
387
421
  const bound = matches.filter((n) => n.physicalId).length;
388
422
  const what = source.kind === "live" ? "live read" : "snapshot";
423
+ if (liveFailures.length > 0) {
424
+ // Could not observe, as distinct from observed nothing. The rows carry the
425
+ // per-entity verdict; this names the lexicons that failed in one line.
426
+ const lexicons = liveFailures.map((e) => e.split(":")[0]).join(", ");
427
+ const unobserved = matches.filter((n) => (n.attrs as Record<string, unknown> | undefined)?._unobserved).length;
428
+ const rest = recorded
429
+ ? "a snapshot of this environment is recorded — answer from it with --at latest"
430
+ : "answered from the declared graph · physical ids unavailable";
431
+ console.log(`— live read failed (${lexicons}) · ${unobserved}/${matches.length} rows unobserved · ${rest}`);
432
+ return;
433
+ }
389
434
  if (bound === 0) {
390
435
  // The estate was asked for and nothing came back bound. Naming it is the
391
436
  // difference between "these do not exist" and "nobody could see them".
@@ -742,6 +787,10 @@ function formatRow(n: IRNode, show: string[]): string {
742
787
  // skip source-mode AttrRef placeholders (objects).
743
788
  const physical = (n as { physicalId?: unknown }).physicalId ?? attrs["physicalId"] ?? attrs["InstanceId"] ?? attrs["Id"];
744
789
  if (physical != null && typeof physical !== "object") parts.push(String(physical));
790
+ // A row nobody could read says so where its physical id would go (#1263). A
791
+ // blank there reads as "declared, not provisioned", which the read never
792
+ // established; the reason is the tri-state's (#1089).
793
+ else if (typeof attrs["_unobserved"] === "string") parts.push(`(unobserved: ${attrs["_unobserved"]})`);
745
794
  for (const key of show) {
746
795
  // Match the name case-insensitively, and report what was actually found.
747
796
  // AWS attribute names are PascalCase and chant's derived ones are not, so a
@@ -763,4 +812,4 @@ function formatRow(n: IRNode, show: string[]): string {
763
812
  }
764
813
 
765
814
  /** Internals exposed for unit tests. */
766
- export const __searchInternals = { parseQuery, matchTerm, formatRow, explain, describeTerm, derivedSurface, availableAttrs, ambientHint, regionSpread, showMiss };
815
+ export const __searchInternals = { parseQuery, matchTerm, formatRow, explain, describeTerm, derivedSurface, availableAttrs, ambientHint, regionSpread, showMiss, provenance };
package/src/cli/main.ts CHANGED
@@ -542,7 +542,9 @@ Options:
542
542
  --fix Auto-fix fixable issues (lint command)
543
543
  --force Force overwrite existing files (import command)
544
544
  -w, --watch Watch for changes and rebuild/re-lint (build, lint)
545
- -v, --verbose Show stack traces on errors
545
+ -v, --verbose Show stack traces on errors; (build) list every
546
+ resolved build parameter and per-file fold decision
547
+ instead of the one-line summaries
546
548
  -h, --help Show this help message
547
549
  -p, --profile <name> Temporal worker profile to use (run command)
548
550
  --local Run an Op with the local in-process executor (default)
@@ -572,8 +574,8 @@ Options:
572
574
  composite factory calls (#1022/#1023), falling back
573
575
  to run per-file for anything else outside the fold
574
576
  subset (a cross-file-only reference, a re-export,
575
- \`export default\`, ...). Logs which path each file
576
- took. DEFAULT since #1134 — this flag forces it on
577
+ \`export default\`, ...). Logs a fold/run count
578
+ (per-file lines under --verbose). DEFAULT since #1134 — this flag forces it on
577
579
  over a chant.config.ts \`build.fold: false\`.
578
580
  --no-fold (build) Opt out of folding for this invocation: every
579
581
  source module is imported and run, the pre-#1134
@@ -99,7 +99,8 @@ describe("CLI end-to-end — resolveProjectLexicons must not execute project sou
99
99
  }
100
100
 
101
101
  test("`chant build --fold` on a project with no `lexicons` config does not execute the hostile file", () => {
102
- const { status, stdout, stderr } = runCli(["build", testDir, "--fold"]);
102
+ // --verbose: the per-file fold decision lines are behind it (#1424).
103
+ const { status, stdout, stderr } = runCli(["build", testDir, "--fold", "--verbose"]);
103
104
 
104
105
  expect(stderr).not.toMatch(/EXFIL/);
105
106
  expect(stdout).not.toMatch(/EXFIL/);