@intentius/chant 0.38.0 → 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.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/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 +19 -0
- 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/managed-fields.d.ts +11 -0
- package/dist/managed-fields.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/build.ts +24 -0
- package/src/cli/commands/build.ts +10 -0
- package/src/cli/commands/check-lexicon.ts +20 -1
- package/src/cli/handlers/lifecycle.ts +10 -0
- 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/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.ts +20 -0
- 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/managed-fields.test.ts +50 -0
- package/src/managed-fields.ts +25 -6
- package/src/meta/peer-deps.test.ts +111 -14
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
3
|
+
import { join, dirname } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `--param`/`--params-file` started as build-only flags and grew: `chant graph`
|
|
8
|
+
* gained them with #1359 (graph and build disagreed about the same source),
|
|
9
|
+
* `chant run --components` with #1108. The help text in `./main.ts` kept saying
|
|
10
|
+
* "(build)", so the CLI's own documentation told users the flag did nothing on
|
|
11
|
+
* the two commands that had just learned it.
|
|
12
|
+
*
|
|
13
|
+
* Asserting the property rather than the wording: every handler that actually
|
|
14
|
+
* reads `args.param` must be named in the flag's help annotation. A new handler
|
|
15
|
+
* honoring the flag fails this test until the help is updated with it.
|
|
16
|
+
*/
|
|
17
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const handlersDir = join(here, "handlers");
|
|
19
|
+
|
|
20
|
+
/** The command name a handler file implements — `handlers/graph.ts` backs `chant graph`. */
|
|
21
|
+
function commandOf(file: string): string {
|
|
22
|
+
return file.replace(/\.ts$/, "");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function handlersReadingParamFlags(): string[] {
|
|
26
|
+
return readdirSync(handlersDir)
|
|
27
|
+
.filter((f) => f.endsWith(".ts") && !f.endsWith(".test.ts"))
|
|
28
|
+
.filter((f) => /\bargs\.param\b/.test(readFileSync(join(handlersDir, f), "utf-8")))
|
|
29
|
+
.map(commandOf)
|
|
30
|
+
.sort();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The help text for one flag: everything from its marker up to the next flag
|
|
35
|
+
* entry or the end of the options block, whichever comes first. Both bounds
|
|
36
|
+
* matter — the LAST flag in a block has no `--` after it, and without the
|
|
37
|
+
* blank-line bound its "block" would run to the end of the file and match
|
|
38
|
+
* every command name incidentally.
|
|
39
|
+
*/
|
|
40
|
+
function helpBlockFor(flag: string): string {
|
|
41
|
+
const source = readFileSync(join(here, "main.ts"), "utf-8");
|
|
42
|
+
const start = source.indexOf(` ${flag}`);
|
|
43
|
+
expect(start, `${flag} is not documented in main.ts's help`).toBeGreaterThan(-1);
|
|
44
|
+
const rest = source.slice(start + flag.length);
|
|
45
|
+
const ends = [rest.search(/\n {2}--/), rest.search(/\n\s*\n/)].filter((i) => i !== -1);
|
|
46
|
+
expect(ends.length, `${flag}'s help block has no terminator`).toBeGreaterThan(0);
|
|
47
|
+
return rest.slice(0, Math.min(...ends));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe("--param help annotation tracks the handlers that honor it", () => {
|
|
51
|
+
test("at least build, graph and run read the flag", () => {
|
|
52
|
+
// Guards the test itself: if the detection regex ever stops matching, the
|
|
53
|
+
// assertions below would pass vacuously against an empty set.
|
|
54
|
+
expect(handlersReadingParamFlags()).toEqual(expect.arrayContaining(["build", "graph", "run"]));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test.each(["--param <name=value>", "--params-file <path>"])(
|
|
58
|
+
"%s names every command that honors it",
|
|
59
|
+
(flag) => {
|
|
60
|
+
const block = helpBlockFor(flag);
|
|
61
|
+
for (const command of handlersReadingParamFlags()) {
|
|
62
|
+
expect(
|
|
63
|
+
block.includes(command),
|
|
64
|
+
`handlers/${command}.ts reads args.param, but ${flag}'s help does not mention "${command}"`,
|
|
65
|
+
).toBe(true);
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
});
|
package/src/cli/plugins.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
|
-
import { loadPlugin, loadPlugins } from "./plugins";
|
|
2
|
+
import { loadPlugin, loadPlugins, resolveLexiconVersions } from "./plugins";
|
|
3
3
|
import { isLexiconPlugin } from "../lexicon";
|
|
4
4
|
|
|
5
5
|
describe("loadPlugin", () => {
|
|
@@ -29,3 +29,35 @@ describe("loadPlugins", () => {
|
|
|
29
29
|
expect(plugins).toHaveLength(0);
|
|
30
30
|
});
|
|
31
31
|
});
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* chant #1442 — the installed version of each lexicon package, read for the
|
|
35
|
+
* build digest.
|
|
36
|
+
*/
|
|
37
|
+
describe("resolveLexiconVersions", () => {
|
|
38
|
+
test("reads the real installed version of a workspace lexicon", () => {
|
|
39
|
+
const versions = resolveLexiconVersions(["k8s"]);
|
|
40
|
+
expect(versions.k8s).toMatch(/^\d+\.\d+\.\d+/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("resolves several lexicons in one call", () => {
|
|
44
|
+
const versions = resolveLexiconVersions(["k8s", "aws"]);
|
|
45
|
+
expect(Object.keys(versions).sort()).toEqual(["aws", "k8s"]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("omits a lexicon that is not installed rather than inventing a version", () => {
|
|
49
|
+
// "unknown" would compare unequal to itself across builds and report a
|
|
50
|
+
// version change on every diff.
|
|
51
|
+
expect(resolveLexiconVersions(["definitely-not-a-lexicon"])).toEqual({});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("an unresolvable name does not prevent the resolvable ones", () => {
|
|
55
|
+
const versions = resolveLexiconVersions(["definitely-not-a-lexicon", "k8s"]);
|
|
56
|
+
expect(versions.k8s).toBeDefined();
|
|
57
|
+
expect(versions["definitely-not-a-lexicon"]).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("no names yields an empty map", () => {
|
|
61
|
+
expect(resolveLexiconVersions([])).toEqual({});
|
|
62
|
+
});
|
|
63
|
+
});
|
package/src/cli/plugins.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
1
4
|
import { isLexiconPlugin, type LexiconPlugin } from "../lexicon";
|
|
2
5
|
import { loadChantConfigUpward } from "../config";
|
|
3
6
|
import { findInfraFiles, detectLexicons } from "../index";
|
|
@@ -40,6 +43,58 @@ export async function loadPlugin(lexiconName: string): Promise<LexiconPlugin> {
|
|
|
40
43
|
throw new Error(`Package ${packageName} does not export a LexiconPlugin or Serializer`);
|
|
41
44
|
}
|
|
42
45
|
|
|
46
|
+
/**
|
|
47
|
+
* The installed version of each named lexicon package (chant #1442).
|
|
48
|
+
*
|
|
49
|
+
* A `LexiconPlugin` does not carry its own version — `LexiconManifest` does,
|
|
50
|
+
* but that is an artifact type describing generated output, not something the
|
|
51
|
+
* plugin object exposes. The version wanted here is a property of the
|
|
52
|
+
* *installed package*, so it is read from the package's own `package.json`.
|
|
53
|
+
*
|
|
54
|
+
* Resolved by walking up from the package's entry point rather than resolving
|
|
55
|
+
* `<pkg>/package.json` directly: lexicon packages declare an `exports` map
|
|
56
|
+
* that does not include `./package.json`, so the direct specifier is blocked
|
|
57
|
+
* by Node. `./manifest` (→ `dist/manifest.json`) is not used either — it is a
|
|
58
|
+
* `prepack` build artifact, absent in a monorepo dev tree, and its absence
|
|
59
|
+
* once read as four unrelated bugs (#1367).
|
|
60
|
+
*
|
|
61
|
+
* A lexicon whose version cannot be determined is omitted rather than
|
|
62
|
+
* recorded as `"unknown"`: a digest that says nothing is honest, one that
|
|
63
|
+
* says `unknown` compares unequal to itself across builds.
|
|
64
|
+
*/
|
|
65
|
+
export function resolveLexiconVersions(lexiconNames: readonly string[]): Record<string, string> {
|
|
66
|
+
const require_ = createRequire(import.meta.url);
|
|
67
|
+
const versions: Record<string, string> = {};
|
|
68
|
+
|
|
69
|
+
for (const name of lexiconNames) {
|
|
70
|
+
const packageName = `@intentius/chant-lexicon-${name}`;
|
|
71
|
+
try {
|
|
72
|
+
let dir = dirname(require_.resolve(packageName));
|
|
73
|
+
// Bounded walk — a resolved entry point is never deeply nested inside
|
|
74
|
+
// its own package, and an unbounded loop here would climb to `/`.
|
|
75
|
+
for (let depth = 0; depth < 10; depth++) {
|
|
76
|
+
const candidate = join(dir, "package.json");
|
|
77
|
+
if (existsSync(candidate)) {
|
|
78
|
+
const pkg = JSON.parse(readFileSync(candidate, "utf-8")) as { name?: string; version?: string };
|
|
79
|
+
// Only the lexicon's OWN package.json — a nested dependency's
|
|
80
|
+
// manifest would otherwise be read as the lexicon's version.
|
|
81
|
+
if (pkg.name === packageName && pkg.version) {
|
|
82
|
+
versions[name] = pkg.version;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const parent = dirname(dir);
|
|
87
|
+
if (parent === dir) break;
|
|
88
|
+
dir = parent;
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
// Not installed, or not resolvable from here — omit it.
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return versions;
|
|
96
|
+
}
|
|
97
|
+
|
|
43
98
|
/**
|
|
44
99
|
* Load plugins for all detected lexicon names.
|
|
45
100
|
* Calls `init()` on each plugin if present.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { loadLexiconRegistry, LexiconRegistryMissingError } from "./registry";
|
|
6
|
+
|
|
7
|
+
const REGISTRY = { Bucket: { resourceType: "AWS::S3::Bucket", kind: "resource" } };
|
|
8
|
+
|
|
9
|
+
function pkg(): string {
|
|
10
|
+
return mkdtempSync(join(tmpdir(), "chant-registry-"));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("loadLexiconRegistry (#1367)", () => {
|
|
14
|
+
test("reads the dev copy — src/generated, which is what a working checkout has", () => {
|
|
15
|
+
const dir = pkg();
|
|
16
|
+
mkdirSync(join(dir, "src", "generated"), { recursive: true });
|
|
17
|
+
writeFileSync(join(dir, "src", "generated", "lexicon-aws.json"), JSON.stringify(REGISTRY));
|
|
18
|
+
expect(loadLexiconRegistry(dir, "aws")).toEqual(REGISTRY);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("falls back to dist/meta.json — what an installed package ships", () => {
|
|
22
|
+
const dir = pkg();
|
|
23
|
+
mkdirSync(join(dir, "dist"), { recursive: true });
|
|
24
|
+
writeFileSync(join(dir, "dist", "meta.json"), JSON.stringify(REGISTRY));
|
|
25
|
+
expect(loadLexiconRegistry(dir, "aws")).toEqual(REGISTRY);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("prefers the dev copy when both exist", () => {
|
|
29
|
+
const dir = pkg();
|
|
30
|
+
mkdirSync(join(dir, "src", "generated"), { recursive: true });
|
|
31
|
+
mkdirSync(join(dir, "dist"), { recursive: true });
|
|
32
|
+
writeFileSync(join(dir, "src", "generated", "lexicon-aws.json"), JSON.stringify({ Dev: REGISTRY.Bucket }));
|
|
33
|
+
writeFileSync(join(dir, "dist", "meta.json"), JSON.stringify({ Dist: REGISTRY.Bucket }));
|
|
34
|
+
expect(Object.keys(loadLexiconRegistry(dir, "aws"))).toEqual(["Dev"]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("throws with the command to run, rather than a module-not-found or an empty map", () => {
|
|
38
|
+
// The whole point. An empty map reads downstream as a lexicon with no
|
|
39
|
+
// resource types, which is how azure's import came to emit
|
|
40
|
+
// `// Unknown resource type: Microsoft.…` and look like a coverage gap.
|
|
41
|
+
const dir = pkg();
|
|
42
|
+
expect(() => loadLexiconRegistry(dir, "azure")).toThrow(LexiconRegistryMissingError);
|
|
43
|
+
expect(() => loadLexiconRegistry(dir, "azure")).toThrow(/npm run --prefix lexicons\/azure prepack/);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("names the lexicon that is missing, not a generic one", () => {
|
|
47
|
+
const dir = pkg();
|
|
48
|
+
try {
|
|
49
|
+
loadLexiconRegistry(dir, "gcp");
|
|
50
|
+
throw new Error("should have thrown");
|
|
51
|
+
} catch (err) {
|
|
52
|
+
expect((err as LexiconRegistryMissingError).lexicon).toBe("gcp");
|
|
53
|
+
expect((err as Error).message).toContain("lexicons/gcp");
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -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
|
+
});
|