@intentius/chant-lexicon-fountain 0.33.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 (171) hide show
  1. package/README.md +63 -0
  2. package/dist/codegen/docs-cli.d.ts +3 -0
  3. package/dist/codegen/docs-cli.d.ts.map +1 -0
  4. package/dist/codegen/docs.d.ts +16 -0
  5. package/dist/codegen/docs.d.ts.map +1 -0
  6. package/dist/codegen/generate-cli.d.ts +3 -0
  7. package/dist/codegen/generate-cli.d.ts.map +1 -0
  8. package/dist/codegen/generate-lexicon.d.ts +24 -0
  9. package/dist/codegen/generate-lexicon.d.ts.map +1 -0
  10. package/dist/codegen/generate-typescript.d.ts +11 -0
  11. package/dist/codegen/generate-typescript.d.ts.map +1 -0
  12. package/dist/codegen/generate.d.ts +15 -0
  13. package/dist/codegen/generate.d.ts.map +1 -0
  14. package/dist/codegen/naming.d.ts +14 -0
  15. package/dist/codegen/naming.d.ts.map +1 -0
  16. package/dist/codegen/package.d.ts +17 -0
  17. package/dist/codegen/package.d.ts.map +1 -0
  18. package/dist/composites/concierge-stack.d.ts +44 -0
  19. package/dist/composites/concierge-stack.d.ts.map +1 -0
  20. package/dist/coverage.d.ts +56 -0
  21. package/dist/coverage.d.ts.map +1 -0
  22. package/dist/describe-resources.d.ts +45 -0
  23. package/dist/describe-resources.d.ts.map +1 -0
  24. package/dist/detect.d.ts +10 -0
  25. package/dist/detect.d.ts.map +1 -0
  26. package/dist/entity-props.d.ts +11 -0
  27. package/dist/entity-props.d.ts.map +1 -0
  28. package/dist/export-resources.d.ts +29 -0
  29. package/dist/export-resources.d.ts.map +1 -0
  30. package/dist/generated/index.d.ts +5 -0
  31. package/dist/generated/index.d.ts.map +1 -0
  32. package/dist/generated/runtime.d.ts +5 -0
  33. package/dist/generated/runtime.d.ts.map +1 -0
  34. package/dist/import/generator.d.ts +15 -0
  35. package/dist/import/generator.d.ts.map +1 -0
  36. package/dist/import/parser.d.ts +19 -0
  37. package/dist/import/parser.d.ts.map +1 -0
  38. package/dist/index.d.ts +8 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/init-templates.d.ts +13 -0
  41. package/dist/init-templates.d.ts.map +1 -0
  42. package/dist/integrity.json +21 -0
  43. package/dist/lint/audit-catalog.d.ts +15 -0
  44. package/dist/lint/audit-catalog.d.ts.map +1 -0
  45. package/dist/lint/post-synth/ftn010-networking-explicit.d.ts +11 -0
  46. package/dist/lint/post-synth/ftn010-networking-explicit.d.ts.map +1 -0
  47. package/dist/lint/post-synth/ftn011-no-unrestricted-networking.d.ts +11 -0
  48. package/dist/lint/post-synth/ftn011-no-unrestricted-networking.d.ts.map +1 -0
  49. package/dist/lint/post-synth/ftn012-no-cloud-credential-env.d.ts +3 -0
  50. package/dist/lint/post-synth/ftn012-no-cloud-credential-env.d.ts.map +1 -0
  51. package/dist/lint/post-synth/ftn013-substitution-resolvable.d.ts +3 -0
  52. package/dist/lint/post-synth/ftn013-substitution-resolvable.d.ts.map +1 -0
  53. package/dist/lint/post-synth/ftn014-vault-shadowing.d.ts +3 -0
  54. package/dist/lint/post-synth/ftn014-vault-shadowing.d.ts.map +1 -0
  55. package/dist/lint/post-synth/ftn015-mcp-secret-env-substitution.d.ts +3 -0
  56. package/dist/lint/post-synth/ftn015-mcp-secret-env-substitution.d.ts.map +1 -0
  57. package/dist/lint/post-synth/ftn016-runtime-model-valid.d.ts +3 -0
  58. package/dist/lint/post-synth/ftn016-runtime-model-valid.d.ts.map +1 -0
  59. package/dist/lint/post-synth/ftn017-unique-resource-names.d.ts +11 -0
  60. package/dist/lint/post-synth/ftn017-unique-resource-names.d.ts.map +1 -0
  61. package/dist/lint/post-synth/index.d.ts +3 -0
  62. package/dist/lint/post-synth/index.d.ts.map +1 -0
  63. package/dist/lint/rules/ftn001-no-secret-literals.d.ts +3 -0
  64. package/dist/lint/rules/ftn001-no-secret-literals.d.ts.map +1 -0
  65. package/dist/lint/rules/index.d.ts +5 -0
  66. package/dist/lint/rules/index.d.ts.map +1 -0
  67. package/dist/lsp/completions.d.ts +7 -0
  68. package/dist/lsp/completions.d.ts.map +1 -0
  69. package/dist/lsp/hover.d.ts +4 -0
  70. package/dist/lsp/hover.d.ts.map +1 -0
  71. package/dist/manifest.json +6 -0
  72. package/dist/meta.json +69 -0
  73. package/dist/op/activities/fountain-apply.d.ts +76 -0
  74. package/dist/op/activities/fountain-apply.d.ts.map +1 -0
  75. package/dist/op/activities/fountain-run.d.ts +36 -0
  76. package/dist/op/activities/fountain-run.d.ts.map +1 -0
  77. package/dist/op/activities/index.d.ts +11 -0
  78. package/dist/op/activities/index.d.ts.map +1 -0
  79. package/dist/package-cli.d.ts +3 -0
  80. package/dist/package-cli.d.ts.map +1 -0
  81. package/dist/plugin.d.ts +8 -0
  82. package/dist/plugin.d.ts.map +1 -0
  83. package/dist/reference-catalog.d.ts +16 -0
  84. package/dist/reference-catalog.d.ts.map +1 -0
  85. package/dist/rules/ftn001-no-secret-literals.ts +71 -0
  86. package/dist/rules/ftn010-networking-explicit.ts +37 -0
  87. package/dist/rules/ftn011-no-unrestricted-networking.ts +36 -0
  88. package/dist/rules/ftn012-no-cloud-credential-env.ts +58 -0
  89. package/dist/rules/ftn013-substitution-resolvable.ts +93 -0
  90. package/dist/rules/ftn014-vault-shadowing.ts +61 -0
  91. package/dist/rules/ftn015-mcp-secret-env-substitution.ts +49 -0
  92. package/dist/rules/ftn016-runtime-model-valid.ts +50 -0
  93. package/dist/rules/ftn017-unique-resource-names.ts +43 -0
  94. package/dist/serializer.d.ts +3 -0
  95. package/dist/serializer.d.ts.map +1 -0
  96. package/dist/skills/chant-fountain-locked-sandboxes.md +34 -0
  97. package/dist/skills/chant-fountain-secrets.md +29 -0
  98. package/dist/skills/chant-fountain.md +45 -0
  99. package/dist/spec/fetch.d.ts +28 -0
  100. package/dist/spec/fetch.d.ts.map +1 -0
  101. package/dist/spec/parse.d.ts +58 -0
  102. package/dist/spec/parse.d.ts.map +1 -0
  103. package/dist/types/index.d.ts +60 -0
  104. package/dist/validate-cli.d.ts +3 -0
  105. package/dist/validate-cli.d.ts.map +1 -0
  106. package/dist/validate.d.ts +15 -0
  107. package/dist/validate.d.ts.map +1 -0
  108. package/package.json +61 -0
  109. package/src/codegen/docs-cli.ts +7 -0
  110. package/src/codegen/docs.ts +278 -0
  111. package/src/codegen/generate-cli.ts +8 -0
  112. package/src/codegen/generate-lexicon.ts +58 -0
  113. package/src/codegen/generate-typescript.ts +69 -0
  114. package/src/codegen/generate.ts +109 -0
  115. package/src/codegen/naming.ts +45 -0
  116. package/src/codegen/package.ts +32 -0
  117. package/src/composites/composites.test.ts +35 -0
  118. package/src/composites/concierge-stack.ts +71 -0
  119. package/src/coverage.test.ts +63 -0
  120. package/src/coverage.ts +178 -0
  121. package/src/describe-resources.test.ts +203 -0
  122. package/src/describe-resources.ts +192 -0
  123. package/src/detect.ts +37 -0
  124. package/src/entity-props.ts +22 -0
  125. package/src/export-resources.ts +103 -0
  126. package/src/generated/.gitkeep +0 -0
  127. package/src/generated/index.d.ts +60 -0
  128. package/src/generated/index.ts +10 -0
  129. package/src/generated/lexicon-fountain.json +69 -0
  130. package/src/generated/runtime.ts +4 -0
  131. package/src/import/generator.ts +112 -0
  132. package/src/import/parser.ts +83 -0
  133. package/src/import/roundtrip.test.ts +113 -0
  134. package/src/index.ts +17 -0
  135. package/src/init-templates.ts +60 -0
  136. package/src/lint/audit-catalog.ts +107 -0
  137. package/src/lint/post-synth/ftn010-networking-explicit.ts +37 -0
  138. package/src/lint/post-synth/ftn011-no-unrestricted-networking.ts +36 -0
  139. package/src/lint/post-synth/ftn012-no-cloud-credential-env.ts +58 -0
  140. package/src/lint/post-synth/ftn013-substitution-resolvable.ts +93 -0
  141. package/src/lint/post-synth/ftn014-vault-shadowing.ts +61 -0
  142. package/src/lint/post-synth/ftn015-mcp-secret-env-substitution.ts +49 -0
  143. package/src/lint/post-synth/ftn016-runtime-model-valid.ts +50 -0
  144. package/src/lint/post-synth/ftn017-unique-resource-names.ts +43 -0
  145. package/src/lint/post-synth/index.ts +21 -0
  146. package/src/lint/post-synth/post-synth.test.ts +133 -0
  147. package/src/lint/rules/ftn001-no-secret-literals.ts +71 -0
  148. package/src/lint/rules/index.ts +7 -0
  149. package/src/lint/rules/rules.test.ts +126 -0
  150. package/src/lsp/completions.test.ts +51 -0
  151. package/src/lsp/completions.ts +21 -0
  152. package/src/lsp/hover.test.ts +47 -0
  153. package/src/lsp/hover.ts +70 -0
  154. package/src/op/activities/fountain-apply.test.ts +208 -0
  155. package/src/op/activities/fountain-apply.ts +249 -0
  156. package/src/op/activities/fountain-run.ts +95 -0
  157. package/src/op/activities/index.ts +28 -0
  158. package/src/package-cli.ts +26 -0
  159. package/src/plugin.test.ts +73 -0
  160. package/src/plugin.ts +170 -0
  161. package/src/reference-catalog.ts +27 -0
  162. package/src/serializer.test.ts +109 -0
  163. package/src/serializer.ts +143 -0
  164. package/src/skills/chant-fountain-locked-sandboxes.md +34 -0
  165. package/src/skills/chant-fountain-secrets.md +29 -0
  166. package/src/skills/chant-fountain.md +45 -0
  167. package/src/spec/fetch.ts +53 -0
  168. package/src/spec/fountain-openapi.snapshot.json +1 -0
  169. package/src/spec/parse.ts +309 -0
  170. package/src/validate-cli.ts +13 -0
  171. package/src/validate.ts +34 -0
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { ConciergeStack } from "./concierge-stack";
3
+
4
+ describe("ConciergeStack", () => {
5
+ it("defaults to deny-all egress, no vaults, and the ownership marker", () => {
6
+ const { environment, agent } = ConciergeStack({
7
+ name: "concierge",
8
+ model: "anthropic/claude-sonnet-4-6",
9
+ });
10
+
11
+ const env = (environment as unknown as { props: Record<string, unknown> }).props;
12
+ const a = (agent as unknown as { props: Record<string, unknown> }).props;
13
+ expect(env.networking_type).toBe("limited");
14
+ expect((env.networking_config as { allowed_hosts: string[] }).allowed_hosts).toEqual([]);
15
+ expect((env.metadata as Record<string, unknown>)["managed-by"]).toBe("chant");
16
+ expect(a.allowed_vault_ids).toEqual([]);
17
+ expect(a.environment).toBe(environment);
18
+ expect(a.runtime).toBe("claude");
19
+ });
20
+
21
+ it("loosening is explicit", () => {
22
+ const { environment, agent } = ConciergeStack({
23
+ name: "helper",
24
+ model: "anthropic/claude-sonnet-4-6",
25
+ allowedHosts: ["github.com"],
26
+ allowedVaultIds: ["vault-1"],
27
+ metadata: { team: "payments" },
28
+ });
29
+
30
+ const env = (environment as unknown as { props: Record<string, unknown> }).props;
31
+ expect((env.networking_config as { allowed_hosts: string[] }).allowed_hosts).toEqual(["github.com"]);
32
+ expect((agent as unknown as { props: Record<string, unknown> }).props.allowed_vault_ids).toEqual(["vault-1"]);
33
+ expect((env.metadata as Record<string, unknown>).team).toBe("payments");
34
+ });
35
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * ConciergeStack — a locked-down Environment + Agent pair with the
3
+ * secure-by-construction defaults for agents that touch anything
4
+ * sensitive:
5
+ *
6
+ * - `networking_type: limited` with an explicit allowlist (empty =
7
+ * deny-all egress — fountain's isolation mode)
8
+ * - `allowed_vault_ids: []` — no conversation may override the
9
+ * reviewed environment at spawn
10
+ * - the `managed-by: chant` marker on both, so owned-only
11
+ * reconcile/prune and drift filtering see them
12
+ *
13
+ * Loosening any of it is a visible, reviewable act: pass an allowlist,
14
+ * pass vault ids, or drop to the raw classes.
15
+ */
16
+
17
+ import { Environment, Agent } from "../generated/index";
18
+
19
+ export interface ConciergeStackOpts {
20
+ /** Base name; the environment is `<name>-env`, the agent `<name>`. */
21
+ name: string;
22
+ /** Canonical provider/model_id. */
23
+ model: string;
24
+ /** Agent runtime. Default "claude". */
25
+ runtime?: "claude" | "codex" | "gemini" | "opencode";
26
+ /** Egress allowlist. Default [] — deny all egress. */
27
+ allowedHosts?: string[];
28
+ /** Vaults that may attach at conversation create. Default [] — none. */
29
+ allowedVaultIds?: string[];
30
+ /** Skill installs. Pin github-sourced entries with `ref`. */
31
+ skills?: Array<Record<string, unknown>>;
32
+ /** MCP server definitions (use ${VAR} references for secrets). */
33
+ mcpServers?: Record<string, unknown>;
34
+ /** System prompt. */
35
+ system?: string;
36
+ /** Extra environment fields (packages, env_vars, setup_script, repositories). */
37
+ environment?: Record<string, unknown>;
38
+ /** Extra metadata merged over the managed-by marker. */
39
+ metadata?: Record<string, unknown>;
40
+ }
41
+
42
+ export interface ConciergeStackResources {
43
+ environment: InstanceType<typeof Environment>;
44
+ agent: InstanceType<typeof Agent>;
45
+ }
46
+
47
+ export function ConciergeStack(opts: ConciergeStackOpts): ConciergeStackResources {
48
+ const metadata = { "managed-by": "chant", ...(opts.metadata ?? {}) };
49
+
50
+ const environment = new Environment({
51
+ name: `${opts.name}-env`,
52
+ networking_type: "limited",
53
+ networking_config: { allowed_hosts: opts.allowedHosts ?? [] },
54
+ metadata,
55
+ ...(opts.environment ?? {}),
56
+ });
57
+
58
+ const agent = new Agent({
59
+ name: opts.name,
60
+ model: opts.model,
61
+ runtime: opts.runtime ?? "claude",
62
+ environment,
63
+ allowed_vault_ids: opts.allowedVaultIds ?? [],
64
+ ...(opts.skills ? { skills: opts.skills } : {}),
65
+ ...(opts.mcpServers ? { mcp_servers: opts.mcpServers } : {}),
66
+ ...(opts.system !== undefined ? { system: opts.system } : {}),
67
+ metadata,
68
+ });
69
+
70
+ return { environment, agent };
71
+ }
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { readFileSync } from "fs";
3
+ import { join, dirname } from "path";
4
+ import { fileURLToPath } from "url";
5
+ import { computeFountainCoverage, formatVerbose, EXCLUDED_KINDS } from "./coverage";
6
+
7
+ const srcDir = dirname(fileURLToPath(import.meta.url));
8
+ const spec = readFileSync(join(srcDir, "spec", "fountain-openapi.snapshot.json"), "utf-8");
9
+ const surface = JSON.parse(
10
+ readFileSync(join(srcDir, "..", "surface.snapshot.json"), "utf-8"),
11
+ ) as { entries: Record<string, { kind: string; props?: string[] }> };
12
+
13
+ describe("fountain coverage", () => {
14
+ it("reports the committed surface as fully covering the spec", () => {
15
+ const report = computeFountainCoverage(spec, surface);
16
+
17
+ expect(report.overallPct).toBe(100);
18
+ expect(report.modeledKinds.sort()).toEqual(["Agent", "Environment", "Vault"]);
19
+ for (const kind of report.kinds) {
20
+ expect(kind.missing).toEqual([]);
21
+ expect(kind.stale).toEqual([]);
22
+ }
23
+ });
24
+
25
+ it("accounts for every request schema in the spec", () => {
26
+ // The guard that matters: a new upstream kind must be modeled or
27
+ // explicitly excluded, never silently absent.
28
+ const report = computeFountainCoverage(spec, surface);
29
+ expect(report.unaccountedKinds).toEqual([]);
30
+ });
31
+
32
+ it("flags a property upstream added that the surface lacks", () => {
33
+ const stripped = structuredClone(surface);
34
+ stripped.entries.Agent.props = stripped.entries.Agent.props!.filter(
35
+ (p) => !p.startsWith("system:"),
36
+ );
37
+
38
+ const report = computeFountainCoverage(spec, stripped);
39
+ const agent = report.kinds.find((k) => k.kind === "Agent")!;
40
+
41
+ expect(agent.missing).toEqual(["system"]);
42
+ expect(report.overallPct).toBeLessThan(100);
43
+ });
44
+
45
+ it("flags a surface property upstream no longer has", () => {
46
+ const extra = structuredClone(surface);
47
+ extra.entries.Vault.props = [...extra.entries.Vault.props!, "retired_field:false"];
48
+
49
+ const report = computeFountainCoverage(spec, extra);
50
+ const vault = report.kinds.find((k) => k.kind === "Vault")!;
51
+
52
+ expect(vault.stale).toEqual(["retired_field"]);
53
+ });
54
+
55
+ it("names the reason for each unmodeled kind in verbose output", () => {
56
+ const text = formatVerbose(computeFountainCoverage(spec, surface));
57
+
58
+ for (const [name, reason] of Object.entries(EXCLUDED_KINDS)) {
59
+ expect(text).toContain(name);
60
+ expect(text).toContain(reason);
61
+ }
62
+ });
63
+ });
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Coverage analysis for the fountain lexicon.
3
+ *
4
+ * The shared `computeCoverage` measures CloudFormation-shaped dimensions
5
+ * (lifecycle flags, return attributes, extension constraints) that a
6
+ * three-kind OpenAPI lexicon has no analog for — it would report 0% on
7
+ * everything and mean nothing. What fountain actually needs to know is
8
+ * whether its generated surface still matches upstream, because the spec
9
+ * is a rolling endpoint with no release tag to pin (see spec/fetch.ts):
10
+ *
11
+ * 1. Property coverage — request-schema properties per modeled kind vs
12
+ * what the committed surface baseline exposes. A gap means upstream
13
+ * added a field and `just generate` has not been rerun.
14
+ * 2. Kind coverage — which request-shaped schemas are modeled as
15
+ * declarables, and which are deliberately not (with the reason), so
16
+ * an unmodeled kind is a decision on record rather than an omission.
17
+ */
18
+
19
+ import { readFileSync } from "fs";
20
+ import { join, dirname } from "path";
21
+ import { fileURLToPath } from "url";
22
+ import { fetchSchemas } from "./spec/fetch";
23
+ import { parseFountainOpenAPI, fountainShortName } from "./spec/parse";
24
+
25
+ /** Request schemas with no typed resource, and why. */
26
+ export const EXCLUDED_KINDS: Record<string, string> = {
27
+ ConversationCreateRequest: "conversations are runs, not declarables — started by the fountainRun op",
28
+ PromptRequest: "turn-level input inside a conversation run",
29
+ SecretRequest: "secrets are a write-only sub-resource — upserted by fountainApply",
30
+ VaultSecretRequest: "secrets are a write-only sub-resource — upserted by fountainApply",
31
+ };
32
+
33
+ export interface KindCoverage {
34
+ kind: string;
35
+ specProps: number;
36
+ modeledProps: number;
37
+ /** In the upstream request schema, absent from the generated surface. */
38
+ missing: string[];
39
+ /** In the generated surface, absent from the upstream request schema. */
40
+ stale: string[];
41
+ }
42
+
43
+ export interface FountainCoverageReport {
44
+ kinds: KindCoverage[];
45
+ /** Request schemas modeled as declarables. */
46
+ modeledKinds: string[];
47
+ /** Request schemas deliberately not modeled → reason. */
48
+ excludedKinds: Record<string, string>;
49
+ /** Request schemas neither modeled nor on the exclusion list. */
50
+ unaccountedKinds: string[];
51
+ /** Modeled properties as a percentage of upstream request properties. */
52
+ overallPct: number;
53
+ }
54
+
55
+ interface SurfaceSnapshot {
56
+ entries: Record<string, { kind: string; props?: string[] }>;
57
+ }
58
+
59
+ /** Strip the `name:required` encoding the surface baseline uses. */
60
+ function surfaceProps(entry: { props?: string[] }): string[] {
61
+ return (entry.props ?? []).map((p) => p.split(":")[0]);
62
+ }
63
+
64
+ export function computeFountainCoverage(
65
+ specJSON: string | Buffer,
66
+ surface: SurfaceSnapshot,
67
+ ): FountainCoverageReport {
68
+ const parsed = parseFountainOpenAPI(specJSON);
69
+
70
+ const kinds: KindCoverage[] = [];
71
+ const modeledKinds: string[] = [];
72
+
73
+ for (const result of parsed) {
74
+ if (result.isProperty) continue;
75
+ const kind = fountainShortName(result.resource.typeName);
76
+ modeledKinds.push(kind);
77
+
78
+ const specNames = result.resource.properties.map((p) => p.name);
79
+ const entry = surface.entries[kind];
80
+ const modeledNames = entry ? surfaceProps(entry) : [];
81
+
82
+ const modeled = new Set(modeledNames);
83
+ const spec = new Set(specNames);
84
+
85
+ kinds.push({
86
+ kind,
87
+ specProps: specNames.length,
88
+ modeledProps: specNames.filter((n) => modeled.has(n)).length,
89
+ missing: specNames.filter((n) => !modeled.has(n)),
90
+ stale: modeledNames.filter((n) => !spec.has(n)),
91
+ });
92
+ }
93
+
94
+ // Which upstream request schemas are neither modeled nor excluded?
95
+ const schemas = Object.keys(
96
+ (JSON.parse(typeof specJSON === "string" ? specJSON : specJSON.toString("utf-8")) as {
97
+ components?: { schemas?: Record<string, unknown> };
98
+ }).components?.schemas ?? {},
99
+ );
100
+ const modeledRequests = new Set(modeledKinds.map((k) => `${k}Request`));
101
+ const unaccountedKinds = schemas.filter(
102
+ (name) =>
103
+ name.endsWith("Request") && !modeledRequests.has(name) && !(name in EXCLUDED_KINDS),
104
+ );
105
+
106
+ const totalSpec = kinds.reduce((n, k) => n + k.specProps, 0);
107
+ const totalModeled = kinds.reduce((n, k) => n + k.modeledProps, 0);
108
+
109
+ return {
110
+ kinds,
111
+ modeledKinds,
112
+ excludedKinds: EXCLUDED_KINDS,
113
+ unaccountedKinds,
114
+ overallPct: totalSpec === 0 ? 0 : Math.round((totalModeled / totalSpec) * 100),
115
+ };
116
+ }
117
+
118
+ export function formatSummary(report: FountainCoverageReport): string {
119
+ const parts = report.kinds.map((k) => `${k.kind} ${k.modeledProps}/${k.specProps}`);
120
+ return `Coverage: ${report.overallPct}% of upstream request properties (${parts.join(", ")}).`;
121
+ }
122
+
123
+ export function formatVerbose(report: FountainCoverageReport): string {
124
+ const lines = [formatSummary(report), ""];
125
+
126
+ for (const k of report.kinds) {
127
+ lines.push(`${k.kind}: ${k.modeledProps}/${k.specProps} properties`);
128
+ if (k.missing.length > 0) {
129
+ lines.push(` missing (upstream has, lexicon does not): ${k.missing.join(", ")}`);
130
+ }
131
+ if (k.stale.length > 0) {
132
+ lines.push(` stale (lexicon has, upstream does not): ${k.stale.join(", ")}`);
133
+ }
134
+ }
135
+
136
+ lines.push("", "Not modeled as declarables:");
137
+ for (const [name, reason] of Object.entries(report.excludedKinds)) {
138
+ lines.push(` ${name} — ${reason}`);
139
+ }
140
+
141
+ if (report.unaccountedKinds.length > 0) {
142
+ lines.push(
143
+ "",
144
+ `Unaccounted request schemas (model them or add to EXCLUDED_KINDS): ${report.unaccountedKinds.join(", ")}`,
145
+ );
146
+ }
147
+
148
+ return lines.join("\n");
149
+ }
150
+
151
+ /** Run coverage analysis for the fountain lexicon. */
152
+ export async function analyzeFountainCoverage(opts?: {
153
+ basePath?: string;
154
+ verbose?: boolean;
155
+ minOverall?: number;
156
+ }): Promise<FountainCoverageReport> {
157
+ const basePath = opts?.basePath ?? dirname(dirname(fileURLToPath(import.meta.url)));
158
+
159
+ const specs = await fetchSchemas();
160
+ const specJSON = specs.get("fountain-openapi.json");
161
+ if (!specJSON) throw new Error("fountain coverage: no spec returned by fetchSchemas");
162
+
163
+ const surface = JSON.parse(
164
+ readFileSync(join(basePath, "surface.snapshot.json"), "utf-8"),
165
+ ) as SurfaceSnapshot;
166
+
167
+ const report = computeFountainCoverage(specJSON, surface);
168
+
169
+ console.error(opts?.verbose ? formatVerbose(report) : formatSummary(report));
170
+
171
+ if (typeof opts?.minOverall === "number" && report.overallPct < opts.minOverall) {
172
+ throw new Error(
173
+ `Coverage ${report.overallPct}% is below the ${opts.minOverall}% threshold`,
174
+ );
175
+ }
176
+
177
+ return report;
178
+ }
@@ -0,0 +1,203 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { describeObservationConformance } from "@intentius/chant-test-utils";
3
+ import { normalizeObservation } from "@intentius/chant/observation";
4
+ import { describeResources, type DescribeResourcesOptions } from "./describe-resources";
5
+ import type { FountainHttp } from "./op/activities/fountain-apply";
6
+
7
+ function entities(
8
+ defs: Record<string, { entityType: string; props?: Record<string, unknown> }>,
9
+ ): Map<string, { entityType: string; props: Record<string, unknown> }> {
10
+ return new Map(
11
+ Object.entries(defs).map(([name, d]) => [name, { entityType: d.entityType, props: d.props ?? {} }]),
12
+ );
13
+ }
14
+
15
+ function opts(
16
+ ents: Map<string, { entityType: string; props: Record<string, unknown> }>,
17
+ extra?: Partial<DescribeResourcesOptions>,
18
+ ): DescribeResourcesOptions {
19
+ return {
20
+ environment: "local",
21
+ buildOutput: "",
22
+ entityNames: [...ents.keys()],
23
+ entities: ents,
24
+ ...extra,
25
+ };
26
+ }
27
+
28
+ describe("list caching under the observer harness", () => {
29
+ it("lists each kind once no matter how many entities of it are declared", async () => {
30
+ const calls: string[] = [];
31
+ const counting: FountainHttp = async (method, path) => {
32
+ calls.push(`${method} ${path}`);
33
+ return { status: 200, json: { data: [] } };
34
+ };
35
+
36
+ await describeResources(
37
+ opts(
38
+ entities({
39
+ a: { entityType: "Fountain::V1::Environment" },
40
+ b: { entityType: "Fountain::V1::Environment" },
41
+ c: { entityType: "Fountain::V1::Environment" },
42
+ d: { entityType: "Fountain::V1::Agent" },
43
+ }),
44
+ ),
45
+ counting,
46
+ );
47
+
48
+ // The harness reads entities concurrently, so the cache must hold the
49
+ // in-flight promise — caching the settled result would let three
50
+ // simultaneous reads each fire their own list.
51
+ expect(calls.filter((c) => c === "GET /api/environments")).toHaveLength(1);
52
+ expect(calls.filter((c) => c === "GET /api/agents")).toHaveLength(1);
53
+ });
54
+
55
+ it("marks only the failed kind read-failed", async () => {
56
+ const partial: FountainHttp = async (_method, path) =>
57
+ path === "/api/agents" ? { status: 500, json: null } : { status: 200, json: { data: [] } };
58
+
59
+ const result = await describeResources(
60
+ opts(
61
+ entities({
62
+ env: { entityType: "Fountain::V1::Environment" },
63
+ agent: { entityType: "Fountain::V1::Agent" },
64
+ }),
65
+ ),
66
+ partial,
67
+ );
68
+
69
+ expect(result.unobserved?.agent?.reason).toBe("read-failed");
70
+ // The environment was genuinely asked about and reported missing —
71
+ // absent, not unobserved, so it stays eligible for `create`.
72
+ expect(result.unobserved?.env).toBeUndefined();
73
+ expect(result.resources?.env).toBeUndefined();
74
+ });
75
+ });
76
+
77
+ function routedHttp(routes: Record<string, { status: number; json?: unknown }>): FountainHttp {
78
+ return async (method, path) => {
79
+ const hit = routes[`${method} ${path}`];
80
+ if (!hit) throw new Error(`unrouted: ${method} ${path}`);
81
+ return { status: hit.status, json: hit.json ?? null };
82
+ };
83
+ }
84
+
85
+ const LIVE = routedHttp({
86
+ "GET /api/environments": {
87
+ status: 200,
88
+ json: {
89
+ data: [
90
+ {
91
+ id: "env-1",
92
+ name: "concierge-env",
93
+ metadata: { "managed-by": "chant" },
94
+ updated_at: "2026-07-30T00:00:00Z",
95
+ },
96
+ ],
97
+ },
98
+ },
99
+ "GET /api/vaults": { status: 200, json: { data: [] } },
100
+ "GET /api/agents": {
101
+ status: 200,
102
+ json: {
103
+ data: [
104
+ { id: "agent-1", name: "researcher", metadata: {}, environment_id: "env-1" },
105
+ ],
106
+ },
107
+ },
108
+ });
109
+
110
+ describe("fountain describeResources", () => {
111
+ it("reports present resources with ownership and reference attributes", async () => {
112
+ const ents = entities({
113
+ conciergeEnv: { entityType: "Fountain::V1::Environment", props: { name: "concierge-env" } },
114
+ researcher: { entityType: "Fountain::V1::Agent", props: { name: "researcher" } },
115
+ gone: { entityType: "Fountain::V1::Vault", props: { name: "not-there" } },
116
+ });
117
+
118
+ const { resources, unobserved } = normalizeObservation(await describeResources(opts(ents), LIVE));
119
+
120
+ expect(resources.conciergeEnv.ownership).toBe("owned");
121
+ expect(resources.conciergeEnv.physicalId).toBe("env-1");
122
+ expect(resources.researcher.ownership).toBe("foreign");
123
+ expect(resources.researcher.attributes?.environment_id).toBe("env-1");
124
+ // asked, absent → eligible for create, not unobserved
125
+ expect(resources.gone).toBeUndefined();
126
+ expect(unobserved.gone).toBeUndefined();
127
+ });
128
+
129
+ it("owned filter withholds foreign resources as filtered", async () => {
130
+ const ents = entities({
131
+ researcher: { entityType: "Fountain::V1::Agent", props: { name: "researcher" } },
132
+ });
133
+ const { resources, unobserved } = normalizeObservation(
134
+ await describeResources(opts(ents, { owned: true }), LIVE),
135
+ );
136
+ expect(resources.researcher).toBeUndefined();
137
+ expect(unobserved.researcher.reason).toBe("filtered");
138
+ });
139
+
140
+ it("a failed kind list marks only that kind read-failed", async () => {
141
+ const ents = entities({
142
+ e: { entityType: "Fountain::V1::Environment", props: { name: "concierge-env" } },
143
+ a: { entityType: "Fountain::V1::Agent", props: { name: "researcher" } },
144
+ });
145
+ const http = routedHttp({
146
+ "GET /api/environments": { status: 500 },
147
+ "GET /api/agents": {
148
+ status: 200,
149
+ json: { data: [{ id: "agent-1", name: "researcher", metadata: {} }] },
150
+ },
151
+ });
152
+ const { resources, unobserved } = normalizeObservation(await describeResources(opts(ents), http));
153
+ expect(unobserved.e.reason).toBe("read-failed");
154
+ expect(resources.a.status).toBe("PRESENT");
155
+ });
156
+ });
157
+
158
+ describeObservationConformance({
159
+ lexicon: "fountain",
160
+ scenarios: [
161
+ {
162
+ name: "mixed present/absent/foreign against a live listing",
163
+ declared: ["conciergeEnv", "researcher", "gone"],
164
+ expectPresent: ["conciergeEnv", "researcher"],
165
+ expectAbsent: ["gone"],
166
+ run: () =>
167
+ describeResources(
168
+ opts(
169
+ entities({
170
+ conciergeEnv: { entityType: "Fountain::V1::Environment", props: { name: "concierge-env" } },
171
+ researcher: { entityType: "Fountain::V1::Agent", props: { name: "researcher" } },
172
+ gone: { entityType: "Fountain::V1::Vault", props: { name: "not-there" } },
173
+ }),
174
+ ),
175
+ LIVE,
176
+ ),
177
+ },
178
+ {
179
+ name: "no credentials — everything unobserved, nothing creates",
180
+ declared: ["e"],
181
+ expectUnobserved: ["e"],
182
+ run: () => {
183
+ const saved = process.env.FOUNTAIN_TOKEN;
184
+ delete process.env.FOUNTAIN_TOKEN;
185
+ const result = describeResources(
186
+ opts(entities({ e: { entityType: "Fountain::V1::Environment" } })),
187
+ );
188
+ if (saved !== undefined) process.env.FOUNTAIN_TOKEN = saved;
189
+ return result;
190
+ },
191
+ },
192
+ {
193
+ name: "unknown kind is unobserved, not absent",
194
+ declared: ["weird"],
195
+ expectUnobserved: ["weird"],
196
+ run: () =>
197
+ describeResources(
198
+ opts(entities({ weird: { entityType: "Fountain::V1::Conversation" } })),
199
+ LIVE,
200
+ ),
201
+ },
202
+ ],
203
+ });