@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.
Files changed (130) hide show
  1. package/dist/build.d.ts +21 -0
  2. package/dist/build.d.ts.map +1 -1
  3. package/dist/cli/commands/build.d.ts.map +1 -1
  4. package/dist/cli/commands/check-lexicon-mcp.d.ts +44 -0
  5. package/dist/cli/commands/check-lexicon-mcp.d.ts.map +1 -0
  6. package/dist/cli/commands/check-lexicon-plugin.d.ts +57 -0
  7. package/dist/cli/commands/check-lexicon-plugin.d.ts.map +1 -0
  8. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  9. package/dist/cli/handlers/emulator.d.ts.map +1 -1
  10. package/dist/cli/handlers/graph.d.ts.map +1 -1
  11. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  12. package/dist/cli/handlers/run-client.d.ts.map +1 -1
  13. package/dist/cli/handlers/search.d.ts +30 -1
  14. package/dist/cli/handlers/search.d.ts.map +1 -1
  15. package/dist/cli/main.d.ts.map +1 -1
  16. package/dist/cli/mcp/server.d.ts +26 -2
  17. package/dist/cli/mcp/server.d.ts.map +1 -1
  18. package/dist/cli/plugins.d.ts +20 -0
  19. package/dist/cli/plugins.d.ts.map +1 -1
  20. package/dist/codegen/registry.d.ts +23 -0
  21. package/dist/codegen/registry.d.ts.map +1 -0
  22. package/dist/components/sandbox/driver.d.ts.map +1 -1
  23. package/dist/composite.d.ts +23 -4
  24. package/dist/composite.d.ts.map +1 -1
  25. package/dist/deep-observation.d.ts +11 -0
  26. package/dist/deep-observation.d.ts.map +1 -1
  27. package/dist/discovery/sandbox/driver.d.ts.map +1 -1
  28. package/dist/graph-declared.d.ts.map +1 -1
  29. package/dist/graph-ir.d.ts +17 -3
  30. package/dist/graph-ir.d.ts.map +1 -1
  31. package/dist/graph-refs.d.ts +24 -0
  32. package/dist/graph-refs.d.ts.map +1 -1
  33. package/dist/kubectl-context.d.ts.map +1 -1
  34. package/dist/lexicon-config.d.ts +61 -0
  35. package/dist/lexicon-config.d.ts.map +1 -0
  36. package/dist/lexicon.d.ts +85 -37
  37. package/dist/lexicon.d.ts.map +1 -1
  38. package/dist/lifecycle/deep-diff.d.ts +11 -0
  39. package/dist/lifecycle/deep-diff.d.ts.map +1 -1
  40. package/dist/lifecycle/digest.d.ts.map +1 -1
  41. package/dist/lifecycle/identity.d.ts +52 -0
  42. package/dist/lifecycle/identity.d.ts.map +1 -0
  43. package/dist/lifecycle/observe.d.ts +5 -0
  44. package/dist/lifecycle/observe.d.ts.map +1 -1
  45. package/dist/lifecycle/replay.d.ts.map +1 -1
  46. package/dist/lifecycle/types.d.ts +30 -0
  47. package/dist/lifecycle/types.d.ts.map +1 -1
  48. package/dist/live-endpoint.d.ts +21 -22
  49. package/dist/live-endpoint.d.ts.map +1 -1
  50. package/dist/managed-fields.d.ts +11 -0
  51. package/dist/managed-fields.d.ts.map +1 -1
  52. package/dist/op/emulator-freshness.d.ts +44 -0
  53. package/dist/op/emulator-freshness.d.ts.map +1 -0
  54. package/dist/op/emulator-lifecycle.d.ts +36 -0
  55. package/dist/op/emulator-lifecycle.d.ts.map +1 -1
  56. package/dist/op/index.d.ts +4 -2
  57. package/dist/op/index.d.ts.map +1 -1
  58. package/dist/ownership.d.ts +33 -0
  59. package/dist/ownership.d.ts.map +1 -1
  60. package/dist/serializer.d.ts +15 -0
  61. package/dist/serializer.d.ts.map +1 -1
  62. package/package.json +1 -1
  63. package/src/audit/catalog.test.ts +58 -6
  64. package/src/build.ts +24 -0
  65. package/src/cli/commands/build.ts +10 -0
  66. package/src/cli/commands/check-lexicon-doc-drift.test.ts +73 -0
  67. package/src/cli/commands/check-lexicon-mcp.test.ts +93 -0
  68. package/src/cli/commands/check-lexicon-mcp.ts +103 -0
  69. package/src/cli/commands/check-lexicon-plugin.test.ts +149 -0
  70. package/src/cli/commands/check-lexicon-plugin.ts +115 -0
  71. package/src/cli/commands/check-lexicon.ts +176 -26
  72. package/src/cli/handlers/components.test.ts +17 -0
  73. package/src/cli/handlers/components.ts +1 -1
  74. package/src/cli/handlers/emulator.ts +12 -8
  75. package/src/cli/handlers/graph.test.ts +71 -12
  76. package/src/cli/handlers/graph.ts +46 -5
  77. package/src/cli/handlers/lifecycle.test.ts +25 -4
  78. package/src/cli/handlers/lifecycle.ts +19 -3
  79. package/src/cli/handlers/run-client.ts +3 -1
  80. package/src/cli/handlers/search-kind.test.ts +45 -0
  81. package/src/cli/handlers/search.ts +102 -4
  82. package/src/cli/main.ts +32 -10
  83. package/src/cli/mcp/server.test.ts +82 -0
  84. package/src/cli/mcp/server.ts +40 -5
  85. package/src/cli/param-flag-scope.test.ts +69 -0
  86. package/src/cli/plugins.test.ts +33 -1
  87. package/src/cli/plugins.ts +55 -0
  88. package/src/codegen/registry.test.ts +56 -0
  89. package/src/codegen/registry.ts +69 -0
  90. package/src/components/SPRAWL-VALIDATION.md +5 -5
  91. package/src/components/sandbox/driver.test.ts +27 -0
  92. package/src/components/sandbox/driver.ts +12 -0
  93. package/src/composite.ts +33 -4
  94. package/src/deep-observation.ts +11 -0
  95. package/src/discovery/sandbox/driver.test.ts +34 -0
  96. package/src/discovery/sandbox/driver.ts +19 -0
  97. package/src/graph-declared.test.ts +86 -0
  98. package/src/graph-declared.ts +14 -2
  99. package/src/graph-ir.ts +32 -8
  100. package/src/graph-refs.test.ts +56 -0
  101. package/src/graph-refs.ts +37 -1
  102. package/src/kubectl-context.ts +4 -1
  103. package/src/lexicon-config.test.ts +111 -0
  104. package/src/lexicon-config.ts +92 -0
  105. package/src/lexicon-doc-coverage.test.ts +128 -0
  106. package/src/lexicon-seams.test.ts +113 -0
  107. package/src/lexicon.ts +88 -38
  108. package/src/lifecycle/deep-diff.test.ts +48 -1
  109. package/src/lifecycle/deep-diff.ts +16 -0
  110. package/src/lifecycle/digest.test.ts +81 -0
  111. package/src/lifecycle/digest.ts +34 -3
  112. package/src/lifecycle/identity.test.ts +39 -0
  113. package/src/lifecycle/identity.ts +61 -0
  114. package/src/lifecycle/observe.test.ts +75 -1
  115. package/src/lifecycle/observe.ts +28 -2
  116. package/src/lifecycle/replay.test.ts +251 -0
  117. package/src/lifecycle/replay.ts +67 -19
  118. package/src/lifecycle/types.ts +26 -0
  119. package/src/live-endpoint.test.ts +51 -12
  120. package/src/live-endpoint.ts +32 -33
  121. package/src/managed-fields.test.ts +50 -0
  122. package/src/managed-fields.ts +25 -6
  123. package/src/meta/peer-deps.test.ts +111 -14
  124. package/src/op/emulator-declaration.test.ts +63 -0
  125. package/src/op/emulator-freshness.test.ts +135 -0
  126. package/src/op/emulator-freshness.ts +102 -0
  127. package/src/op/emulator-lifecycle.ts +49 -0
  128. package/src/op/index.ts +4 -2
  129. package/src/ownership.ts +41 -0
  130. 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 { EmulatorCapability } from "./op/emulator-lifecycle";
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
- /** Local emulator (#920), if this lexicon has one (Floci for aws, floci-az/gcp,
443
- * mudflaps/spritzer for fly). Drives `chant emulator up|down|status` and lets a
444
- * consumer (behold `--local`) boot it + point apply/observe at it — no cloud
445
- * account. Absent when the lexicon has no local emulator. */
446
- readonly emulator?: EmulatorCapability;
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
+ });