@intentius/chant 0.27.0 → 0.29.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 (106) hide show
  1. package/dist/cli/commands/build.d.ts.map +1 -1
  2. package/dist/cli/commands/lint.d.ts.map +1 -1
  3. package/dist/cli/handlers/components.d.ts.map +1 -1
  4. package/dist/cli/handlers/graph.d.ts.map +1 -1
  5. package/dist/cli/handlers/lifecycle.d.ts +5 -3
  6. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  7. package/dist/config.d.ts +46 -4
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/discovery/fold-import.d.ts +153 -17
  10. package/dist/discovery/fold-import.d.ts.map +1 -1
  11. package/dist/discovery/sandbox/config-run.d.ts.map +1 -1
  12. package/dist/discovery/sandbox/config-wire.d.ts +3 -2
  13. package/dist/discovery/sandbox/config-wire.d.ts.map +1 -1
  14. package/dist/discovery/sandbox/fork.d.ts +25 -0
  15. package/dist/discovery/sandbox/fork.d.ts.map +1 -1
  16. package/dist/discovery/sandbox/policy-run.d.ts.map +1 -1
  17. package/dist/discovery/sandbox/run.d.ts.map +1 -1
  18. package/dist/env.d.ts +5 -2
  19. package/dist/env.d.ts.map +1 -1
  20. package/dist/fold/fold.d.ts +12 -0
  21. package/dist/fold/fold.d.ts.map +1 -1
  22. package/dist/graph-ir.d.ts +29 -4
  23. package/dist/graph-ir.d.ts.map +1 -1
  24. package/dist/index.d.ts +1 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/kubectl-context.d.ts +100 -0
  27. package/dist/kubectl-context.d.ts.map +1 -0
  28. package/dist/lexicon.d.ts +31 -6
  29. package/dist/lexicon.d.ts.map +1 -1
  30. package/dist/lifecycle/change-set.d.ts +26 -5
  31. package/dist/lifecycle/change-set.d.ts.map +1 -1
  32. package/dist/lifecycle/live-diff.d.ts +25 -1
  33. package/dist/lifecycle/live-diff.d.ts.map +1 -1
  34. package/dist/lifecycle/observe.d.ts +4 -2
  35. package/dist/lifecycle/observe.d.ts.map +1 -1
  36. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  37. package/dist/lifecycle/status.d.ts +26 -1
  38. package/dist/lifecycle/status.d.ts.map +1 -1
  39. package/dist/lifecycle/types.d.ts +8 -0
  40. package/dist/lifecycle/types.d.ts.map +1 -1
  41. package/dist/lint/config.d.ts +80 -0
  42. package/dist/lint/config.d.ts.map +1 -1
  43. package/dist/lint/policy.d.ts +8 -2
  44. package/dist/lint/policy.d.ts.map +1 -1
  45. package/dist/lint/post-synth.d.ts +18 -1
  46. package/dist/lint/post-synth.d.ts.map +1 -1
  47. package/dist/live-endpoint.d.ts +92 -0
  48. package/dist/live-endpoint.d.ts.map +1 -0
  49. package/dist/observation.d.ts +123 -0
  50. package/dist/observation.d.ts.map +1 -0
  51. package/dist/stack-output.d.ts +9 -4
  52. package/dist/stack-output.d.ts.map +1 -1
  53. package/package.json +1 -1
  54. package/src/cli/commands/build.test.ts +190 -0
  55. package/src/cli/commands/build.ts +34 -2
  56. package/src/cli/commands/lint.ts +17 -25
  57. package/src/cli/handlers/components.test.ts +63 -4
  58. package/src/cli/handlers/components.ts +78 -35
  59. package/src/cli/handlers/graph.test.ts +69 -6
  60. package/src/cli/handlers/graph.ts +61 -27
  61. package/src/cli/handlers/lifecycle.test.ts +285 -6
  62. package/src/cli/handlers/lifecycle.ts +297 -185
  63. package/src/config.test.ts +75 -0
  64. package/src/config.ts +61 -3
  65. package/src/discovery/fold-composite.test.ts +594 -0
  66. package/src/discovery/fold-import.ts +987 -43
  67. package/src/discovery/sandbox/config-boundary.test.ts +55 -1
  68. package/src/discovery/sandbox/config-run.ts +3 -0
  69. package/src/discovery/sandbox/config-wire.ts +3 -2
  70. package/src/discovery/sandbox/fork.ts +75 -1
  71. package/src/discovery/sandbox/policy-boundary.test.ts +56 -1
  72. package/src/discovery/sandbox/policy-run.ts +15 -1
  73. package/src/discovery/sandbox/run.test.ts +85 -1
  74. package/src/discovery/sandbox/run.ts +3 -0
  75. package/src/env.test.ts +12 -0
  76. package/src/env.ts +12 -4
  77. package/src/fold/fold.ts +12 -2
  78. package/src/graph-ir-live.test.ts +28 -1
  79. package/src/graph-ir.ts +68 -12
  80. package/src/index.ts +1 -0
  81. package/src/kubectl-context.test.ts +94 -0
  82. package/src/kubectl-context.ts +207 -0
  83. package/src/lexicon.ts +41 -6
  84. package/src/lifecycle/change-set.test.ts +93 -1
  85. package/src/lifecycle/change-set.ts +65 -13
  86. package/src/lifecycle/live-diff.test.ts +39 -0
  87. package/src/lifecycle/live-diff.ts +51 -5
  88. package/src/lifecycle/observe.test.ts +74 -3
  89. package/src/lifecycle/observe.ts +82 -22
  90. package/src/lifecycle/snapshot.test.ts +39 -1
  91. package/src/lifecycle/snapshot.ts +34 -9
  92. package/src/lifecycle/status.test.ts +89 -8
  93. package/src/lifecycle/status.ts +53 -3
  94. package/src/lifecycle/types.ts +8 -0
  95. package/src/lint/config.test.ts +93 -1
  96. package/src/lint/config.ts +108 -0
  97. package/src/lint/policy.test.ts +90 -0
  98. package/src/lint/policy.ts +17 -5
  99. package/src/lint/post-synth.test.ts +4 -0
  100. package/src/lint/post-synth.ts +30 -1
  101. package/src/live-endpoint.test.ts +115 -0
  102. package/src/live-endpoint.ts +148 -0
  103. package/src/observation.test.ts +96 -0
  104. package/src/observation.ts +213 -0
  105. package/src/stack-output.test.ts +76 -3
  106. package/src/stack-output.ts +59 -26
@@ -18,8 +18,7 @@ import { formatError, formatInfo } from "../format";
18
18
  import { GENERATED_MARKER } from "../../discovery/files";
19
19
 
20
20
  // Import config loader
21
- import { loadConfig, resolveRulesForFile, parseRuleConfig, findProjectRoot } from "../../lint/config";
22
- import type { RuleConfig } from "../../lint/rule";
21
+ import { loadConfig, resolveRulesForFile, resolveConfiguredSeverity, findProjectRoot } from "../../lint/config";
23
22
 
24
23
  /**
25
24
  * Type guard to check if a value conforms to the LintRule interface.
@@ -263,28 +262,22 @@ function getDefaultRules(
263
262
  const ruleOptions = new Map<string, Record<string, unknown>>();
264
263
 
265
264
  for (const [ruleId, rule] of allRules) {
266
- const configValue: RuleConfig | undefined = effectiveRules?.[ruleId];
267
-
268
- if (configValue === undefined) {
269
- // Rule not mentioned in config — include with default severity
270
- rules.push(rule);
271
- continue;
272
- }
273
-
274
- const parsed = parseRuleConfig(configValue);
265
+ // chant #1138 the same resolution post-synth checks and COMP* checks
266
+ // now go through too (`resolveConfiguredSeverity`, ../../lint/config.ts),
267
+ // so `lint.rules: { ID: "off" }` suppresses a rule id identically no
268
+ // matter which phase produced it.
269
+ const { severity, options } = resolveConfiguredSeverity(effectiveRules, ruleId, rule.severity);
275
270
 
276
271
  // Skip rules that are explicitly turned off
277
- if (parsed.severity === "off") continue;
272
+ if (severity === "off") continue;
278
273
 
279
- // Override severity from config
280
- rules.push({
281
- ...rule,
282
- severity: parsed.severity as "error" | "warning" | "info",
283
- });
274
+ // Override severity from config (a no-op when the rule wasn't mentioned —
275
+ // `severity` is then just `rule.severity` again)
276
+ rules.push({ ...rule, severity });
284
277
 
285
278
  // Store options if present
286
- if (parsed.options) {
287
- ruleOptions.set(ruleId, parsed.options);
279
+ if (options) {
280
+ ruleOptions.set(ruleId, options);
288
281
  }
289
282
  }
290
283
 
@@ -394,12 +387,11 @@ async function runComponentCheckDiagnostics(
394
387
  // Discovery errors (COMP000) always surface at error severity — not user-configurable.
395
388
  let severity = d.severity;
396
389
  if (d.checkId !== "COMP000") {
397
- const configValue = config.rules?.[d.checkId];
398
- if (configValue !== undefined) {
399
- const parsed = parseRuleConfig(configValue);
400
- if (parsed.severity === "off") continue;
401
- severity = parsed.severity;
402
- }
390
+ // chant #1138 — same resolution function AST rules and post-synth
391
+ // checks use (`resolveConfiguredSeverity`, ../../lint/config.ts).
392
+ const resolved = resolveConfiguredSeverity(config.rules, d.checkId, d.severity);
393
+ if (resolved.severity === "off") continue;
394
+ severity = resolved.severity;
403
395
  }
404
396
 
405
397
  const disable = fileLevelDisable(d.file, d.checkId);
@@ -1,4 +1,4 @@
1
- import { describe, test, expect, vi, beforeEach } from "vitest";
1
+ import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
2
2
  import { createMockPlugin, staticDescribeResources } from "@intentius/chant-test-utils";
3
3
  import type { LexiconPlugin, ResourceMetadata } from "../../lexicon";
4
4
  import type { BuildResult } from "../../build";
@@ -38,9 +38,13 @@ vi.mock("../../lifecycle/release-ledger", async () => {
38
38
  };
39
39
  });
40
40
 
41
- vi.mock("../../config", () => ({
42
- loadChantConfig: (...args: unknown[]) => loadChantConfigMock(...args),
43
- }));
41
+ vi.mock("../../config", async () => {
42
+ const actual = await vi.importActual<typeof import("../../config")>("../../config");
43
+ return {
44
+ ...actual,
45
+ loadChantConfig: (...args: unknown[]) => loadChantConfigMock(...args),
46
+ };
47
+ });
44
48
 
45
49
  vi.mock("../../build", () => ({
46
50
  build: (...args: unknown[]) => buildMock(...args),
@@ -267,6 +271,61 @@ describe("components handlers", () => {
267
271
  expect(rows[0]).toMatchObject({ component: "svc", reconciliation: "reconciled" });
268
272
  });
269
273
 
274
+ // #1166 — an environment can declare its own endpoint (a local emulator
275
+ // like Floci), applied here unless the ambient shell already set it, so
276
+ // `--live` status doesn't silently read the wrong account.
277
+ describe("declared endpoint (#1166)", () => {
278
+ const prevEndpoint = process.env.AWS_ENDPOINT_URL;
279
+
280
+ afterEach(() => {
281
+ if (prevEndpoint === undefined) delete process.env.AWS_ENDPOINT_URL;
282
+ else process.env.AWS_ENDPOINT_URL = prevEndpoint;
283
+ });
284
+
285
+ test("applies the declared endpoint to AWS_ENDPOINT_URL for the live describe, then restores it", async () => {
286
+ delete process.env.AWS_ENDPOINT_URL;
287
+ readReleaseLedgerMock.mockResolvedValue({
288
+ records: [{
289
+ version: 1,
290
+ component: "svc",
291
+ env: "floci",
292
+ digest: "sha256:abc",
293
+ gitSha: "sha1",
294
+ runId: "run-1",
295
+ timestamp: "2026-01-01T00:00:00.000Z",
296
+ actor: "alice",
297
+ }],
298
+ malformed: 0,
299
+ });
300
+ buildMock.mockResolvedValue(makeBuildResult({ aws: ["svc"] }));
301
+ loadChantConfigMock.mockResolvedValue({
302
+ config: { environments: [{ name: "floci", endpoint: "http://localhost:4566" }] },
303
+ });
304
+
305
+ let seenDuringDescribe: string | undefined;
306
+ const plugins: LexiconPlugin[] = [
307
+ createMockPlugin({
308
+ name: "aws",
309
+ describeResources: async () => {
310
+ seenDuringDescribe = process.env.AWS_ENDPOINT_URL;
311
+ return { svc: meta() };
312
+ },
313
+ }),
314
+ ];
315
+
316
+ const ctx = {
317
+ args: makeArgs({ extraPositional: "floci", live: true, json: true }),
318
+ plugins,
319
+ serializers: plugins.map((p) => p.serializer),
320
+ };
321
+ const exit = await runComponentsStatus(ctx);
322
+ expect(exit).toBe(0);
323
+ expect(seenDuringDescribe).toBe("http://localhost:4566");
324
+ expect(process.env.AWS_ENDPOINT_URL).toBeUndefined();
325
+ expect(stderrBuf.join("\n")).toMatch(/environment "floci" declares endpoint http:\/\/localhost:4566/);
326
+ });
327
+ });
328
+
270
329
  test("--live flags an unrecorded live+owned component", async () => {
271
330
  readReleaseLedgerMock.mockResolvedValue({ records: [], malformed: 0 });
272
331
  listReleaseEnvironmentsMock.mockResolvedValue(["prod"]);
@@ -40,11 +40,13 @@ import { buildLedgerEntries, componentBomSummary, type BuildLedgerEntry } from "
40
40
  import { findBuildManifestByArtifactDigest } from "../../lifecycle/build-ledger-store";
41
41
  import type { ComponentBomSummary } from "../../lifecycle/build-ledger";
42
42
  import { loadChantConfig } from "../../config";
43
+ import { applyLiveEndpoint } from "../../live-endpoint";
43
44
  import { build } from "../../build";
44
45
  import { discoverComponents } from "../../components/discover";
45
46
  import { formatError, formatWarning, formatSuccess, formatBold } from "../format";
46
47
  import type { CommandContext } from "../registry";
47
- import type { ResourceMetadata, LexiconPlugin } from "../../lexicon";
48
+ import type { LexiconPlugin } from "../../lexicon";
49
+ import { normalizeObservation, unobservedAll, type NormalizedObservation } from "../../observation";
48
50
  import type { Phase, Component } from "../../components/component";
49
51
 
50
52
  /**
@@ -177,7 +179,10 @@ interface StatusJsonRow {
177
179
  } | null;
178
180
  reconciliation: string;
179
181
  detail: string;
182
+ /** Present only when live state was requested AND read (#1089) — see `unobserved`. */
180
183
  live?: boolean;
184
+ /** Why live state could not be read for this component (#1089). Mutually exclusive with `live`. */
185
+ unobserved?: { reason: string; detail?: string };
181
186
  stack?: { name: string; status?: string; healthy?: boolean };
182
187
  }
183
188
 
@@ -248,7 +253,20 @@ async function observeComponentStacks(
248
253
  stacks.map((stack) => observer.describeStackStatus!({ environment, stack }).catch(() => null)),
249
254
  );
250
255
  const determinate = observed.filter((o): o is NonNullable<typeof o> => o !== null);
251
- if (determinate.length === 0) continue;
256
+ if (determinate.length === 0) {
257
+ // Every stack read came back indeterminate (the lexicon's own "I cannot
258
+ // tell" — see describeStackStatus). Record the hole rather than dropping
259
+ // the component, which would leave a recorded component reading `stale`
260
+ // (#1089).
261
+ evidence.set(name, {
262
+ live: false,
263
+ unobserved: {
264
+ reason: "read-failed",
265
+ detail: `no determinate status for stack(s): ${stacks.join(", ")}`,
266
+ },
267
+ });
268
+ continue;
269
+ }
252
270
  const present = determinate.every((o) => o.present);
253
271
  // Surface the (first present, else first) stack's raw status for a richer
254
272
  // palette than the reconciliation verdict. One cfn-deploy stack per component
@@ -310,42 +328,66 @@ export async function runComponentsStatus(ctx: CommandContext): Promise<number>
310
328
 
311
329
  let liveEvidence;
312
330
  if (args.live) {
313
- const targetSerializers = serializers;
314
- const buildResult = await build(resolve(args.src ?? config.sourceDir ?? "."), targetSerializers);
315
- const merged: { env: string; entries: import("../../lifecycle/change-set").ChangeSetEntry[] } = { env: environment, entries: [] };
316
- for (const plugin of plugins) {
317
- if (!plugin.describeResources) continue;
318
- const declared = new Set<string>();
319
- const entities = new Map<string, { entityType: string; props: Record<string, unknown> }>();
320
- for (const [name, entity] of buildResult.entities) {
321
- if (entity.lexicon === plugin.name) {
322
- declared.add(name);
323
- entities.set(name, {
324
- entityType: entity.entityType,
325
- props: ("props" in entity && entity.props != null ? entity.props : {}) as Record<string, unknown>,
326
- });
331
+ // #1166 an environment can declare its own endpoint (a local emulator
332
+ // like Floci), applied here unless the ambient shell already set it, so
333
+ // `--live` status doesn't silently read the wrong account per environment.
334
+ const endpointResult = applyLiveEndpoint(
335
+ config.environments,
336
+ environment,
337
+ plugins.filter((p) => p.describeResources || p.describeStackStatus).map((p) => p.name),
338
+ );
339
+ if (endpointResult.notice) console.error(formatWarning({ message: endpointResult.notice }));
340
+ try {
341
+ const targetSerializers = serializers;
342
+ const buildResult = await build(resolve(args.src ?? config.sourceDir ?? "."), targetSerializers);
343
+ const merged: { env: string; entries: import("../../lifecycle/change-set").ChangeSetEntry[] } = { env: environment, entries: [] };
344
+ for (const plugin of plugins) {
345
+ if (!plugin.describeResources) continue;
346
+ const declared = new Set<string>();
347
+ const entities = new Map<string, { entityType: string; props: Record<string, unknown> }>();
348
+ for (const [name, entity] of buildResult.entities) {
349
+ if (entity.lexicon === plugin.name) {
350
+ declared.add(name);
351
+ entities.set(name, {
352
+ entityType: entity.entityType,
353
+ props: ("props" in entity && entity.props != null ? entity.props : {}) as Record<string, unknown>,
354
+ });
355
+ }
327
356
  }
357
+ let observed: NormalizedObservation;
358
+ try {
359
+ observed = normalizeObservation(
360
+ await plugin.describeResources({ environment, buildOutput: "", entityNames: Array.from(declared), entities }),
361
+ );
362
+ } catch (err) {
363
+ // A failed read is not an empty environment (#1089): mark every
364
+ // declared entity NOT-OBSERVED so the status rows say "unknown"
365
+ // rather than "stale" (recorded, and nothing live).
366
+ const message = err instanceof Error ? err.message : String(err);
367
+ console.error(formatWarning({ message: `${plugin.name}: describeResources failed — ${message} (components in this lexicon report unknown, not stale)` }));
368
+ observed = { resources: {}, unobserved: unobservedAll(declared, "read-failed", message, entities) };
369
+ }
370
+ const cs = buildChangeSet(environment, {
371
+ declared,
372
+ observedNow: observed.resources,
373
+ observedThen: undefined,
374
+ unobserved: observed.unobserved,
375
+ });
376
+ merged.entries.push(...cs.entries);
328
377
  }
329
- let observedNow: Record<string, ResourceMetadata>;
330
- try {
331
- observedNow = await plugin.describeResources({ environment, buildOutput: "", entityNames: Array.from(declared), entities });
332
- } catch (err) {
333
- console.error(formatWarning({ message: `${plugin.name}: describeResources failed — ${err instanceof Error ? err.message : String(err)}` }));
334
- continue;
335
- }
336
- const cs = buildChangeSet(environment, { declared, observedNow, observedThen: undefined });
337
- merged.entries.push(...cs.entries);
338
- }
339
- liveEvidence = liveEvidenceFromChangeSet(merged, liveNameMapping);
378
+ liveEvidence = liveEvidenceFromChangeSet(merged, liveNameMapping);
340
379
 
341
- // Multi-stack component projects (each component owns its own stack) are
342
- // invisible to the entity-keyed, single-stack `describeResources` above —
343
- // observe each component's own cfn-deploy stack directly and overlay it as
344
- // the authoritative presence signal (#57).
345
- const stackObserver = plugins.find((p) => p.describeStackStatus);
346
- if (stackObserver) {
347
- const stackEvidence = await observeComponentStacks(discovery.components, stackObserver, environment);
348
- liveEvidence = mergeLiveEvidence(liveEvidence, stackEvidence);
380
+ // Multi-stack component projects (each component owns its own stack) are
381
+ // invisible to the entity-keyed, single-stack `describeResources` above —
382
+ // observe each component's own cfn-deploy stack directly and overlay it as
383
+ // the authoritative presence signal (#57).
384
+ const stackObserver = plugins.find((p) => p.describeStackStatus);
385
+ if (stackObserver) {
386
+ const stackEvidence = await observeComponentStacks(discovery.components, stackObserver, environment);
387
+ liveEvidence = mergeLiveEvidence(liveEvidence, stackEvidence);
388
+ }
389
+ } finally {
390
+ endpointResult.restore();
349
391
  }
350
392
  }
351
393
 
@@ -420,6 +462,7 @@ export async function runComponentsStatus(ctx: CommandContext): Promise<number>
420
462
  reconciliation: row.reconciliation,
421
463
  detail: row.detail,
422
464
  ...(row.live !== undefined ? { live: row.live } : {}),
465
+ ...(row.unobserved ? { unobserved: row.unobserved } : {}),
423
466
  ...(row.stack ? { stack: row.stack } : {}),
424
467
  });
425
468
  }
@@ -1,4 +1,4 @@
1
- import { describe, test, expect, vi, beforeEach } from "vitest";
1
+ import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
2
2
  import type { ParsedArgs } from "../registry";
3
3
  import { DECLARABLE_MARKER, type Declarable } from "../../declarable";
4
4
  import { AttrRef } from "../../attrref";
@@ -52,9 +52,14 @@ const discoverComponentsMock = vi.fn();
52
52
  vi.mock("../../components/discover", () => ({
53
53
  discoverComponents: (...a: unknown[]) => discoverComponentsMock(...a),
54
54
  }));
55
- vi.mock("../../config", () => ({
56
- loadChantConfig: () => Promise.resolve({ config: {} }),
57
- }));
55
+ const loadChantConfigMock = vi.fn();
56
+ vi.mock("../../config", async () => {
57
+ const actual = await vi.importActual<typeof import("../../config")>("../../config");
58
+ return {
59
+ ...actual,
60
+ loadChantConfig: (...a: unknown[]) => loadChantConfigMock(...a),
61
+ };
62
+ });
58
63
  vi.mock("../../build", () => ({
59
64
  build: () => Promise.resolve({ errors: [] }),
60
65
  partitionByLexicon: () => ({}),
@@ -107,6 +112,8 @@ describe("runGraph", () => {
107
112
  observeMock.mockReset();
108
113
  loadPluginsMock.mockReset();
109
114
  resolveLexMock.mockReset();
115
+ loadChantConfigMock.mockReset();
116
+ loadChantConfigMock.mockResolvedValue({ config: {} });
110
117
  });
111
118
 
112
119
  describe("Op graph (default)", () => {
@@ -332,6 +339,7 @@ describe("runGraph", () => {
332
339
  observeMock.mockResolvedValue({
333
340
  observations: [{ lexicon: "aws", resources: { "web-vpc": { type: "AWS::EC2::VPC", status: "OK" } } }],
334
341
  errors: [],
342
+ warnings: [],
335
343
  });
336
344
  const exit = await runGraph({ args: makeArgs({ format: "ir", live: true, env: "prod" }), plugins: [], serializers: [] });
337
345
  expect(exit).toBe(0);
@@ -349,7 +357,7 @@ describe("runGraph", () => {
349
357
  loadPluginsMock.mockResolvedValue([
350
358
  { name: "aws", serializer: {}, describeResources: () => Promise.resolve({}) },
351
359
  ]);
352
- observeMock.mockResolvedValue({ observations: [], errors: [] });
360
+ observeMock.mockResolvedValue({ observations: [], errors: [], warnings: [] });
353
361
  const exit = await runGraph({ args: makeArgs({ format: "ir", live: true, env: "prod" }), plugins: [], serializers: [] });
354
362
  expect(exit).toBe(0);
355
363
  expect(observeMock).toHaveBeenCalledWith("prod", expect.anything(), expect.anything(), {
@@ -390,6 +398,7 @@ describe("runGraph", () => {
390
398
  observeMock.mockResolvedValue({
391
399
  observations: [{ lexicon: "aws", resources: { "loom-db": { type: "AWS::RDS::DBInstance", status: "OK" } } }],
392
400
  errors: [],
401
+ warnings: [],
393
402
  });
394
403
  const exit = await runGraph({ args: makeArgs({ format: "ir", live: true, env: "local" }), plugins: [], serializers: [] });
395
404
  expect(exit).toBe(0);
@@ -409,7 +418,7 @@ describe("runGraph", () => {
409
418
  { name: "aws", serializer: {}, describeResources: () => Promise.resolve({}) },
410
419
  ]);
411
420
  discoverComponentsMock.mockResolvedValue({ errors: [{ message: "bad component" }], sourceFiles: [], components: new Map() });
412
- observeMock.mockResolvedValue({ observations: [], errors: [] });
421
+ observeMock.mockResolvedValue({ observations: [], errors: [], warnings: [] });
413
422
  const exit = await runGraph({ args: makeArgs({ format: "ir", live: true, env: "prod" }), plugins: [], serializers: [] });
414
423
  expect(exit).toBe(0);
415
424
  expect(observeMock).toHaveBeenCalledWith("prod", expect.anything(), expect.anything(), {
@@ -418,5 +427,59 @@ describe("runGraph", () => {
418
427
  });
419
428
  expect(stderrBuf.join("\n")).toMatch(/component discovery failed/i);
420
429
  });
430
+
431
+ // #1166 — an environment can declare its own endpoint (a local emulator
432
+ // like Floci), so `--live --env floci` observes the right target even when
433
+ // the ambient shell never exported AWS_ENDPOINT_URL.
434
+ describe("declared endpoint (#1166)", () => {
435
+ const prevEndpoint = process.env.AWS_ENDPOINT_URL;
436
+
437
+ afterEach(() => {
438
+ if (prevEndpoint === undefined) delete process.env.AWS_ENDPOINT_URL;
439
+ else process.env.AWS_ENDPOINT_URL = prevEndpoint;
440
+ });
441
+
442
+ test("applies the declared endpoint to AWS_ENDPOINT_URL for the observe call, then restores it", async () => {
443
+ delete process.env.AWS_ENDPOINT_URL;
444
+ loadChantConfigMock.mockResolvedValue({
445
+ config: { environments: [{ name: "floci", endpoint: "http://localhost:4566" }] },
446
+ });
447
+ resolveLexMock.mockResolvedValue(["aws"]);
448
+ loadPluginsMock.mockResolvedValue([
449
+ { name: "aws", serializer: {}, describeResources: () => Promise.resolve({}) },
450
+ ]);
451
+ let seenDuringObserve: string | undefined;
452
+ observeMock.mockImplementation(async () => {
453
+ seenDuringObserve = process.env.AWS_ENDPOINT_URL;
454
+ return { observations: [], errors: [], warnings: [] };
455
+ });
456
+ const exit = await runGraph({ args: makeArgs({ format: "ir", live: true, env: "floci" }), plugins: [], serializers: [] });
457
+ expect(exit).toBe(0);
458
+ expect(seenDuringObserve).toBe("http://localhost:4566");
459
+ expect(process.env.AWS_ENDPOINT_URL).toBeUndefined(); // restored after the read
460
+ expect(stderrBuf.join("\n")).toMatch(/environment "floci" declares endpoint http:\/\/localhost:4566/);
461
+ });
462
+
463
+ test("ambient AWS_ENDPOINT_URL still wins when already set", async () => {
464
+ process.env.AWS_ENDPOINT_URL = "http://real-endpoint.example";
465
+ loadChantConfigMock.mockResolvedValue({
466
+ config: { environments: [{ name: "floci", endpoint: "http://localhost:4566" }] },
467
+ });
468
+ resolveLexMock.mockResolvedValue(["aws"]);
469
+ loadPluginsMock.mockResolvedValue([
470
+ { name: "aws", serializer: {}, describeResources: () => Promise.resolve({}) },
471
+ ]);
472
+ let seenDuringObserve: string | undefined;
473
+ observeMock.mockImplementation(async () => {
474
+ seenDuringObserve = process.env.AWS_ENDPOINT_URL;
475
+ return { observations: [], errors: [], warnings: [] };
476
+ });
477
+ const exit = await runGraph({ args: makeArgs({ format: "ir", live: true, env: "floci" }), plugins: [], serializers: [] });
478
+ expect(exit).toBe(0);
479
+ expect(seenDuringObserve).toBe("http://real-endpoint.example"); // ambient wins
480
+ expect(process.env.AWS_ENDPOINT_URL).toBe("http://real-endpoint.example"); // untouched
481
+ expect(stderrBuf.join("\n")).toMatch(/ambient AWS_ENDPOINT_URL already set/);
482
+ });
483
+ });
421
484
  });
422
485
  });
@@ -2,10 +2,11 @@ import { resolve } from "node:path";
2
2
  import { discoverOps } from "../../op/discover";
3
3
  import { discover } from "../../discovery/index";
4
4
  import { partitionByLexicon, computeStackGraph, build } from "../../build";
5
- import { buildGraphIr, buildLiveGraphIr, overlayGraphs, sourceOverlayGraphs, type GraphIR } from "../../graph-ir";
5
+ import { buildGraphIr, buildLiveGraphIr, collectUnobserved, overlayGraphs, sourceOverlayGraphs, type GraphIR, type LiveObservation } from "../../graph-ir";
6
6
  import { reconstructEdges, mergeCatalogs, containmentGroups, type ReferenceCatalog, type ContainmentPair } from "../../graph-refs";
7
7
  import { observeResources } from "../../lifecycle/observe";
8
- import { loadChantConfig } from "../../config";
8
+ import { loadChantConfig, environmentNames } from "../../config";
9
+ import { applyLiveEndpoint } from "../../live-endpoint";
9
10
  import { applyDetail, type DetailLevel } from "../../graph-detail";
10
11
  import { applyLens, parseLens } from "../../graph-lens";
11
12
  import { toMermaid } from "../../graph-mermaid";
@@ -76,10 +77,11 @@ async function runGraphLive(
76
77
  // lexicon), so `ctx.plugins` is empty. The live path needs the project's
77
78
  // observation plugins — load them here, mirroring the lifecycle handlers.
78
79
  const plugins = ctx.plugins.length > 0 ? ctx.plugins : await loadPlugins(await resolveProjectLexicons(projectPath));
79
- if (config.environments && !config.environments.includes(environment)) {
80
+ const declaredEnvNames = environmentNames(config.environments);
81
+ if (declaredEnvNames && !declaredEnvNames.includes(environment)) {
80
82
  console.error(formatError({
81
83
  message: `Unknown environment "${environment}"`,
82
- hint: `Defined environments: ${config.environments.join(", ")}`,
84
+ hint: `Defined environments: ${declaredEnvNames.join(", ")}`,
83
85
  }));
84
86
  return 1;
85
87
  }
@@ -121,30 +123,58 @@ async function runGraphLive(
121
123
  console.error(formatWarning({ message: "component discovery failed — observing the single-stack convention instead" }));
122
124
  }
123
125
 
124
- const { observations, errors } = await observeResources(environment, observing, buildResult, {
125
- owned: true,
126
- stacks: [...stacks],
127
- });
128
- for (const e of errors) console.error(formatWarning({ message: e }));
126
+ // #1166 an environment can declare its own endpoint (a local emulator like
127
+ // Floci), so this read is self-sufficient even when the ambient shell never
128
+ // exported e.g. AWS_ENDPOINT_URL. Ambient always wins when it's already set.
129
+ // Scoped to just this describe/enrich pass — restored in `finally` so it
130
+ // never leaks into a later invocation in the same process.
131
+ const endpointResult = applyLiveEndpoint(config.environments, environment, observing.map((p) => p.name));
132
+ if (endpointResult.notice) console.error(formatWarning({ message: endpointResult.notice }));
129
133
 
130
- let ir: GraphIR = buildLiveGraphIr(observations);
134
+ let ir: GraphIR;
135
+ let observations: LiveObservation[];
136
+ try {
137
+ const observeResult = await observeResources(environment, observing, buildResult, {
138
+ owned: true,
139
+ stacks: [...stacks],
140
+ });
141
+ observations = observeResult.observations;
142
+ const { errors, warnings } = observeResult;
143
+ for (const e of errors) console.error(formatWarning({ message: e }));
144
+ // Unobserved entities (#1089) arrive as warnings — a node missing from the
145
+ // live graph because nobody looked is a different fact from one that isn't
146
+ // deployed, and the diagram alone cannot say which. Capped: an estate with no
147
+ // ownership markers can produce one per declared entity, and a wall of them
148
+ // buries the graph output. The full list is `lifecycle diff --live`.
149
+ const WARN_CAP = 5;
150
+ for (const w of warnings.slice(0, WARN_CAP)) console.error(formatWarning({ message: w }));
151
+ if (warnings.length > WARN_CAP) {
152
+ console.error(formatWarning({
153
+ message: `... and ${warnings.length - WARN_CAP} more entity(ies) not observed — run \`chant lifecycle diff ${environment} --live\` for the full list`,
154
+ }));
155
+ }
131
156
 
132
- // Enrich node attrs from the fuller live config (#784) so references are
133
- // present for edge reconstruction — describeResources metadata alone is often
134
- // too thin (e.g. AWS returns stack outputs, not per-resource references).
135
- for (const p of observing) {
136
- if (!p.enrichLiveAttrs) continue;
137
- try {
138
- const enriched = await p.enrichLiveAttrs({ environment, owned: true });
139
- ir = {
140
- ...ir,
141
- nodes: ir.nodes.map((n) =>
142
- n.lexicon === p.name && enriched[n.id] ? { ...n, attrs: { ...n.attrs, ...enriched[n.id] } } : n,
143
- ),
144
- };
145
- } catch (err) {
146
- console.error(formatWarning({ message: `${p.name}: live attr enrichment failed — edges may be sparse (${err instanceof Error ? err.message : String(err)})` }));
157
+ ir = buildLiveGraphIr(observations);
158
+
159
+ // Enrich node attrs from the fuller live config (#784) so references are
160
+ // present for edge reconstruction describeResources metadata alone is often
161
+ // too thin (e.g. AWS returns stack outputs, not per-resource references).
162
+ for (const p of observing) {
163
+ if (!p.enrichLiveAttrs) continue;
164
+ try {
165
+ const enriched = await p.enrichLiveAttrs({ environment, owned: true });
166
+ ir = {
167
+ ...ir,
168
+ nodes: ir.nodes.map((n) =>
169
+ n.lexicon === p.name && enriched[n.id] ? { ...n, attrs: { ...n.attrs, ...enriched[n.id] } } : n,
170
+ ),
171
+ };
172
+ } catch (err) {
173
+ console.error(formatWarning({ message: `${p.name}: live attr enrichment failed — edges may be sparse (${err instanceof Error ? err.message : String(err)})` }));
174
+ }
147
175
  }
176
+ } finally {
177
+ endpointResult.restore();
148
178
  }
149
179
 
150
180
  // Reconstruct edges + containment from live references (#778): merge the
@@ -166,10 +196,14 @@ async function runGraphLive(
166
196
  const declared = await discover(resolve(args.src ?? config.sourceDir ?? "."));
167
197
  if (declared.errors.length === 0) {
168
198
  const declaredIr = buildGraphIr(declared.entities, projectPath);
199
+ // Declared nodes chant could not read are painted `neutral`, not
200
+ // `accent`/pending (#1089) — a wrong-cluster or unsupported-kind read
201
+ // must not draw the estate as "not deployed yet".
202
+ const overlayOpts = { unobserved: collectUnobserved(observations) };
169
203
  ir =
170
204
  args.overlayAnchor === "live"
171
- ? overlayGraphs(ir, declaredIr)
172
- : sourceOverlayGraphs(declaredIr, ir);
205
+ ? overlayGraphs(ir, declaredIr, overlayOpts)
206
+ : sourceOverlayGraphs(declaredIr, ir, overlayOpts);
173
207
  } else {
174
208
  console.error(formatWarning({ message: "overlay: source has discovery errors — showing the provisioned graph without the declared overlay" }));
175
209
  }