@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.
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts +5 -3
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/config.d.ts +46 -4
- package/dist/config.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +153 -17
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/discovery/sandbox/config-run.d.ts.map +1 -1
- package/dist/discovery/sandbox/config-wire.d.ts +3 -2
- package/dist/discovery/sandbox/config-wire.d.ts.map +1 -1
- package/dist/discovery/sandbox/fork.d.ts +25 -0
- package/dist/discovery/sandbox/fork.d.ts.map +1 -1
- package/dist/discovery/sandbox/policy-run.d.ts.map +1 -1
- package/dist/discovery/sandbox/run.d.ts.map +1 -1
- package/dist/env.d.ts +5 -2
- package/dist/env.d.ts.map +1 -1
- package/dist/fold/fold.d.ts +12 -0
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +29 -4
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/kubectl-context.d.ts +100 -0
- package/dist/kubectl-context.d.ts.map +1 -0
- package/dist/lexicon.d.ts +31 -6
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/change-set.d.ts +26 -5
- package/dist/lifecycle/change-set.d.ts.map +1 -1
- package/dist/lifecycle/live-diff.d.ts +25 -1
- package/dist/lifecycle/live-diff.d.ts.map +1 -1
- package/dist/lifecycle/observe.d.ts +4 -2
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/lifecycle/snapshot.d.ts.map +1 -1
- package/dist/lifecycle/status.d.ts +26 -1
- package/dist/lifecycle/status.d.ts.map +1 -1
- package/dist/lifecycle/types.d.ts +8 -0
- package/dist/lifecycle/types.d.ts.map +1 -1
- package/dist/lint/config.d.ts +80 -0
- package/dist/lint/config.d.ts.map +1 -1
- package/dist/lint/policy.d.ts +8 -2
- package/dist/lint/policy.d.ts.map +1 -1
- package/dist/lint/post-synth.d.ts +18 -1
- package/dist/lint/post-synth.d.ts.map +1 -1
- package/dist/live-endpoint.d.ts +92 -0
- package/dist/live-endpoint.d.ts.map +1 -0
- package/dist/observation.d.ts +123 -0
- package/dist/observation.d.ts.map +1 -0
- package/dist/stack-output.d.ts +9 -4
- package/dist/stack-output.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/build.test.ts +190 -0
- package/src/cli/commands/build.ts +34 -2
- package/src/cli/commands/lint.ts +17 -25
- package/src/cli/handlers/components.test.ts +63 -4
- package/src/cli/handlers/components.ts +78 -35
- package/src/cli/handlers/graph.test.ts +69 -6
- package/src/cli/handlers/graph.ts +61 -27
- package/src/cli/handlers/lifecycle.test.ts +285 -6
- package/src/cli/handlers/lifecycle.ts +297 -185
- package/src/config.test.ts +75 -0
- package/src/config.ts +61 -3
- package/src/discovery/fold-composite.test.ts +594 -0
- package/src/discovery/fold-import.ts +987 -43
- package/src/discovery/sandbox/config-boundary.test.ts +55 -1
- package/src/discovery/sandbox/config-run.ts +3 -0
- package/src/discovery/sandbox/config-wire.ts +3 -2
- package/src/discovery/sandbox/fork.ts +75 -1
- package/src/discovery/sandbox/policy-boundary.test.ts +56 -1
- package/src/discovery/sandbox/policy-run.ts +15 -1
- package/src/discovery/sandbox/run.test.ts +85 -1
- package/src/discovery/sandbox/run.ts +3 -0
- package/src/env.test.ts +12 -0
- package/src/env.ts +12 -4
- package/src/fold/fold.ts +12 -2
- package/src/graph-ir-live.test.ts +28 -1
- package/src/graph-ir.ts +68 -12
- package/src/index.ts +1 -0
- package/src/kubectl-context.test.ts +94 -0
- package/src/kubectl-context.ts +207 -0
- package/src/lexicon.ts +41 -6
- package/src/lifecycle/change-set.test.ts +93 -1
- package/src/lifecycle/change-set.ts +65 -13
- package/src/lifecycle/live-diff.test.ts +39 -0
- package/src/lifecycle/live-diff.ts +51 -5
- package/src/lifecycle/observe.test.ts +74 -3
- package/src/lifecycle/observe.ts +82 -22
- package/src/lifecycle/snapshot.test.ts +39 -1
- package/src/lifecycle/snapshot.ts +34 -9
- package/src/lifecycle/status.test.ts +89 -8
- package/src/lifecycle/status.ts +53 -3
- package/src/lifecycle/types.ts +8 -0
- package/src/lint/config.test.ts +93 -1
- package/src/lint/config.ts +108 -0
- package/src/lint/policy.test.ts +90 -0
- package/src/lint/policy.ts +17 -5
- package/src/lint/post-synth.test.ts +4 -0
- package/src/lint/post-synth.ts +30 -1
- package/src/live-endpoint.test.ts +115 -0
- package/src/live-endpoint.ts +148 -0
- package/src/observation.test.ts +96 -0
- package/src/observation.ts +213 -0
- package/src/stack-output.test.ts +76 -3
- package/src/stack-output.ts +59 -26
package/src/cli/commands/lint.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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 (
|
|
272
|
+
if (severity === "off") continue;
|
|
278
273
|
|
|
279
|
-
// Override severity from config
|
|
280
|
-
|
|
281
|
-
|
|
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 (
|
|
287
|
-
ruleOptions.set(ruleId,
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
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 {
|
|
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)
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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.
|
|
56
|
-
|
|
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
|
-
|
|
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: ${
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
}
|