@intentius/chant 0.37.2 → 0.39.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/build.d.ts +21 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon-mcp.d.ts +44 -0
- package/dist/cli/commands/check-lexicon-mcp.d.ts.map +1 -0
- package/dist/cli/commands/check-lexicon-plugin.d.ts +57 -0
- package/dist/cli/commands/check-lexicon-plugin.d.ts.map +1 -0
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/cli/handlers/emulator.d.ts.map +1 -1
- package/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/run-client.d.ts.map +1 -1
- package/dist/cli/handlers/search.d.ts +30 -1
- package/dist/cli/handlers/search.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/server.d.ts +26 -2
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +20 -0
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/codegen/registry.d.ts +23 -0
- package/dist/codegen/registry.d.ts.map +1 -0
- package/dist/components/sandbox/driver.d.ts.map +1 -1
- package/dist/composite.d.ts +23 -4
- package/dist/composite.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +11 -0
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/discovery/sandbox/driver.d.ts.map +1 -1
- package/dist/graph-declared.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +17 -3
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/graph-refs.d.ts +24 -0
- package/dist/graph-refs.d.ts.map +1 -1
- package/dist/kubectl-context.d.ts.map +1 -1
- package/dist/lexicon-config.d.ts +61 -0
- package/dist/lexicon-config.d.ts.map +1 -0
- package/dist/lexicon.d.ts +85 -37
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/deep-diff.d.ts +11 -0
- package/dist/lifecycle/deep-diff.d.ts.map +1 -1
- package/dist/lifecycle/digest.d.ts.map +1 -1
- package/dist/lifecycle/identity.d.ts +52 -0
- package/dist/lifecycle/identity.d.ts.map +1 -0
- package/dist/lifecycle/observe.d.ts +5 -0
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/lifecycle/replay.d.ts.map +1 -1
- package/dist/lifecycle/types.d.ts +30 -0
- package/dist/lifecycle/types.d.ts.map +1 -1
- package/dist/live-endpoint.d.ts +21 -22
- package/dist/live-endpoint.d.ts.map +1 -1
- package/dist/managed-fields.d.ts +11 -0
- package/dist/managed-fields.d.ts.map +1 -1
- package/dist/op/emulator-freshness.d.ts +44 -0
- package/dist/op/emulator-freshness.d.ts.map +1 -0
- package/dist/op/emulator-lifecycle.d.ts +36 -0
- package/dist/op/emulator-lifecycle.d.ts.map +1 -1
- package/dist/op/index.d.ts +4 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/ownership.d.ts +33 -0
- package/dist/ownership.d.ts.map +1 -1
- package/dist/serializer.d.ts +15 -0
- package/dist/serializer.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/audit/catalog.test.ts +58 -6
- package/src/build.ts +24 -0
- package/src/cli/commands/build.ts +10 -0
- package/src/cli/commands/check-lexicon-doc-drift.test.ts +73 -0
- package/src/cli/commands/check-lexicon-mcp.test.ts +93 -0
- package/src/cli/commands/check-lexicon-mcp.ts +103 -0
- package/src/cli/commands/check-lexicon-plugin.test.ts +149 -0
- package/src/cli/commands/check-lexicon-plugin.ts +115 -0
- package/src/cli/commands/check-lexicon.ts +176 -26
- package/src/cli/handlers/components.test.ts +17 -0
- package/src/cli/handlers/components.ts +1 -1
- package/src/cli/handlers/emulator.ts +12 -8
- package/src/cli/handlers/graph.test.ts +71 -12
- package/src/cli/handlers/graph.ts +46 -5
- package/src/cli/handlers/lifecycle.test.ts +25 -4
- package/src/cli/handlers/lifecycle.ts +19 -3
- package/src/cli/handlers/run-client.ts +3 -1
- package/src/cli/handlers/search-kind.test.ts +45 -0
- package/src/cli/handlers/search.ts +102 -4
- package/src/cli/main.ts +32 -10
- package/src/cli/mcp/server.test.ts +82 -0
- package/src/cli/mcp/server.ts +40 -5
- package/src/cli/param-flag-scope.test.ts +69 -0
- package/src/cli/plugins.test.ts +33 -1
- package/src/cli/plugins.ts +55 -0
- package/src/codegen/registry.test.ts +56 -0
- package/src/codegen/registry.ts +69 -0
- package/src/components/SPRAWL-VALIDATION.md +5 -5
- package/src/components/sandbox/driver.test.ts +27 -0
- package/src/components/sandbox/driver.ts +12 -0
- package/src/composite.ts +33 -4
- package/src/deep-observation.ts +11 -0
- package/src/discovery/sandbox/driver.test.ts +34 -0
- package/src/discovery/sandbox/driver.ts +19 -0
- package/src/graph-declared.test.ts +86 -0
- package/src/graph-declared.ts +14 -2
- package/src/graph-ir.ts +32 -8
- package/src/graph-refs.test.ts +56 -0
- package/src/graph-refs.ts +37 -1
- package/src/kubectl-context.ts +4 -1
- package/src/lexicon-config.test.ts +111 -0
- package/src/lexicon-config.ts +92 -0
- package/src/lexicon-doc-coverage.test.ts +128 -0
- package/src/lexicon-seams.test.ts +113 -0
- package/src/lexicon.ts +88 -38
- package/src/lifecycle/deep-diff.test.ts +48 -1
- package/src/lifecycle/deep-diff.ts +16 -0
- package/src/lifecycle/digest.test.ts +81 -0
- package/src/lifecycle/digest.ts +34 -3
- package/src/lifecycle/identity.test.ts +39 -0
- package/src/lifecycle/identity.ts +61 -0
- package/src/lifecycle/observe.test.ts +75 -1
- package/src/lifecycle/observe.ts +28 -2
- package/src/lifecycle/replay.test.ts +251 -0
- package/src/lifecycle/replay.ts +67 -19
- package/src/lifecycle/types.ts +26 -0
- package/src/live-endpoint.test.ts +51 -12
- package/src/live-endpoint.ts +32 -33
- package/src/managed-fields.test.ts +50 -0
- package/src/managed-fields.ts +25 -6
- package/src/meta/peer-deps.test.ts +111 -14
- package/src/op/emulator-declaration.test.ts +63 -0
- package/src/op/emulator-freshness.test.ts +135 -0
- package/src/op/emulator-freshness.ts +102 -0
- package/src/op/emulator-lifecycle.ts +49 -0
- package/src/op/index.ts +4 -2
- package/src/ownership.ts +41 -0
- package/src/serializer.ts +16 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locating a lexicon's generated registry (#1367).
|
|
3
|
+
*
|
|
4
|
+
* The registry — every resource type the lexicon knows, keyed by class name —
|
|
5
|
+
* is written to two places by `npm run --prefix lexicons/<name> prepack`:
|
|
6
|
+
* `src/generated/lexicon-<name>.json` by the generate step, and `dist/meta.json`
|
|
7
|
+
* by the bundle step, byte-identical. Both are gitignored, so a fresh clone has
|
|
8
|
+
* neither until that command runs; CI runs it explicitly before anything else.
|
|
9
|
+
*
|
|
10
|
+
* That is a legitimate state. Being silent about it is not, and the two readers
|
|
11
|
+
* that needed the registry were silent in opposite directions:
|
|
12
|
+
*
|
|
13
|
+
* - AWS's import generator `require`d `dist/meta.json` with no catch, so a
|
|
14
|
+
* fresh clone got `Cannot find module …/lexicons/aws/dist/meta.json` — from
|
|
15
|
+
* `chant import`, and from twelve tests that mention neither modules nor
|
|
16
|
+
* registries.
|
|
17
|
+
* - Azure's caught the failure and continued with an empty map, so the import
|
|
18
|
+
* emitted `// Unknown resource type: Microsoft.Network/virtualNetworks` and
|
|
19
|
+
* its round-trip tests failed as though the registry were incomplete. That is
|
|
20
|
+
* the worse of the two: it looks like a coverage gap in the lexicon.
|
|
21
|
+
*
|
|
22
|
+
* Both now go through here, which tries the dev path first (matching what the
|
|
23
|
+
* azure serializer already did) and, failing both, says what to run.
|
|
24
|
+
*/
|
|
25
|
+
import { readFileSync } from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
|
|
28
|
+
/** Thrown when neither copy of the registry exists — carries the fix. */
|
|
29
|
+
export class LexiconRegistryMissingError extends Error {
|
|
30
|
+
constructor(readonly lexicon: string) {
|
|
31
|
+
super(
|
|
32
|
+
`${lexicon} lexicon registry not found — it is a build artifact and this checkout has not built it.\n` +
|
|
33
|
+
`Run: npm run --prefix lexicons/${lexicon} prepack\n` +
|
|
34
|
+
`(generate writes src/generated/lexicon-${lexicon}.json; bundle writes dist/meta.json)`,
|
|
35
|
+
);
|
|
36
|
+
this.name = "LexiconRegistryMissingError";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One entry in a lexicon's generated registry. */
|
|
41
|
+
export interface LexiconRegistryEntry {
|
|
42
|
+
resourceType: string;
|
|
43
|
+
kind: string;
|
|
44
|
+
apiVersion?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Read a lexicon's generated registry, dev copy first.
|
|
49
|
+
*
|
|
50
|
+
* `pkgDir` is the lexicon PACKAGE directory (the one holding `src/` and
|
|
51
|
+
* `dist/`) — callers pass an `import.meta.dirname`-derived path, so this stays
|
|
52
|
+
* free of assumptions about where it is imported from. Throws
|
|
53
|
+
* {@link LexiconRegistryMissingError} when neither copy exists, rather than
|
|
54
|
+
* returning an empty map that reads downstream as a lexicon with no resource
|
|
55
|
+
* types at all.
|
|
56
|
+
*/
|
|
57
|
+
export function loadLexiconRegistry(pkgDir: string, lexicon: string): Record<string, LexiconRegistryEntry> {
|
|
58
|
+
for (const candidate of [
|
|
59
|
+
join(pkgDir, "src", "generated", `lexicon-${lexicon}.json`),
|
|
60
|
+
join(pkgDir, "dist", "meta.json"),
|
|
61
|
+
]) {
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(readFileSync(candidate, "utf-8")) as Record<string, LexiconRegistryEntry>;
|
|
64
|
+
} catch {
|
|
65
|
+
// Try the next candidate; only both failing is an error.
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
throw new LexiconRegistryMissingError(lexicon);
|
|
69
|
+
}
|
|
@@ -16,7 +16,7 @@ generic `runInterpretDriver` (#556, [`../driver.ts`](./driver.ts)), unchanged.
|
|
|
16
16
|
## Before / after: the ALB/ECS pipeline glue this replaces
|
|
17
17
|
|
|
18
18
|
The component model's whole reason for existing is visible in one concrete
|
|
19
|
-
diff. [`examples/gitlab-aws-alb-api/src/pipeline.ts`](
|
|
19
|
+
diff. [`examples/gitlab-aws-alb-api/src/pipeline.ts`](../../../../examples/gitlab-aws-alb-api/src/pipeline.ts)
|
|
20
20
|
hand-rolls a `deployService` job that shells out to CloudFormation and greps
|
|
21
21
|
its own infra stack's outputs before it can deploy:
|
|
22
22
|
|
|
@@ -49,7 +49,7 @@ No `describe-stacks`, no `jq`, no shell string-building. `dependsOn:
|
|
|
49
49
|
["shared-alb"]` plus three `stackOutput()` references replace the whole glue
|
|
50
50
|
block; `docker build`/`docker push` become the `docker-build`/`publish-image`
|
|
51
51
|
capabilities the driver dispatches to. [`driver.test.ts`](./driver.test.ts)
|
|
52
|
-
and [`pilots/pilots
|
|
52
|
+
and [`pilots/pilots.test.ts`](./pilots/pilots.test.ts) exercise this
|
|
53
53
|
exact wiring end to end against a mock `CloudExecutor` — `cfn-deploy`
|
|
54
54
|
resolves the `stackOutput` reference itself; no orchestrator code parses a
|
|
55
55
|
CloudFormation output.
|
|
@@ -59,7 +59,7 @@ CloudFormation output.
|
|
|
59
59
|
[`driver.test.ts`](./driver.test.ts)'s `"runs the three pilots through one
|
|
60
60
|
driver instance with zero per-component driver code (sprawl metric)"` test
|
|
61
61
|
(pre-existing from #556/#557, still green) and
|
|
62
|
-
[`pilots
|
|
62
|
+
[`pilots.test.ts`](./pilots/pilots.test.ts)'s first `describe` block
|
|
63
63
|
run Neo4j fan-out, DynamoDB (sticky apply), and ALB/ECS (cross-stack, build)
|
|
64
64
|
through the same `runInterpretDriver` call, dispatching to the real,
|
|
65
65
|
`MockCloudExecutor`-backed capability implementations from #557
|
|
@@ -103,7 +103,7 @@ uses carries the image reference into the apply step.
|
|
|
103
103
|
|
|
104
104
|
### The one new capability: `lambda-deploy`
|
|
105
105
|
|
|
106
|
-
[`
|
|
106
|
+
[`lexicons/aws/src/components/apply.ts`](../../../../lexicons/aws/src/components/apply.ts)'s `createLambdaDeployCapability` is the
|
|
107
107
|
only new leaf this component required, built the same way #557 built the
|
|
108
108
|
other real leaves: typed input/output, an injectable `CloudExecutor`
|
|
109
109
|
(extended with a `lambda` client — [`verbs/cloud-executor.ts`](./verbs/cloud-executor.ts)),
|
|
@@ -121,7 +121,7 @@ No other verb was touched or added. `driver.ts` was not edited.
|
|
|
121
121
|
|
|
122
122
|
[`driver.test.ts`](./driver.test.ts)'s `"runs all four components ... through
|
|
123
123
|
one driver instance with zero per-component driver code (sprawl metric,
|
|
124
|
-
extended)"` test and [`pilots
|
|
124
|
+
extended)"` test and [`pilots.test.ts`](./pilots/pilots.test.ts)'s
|
|
125
125
|
second `describe`-block test run all five components (`shared-alb`,
|
|
126
126
|
`orders-table`, `neo4j-cluster`, `search-service`, `image-processor-lambda`)
|
|
127
127
|
through one `runInterpretDriver` call, dispatching `image-processor-lambda`'s
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, test, expect, afterEach } from "vitest";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { generateComponentDriverSource } from "./driver";
|
|
4
|
+
import { setBuildParams } from "../../params";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* chant #1108 — same re-binding the entity driver does (../../discovery/
|
|
8
|
+
* sandbox/driver.test.ts): a `*.component.ts` file imported in the sandboxed
|
|
9
|
+
* child must see the parent's resolved build-time parameters, not `{}`.
|
|
10
|
+
*/
|
|
11
|
+
describe("generateComponentDriverSource — build-time parameters (#1108)", () => {
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
setBuildParams({});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("embeds a snapshot of the parent's current params, bound before any component import", () => {
|
|
17
|
+
setBuildParams({ stage: "prod" });
|
|
18
|
+
const source = generateComponentDriverSource({ files: ["proj/svc.component.ts"] });
|
|
19
|
+
|
|
20
|
+
expect(source).toContain("import { setBuildParams } from ");
|
|
21
|
+
const bindAt = source.indexOf(`setBuildParams(${JSON.stringify({ stage: "prod" })});`);
|
|
22
|
+
const firstImportAt = source.indexOf(`await import(${JSON.stringify(resolve("proj/svc.component.ts"))})`);
|
|
23
|
+
expect(bindAt).toBeGreaterThan(-1);
|
|
24
|
+
expect(firstImportAt).toBeGreaterThan(-1);
|
|
25
|
+
expect(bindAt).toBeLessThan(firstImportAt);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { join, resolve } from "node:path";
|
|
2
|
+
import { params as currentBuildParams } from "../../params";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* chant #1051 — generates the source of the "driver" module that runs INSIDE
|
|
@@ -29,6 +30,12 @@ const COMPONENTS_DIR = join(HERE, "..");
|
|
|
29
30
|
/** Absolute paths to chant's OWN trusted modules the generated driver imports — resolved relative to THIS file's own location on disk, exactly like `../../discovery/sandbox/driver.ts` does for the entity path (works whether chant runs from the monorepo or a consumer's `node_modules`). */
|
|
30
31
|
const DISCOVER_MODULE = join(COMPONENTS_DIR, "discover.ts");
|
|
31
32
|
const CHILD_ERRORS_MODULE = join(COMPONENTS_DIR, "..", "discovery", "sandbox", "child-errors.ts");
|
|
33
|
+
// chant #1108 — same re-binding the entity driver does (see ../../discovery/
|
|
34
|
+
// sandbox/driver.ts's PARAMS_MODULE doc): the child's copy of the shared
|
|
35
|
+
// `params` object starts empty, so a `*.component.ts` file importing
|
|
36
|
+
// `@intentius/chant/params` would otherwise see `{}` under sandboxed
|
|
37
|
+
// discovery no matter what the parent resolved.
|
|
38
|
+
const PARAMS_MODULE = join(COMPONENTS_DIR, "..", "params.ts");
|
|
32
39
|
|
|
33
40
|
export interface GenerateComponentDriverOptions {
|
|
34
41
|
/** Absolute paths to every discovered `*.component.ts` file for this build. */
|
|
@@ -52,6 +59,11 @@ export function generateComponentDriverSource(options: GenerateComponentDriverOp
|
|
|
52
59
|
const lines: string[] = [
|
|
53
60
|
`import { collectComponents } from ${lit(DISCOVER_MODULE)};`,
|
|
54
61
|
`import { classifyChildError } from ${lit(CHILD_ERRORS_MODULE)};`,
|
|
62
|
+
`import { setBuildParams } from ${lit(PARAMS_MODULE)};`,
|
|
63
|
+
``,
|
|
64
|
+
// chant #1108 — snapshot of the parent's resolved build-time parameter
|
|
65
|
+
// values (scalars only), bound before any component file is imported.
|
|
66
|
+
`setBuildParams(${lit({ ...currentBuildParams })});`,
|
|
55
67
|
``,
|
|
56
68
|
`function send(payload) {`,
|
|
57
69
|
` if (typeof process.send === "function") process.send(payload);`,
|
package/src/composite.ts
CHANGED
|
@@ -7,14 +7,43 @@ import { setProvenance } from "./provenance";
|
|
|
7
7
|
export const COMPOSITE_MARKER = Symbol.for("chant.composite");
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* A record of named
|
|
10
|
+
* A record of named members produced by a composite factory.
|
|
11
|
+
*
|
|
12
|
+
* What a consumer reads off `instance.members`. Deliberately narrow: every
|
|
13
|
+
* value here is a real Declarable, so `.entityType` and friends resolve without
|
|
14
|
+
* narrowing. What a factory may RETURN is wider — see
|
|
15
|
+
* {@link CompositeFactoryMembers}.
|
|
11
16
|
*/
|
|
12
17
|
export type CompositeMembers = Record<string, Declarable>;
|
|
13
18
|
|
|
19
|
+
/**
|
|
20
|
+
* What a factory is allowed to RETURN — {@link CompositeMembers} plus
|
|
21
|
+
* `undefined`, used only as the generic constraint.
|
|
22
|
+
*
|
|
23
|
+
* A member produced by a conditional spread (`...(cond ? { policy } : {})`) is
|
|
24
|
+
* typed optional, and an optional property is not assignable to a
|
|
25
|
+
* required-value record. Widening the constraint lets such a factory typecheck;
|
|
26
|
+
* widening `CompositeMembers` itself would make every member possibly-undefined
|
|
27
|
+
* for everyone reading `instance.members`, which is a worse trade.
|
|
28
|
+
*
|
|
29
|
+
* The key is absent at runtime rather than present-and-undefined, so nothing
|
|
30
|
+
* reaches the validation in `Composite` below.
|
|
31
|
+
*/
|
|
32
|
+
export type CompositeFactoryMembers =
|
|
33
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
34
|
+
| Record<string, Declarable | CompositeInstance<any> | undefined>
|
|
35
|
+
// A pass-through composite returns another composite's INSTANCE rather than
|
|
36
|
+
// building a record (`return FargateService({...})`). That works at runtime
|
|
37
|
+
// because `members` and `_definition` are defined non-enumerable below
|
|
38
|
+
// precisely so an instance exposes only its member resources — but a type
|
|
39
|
+
// cannot say "non-enumerable", so the instance has to be admitted directly.
|
|
40
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
41
|
+
| CompositeInstance<any>;
|
|
42
|
+
|
|
14
43
|
/**
|
|
15
44
|
* The result of instantiating a composite — contains the marker and expanded members.
|
|
16
45
|
*/
|
|
17
|
-
export interface CompositeInstance<M extends
|
|
46
|
+
export interface CompositeInstance<M extends CompositeFactoryMembers = CompositeMembers> {
|
|
18
47
|
readonly [COMPOSITE_MARKER]: true;
|
|
19
48
|
readonly members: M;
|
|
20
49
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -24,7 +53,7 @@ export interface CompositeInstance<M extends CompositeMembers = CompositeMembers
|
|
|
24
53
|
/**
|
|
25
54
|
* A composite definition: a callable that produces a CompositeInstance.
|
|
26
55
|
*/
|
|
27
|
-
export interface CompositeDefinition<P, M extends
|
|
56
|
+
export interface CompositeDefinition<P, M extends CompositeFactoryMembers = CompositeMembers> {
|
|
28
57
|
(props: P): CompositeInstance<M> & M;
|
|
29
58
|
readonly compositeName: string;
|
|
30
59
|
readonly _id: symbol;
|
|
@@ -78,7 +107,7 @@ export class CompositeRegistry {
|
|
|
78
107
|
* export const storage = SecureStorage({ name: "data" });
|
|
79
108
|
* ```
|
|
80
109
|
*/
|
|
81
|
-
export function Composite<P, M extends
|
|
110
|
+
export function Composite<P, M extends CompositeFactoryMembers = CompositeMembers>(
|
|
82
111
|
factory: (props: P) => M,
|
|
83
112
|
name?: string,
|
|
84
113
|
): CompositeDefinition<P, M> {
|
package/src/deep-observation.ts
CHANGED
|
@@ -56,6 +56,17 @@ export interface DeepResourceObservation {
|
|
|
56
56
|
physicalId?: string;
|
|
57
57
|
/** The normalized live property tree. JSON-safe. */
|
|
58
58
|
properties: Record<string, unknown>;
|
|
59
|
+
/**
|
|
60
|
+
* Path → the field manager that owns it, where the substrate records one
|
|
61
|
+
* (#1189). Additive to the v1 envelope: a lexicon with no per-field ownership
|
|
62
|
+
* omits it, and every consumer behaves exactly as before.
|
|
63
|
+
*
|
|
64
|
+
* Only Kubernetes has this — the API server maintains `managedFields`
|
|
65
|
+
* server-side. It is the difference between "this field is foreign" and
|
|
66
|
+
* "`hpa-controller` owns `spec.replicas`", which are the same category and
|
|
67
|
+
* very different answers to an operator.
|
|
68
|
+
*/
|
|
69
|
+
fieldOwners?: Record<string, string>;
|
|
59
70
|
}
|
|
60
71
|
|
|
61
72
|
/**
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, test, expect, afterEach } from "vitest";
|
|
2
|
+
import { generateDriverSource } from "./driver";
|
|
3
|
+
import { setBuildParams } from "../../params";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* chant #1108 — the generated child driver must re-bind the parent's resolved
|
|
7
|
+
* build-time parameters BEFORE importing any project file: the child process
|
|
8
|
+
* starts with its own empty copy of ../../params.ts's `params` object, so
|
|
9
|
+
* without this a run-fallback file reading `params.<name>` saw `undefined`
|
|
10
|
+
* under `--sandbox` while the fold path (substituting in the parent) saw the
|
|
11
|
+
* resolved value.
|
|
12
|
+
*/
|
|
13
|
+
describe("generateDriverSource — build-time parameters (#1108)", () => {
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
setBuildParams({});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("embeds a snapshot of the parent's current params, bound before any project import", () => {
|
|
19
|
+
setBuildParams({ tier: "production-ha", replicas: 3, ha: true });
|
|
20
|
+
const source = generateDriverSource({ files: ["/tmp/project/a.ts"], buildRoot: "/tmp/project" });
|
|
21
|
+
|
|
22
|
+
expect(source).toContain("import { setBuildParams } from ");
|
|
23
|
+
const bindAt = source.indexOf(`setBuildParams(${JSON.stringify({ tier: "production-ha", replicas: 3, ha: true })});`);
|
|
24
|
+
const firstImportAt = source.indexOf(`await import("/tmp/project/a.ts")`);
|
|
25
|
+
expect(bindAt).toBeGreaterThan(-1);
|
|
26
|
+
expect(firstImportAt).toBeGreaterThan(-1);
|
|
27
|
+
expect(bindAt).toBeLessThan(firstImportAt);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("with no params resolved, binds an empty snapshot (explicit, not absent)", () => {
|
|
31
|
+
const source = generateDriverSource({ files: ["/tmp/project/a.ts"], buildRoot: "/tmp/project" });
|
|
32
|
+
expect(source).toContain("setBuildParams({});");
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { dirname, join } from "node:path";
|
|
2
|
+
import { params as currentBuildParams } from "../../params";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* chant #1045 Phase 2 — generates the source of the "driver" module that runs
|
|
@@ -46,6 +47,16 @@ const CONFIG_WIRE_MODULE = join(HERE, "config-wire.ts");
|
|
|
46
47
|
// chant #1131 — the policy driver's build-result decoding + diagnostics contract.
|
|
47
48
|
const POLICY_WIRE_MODULE = join(HERE, "policy-wire.ts");
|
|
48
49
|
const POST_SYNTH_MODULE = join(dirname(DISCOVERY_DIR), "lint", "post-synth.ts");
|
|
50
|
+
// chant #1108 — the shared build-time params module (../params.ts). The child
|
|
51
|
+
// process starts with its own, EMPTY copy of that module's `params` object;
|
|
52
|
+
// without re-binding, every run-fallback file imported in the child would see
|
|
53
|
+
// `params.* === undefined` while the fold path (which substitutes values in
|
|
54
|
+
// the PARENT) sees the resolved values — the exact split-brain #1064 exists
|
|
55
|
+
// to prevent. esbuild resolves a project file's own
|
|
56
|
+
// `import { params } from "@intentius/chant/params"` to this same absolute
|
|
57
|
+
// file, so the driver's `setBuildParams` call below mutates the one object
|
|
58
|
+
// every bundled import observes.
|
|
59
|
+
const PARAMS_MODULE = join(dirname(DISCOVERY_DIR), "params.ts");
|
|
49
60
|
|
|
50
61
|
export interface GenerateDriverOptions {
|
|
51
62
|
/** Absolute paths to the run-fallback files this build decided NOT to fold — see `discover()`'s fold/taint loop in `../index.ts`. */
|
|
@@ -73,9 +84,17 @@ export function generateDriverSource(options: GenerateDriverOptions): string {
|
|
|
73
84
|
`import { encodeEntitySet } from ${lit(ENTITY_WIRE_CODEC_MODULE)};`,
|
|
74
85
|
`import { classifyChildError } from ${lit(CHILD_ERRORS_MODULE)};`,
|
|
75
86
|
`import { getProvenance } from ${lit(PROVENANCE_MODULE)};`,
|
|
87
|
+
`import { setBuildParams } from ${lit(PARAMS_MODULE)};`,
|
|
76
88
|
``,
|
|
77
89
|
`const BUILD_ROOT = ${lit(buildRoot)};`,
|
|
78
90
|
``,
|
|
91
|
+
// chant #1108 — a snapshot of the PARENT's resolved build-time parameter
|
|
92
|
+
// values, embedded as a literal at generation time (the parent bound them
|
|
93
|
+
// via applyBuildParams/discover() before this source was generated).
|
|
94
|
+
// Values are declared-scalar only (BuildParamValue), so JSON round-trips
|
|
95
|
+
// them exactly. Bound before any project import below.
|
|
96
|
+
`setBuildParams(${lit({ ...currentBuildParams })});`,
|
|
97
|
+
``,
|
|
79
98
|
`function send(payload) {`,
|
|
80
99
|
` if (typeof process.send === "function") process.send(payload);`,
|
|
81
100
|
` else console.log(JSON.stringify(payload));`,
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from "vitest";
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { buildDeclaredPerStack } from "./graph-declared";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Side-by-side stacks (#1433) — several stacks declared in config, each with its
|
|
9
|
+
* own `src`, composed into one graph.
|
|
10
|
+
*
|
|
11
|
+
* This is chant's ordinary multi-stack shape, as distinct from directory
|
|
12
|
+
* partitioning (subdirectories of one `src/`), and it is the one where stack
|
|
13
|
+
* membership is *declared* rather than inferred: the caller passes the names, and
|
|
14
|
+
* `buildDeclaredPerStack` already renames every node `${stack}::${id}`. The
|
|
15
|
+
* grouping was being discarded, so `groups.byStack` — the axis a boundary-box
|
|
16
|
+
* renderer reads — came back empty for exactly the projects that have real
|
|
17
|
+
* stacks to box.
|
|
18
|
+
*/
|
|
19
|
+
let dir: string;
|
|
20
|
+
|
|
21
|
+
// The shape discovery actually recognises — a plain object carrying the
|
|
22
|
+
// declarable marker, as `env.test.ts`'s fixtures do.
|
|
23
|
+
const stackSrc = (resource: string) =>
|
|
24
|
+
`export const ${resource} = { entityType: "Thing", lexicon: "aws", kind: "resource", [Symbol.for("chant.declarable")]: true };\n`;
|
|
25
|
+
|
|
26
|
+
beforeAll(() => {
|
|
27
|
+
dir = mkdtempSync(join(tmpdir(), "chant-side-by-side-"));
|
|
28
|
+
for (const [stack, resource] of [
|
|
29
|
+
["network", "vpc"],
|
|
30
|
+
["app", "service"],
|
|
31
|
+
] as const) {
|
|
32
|
+
mkdirSync(join(dir, stack, "src"), { recursive: true });
|
|
33
|
+
writeFileSync(join(dir, stack, "src", "main.ts"), stackSrc(resource));
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
|
37
|
+
|
|
38
|
+
describe("buildDeclaredPerStack groups by declared stack (#1433)", () => {
|
|
39
|
+
test("every node lands in the stack that declared it", async () => {
|
|
40
|
+
const ir = await buildDeclaredPerStack(
|
|
41
|
+
[
|
|
42
|
+
{ name: "network", src: "network/src" },
|
|
43
|
+
{ name: "app", src: "app/src" },
|
|
44
|
+
],
|
|
45
|
+
dir,
|
|
46
|
+
);
|
|
47
|
+
const byStack = ir.groups.byStack ?? {};
|
|
48
|
+
expect(Object.keys(byStack).sort()).toEqual(["app", "network"]);
|
|
49
|
+
// Membership uses the SAME qualified ids the nodes carry — a consumer joins
|
|
50
|
+
// the two without re-deriving anything.
|
|
51
|
+
const ids = new Set(ir.nodes.map((n) => n.id));
|
|
52
|
+
for (const members of Object.values(byStack)) {
|
|
53
|
+
for (const id of members) expect(ids.has(id)).toBe(true);
|
|
54
|
+
}
|
|
55
|
+
// Every node is in exactly one stack: a boundary-box renderer that drew from
|
|
56
|
+
// this would otherwise leave resources outside every box.
|
|
57
|
+
expect(Object.values(byStack).flat().sort()).toEqual([...ids].sort());
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("a stack with no src contributes no group, as it contributes no nodes", async () => {
|
|
61
|
+
const ir = await buildDeclaredPerStack(
|
|
62
|
+
[{ name: "network", src: "network/src" }, { name: "unbuilt" }],
|
|
63
|
+
dir,
|
|
64
|
+
);
|
|
65
|
+
expect(Object.keys(ir.groups.byStack ?? {})).toEqual(["network"]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("no stacks with src means no grouping at all, not an empty box", async () => {
|
|
69
|
+
const ir = await buildDeclaredPerStack([{ name: "unbuilt" }], dir);
|
|
70
|
+
expect(ir.groups.byStack).toBeUndefined();
|
|
71
|
+
expect(ir.nodes).toEqual([]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("members are sorted, so the IR stays byte-stable across runs", async () => {
|
|
75
|
+
const stacks = [
|
|
76
|
+
{ name: "network", src: "network/src" },
|
|
77
|
+
{ name: "app", src: "app/src" },
|
|
78
|
+
];
|
|
79
|
+
const a = await buildDeclaredPerStack(stacks, dir);
|
|
80
|
+
const b = await buildDeclaredPerStack(stacks, dir);
|
|
81
|
+
expect(a.groups.byStack).toEqual(b.groups.byStack);
|
|
82
|
+
for (const members of Object.values(a.groups.byStack ?? {})) {
|
|
83
|
+
expect(members).toEqual([...members].sort());
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
});
|
package/src/graph-declared.ts
CHANGED
|
@@ -22,12 +22,24 @@ export async function buildDeclaredPerStack(
|
|
|
22
22
|
): Promise<GraphIR> {
|
|
23
23
|
const nodes: IRNode[] = [];
|
|
24
24
|
const edges: GraphIR["edges"] = [];
|
|
25
|
+
// Which nodes belong to which stack (#1433). This is not derived or guessed:
|
|
26
|
+
// the stack is declared in config and every node here is being renamed with
|
|
27
|
+
// its name on the line below. The membership was always known and thrown
|
|
28
|
+
// away, which left `byStack` — the axis consumers draw boundary boxes from —
|
|
29
|
+
// empty for the one project shape that genuinely has side-by-side stacks.
|
|
30
|
+
const byStack: Record<string, string[]> = {};
|
|
25
31
|
for (const st of stacks) {
|
|
26
32
|
if (!st.src) continue;
|
|
27
33
|
const g = buildGraphIr((await discover(resolve(projectPath, st.src))).entities, projectPath);
|
|
28
34
|
const q = (id: string) => `${st.name}::${id}`;
|
|
29
|
-
for (const n of g.nodes)
|
|
35
|
+
for (const n of g.nodes) {
|
|
36
|
+
const id = q(n.id);
|
|
37
|
+
nodes.push({ ...n, id });
|
|
38
|
+
(byStack[st.name] ??= []).push(id);
|
|
39
|
+
}
|
|
30
40
|
for (const e of g.edges) edges.push({ ...e, from: q(e.from), to: q(e.to) });
|
|
31
41
|
}
|
|
32
|
-
|
|
42
|
+
for (const ids of Object.values(byStack)) ids.sort();
|
|
43
|
+
const groups: GraphIR["groups"] = Object.keys(byStack).length ? { byStack } : {};
|
|
44
|
+
return { nodes, edges, groups };
|
|
33
45
|
}
|
package/src/graph-ir.ts
CHANGED
|
@@ -94,9 +94,23 @@ export interface IREdge {
|
|
|
94
94
|
export interface IRGroups {
|
|
95
95
|
byLexicon?: Record<string, string[]>;
|
|
96
96
|
byComposite?: Record<string, string[]>;
|
|
97
|
-
/**
|
|
98
|
-
*
|
|
99
|
-
*
|
|
97
|
+
/**
|
|
98
|
+
* Deployable-stack grouping (`stackName → nodeIds`). Consumers (e.g. pinhole's
|
|
99
|
+
* boundary boxes) read this rather than inferring stacks — which is the point,
|
|
100
|
+
* so it should never require one.
|
|
101
|
+
*
|
|
102
|
+
* Two sources, depending on how the project is shaped:
|
|
103
|
+
*
|
|
104
|
+
* - **side-by-side stacks**, declared in config and composed by
|
|
105
|
+
* `buildDeclaredPerStack` — keys are the declared stack names. Nothing is
|
|
106
|
+
* inferred; the project stated both the names and the membership.
|
|
107
|
+
* - **one source tree** — keys are lexicon partitions, since each lexicon
|
|
108
|
+
* serialises to one deployable stack.
|
|
109
|
+
*
|
|
110
|
+
* Formerly documented as awaiting "#513 phase 2" to regroup by nested
|
|
111
|
+
* child-project. #513 is closed and that phase was never filed; directory
|
|
112
|
+
* partitioning is the exception rather than the rule (#1433).
|
|
113
|
+
*/
|
|
100
114
|
byStack?: Record<string, string[]>;
|
|
101
115
|
/** Live containment (#779): a container node id → the node ids directly inside
|
|
102
116
|
* it (VPC → subnets/SGs, subnet → instances/service). Nested *flatly* — a
|
|
@@ -415,11 +429,21 @@ export function buildGraphIr(
|
|
|
415
429
|
nodes.push(node);
|
|
416
430
|
|
|
417
431
|
(byLexicon[entity.lexicon] ??= []).push(name);
|
|
418
|
-
//
|
|
419
|
-
// stack
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
//
|
|
432
|
+
// Within ONE source tree a stack is a lexicon partition — each lexicon
|
|
433
|
+
// serialises to one deployable stack (a CloudFormation template, a CI
|
|
434
|
+
// config) — so that is what `byStack` reports here. It stays a distinct axis
|
|
435
|
+
// from `byLexicon` (which is for provenance/colouring), emitted separately
|
|
436
|
+
// even where the two coincide.
|
|
437
|
+
//
|
|
438
|
+
// A project with genuinely separate, side-by-side stacks declares them in
|
|
439
|
+
// config, and `buildDeclaredPerStack` (./graph-declared.ts) groups those by
|
|
440
|
+
// their declared names — no inference, since the project already said. That
|
|
441
|
+
// is the multi-stack shape chant steers toward (#1433).
|
|
442
|
+
//
|
|
443
|
+
// This previously promised that "#513 phase 2 will regroup it by nested
|
|
444
|
+
// child-project". #513 is closed, that phase was never filed, and directory
|
|
445
|
+
// partitioning is the exception rather than the rule — so the promise is
|
|
446
|
+
// withdrawn rather than left pointing at a closed issue.
|
|
423
447
|
(byStack[entity.lexicon] ??= []).push(name);
|
|
424
448
|
if (prov?.composite) (byComposite[prov.composite] ??= []).push(name);
|
|
425
449
|
}
|
package/src/graph-refs.test.ts
CHANGED
|
@@ -188,3 +188,59 @@ describe("traversal name vs rendering label (#1275)", () => {
|
|
|
188
188
|
expect(edges).toEqual([]);
|
|
189
189
|
});
|
|
190
190
|
});
|
|
191
|
+
|
|
192
|
+
describe("containment is traversable without being drawn", () => {
|
|
193
|
+
// "What is inside this" is a query, not a picture. The two had been decided
|
|
194
|
+
// by one field: a containment rule became a real edge only if it set
|
|
195
|
+
// `viaAttr`, which a renderer also reads. So making a relationship queryable
|
|
196
|
+
// meant drawing it, and the list of which ones had been remembered was the
|
|
197
|
+
// list of which questions could be asked.
|
|
198
|
+
const catalog: ReferenceCatalog = {
|
|
199
|
+
identities: [
|
|
200
|
+
{ kind: "Subnet", ids: ["SubnetId"] },
|
|
201
|
+
{ kind: "Vpc", ids: ["VpcId"] },
|
|
202
|
+
],
|
|
203
|
+
refs: [
|
|
204
|
+
{ from: "Eni", path: "SubnetId", targetKind: "Subnet", relation: "containment", label: "in subnet" },
|
|
205
|
+
{ from: "Subnet", path: "VpcId", targetKind: "Vpc", relation: "containment", label: "in VPC" },
|
|
206
|
+
],
|
|
207
|
+
};
|
|
208
|
+
const nodes = [
|
|
209
|
+
{ id: "vpc-1", kind: "Vpc", lexicon: "x", attrs: { VpcId: "vpc-1" } },
|
|
210
|
+
{ id: "sub-1", kind: "Subnet", lexicon: "x", attrs: { SubnetId: "sub-1", VpcId: "vpc-1" } },
|
|
211
|
+
{ id: "sub-2", kind: "Subnet", lexicon: "x", attrs: { SubnetId: "sub-2", VpcId: "vpc-1" } },
|
|
212
|
+
{ id: "eni-1", kind: "Eni", lexicon: "x", attrs: { SubnetId: "sub-1" } },
|
|
213
|
+
];
|
|
214
|
+
|
|
215
|
+
it("still draws no containment lines — the boundary stays a boundary", () => {
|
|
216
|
+
const { edges } = reconstructEdges(nodes, catalog);
|
|
217
|
+
expect(edges).toEqual([]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("still reports the boundary pairs a renderer groups by", () => {
|
|
221
|
+
const { containment } = reconstructEdges(nodes, catalog);
|
|
222
|
+
expect(containment).toContainEqual({ child: "eni-1", parent: "sub-1", label: "in subnet" });
|
|
223
|
+
expect(containment).toContainEqual({ child: "sub-1", parent: "vpc-1", label: "in VPC" });
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("offers the same pairs as edges a query can walk", () => {
|
|
227
|
+
const { containmentEdges } = reconstructEdges(nodes, catalog);
|
|
228
|
+
expect(containmentEdges).toContainEqual({ from: "eni-1", to: "sub-1", kind: "ref", viaAttr: "SubnetId" });
|
|
229
|
+
expect(containmentEdges).toContainEqual({ from: "sub-1", to: "vpc-1", kind: "ref", viaAttr: "VpcId" });
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("no rule had to opt in — neither declares viaAttr", () => {
|
|
233
|
+
// The point of the change. Both rules above are plain containment; the
|
|
234
|
+
// traversal name is derived from the attribute the containment was read
|
|
235
|
+
// through, so a new kind is queryable the day its rule is written.
|
|
236
|
+
expect(catalog.refs.every((r) => r.viaAttr === undefined)).toBe(true);
|
|
237
|
+
// eni-1 -> sub-1, and both subnets -> vpc-1.
|
|
238
|
+
const { containmentEdges } = reconstructEdges(nodes, catalog);
|
|
239
|
+
expect(containmentEdges).toHaveLength(3);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("an empty container is reached by nothing, which is the whole question", () => {
|
|
243
|
+
const { containmentEdges } = reconstructEdges(nodes, catalog);
|
|
244
|
+
expect(containmentEdges.some((e) => e.to === "sub-2")).toBe(false);
|
|
245
|
+
});
|
|
246
|
+
});
|
package/src/graph-refs.ts
CHANGED
|
@@ -86,6 +86,30 @@ export interface DanglingRef {
|
|
|
86
86
|
export interface ReconstructedEdges {
|
|
87
87
|
edges: IREdge[];
|
|
88
88
|
containment: ContainmentPair[];
|
|
89
|
+
/**
|
|
90
|
+
* The same containment pairs, as edges a query can walk.
|
|
91
|
+
*
|
|
92
|
+
* Containment is a boundary when you are drawing it and a relationship when
|
|
93
|
+
* you are asking about it, and those two consumers had been served by one
|
|
94
|
+
* decision. `edges` is what a renderer draws as lines, so putting "is in this
|
|
95
|
+
* VPC" there would draw a line from every resource to its VPC and undo the
|
|
96
|
+
* boxes; that is why containment is kept out of it, and why it stays out.
|
|
97
|
+
*
|
|
98
|
+
* But `->`/`<-` is asking which nodes reach which, and being inside something
|
|
99
|
+
* is a way of reaching it. "Which subnets have no network interfaces in them"
|
|
100
|
+
* and "which VPCs have no instances in them" are the same question, and both
|
|
101
|
+
* are containment. With only `edges` to walk, the negation matched everything
|
|
102
|
+
* and reported an estate where nothing is anywhere.
|
|
103
|
+
*
|
|
104
|
+
* The escape hatch this replaces was per-rule: a containment rule could set
|
|
105
|
+
* `viaAttr` and become a real edge. That put the query layer's needs in a
|
|
106
|
+
* field the renderer also reads, and it had to be remembered per rule —
|
|
107
|
+
* `AWS::EC2::Instance -> Subnet` had it and `AWS::EC2::NetworkInterface ->
|
|
108
|
+
* Subnet` did not, which is the kind of gap hand-maintained lists always
|
|
109
|
+
* develop. Deriving them here means a containment rule is traversable because
|
|
110
|
+
* it is a containment rule, not because someone remembered.
|
|
111
|
+
*/
|
|
112
|
+
containmentEdges: IREdge[];
|
|
89
113
|
dangling: DanglingRef[];
|
|
90
114
|
}
|
|
91
115
|
|
|
@@ -158,6 +182,8 @@ export function reconstructEdges(nodes: IRNode[], catalog: ReferenceCatalog): Re
|
|
|
158
182
|
|
|
159
183
|
const edges: IREdge[] = [];
|
|
160
184
|
const containment: ContainmentPair[] = [];
|
|
185
|
+
const containmentEdges: IREdge[] = [];
|
|
186
|
+
const seenContEdge = new Set<string>();
|
|
161
187
|
const dangling: DanglingRef[] = [];
|
|
162
188
|
const seenEdge = new Set<string>();
|
|
163
189
|
const seenCont = new Set<string>();
|
|
@@ -185,6 +211,15 @@ export function reconstructEdges(nodes: IRNode[], catalog: ReferenceCatalog): Re
|
|
|
185
211
|
seenCont.add(k);
|
|
186
212
|
containment.push({ child: node.id, parent: match.id, ...(rule.label ? { label: rule.label } : {}) });
|
|
187
213
|
}
|
|
214
|
+
// Traversable by construction. The attribute the containment was read
|
|
215
|
+
// through is its traversal name, so `<-attr:` can still discriminate
|
|
216
|
+
// between two ways of being inside something.
|
|
217
|
+
const via = rule.viaAttr ?? rule.path;
|
|
218
|
+
const ke = `${node.id}|${match.id}|${via}`;
|
|
219
|
+
if (!seenContEdge.has(ke)) {
|
|
220
|
+
seenContEdge.add(ke);
|
|
221
|
+
containmentEdges.push({ from: node.id, to: match.id, kind: "ref", viaAttr: via });
|
|
222
|
+
}
|
|
188
223
|
// A containment relation is a boundary hint, not an edge — unless the
|
|
189
224
|
// rule declares a traversal name (#1275). A fold's first hop is
|
|
190
225
|
// sometimes exactly a containment ("an instance is in a subnet"), and
|
|
@@ -202,10 +237,11 @@ export function reconstructEdges(nodes: IRNode[], catalog: ReferenceCatalog): Re
|
|
|
202
237
|
}
|
|
203
238
|
|
|
204
239
|
edges.sort((a, b) => `${a.from}|${a.to}|${a.viaAttr}`.localeCompare(`${b.from}|${b.to}|${b.viaAttr}`));
|
|
240
|
+
containmentEdges.sort((a, b) => `${a.from}|${a.to}|${a.viaAttr}`.localeCompare(`${b.from}|${b.to}|${b.viaAttr}`));
|
|
205
241
|
containment.sort((a, b) => `${a.child}|${a.parent}`.localeCompare(`${b.child}|${b.parent}`));
|
|
206
242
|
dangling.sort((a, b) => `${a.from}|${a.path}|${a.value}`.localeCompare(`${b.from}|${b.path}|${b.value}`));
|
|
207
243
|
|
|
208
|
-
return { edges, containment, dangling };
|
|
244
|
+
return { edges, containment, containmentEdges, dangling };
|
|
209
245
|
}
|
|
210
246
|
|
|
211
247
|
/**
|
package/src/kubectl-context.ts
CHANGED
|
@@ -126,7 +126,10 @@ export async function resolveClusterTarget(
|
|
|
126
126
|
lexiconName: string,
|
|
127
127
|
options: ResolveClusterTargetOptions = {},
|
|
128
128
|
): Promise<ResolvedClusterTarget> {
|
|
129
|
-
|
|
129
|
+
// #1344 — the k8s lexicon declares this namespace and core validates it at
|
|
130
|
+
// load, so the shape is checked rather than asserted. `K8sConfigShape` stays
|
|
131
|
+
// as core's local description for the case where the lexicon is absent.
|
|
132
|
+
const k8sConfig = (config as { k8s?: K8sConfigShape }).k8s;
|
|
130
133
|
const bound = k8sConfig?.profiles?.[environment]?.context;
|
|
131
134
|
|
|
132
135
|
if (!bound) {
|