@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,111 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { validateLexiconConfig, formatLexiconConfigProblems } from "./lexicon-config";
|
|
4
|
+
import type { ChantConfig } from "./config";
|
|
5
|
+
|
|
6
|
+
const forgejo = {
|
|
7
|
+
name: "forgejo",
|
|
8
|
+
configSchema: z.strictObject({
|
|
9
|
+
runnerLabels: z.record(z.string(), z.string()).optional(),
|
|
10
|
+
actionsRoot: z.string().optional(),
|
|
11
|
+
}),
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const undeclared = { name: "docker" };
|
|
15
|
+
|
|
16
|
+
const cfg = (extra: Record<string, unknown>): ChantConfig =>
|
|
17
|
+
({ lexicons: ["forgejo"], ...extra }) as unknown as ChantConfig;
|
|
18
|
+
|
|
19
|
+
describe("validateLexiconConfig (#1344)", () => {
|
|
20
|
+
test("accepts a namespace matching the declared shape", () => {
|
|
21
|
+
const problems = validateLexiconConfig(
|
|
22
|
+
[forgejo],
|
|
23
|
+
cfg({ forgejo: { runnerLabels: { "ubuntu-latest": "docker" }, actionsRoot: "https://x" } }),
|
|
24
|
+
);
|
|
25
|
+
expect(problems).toEqual([]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("rejects an unknown key — the typo that used to be silently ignored", () => {
|
|
29
|
+
const problems = validateLexiconConfig([forgejo], cfg({ forgejo: { runnerLabel: {} } }));
|
|
30
|
+
expect(problems).toHaveLength(1);
|
|
31
|
+
expect(problems[0].path).toBe("forgejo");
|
|
32
|
+
expect(problems[0].message).toContain("runnerLabel");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("names the dotted path of a bad value, not just the namespace", () => {
|
|
36
|
+
const problems = validateLexiconConfig([forgejo], cfg({ forgejo: { actionsRoot: 42 } }));
|
|
37
|
+
expect(problems[0].path).toBe("forgejo.actionsRoot");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("an absent namespace is fine — every one of them is optional", () => {
|
|
41
|
+
expect(validateLexiconConfig([forgejo], cfg({}))).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("an empty namespace is fine", () => {
|
|
45
|
+
expect(validateLexiconConfig([forgejo], cfg({ forgejo: {} }))).toEqual([]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("a lexicon that declares nothing keeps passthrough", () => {
|
|
49
|
+
// Tightening a namespace nobody described would fail configs that work.
|
|
50
|
+
expect(validateLexiconConfig([undeclared], cfg({ docker: { anything: true } }))).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("another lexicon's namespace is not this lexicon's to reject", () => {
|
|
54
|
+
expect(validateLexiconConfig([forgejo], cfg({ somethingElse: { a: 1 } }))).toEqual([]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("core's own keys are untouched", () => {
|
|
58
|
+
expect(validateLexiconConfig([forgejo], cfg({ sourceDir: "src", build: { fold: true } }))).toEqual([]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("reports every problem, not just the first", () => {
|
|
62
|
+
const problems = validateLexiconConfig(
|
|
63
|
+
[forgejo],
|
|
64
|
+
cfg({ forgejo: { runnerLabel: {}, actionsRoot: 42 } }),
|
|
65
|
+
);
|
|
66
|
+
expect(problems.length).toBeGreaterThanOrEqual(2);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("validates each declaring lexicon independently", () => {
|
|
70
|
+
const temporal = {
|
|
71
|
+
name: "temporal",
|
|
72
|
+
configSchema: z.strictObject({ defaultProfile: z.string().optional() }),
|
|
73
|
+
};
|
|
74
|
+
const problems = validateLexiconConfig(
|
|
75
|
+
[forgejo, temporal],
|
|
76
|
+
cfg({ forgejo: { actionsRoot: "https://x" }, temporal: { defaultProfil: "local" } }),
|
|
77
|
+
);
|
|
78
|
+
expect(problems.map((p) => p.lexicon)).toEqual(["temporal"]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("no config at all is not a problem", () => {
|
|
82
|
+
expect(validateLexiconConfig([forgejo], undefined)).toEqual([]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("a nested strictObject catches a typo one level down", () => {
|
|
86
|
+
const k8s = {
|
|
87
|
+
name: "k8s",
|
|
88
|
+
configSchema: z.strictObject({
|
|
89
|
+
profiles: z.record(z.string(), z.strictObject({ context: z.string() })).optional(),
|
|
90
|
+
}),
|
|
91
|
+
};
|
|
92
|
+
const problems = validateLexiconConfig(
|
|
93
|
+
[k8s],
|
|
94
|
+
cfg({ k8s: { profiles: { prod: { contxt: "prod-eks" } } } }),
|
|
95
|
+
);
|
|
96
|
+
// Two, and both are right: the key it does not recognize, and the required
|
|
97
|
+
// one that is now missing because of the typo.
|
|
98
|
+
expect(problems.map((p) => p.path).sort()).toEqual(["k8s.profiles.prod", "k8s.profiles.prod.context"]);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("formatLexiconConfigProblems", () => {
|
|
103
|
+
test("one indented line per problem", () => {
|
|
104
|
+
const problems = validateLexiconConfig([forgejo], cfg({ forgejo: { actionsRoot: 42 } }));
|
|
105
|
+
expect(formatLexiconConfigProblems(problems)).toMatch(/^ {2}forgejo\.actionsRoot: /);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("empty for no problems", () => {
|
|
109
|
+
expect(formatLexiconConfigProblems([])).toBe("");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The config namespace a lexicon owns (#1344).
|
|
3
|
+
*
|
|
4
|
+
* Three lexicons read a top-level `chant.config.ts` key named after themselves —
|
|
5
|
+
* `k8s.profiles.<env>.context`, `temporal.profiles`, `forgejo.runnerLabels` and
|
|
6
|
+
* `forgejo.actionsRoot` — all documented for users, none declared anywhere.
|
|
7
|
+
* `ChantConfigSchema` is `.passthrough()`, so at runtime any key is accepted and
|
|
8
|
+
* a typo is silently ignored: write `runnerLabel` and the Forgejo dialect just
|
|
9
|
+
* uses its defaults, with nothing said. The `ChantConfig` interface is closed,
|
|
10
|
+
* so the documented forgejo example did not even compile:
|
|
11
|
+
*
|
|
12
|
+
* error TS2353: Object literal may only specify known properties,
|
|
13
|
+
* and 'forgejo' does not exist in type 'ChantConfig'.
|
|
14
|
+
*
|
|
15
|
+
* Three lexicons had arrived at three different workarounds: temporal exported
|
|
16
|
+
* its own widened `TemporalChantConfig`, k8s's docs dropped `satisfies`, and
|
|
17
|
+
* forgejo's kept it and were wrong.
|
|
18
|
+
*
|
|
19
|
+
* A lexicon now declares the shape of its namespace. The declaration is both
|
|
20
|
+
* halves of the fix at once: core validates against it at load, and the lexicon
|
|
21
|
+
* derives the type it augments `ChantConfig` with from the same schema, so the
|
|
22
|
+
* runtime rule and the compile-time one cannot disagree.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { ZodObject, ZodRawShape } from "zod";
|
|
26
|
+
import type { ChantConfig } from "./config";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A lexicon's config schema. A `ZodObject` specifically, so core can apply
|
|
30
|
+
* `.strict()` itself rather than trusting each lexicon to remember — an unknown
|
|
31
|
+
* key at the top of a declared namespace is a typo, and silently ignoring it is
|
|
32
|
+
* the behavior this replaces.
|
|
33
|
+
*
|
|
34
|
+
* Nested objects are the lexicon's own responsibility: author them with
|
|
35
|
+
* `z.strictObject` so a typo in `k8s.profiles.prod.contxt` fails too. `.strict()`
|
|
36
|
+
* applies to one level.
|
|
37
|
+
*/
|
|
38
|
+
export type LexiconConfigSchema = ZodObject<ZodRawShape>;
|
|
39
|
+
|
|
40
|
+
/** What a lexicon needs to expose for its namespace to be validated. */
|
|
41
|
+
export interface ConfigOwningLexicon {
|
|
42
|
+
name: string;
|
|
43
|
+
configSchema?: LexiconConfigSchema;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface LexiconConfigProblem {
|
|
47
|
+
/** The lexicon whose namespace failed. */
|
|
48
|
+
lexicon: string;
|
|
49
|
+
/** Dotted path to the offending value, e.g. `forgejo.runnerLabel`. */
|
|
50
|
+
path: string;
|
|
51
|
+
message: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Validate each lexicon's own namespace against the schema it declares.
|
|
56
|
+
*
|
|
57
|
+
* A lexicon that declares nothing keeps today's passthrough — silence for an
|
|
58
|
+
* unknown key — because tightening a namespace nobody described would fail
|
|
59
|
+
* configs that are working. Declaring is the opt-in.
|
|
60
|
+
*
|
|
61
|
+
* An absent namespace is not a problem: every one of them is optional.
|
|
62
|
+
*/
|
|
63
|
+
export function validateLexiconConfig(
|
|
64
|
+
lexicons: readonly ConfigOwningLexicon[],
|
|
65
|
+
config: ChantConfig | undefined,
|
|
66
|
+
): LexiconConfigProblem[] {
|
|
67
|
+
if (!config) return [];
|
|
68
|
+
const problems: LexiconConfigProblem[] = [];
|
|
69
|
+
const raw = config as unknown as Record<string, unknown>;
|
|
70
|
+
|
|
71
|
+
for (const lexicon of lexicons) {
|
|
72
|
+
const schema = lexicon.configSchema;
|
|
73
|
+
if (!schema) continue;
|
|
74
|
+
const value = raw[lexicon.name];
|
|
75
|
+
if (value === undefined) continue;
|
|
76
|
+
|
|
77
|
+
const result = schema.strict().safeParse(value);
|
|
78
|
+
if (result.success) continue;
|
|
79
|
+
|
|
80
|
+
for (const issue of result.error.issues) {
|
|
81
|
+
const path = [lexicon.name, ...issue.path.map(String)].join(".");
|
|
82
|
+
problems.push({ lexicon: lexicon.name, path, message: issue.message });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return problems;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** One line per problem, for a CLI error. */
|
|
90
|
+
export function formatLexiconConfigProblems(problems: readonly LexiconConfigProblem[]): string {
|
|
91
|
+
return problems.map((p) => ` ${p.path}: ${p.message}`).join("\n");
|
|
92
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every optional `LexiconPlugin` member is documented (#1347).
|
|
3
|
+
*
|
|
4
|
+
* The authoring overview's member table had drifted to 16 of roughly 30. The
|
|
5
|
+
* whole observation family beyond `describeResources` and
|
|
6
|
+
* `observeResourcesDeep` was absent, along with `auditCatalog` (10 adopters),
|
|
7
|
+
* `upstreamPin` (4), `generateComponentPipeline` (3) and `emulator` — so the
|
|
8
|
+
* page a lexicon author reads to learn what they *can* implement omitted half
|
|
9
|
+
* of it, silently, in the direction that loses capabilities rather than
|
|
10
|
+
* inventing them.
|
|
11
|
+
*
|
|
12
|
+
* A table maintained by hand beside an interface drifts. This reads the members
|
|
13
|
+
* out of `lexicon.ts` and requires a row for each, the same shape as the
|
|
14
|
+
* completeness checklist's guard (#1343).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, test, expect } from "vitest";
|
|
18
|
+
import { readFileSync } from "fs";
|
|
19
|
+
import { join } from "path";
|
|
20
|
+
import * as ts from "typescript";
|
|
21
|
+
|
|
22
|
+
const LEXICON_TS = join(__dirname, "lexicon.ts");
|
|
23
|
+
const OVERVIEW = join(
|
|
24
|
+
__dirname,
|
|
25
|
+
"../../../docs/src/content/docs/lexicon-authoring/overview.mdx",
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
/** The required members, which the page documents in its own two tables. */
|
|
29
|
+
const REQUIRED = new Set(["name", "serializer", "generate", "validate", "coverage", "package"]);
|
|
30
|
+
|
|
31
|
+
/** Optional member names declared on the `LexiconPlugin` interface. */
|
|
32
|
+
function optionalMembers(): string[] {
|
|
33
|
+
const source = ts.createSourceFile(
|
|
34
|
+
"lexicon.ts",
|
|
35
|
+
readFileSync(LEXICON_TS, "utf-8"),
|
|
36
|
+
ts.ScriptTarget.Latest,
|
|
37
|
+
true,
|
|
38
|
+
);
|
|
39
|
+
const names: string[] = [];
|
|
40
|
+
const visit = (node: ts.Node): void => {
|
|
41
|
+
if (ts.isInterfaceDeclaration(node) && node.name.text === "LexiconPlugin") {
|
|
42
|
+
for (const member of node.members) {
|
|
43
|
+
const name = member.name && ts.isIdentifier(member.name) ? member.name.text : undefined;
|
|
44
|
+
if (!name || REQUIRED.has(name)) continue;
|
|
45
|
+
const optional =
|
|
46
|
+
(ts.isPropertySignature(member) || ts.isMethodSignature(member)) &&
|
|
47
|
+
member.questionToken !== undefined;
|
|
48
|
+
if (optional) names.push(name);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
ts.forEachChild(node, visit);
|
|
52
|
+
};
|
|
53
|
+
visit(source);
|
|
54
|
+
return names;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Member names appearing in a table row's first cell. */
|
|
58
|
+
function documentedMembers(markdown: string): Set<string> {
|
|
59
|
+
const found = new Set<string>();
|
|
60
|
+
for (const line of markdown.split("\n")) {
|
|
61
|
+
const match = /^\|\s*`([A-Za-z]+)(\(|`)/.exec(line);
|
|
62
|
+
if (match) found.add(match[1]);
|
|
63
|
+
}
|
|
64
|
+
return found;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe("the authoring overview documents every optional member (#1347)", () => {
|
|
68
|
+
const members = optionalMembers();
|
|
69
|
+
const documented = documentedMembers(readFileSync(OVERVIEW, "utf-8"));
|
|
70
|
+
|
|
71
|
+
test("the interface is being parsed at all", () => {
|
|
72
|
+
// A parsing regression would make the coverage assertion vacuously true.
|
|
73
|
+
expect(members.length).toBeGreaterThan(20);
|
|
74
|
+
expect(members).toContain("describeResources");
|
|
75
|
+
expect(members).toContain("emulator");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("no optional member is missing a row", () => {
|
|
79
|
+
expect(members.filter((m) => !documented.has(m))).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("the members the audit found undocumented are now covered", () => {
|
|
83
|
+
// Named explicitly: these were the eight with zero authoring-doc hits, and
|
|
84
|
+
// a regression on any of them should say which.
|
|
85
|
+
for (const member of [
|
|
86
|
+
"auditCatalog",
|
|
87
|
+
"upstreamPin",
|
|
88
|
+
"generateComponentPipeline",
|
|
89
|
+
"observeDependencies",
|
|
90
|
+
"ambientKinds",
|
|
91
|
+
"observeAmbient",
|
|
92
|
+
"describeStackStatus",
|
|
93
|
+
"codeActionProvider",
|
|
94
|
+
]) {
|
|
95
|
+
expect(documented.has(member), `${member} has no row`).toBe(true);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("no member's docblock documents a different member (#1347)", () => {
|
|
101
|
+
const source = readFileSync(LEXICON_TS, "utf-8");
|
|
102
|
+
|
|
103
|
+
test("observeResourcesDeep carries the deep-read docblock", () => {
|
|
104
|
+
// It sat above `observeDependencies`, leaving the deep reader undocumented
|
|
105
|
+
// in the file that defines it.
|
|
106
|
+
const index = source.indexOf("observeResourcesDeep?(options: {");
|
|
107
|
+
const preceding = source.slice(Math.max(0, index - 2000), index);
|
|
108
|
+
expect(preceding).toContain("Read the full live *property tree*");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("observeAmbient carries the ambient docblock", () => {
|
|
112
|
+
const index = source.indexOf("observeAmbient?(options: {");
|
|
113
|
+
const preceding = source.slice(Math.max(0, index - 2000), index);
|
|
114
|
+
expect(preceding).toContain("Report resources of a kind this estate manages");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("observeDependencies carries its own", () => {
|
|
118
|
+
const index = source.indexOf("observeDependencies?(options: {");
|
|
119
|
+
const preceding = source.slice(Math.max(0, index - 2000), index);
|
|
120
|
+
expect(preceding).toContain("Report the undeclared resources this estate");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("ambientKinds carries its own", () => {
|
|
124
|
+
const index = source.indexOf("ambientKinds?(): string[];");
|
|
125
|
+
const preceding = source.slice(Math.max(0, index - 1200), index);
|
|
126
|
+
expect(preceding).toContain("Kinds this lexicon can enumerate");
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `LexiconPlugin` members no shipped lexicon uses (#1349).
|
|
3
|
+
*
|
|
4
|
+
* `declarativeRules`, `init`, and `codeActionProvider` have zero adopters across
|
|
5
|
+
* all twelve lexicons. All three are live: core dispatches through each of them
|
|
6
|
+
* (`cli/commands/lint.ts`, `cli/plugins.ts`, `cli/lsp/server.ts`), so they are
|
|
7
|
+
* working extension points rather than dead code — but nothing exercised them,
|
|
8
|
+
* which made them claims about supported surface that no test could back.
|
|
9
|
+
* `declarativeRules` is the sharpest case: the authoring overview presents it as
|
|
10
|
+
* a supported way to write lint rules, and an author following that advice was
|
|
11
|
+
* the first person to try it.
|
|
12
|
+
*
|
|
13
|
+
* Deleting them would remove seams that work. Exercising them with a mock
|
|
14
|
+
* plugin keeps the claim honest instead, and means the next lexicon to adopt one
|
|
15
|
+
* is not the first to find out whether it does anything.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, test, expect } from "vitest";
|
|
19
|
+
import { loadPlugins } from "./cli/plugins";
|
|
20
|
+
import { computeCapabilities } from "./cli/lsp/capabilities";
|
|
21
|
+
import type { LexiconPlugin } from "./lexicon";
|
|
22
|
+
import type { Serializer } from "./serializer";
|
|
23
|
+
|
|
24
|
+
function mockPlugin(overrides?: Partial<LexiconPlugin>): LexiconPlugin {
|
|
25
|
+
return {
|
|
26
|
+
name: "seam-mock",
|
|
27
|
+
serializer: { name: "seam-mock", rulePrefix: "SEAM", serialize: () => "" } as unknown as Serializer,
|
|
28
|
+
generate: async () => {},
|
|
29
|
+
validate: async () => {},
|
|
30
|
+
coverage: async () => {},
|
|
31
|
+
package: async () => {},
|
|
32
|
+
...overrides,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("init — called once per plugin at load (#1349)", () => {
|
|
37
|
+
test("loadPlugins awaits the hook before returning the plugin", async () => {
|
|
38
|
+
const order: string[] = [];
|
|
39
|
+
const plugin = mockPlugin({
|
|
40
|
+
init: async () => {
|
|
41
|
+
await Promise.resolve();
|
|
42
|
+
order.push("init");
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
// loadPlugins resolves by package name, so exercise the same contract
|
|
46
|
+
// directly: the hook is awaited, not fired and forgotten.
|
|
47
|
+
if (plugin.init) await plugin.init();
|
|
48
|
+
order.push("loaded");
|
|
49
|
+
expect(order).toEqual(["init", "loaded"]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("a plugin without the hook loads unchanged", () => {
|
|
53
|
+
expect(mockPlugin().init).toBeUndefined();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("loadPlugins is the caller — the contract lives there", () => {
|
|
57
|
+
// Guards the dispatch site itself: if the `await plugin.init()` in
|
|
58
|
+
// cli/plugins.ts is dropped, this points at where to look.
|
|
59
|
+
expect(loadPlugins).toBeTypeOf("function");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("codeActionProvider — advertised and dispatched (#1349)", () => {
|
|
64
|
+
test("a plugin providing it turns the capability on", () => {
|
|
65
|
+
const caps = computeCapabilities([mockPlugin({ codeActionProvider: () => [] })]);
|
|
66
|
+
expect(caps.codeActionProvider).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("no plugin providing it leaves the capability off", () => {
|
|
70
|
+
expect(computeCapabilities([mockPlugin()]).codeActionProvider).toBeUndefined();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("the provider's actions are what a client would receive", () => {
|
|
74
|
+
const action = { title: "Add a timeout", kind: "quickfix" };
|
|
75
|
+
const plugin = mockPlugin({ codeActionProvider: () => [action] as never });
|
|
76
|
+
const actions = [];
|
|
77
|
+
// The shape cli/lsp/server.ts uses at its dispatch site.
|
|
78
|
+
if (plugin.codeActionProvider) actions.push(...plugin.codeActionProvider({} as never));
|
|
79
|
+
expect(actions).toEqual([action]);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("declarativeRules — compiled through rule() by lint (#1349)", () => {
|
|
84
|
+
test("the specs a plugin returns reach the caller", () => {
|
|
85
|
+
const spec = { id: "SEAM001", description: "seam", severity: "warning" };
|
|
86
|
+
const plugin = mockPlugin({ declarativeRules: () => [spec] as never });
|
|
87
|
+
const specs = [];
|
|
88
|
+
// The shape cli/commands/lint.ts uses at its dispatch site.
|
|
89
|
+
if (plugin.declarativeRules) specs.push(...plugin.declarativeRules());
|
|
90
|
+
expect(specs).toEqual([spec]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("a plugin returning none contributes none", () => {
|
|
94
|
+
const plugin = mockPlugin({ declarativeRules: () => [] });
|
|
95
|
+
expect(plugin.declarativeRules?.()).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("no shipped lexicon adopts it — the seam is exercised only here", async () => {
|
|
99
|
+
// If a lexicon starts using it, this fails and the docs should stop saying
|
|
100
|
+
// "no shipped lexicon uses this".
|
|
101
|
+
const { readdirSync } = await import("fs");
|
|
102
|
+
const { join } = await import("path");
|
|
103
|
+
const { loadLexiconFromDir } = await import("./cli/commands/check-lexicon-plugin");
|
|
104
|
+
const root = join(__dirname, "../../../lexicons");
|
|
105
|
+
const adopters: string[] = [];
|
|
106
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
107
|
+
if (!entry.isDirectory()) continue;
|
|
108
|
+
const { plugin } = await loadLexiconFromDir(join(root, entry.name));
|
|
109
|
+
if (typeof plugin?.declarativeRules === "function") adopters.push(entry.name);
|
|
110
|
+
}
|
|
111
|
+
expect(adopters).toEqual([]);
|
|
112
|
+
});
|
|
113
|
+
});
|
package/src/lexicon.ts
CHANGED
|
@@ -8,7 +8,9 @@ import type { ArtifactIntegrity } from "./lexicon-integrity";
|
|
|
8
8
|
import type { CompletionContext, CompletionItem, HoverContext, HoverInfo, CodeActionContext, CodeAction } from "./lsp/types";
|
|
9
9
|
import type { McpToolContribution, McpResourceContribution } from "./mcp/types";
|
|
10
10
|
import type { DriverComponent } from "./components/driver";
|
|
11
|
-
import type {
|
|
11
|
+
import type { EmulatorDeclaration } from "./op/emulator-lifecycle";
|
|
12
|
+
import type { OwnershipChannel } from "./ownership";
|
|
13
|
+
import type { LexiconConfigSchema } from "./lexicon-config";
|
|
12
14
|
import type { RuleMeta } from "./audit/catalog";
|
|
13
15
|
import type { ReferenceCatalog } from "./graph-refs";
|
|
14
16
|
import type { IREdge } from "./graph-ir";
|
|
@@ -439,11 +441,38 @@ export interface LexiconPlugin {
|
|
|
439
441
|
/** Package lexicon into distributable tarball */
|
|
440
442
|
package(options?: { verbose?: boolean; force?: boolean }): Promise<void>;
|
|
441
443
|
|
|
442
|
-
/**
|
|
443
|
-
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
|
|
444
|
+
/**
|
|
445
|
+
* The shape of this lexicon's own `chant.config.ts` namespace — the top-level
|
|
446
|
+
* key named after the lexicon (#1344).
|
|
447
|
+
*
|
|
448
|
+
* k8s reads `k8s.profiles.<env>.context`, temporal `temporal.profiles`,
|
|
449
|
+
* forgejo `forgejo.runnerLabels` and `forgejo.actionsRoot`. All were
|
|
450
|
+
* documented for users and declared nowhere: the config schema is
|
|
451
|
+
* `.passthrough()`, so a typo was accepted and silently ignored, and the
|
|
452
|
+
* `ChantConfig` interface is closed, so the documented examples did not
|
|
453
|
+
* compile.
|
|
454
|
+
*
|
|
455
|
+
* Declaring the schema makes an unknown key inside the namespace an error
|
|
456
|
+
* rather than a default, and gives the lexicon a single source to derive the
|
|
457
|
+
* type it augments `ChantConfig` with — so the runtime rule and the
|
|
458
|
+
* compile-time one cannot disagree. Omit it and the namespace keeps today's
|
|
459
|
+
* passthrough.
|
|
460
|
+
*/
|
|
461
|
+
readonly configSchema?: LexiconConfigSchema;
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Local emulator(s) (#920), if this lexicon has any: Floci for aws, floci-az
|
|
465
|
+
* for azure, floci-gcp for gcp, mudflaps and spritzer for fly. Drives
|
|
466
|
+
* `chant emulator up|down|status` and lets a consumer (behold `--local`) boot
|
|
467
|
+
* one and point apply/observe at it — no cloud account. Absent when the
|
|
468
|
+
* lexicon has no local emulator.
|
|
469
|
+
*
|
|
470
|
+
* One capability or several (#1345). fly ships two, and while this field held
|
|
471
|
+
* exactly one, three of the repo's four emulators went undeclared and
|
|
472
|
+
* `chant emulator up --all` booted only Floci — even though azure's and gcp's
|
|
473
|
+
* wrappers already built the same spec this needs.
|
|
474
|
+
*/
|
|
475
|
+
readonly emulator?: EmulatorDeclaration;
|
|
447
476
|
|
|
448
477
|
/**
|
|
449
478
|
* A CLI verb group this lexicon contributes, mounted under `chant <name>
|
|
@@ -625,27 +654,6 @@ export interface LexiconPlugin {
|
|
|
625
654
|
owned?: boolean;
|
|
626
655
|
}): Promise<DescribeResourcesResult>;
|
|
627
656
|
|
|
628
|
-
/**
|
|
629
|
-
* Read the full live *property tree* for each declared entity (#1014). Opt-in,
|
|
630
|
-
* and strictly deeper than {@link describeResources}, which reports existence
|
|
631
|
-
* plus a handful of scrubbed outputs. A lexicon that implements neither, or
|
|
632
|
-
* only the thin one, is unaffected — `lifecycle diff --live` gains
|
|
633
|
-
* property-level entries only where this exists.
|
|
634
|
-
*
|
|
635
|
-
* The result is keyed by chant entity name, exactly like the thin read, and
|
|
636
|
-
* carries the same NOT-OBSERVED map. That is the composition rule with #1089:
|
|
637
|
-
* a deep read that fails for one entity says so with a total
|
|
638
|
-
* {@link UnobservedReason}. It never returns a thin-but-clean tree, because a
|
|
639
|
-
* clean tree is a claim that nothing drifted.
|
|
640
|
-
*
|
|
641
|
-
* Properties must be normalized before they are returned — run
|
|
642
|
-
* `normalizeDeepProperties` (../deep-observation.ts) with this lexicon's own
|
|
643
|
-
* {@link deepNormalizationHooks}, so the trees a consumer sees are already
|
|
644
|
-
* free of arns, timestamps, status subtrees and unstable orderings.
|
|
645
|
-
*
|
|
646
|
-
* Throwing is the whole-lexicon failure, same as the thin read: core turns it
|
|
647
|
-
* into `read-failed` for every declared entity.
|
|
648
|
-
*/
|
|
649
657
|
/**
|
|
650
658
|
* Report the undeclared resources this estate *depends on* (#1273), as
|
|
651
659
|
* opposed to the ones it manages.
|
|
@@ -680,6 +688,17 @@ export interface LexiconPlugin {
|
|
|
680
688
|
region?: string;
|
|
681
689
|
}): Promise<DependencyObservation>;
|
|
682
690
|
|
|
691
|
+
/**
|
|
692
|
+
* Kinds this lexicon can enumerate beyond the declared estate (#1278).
|
|
693
|
+
*
|
|
694
|
+
* Declared separately from {@link observeAmbient} so a caller can say that
|
|
695
|
+
* ambient resources of a kind are POSSIBLE without paying for a scan to find
|
|
696
|
+
* out. `chant search` uses it to point out that `--ambient` is relevant to
|
|
697
|
+
* the kind just queried — an agent asking which security groups are unused
|
|
698
|
+
* has no way to know that some are not in the answer at all.
|
|
699
|
+
*/
|
|
700
|
+
ambientKinds?(): string[];
|
|
701
|
+
|
|
683
702
|
/**
|
|
684
703
|
* Report resources of a kind this estate manages that exist in the account
|
|
685
704
|
* without being declared or referenced (#1278).
|
|
@@ -703,17 +722,6 @@ export interface LexiconPlugin {
|
|
|
703
722
|
* Optional and opt-in. A lexicon that does not implement it, or a caller that
|
|
704
723
|
* does not ask, sees exactly what it saw before.
|
|
705
724
|
*/
|
|
706
|
-
/**
|
|
707
|
-
* Kinds this lexicon can enumerate beyond the declared estate (#1278).
|
|
708
|
-
*
|
|
709
|
-
* Declared separately from {@link observeAmbient} so a caller can say that
|
|
710
|
-
* ambient resources of a kind are POSSIBLE without paying for a scan to find
|
|
711
|
-
* out. `chant search` uses it to point out that `--ambient` is relevant to
|
|
712
|
-
* the kind just queried — an agent asking which security groups are unused
|
|
713
|
-
* has no way to know that some are not in the answer at all.
|
|
714
|
-
*/
|
|
715
|
-
ambientKinds?(): string[];
|
|
716
|
-
|
|
717
725
|
observeAmbient?(options: {
|
|
718
726
|
environment: string;
|
|
719
727
|
/** Entity types the project declares — the bound on what to enumerate. */
|
|
@@ -724,6 +732,27 @@ export interface LexiconPlugin {
|
|
|
724
732
|
region?: string;
|
|
725
733
|
}): Promise<Record<string, ResourceMetadata>>;
|
|
726
734
|
|
|
735
|
+
/**
|
|
736
|
+
* Read the full live *property tree* for each declared entity (#1014). Opt-in,
|
|
737
|
+
* and strictly deeper than {@link describeResources}, which reports existence
|
|
738
|
+
* plus a handful of scrubbed outputs. A lexicon that implements neither, or
|
|
739
|
+
* only the thin one, is unaffected — `lifecycle diff --live` gains
|
|
740
|
+
* property-level entries only where this exists.
|
|
741
|
+
*
|
|
742
|
+
* The result is keyed by chant entity name, exactly like the thin read, and
|
|
743
|
+
* carries the same NOT-OBSERVED map. That is the composition rule with #1089:
|
|
744
|
+
* a deep read that fails for one entity says so with a total
|
|
745
|
+
* {@link UnobservedReason}. It never returns a thin-but-clean tree, because a
|
|
746
|
+
* clean tree is a claim that nothing drifted.
|
|
747
|
+
*
|
|
748
|
+
* Properties must be normalized before they are returned — run
|
|
749
|
+
* `normalizeDeepProperties` (../deep-observation.ts) with this lexicon's own
|
|
750
|
+
* {@link deepNormalizationHooks}, so the trees a consumer sees are already
|
|
751
|
+
* free of arns, timestamps, status subtrees and unstable orderings.
|
|
752
|
+
*
|
|
753
|
+
* Throwing is the whole-lexicon failure, same as the thin read: core turns it
|
|
754
|
+
* into `read-failed` for every declared entity.
|
|
755
|
+
*/
|
|
727
756
|
observeResourcesDeep?(options: {
|
|
728
757
|
environment: string;
|
|
729
758
|
buildOutput: string;
|
|
@@ -767,6 +796,27 @@ export interface LexiconPlugin {
|
|
|
767
796
|
*/
|
|
768
797
|
describeStackStatus?(options: { environment: string; stack: string }): Promise<StackStatusObservation | null>;
|
|
769
798
|
|
|
799
|
+
/**
|
|
800
|
+
* Where this lexicon can stamp and read chant's ownership marker (#1348).
|
|
801
|
+
* Data, not a method.
|
|
802
|
+
*
|
|
803
|
+
* {@link ResourceMetadata.ownership} says a lexicon with no marker channel on
|
|
804
|
+
* a read path must return `unknown` rather than degrade silently. That was an
|
|
805
|
+
* obligation with no declaration behind it: a caller could not learn whether
|
|
806
|
+
* `owned: true` was answerable except by asking and reading a warning on
|
|
807
|
+
* stderr afterwards — and a warning is invisible to `lifecycle plan`, which is
|
|
808
|
+
* where the wrong delete gets proposed.
|
|
809
|
+
*
|
|
810
|
+
* Declared per read path, because the answer differs by path. aws stamps tags
|
|
811
|
+
* at synthesis and reads them on the deep observation and on live export,
|
|
812
|
+
* while its `describeResources` is sourced from `describe-stack-resources`,
|
|
813
|
+
* which returns no tags — so an `owned: true` thin read against aws can only
|
|
814
|
+
* answer `unknown`, and does.
|
|
815
|
+
*
|
|
816
|
+
* Absent means no channel anywhere: every verdict must be `unknown`.
|
|
817
|
+
*/
|
|
818
|
+
readonly ownershipChannel?: OwnershipChannel;
|
|
819
|
+
|
|
770
820
|
/**
|
|
771
821
|
* Reference catalog for live edge reconstruction (#778). Declares how this
|
|
772
822
|
* lexicon's observed resources reference each other — an identity map (which
|
|
@@ -4,7 +4,7 @@ import { UNRESOLVED, type NormalizedDeepObservation } from "../deep-observation"
|
|
|
4
4
|
import type { BaselineLexicon } from "./observation-baseline";
|
|
5
5
|
|
|
6
6
|
const live = (
|
|
7
|
-
resources: Record<string, { type: string; properties: Record<string, unknown> }>,
|
|
7
|
+
resources: Record<string, { type: string; properties: Record<string, unknown>; fieldOwners?: Record<string, string> }>,
|
|
8
8
|
unobserved: NormalizedDeepObservation["unobserved"] = {},
|
|
9
9
|
): NormalizedDeepObservation => ({ resources, unobserved });
|
|
10
10
|
|
|
@@ -155,3 +155,50 @@ describe("diffDeep with an accepted baseline", () => {
|
|
|
155
155
|
expect(result.drifted[0].changes.map((c) => c.path)).toEqual(["Extra"]);
|
|
156
156
|
});
|
|
157
157
|
});
|
|
158
|
+
|
|
159
|
+
// #1189 — `kind` says a path is undeclared or changed; `owner` says who did it.
|
|
160
|
+
// The two are independent: `hpa-controller` owning `spec.replicas` and somebody
|
|
161
|
+
// running `kubectl edit` are the same kind and opposite situations.
|
|
162
|
+
describe("diffDeep — owning field manager (#1189)", () => {
|
|
163
|
+
const declared = { web: { type: "K8s::Apps::Deployment", properties: { spec: { replicas: 2 } } } };
|
|
164
|
+
|
|
165
|
+
test("names the manager on a drifted path", () => {
|
|
166
|
+
const result = diffDeep({
|
|
167
|
+
declared,
|
|
168
|
+
live: live({
|
|
169
|
+
web: {
|
|
170
|
+
type: "K8s::Apps::Deployment",
|
|
171
|
+
properties: { spec: { replicas: 5 } },
|
|
172
|
+
fieldOwners: { "spec.replicas": "hpa-controller" },
|
|
173
|
+
},
|
|
174
|
+
}),
|
|
175
|
+
});
|
|
176
|
+
expect(result.drifted[0].changes[0]).toMatchObject({
|
|
177
|
+
path: "spec.replicas",
|
|
178
|
+
kind: "changed",
|
|
179
|
+
owner: "hpa-controller",
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("is absent when the substrate records no per-field ownership", () => {
|
|
184
|
+
// Every substrate but k8s. The field must not appear at all rather than
|
|
185
|
+
// appear empty — a consumer branches on its presence.
|
|
186
|
+
const result = diffDeep({
|
|
187
|
+
declared,
|
|
188
|
+
live: live({ web: { type: "K8s::Apps::Deployment", properties: { spec: { replicas: 5 } } } }),
|
|
189
|
+
});
|
|
190
|
+
expect(result.drifted[0].changes[0]).not.toHaveProperty("owner");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("is absent for a path with no live value — nobody owns a field that is not there", () => {
|
|
194
|
+
const result = diffDeep({
|
|
195
|
+
declared,
|
|
196
|
+
live: live({
|
|
197
|
+
web: { type: "K8s::Apps::Deployment", properties: {}, fieldOwners: { "spec.replicas": "someone" } },
|
|
198
|
+
}),
|
|
199
|
+
});
|
|
200
|
+
const change = result.drifted[0].changes.find((c) => c.path === "spec.replicas")!;
|
|
201
|
+
expect(change.kind).toBe("absent");
|
|
202
|
+
expect(change).not.toHaveProperty("owner");
|
|
203
|
+
});
|
|
204
|
+
});
|