@intentius/chant-lexicon-fountain 0.76.0 → 0.78.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 (56) hide show
  1. package/README.md +6 -5
  2. package/dist/coverage.d.ts +6 -5
  3. package/dist/coverage.d.ts.map +1 -1
  4. package/dist/generated/index.d.ts.map +1 -1
  5. package/dist/integrity.json +10 -9
  6. package/dist/lint/audit-catalog.d.ts.map +1 -1
  7. package/dist/lint/post-synth/ftn023-acp-runtime-command.d.ts +3 -2
  8. package/dist/lint/post-synth/ftn023-acp-runtime-command.d.ts.map +1 -1
  9. package/dist/lint/post-synth/ftn024-setup-timeout-range.d.ts +3 -0
  10. package/dist/lint/post-synth/ftn024-setup-timeout-range.d.ts.map +1 -0
  11. package/dist/lint/post-synth/index.d.ts.map +1 -1
  12. package/dist/lsp/hover.d.ts.map +1 -1
  13. package/dist/manifest.json +2 -2
  14. package/dist/meta.json +14 -2
  15. package/dist/okf/index.md +2 -1
  16. package/dist/okf/rules/FTN023.md +2 -2
  17. package/dist/okf/rules/FTN024.md +15 -0
  18. package/dist/okf/types/Agent.md +7 -5
  19. package/dist/okf/types/Environment.md +3 -0
  20. package/dist/okf/types/Vault.md +1 -0
  21. package/dist/rules/ftn016-runtime-model-valid.ts +5 -5
  22. package/dist/rules/ftn023-acp-runtime-command.ts +4 -3
  23. package/dist/rules/ftn024-setup-timeout-range.ts +48 -0
  24. package/dist/skills/chant-fountain-ops.md +6 -5
  25. package/dist/skills/chant-fountain-secrets.md +2 -2
  26. package/dist/skills/chant-fountain.md +2 -2
  27. package/dist/spec/fetch.d.ts +18 -3
  28. package/dist/spec/fetch.d.ts.map +1 -1
  29. package/dist/spec/parse.d.ts +5 -3
  30. package/dist/spec/parse.d.ts.map +1 -1
  31. package/dist/types/index.d.ts +14 -6
  32. package/package.json +2 -2
  33. package/src/codegen/docs.ts +2 -2
  34. package/src/composites/composites.test.ts +19 -0
  35. package/src/coverage.test.ts +8 -0
  36. package/src/coverage.ts +24 -8
  37. package/src/generated/index.d.ts +14 -6
  38. package/src/generated/index.ts +2 -2
  39. package/src/generated/lexicon-fountain.json +14 -2
  40. package/src/lint/audit-catalog.ts +11 -3
  41. package/src/lint/post-synth/ftn016-runtime-model-valid.ts +5 -5
  42. package/src/lint/post-synth/ftn023-acp-runtime-command.ts +4 -3
  43. package/src/lint/post-synth/ftn024-setup-timeout-range.ts +48 -0
  44. package/src/lint/post-synth/index.ts +2 -0
  45. package/src/lint/post-synth/post-synth.test.ts +24 -0
  46. package/src/lsp/hover.test.ts +9 -2
  47. package/src/lsp/hover.ts +6 -4
  48. package/src/plugin.test.ts +1 -1
  49. package/src/serializer.test.ts +2 -2
  50. package/src/skills/chant-fountain-ops.md +6 -5
  51. package/src/skills/chant-fountain-secrets.md +2 -2
  52. package/src/skills/chant-fountain.md +2 -2
  53. package/src/spec/fetch.ts +46 -6
  54. package/src/spec/fountain-openapi.snapshot.json +12221 -4739
  55. package/src/spec/parse.ts +84 -41
  56. package/src/spec/spec.test.ts +163 -0
package/src/spec/parse.ts CHANGED
@@ -20,9 +20,11 @@
20
20
  * add one that reaches the API only as a path parameter (a schedule's
21
21
  * teammate), into a by-name reference the serializer resolves and FTN021
22
22
  * proves resolvable — the same shape an Agent's `environment` has.
23
- * - Extensions. A prop chant accepts ahead of upstream, declared here so the
24
- * generated type documents it instead of leaving authors to smuggle it
25
- * through `metadata`. Today that is the ACP runtime.
23
+ * - Extensions. A prop chant accepts that the request schema does not
24
+ * describe, declared here so the generated type documents it instead of
25
+ * leaving authors to smuggle it through `metadata` or a cast. Today that is
26
+ * the inline `secrets` on Environment and Vault, which upstream documents
27
+ * on its manifest format rather than on either request schema.
26
28
  */
27
29
 
28
30
  import {
@@ -96,7 +98,7 @@ interface RefSpec {
96
98
  description: string;
97
99
  }
98
100
 
99
- /** A prop chant accepts that the pinned spec does not describe yet. */
101
+ /** A prop chant accepts that the kind's request schema does not describe. */
100
102
  interface ExtensionSpec {
101
103
  name: string;
102
104
  tsType: string;
@@ -109,23 +111,29 @@ interface ResourceSpec {
109
111
  request: string;
110
112
  response: string;
111
113
  refs?: RefSpec[];
112
- /** Extra accepted values, per enum-valued request property. */
113
- enumExtensions?: Record<string, string[]>;
114
114
  extensions?: ExtensionSpec[];
115
115
  }
116
116
 
117
117
  /**
118
- * The ACP runtime (BinaryBourbon/fountain#1634).
118
+ * The inline secrets a manifest document carries (Environment and Vault).
119
119
  *
120
- * `runtime: "acp"` and `runtime_command` are not in the pinned spec; the
121
- * upstream PR that adds them is open. chant models them anyway, because
122
- * `chant acp` is what a steward's agent runs, and an author who cannot name
123
- * the runtime has nowhere to put it but `metadata`. An instance without #1634
124
- * rejects them at apply — a 422 with an obvious cause, which is better than a
125
- * generated type that cannot express the deployment this lexicon is for.
120
+ * Neither request schema has the field: secrets are a write-only
121
+ * sub-resource with routes of their own. Upstream's `ManifestResource` says a
122
+ * manifest spec is the create schema "plus an inline `secrets` map
123
+ * (Environment and Vault)", and `fountainApply` turns the authored list into
124
+ * that map. Without the extension the generated types have no field for what
125
+ * the chant-fountain-secrets skill tells an author to write.
126
126
  */
127
- const ACP_NOTE =
128
- "chant extension, pending BinaryBourbon/fountain#1634 — an instance without that PR rejects it at apply.";
127
+ const SECRETS_EXTENSION: ExtensionSpec = {
128
+ name: "secrets",
129
+ tsType: "{ key: string; value: string }[]",
130
+ required: false,
131
+ description:
132
+ "Secrets upserted with the resource at apply, as key/value pairs; fountainApply sends them as the manifest's " +
133
+ "inline `secrets` map. Values are write-only upstream and can never be read back or diffed. Write a reference " +
134
+ "that resolves at build (an env var, a secret-manager lookup), never a literal: FTN001 flags a literal here " +
135
+ "as it does anywhere else in a declaration.",
136
+ };
129
137
 
130
138
  const REF_TARGET = {
131
139
  agent: `Fountain::${SERVICE}::Agent`,
@@ -135,22 +143,19 @@ const REF_TARGET = {
135
143
  };
136
144
 
137
145
  const RESOURCES: ResourceSpec[] = [
138
- { typeName: `Fountain::${SERVICE}::Environment`, request: "EnvironmentRequest", response: "Environment" },
139
- { typeName: `Fountain::${SERVICE}::Vault`, request: "VaultRequest", response: "Vault" },
140
146
  {
141
- typeName: `Fountain::${SERVICE}::Agent`,
142
- request: "AgentRequest",
143
- response: "Agent",
144
- enumExtensions: { runtime: ["acp"] },
145
- extensions: [
146
- {
147
- name: "runtime_command",
148
- tsType: "string",
149
- required: false,
150
- description: `The command line an "acp" agent speaks the Agent Client Protocol over, e.g. "chant acp". ${ACP_NOTE}`,
151
- },
152
- ],
147
+ typeName: `Fountain::${SERVICE}::Environment`,
148
+ request: "EnvironmentRequest",
149
+ response: "Environment",
150
+ extensions: [SECRETS_EXTENSION],
153
151
  },
152
+ {
153
+ typeName: `Fountain::${SERVICE}::Vault`,
154
+ request: "VaultRequest",
155
+ response: "Vault",
156
+ extensions: [SECRETS_EXTENSION],
157
+ },
158
+ { typeName: `Fountain::${SERVICE}::Agent`, request: "AgentRequest", response: "Agent" },
154
159
  {
155
160
  typeName: `Fountain::${SERVICE}::Teammate`,
156
161
  request: "TeamAddRequest",
@@ -254,20 +259,12 @@ export function parseFountainOpenAPI(data: string | Buffer): FountainParseResult
254
259
  properties.push(refProperty(ref));
255
260
  continue;
256
261
  }
257
- const extraEnum = rspec.enumExtensions?.[name];
258
- const constraints = coreExtractConstraints(prop as JsonSchemaProperty);
259
- let tsType = resolve(prop);
260
- if (extraEnum && extraEnum.length > 0) {
261
- const values = [...(prop.enum ?? []), ...extraEnum];
262
- constraints.enum = values;
263
- tsType = [...values].sort().map((v) => JSON.stringify(v)).join(" | ");
264
- }
265
262
  properties.push({
266
263
  name,
267
- tsType,
264
+ tsType: resolve(prop),
268
265
  required: requiredSet.has(name),
269
266
  description: prop.description,
270
- constraints,
267
+ constraints: coreExtractConstraints(prop as JsonSchemaProperty),
271
268
  });
272
269
  }
273
270
 
@@ -278,6 +275,15 @@ export function parseFountainOpenAPI(data: string | Buffer): FountainParseResult
278
275
  }
279
276
 
280
277
  for (const ext of rspec.extensions ?? []) {
278
+ // An extension exists because the spec lacks the prop. Once upstream
279
+ // describes it, the extension would emit a second copy with chant's
280
+ // type in place of upstream's, so the build stops and says so.
281
+ if (ext.name in reqProps) {
282
+ throw new Error(
283
+ `fountain parse: ${rspec.request} now declares "${ext.name}", which ${fountainShortName(rspec.typeName)} ` +
284
+ `carries as a chant extension. Remove the extension from RESOURCES in src/spec/parse.ts.`,
285
+ );
286
+ }
281
287
  properties.push({
282
288
  name: ext.name,
283
289
  tsType: ext.tsType,
@@ -386,9 +392,33 @@ function collectRefs(node: unknown, acc: Set<string> = new Set()): Set<string> {
386
392
  return acc;
387
393
  }
388
394
 
389
- /** An object schema with properties (not a pure enum). */
395
+ /**
396
+ * An object schema with properties (not a pure enum, and not an open map).
397
+ *
398
+ * A schema with named properties and a typed `additionalProperties` is a map
399
+ * that reserves a few keys, like v0.21.0's `PermissionPolicy`: tool names to
400
+ * verdicts, plus `ask_timeout`. Emitting it as a class would keep the reserved
401
+ * keys and drop the map, so it resolves to a `Record` instead.
402
+ */
390
403
  function isObjectSchema(def: OpenAPISchema): boolean {
391
- return !!def.properties && Object.keys(def.properties).length > 0 && !isEnumDefinition(def);
404
+ return (
405
+ !!def.properties &&
406
+ Object.keys(def.properties).length > 0 &&
407
+ !isEnumDefinition(def) &&
408
+ !isOpenMap(def)
409
+ );
410
+ }
411
+
412
+ /** An object whose `additionalProperties` is a schema, not `true` or absent. */
413
+ function isOpenMap(def: OpenAPISchema): boolean {
414
+ return !!def.additionalProperties && typeof def.additionalProperties === "object";
415
+ }
416
+
417
+ /** The TypeScript union of a set of member types, deduplicated and sorted. */
418
+ function unionOf(types: string[]): string {
419
+ const parts = new Set(types.flatMap((t) => t.split(" | ")));
420
+ if (parts.has("any")) return "any";
421
+ return [...parts].sort().join(" | ");
392
422
  }
393
423
 
394
424
  // ── Type resolution ────────────────────────────────────────────────
@@ -411,6 +441,12 @@ function resolveType(
411
441
  return [...prop.enum].sort().map((v) => JSON.stringify(v)).join(" | ");
412
442
  }
413
443
 
444
+ const members = (prop as { oneOf?: OpenAPISchema[]; anyOf?: OpenAPISchema[] }).oneOf ??
445
+ (prop as { anyOf?: OpenAPISchema[] }).anyOf;
446
+ if (members && members.length > 0) {
447
+ return unionOf(members.map((m) => resolveType(m, schemas, emitted)));
448
+ }
449
+
414
450
  const pt = primaryType(prop.type);
415
451
  switch (pt) {
416
452
  case "string":
@@ -450,6 +486,13 @@ function resolveRefType(ref: string, schemas: Record<string, OpenAPISchema>, emi
450
486
  return [...(def.enum ?? [])].sort().map((v) => JSON.stringify(v)).join(" | ");
451
487
  }
452
488
 
489
+ if (isOpenMap(def)) {
490
+ const value = resolveType(def.additionalProperties as OpenAPISchema, schemas, emitted);
491
+ // Reserved keys ride in the same map, so their types join the value union.
492
+ const reserved = Object.values(def.properties ?? {}).map((p) => resolveType(p, schemas, emitted));
493
+ return `Record<string, ${unionOf([value, ...reserved])}>`;
494
+ }
495
+
453
496
  if (def.properties) return "Record<string, any>";
454
497
 
455
498
  const pt = primaryType(def.type);
@@ -0,0 +1,163 @@
1
+ import { afterAll, describe, expect, it, vi } from "vitest";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
3
+ import ts from "typescript";
4
+ import { join, dirname } from "path";
5
+ import { tmpdir } from "os";
6
+ import { fileURLToPath } from "url";
7
+ import { FOUNTAIN_SPEC_VERSION, fetchSchemas, readSnapshot } from "./fetch";
8
+
9
+ // No network here, so fetchSchemas always takes the snapshot fallback.
10
+ vi.mock("@intentius/chant/codegen/fetch", () => ({
11
+ fetchWithCache: () => Promise.reject(new Error("offline (test)")),
12
+ }));
13
+ import { parseFountainOpenAPI, type ParsedProperty } from "./parse";
14
+
15
+ const snapshotFile = join(dirname(fileURLToPath(import.meta.url)), "fountain-openapi.snapshot.json");
16
+ const snapshot = readFileSync(snapshotFile, "utf-8");
17
+
18
+ const scratch = mkdtempSync(join(tmpdir(), "fountain-spec-"));
19
+ afterAll(() => rmSync(scratch, { recursive: true, force: true }));
20
+
21
+ /** Write a spec whose info.version is `version` and return its path. */
22
+ function specAt(version: unknown): string {
23
+ const file = join(scratch, `spec-${String(version)}.json`);
24
+ writeFileSync(file, JSON.stringify({ openapi: "3.0.0", info: { title: "fountain", version } }));
25
+ return file;
26
+ }
27
+
28
+ describe("snapshot fallback (#2389)", () => {
29
+ it("is the pinned release", () => {
30
+ // The committed snapshot is what an offline generate reads. If it lags
31
+ // the pin, every other assertion in this file is about the wrong spec.
32
+ expect(() => readSnapshot()).not.toThrow();
33
+ expect(`v${JSON.parse(snapshot).info.version}`).toBe(FOUNTAIN_SPEC_VERSION);
34
+ });
35
+
36
+ it("refuses a snapshot from another release, naming both versions", () => {
37
+ expect(() => readSnapshot(specAt("0.16.0"), "v0.21.0")).toThrow(/0\.16\.0.*v0\.21\.0/);
38
+ });
39
+
40
+ it("is what fetchSchemas falls back through when the pinned release cannot be fetched", async () => {
41
+ await expect(fetchSchemas({ snapshotFile: specAt("0.16.0") })).rejects.toThrow(
42
+ new RegExp(`fountain 0\\.16\\.0, but the pin is ${FOUNTAIN_SPEC_VERSION.replace(/\./g, "\\.")}`),
43
+ );
44
+ const served = await fetchSchemas();
45
+ expect(JSON.parse(served.get("fountain-openapi.json")!.toString("utf-8")).info.version).toBe("0.21.0");
46
+ });
47
+
48
+ it("refuses a snapshot with no info.version", () => {
49
+ expect(() => readSnapshot(specAt(undefined), "v0.21.0")).toThrow(/no info\.version.*v0\.21\.0/);
50
+ });
51
+
52
+ it("accepts the tag and the bare version as the same release", () => {
53
+ expect(readSnapshot(specAt("0.21.0"), "v0.21.0").length).toBeGreaterThan(0);
54
+ expect(readSnapshot(specAt("v0.21.0"), "v0.21.0").length).toBeGreaterThan(0);
55
+ });
56
+ });
57
+
58
+ describe("the generated surface at v0.21.0", () => {
59
+ const parsed = parseFountainOpenAPI(snapshot);
60
+ const propsOf = (kind: string): Map<string, ParsedProperty> => {
61
+ const result = parsed.find((r) => r.resource.typeName === `Fountain::V1::${kind}`);
62
+ if (!result) throw new Error(`no ${kind} in the parse`);
63
+ return new Map(result.resource.properties.map((p) => [p.name, p]));
64
+ };
65
+
66
+ it("gives Environment setup_timeout_seconds, with upstream's bounds", () => {
67
+ const timeout = propsOf("Environment").get("setup_timeout_seconds");
68
+ expect(timeout?.tsType).toBe("number");
69
+ expect(timeout?.required).toBe(false);
70
+ expect(timeout?.constraints).toMatchObject({ minimum: 1, maximum: 900 });
71
+ });
72
+
73
+ it.each(["Environment", "Vault"])("types %s secrets as the authored key/value list", (kind) => {
74
+ const secrets = propsOf(kind).get("secrets");
75
+ expect(secrets?.tsType).toBe("{ key: string; value: string }[]");
76
+ expect(secrets?.required).toBe(false);
77
+ expect(secrets?.description).toContain("FTN001");
78
+ });
79
+
80
+ it("takes the acp runtime and runtime_command from the spec, with model optional", () => {
81
+ const agent = propsOf("Agent");
82
+ expect(agent.get("runtime")?.tsType).toBe('"acp" | "claude" | "codex" | "gemini" | "opencode"');
83
+ expect(agent.get("runtime")?.required).toBe(true);
84
+ // Upstream's own description, not chant's extension note.
85
+ expect(agent.get("runtime_command")?.description).toContain("Required when runtime is acp");
86
+ expect(agent.get("runtime_command")?.description).not.toContain("extension");
87
+ expect(agent.get("model")?.required).toBe(false);
88
+ });
89
+
90
+ it("keeps permission_policy a map, with ask_timeout's number in the value union", () => {
91
+ // v0.21.0 moved the policy behind a PermissionPolicy $ref that has one
92
+ // named property and a typed additionalProperties. Emitted as a class it
93
+ // would have only ask_timeout, and { default: "auto_allow" } would stop
94
+ // compiling.
95
+ expect(propsOf("Agent").get("permission_policy")?.tsType).toBe(
96
+ 'Record<string, "ask" | "auto_allow" | "auto_deny" | number>',
97
+ );
98
+ expect(parsed.some((r) => r.resource.typeName === "Fountain::V1::PermissionPolicy")).toBe(false);
99
+ });
100
+
101
+ it("refuses an extension once upstream declares the prop itself", () => {
102
+ const spec = JSON.parse(snapshot);
103
+ spec.components.schemas.VaultRequest.properties.secrets = { type: "object" };
104
+ expect(() => parseFountainOpenAPI(JSON.stringify(spec))).toThrow(/VaultRequest now declares "secrets"/);
105
+ });
106
+ });
107
+
108
+ describe("the generated declarations accept what the skill tells an author to write", () => {
109
+ // The .d.ts is what a consumer of the published package compiles against.
110
+ // In-repo, "../generated/index" resolves to the untyped runtime barrel, so a
111
+ // plain import would check nothing. This compiles a probe against a copy of
112
+ // the declaration instead. Every accepted line needed a cast at v0.16.0.
113
+ const dts = join(dirname(snapshotFile), "..", "generated", "index.d.ts");
114
+
115
+ function compile(body: string): string[] {
116
+ const dir = mkdtempSync(join(scratch, "dts-"));
117
+ writeFileSync(join(dir, "decl.d.ts"), readFileSync(dts, "utf-8"));
118
+ writeFileSync(
119
+ join(dir, "probe.ts"),
120
+ `import type { Agent, Environment, Vault } from "./decl";\n` +
121
+ `type EnvProps = ConstructorParameters<typeof Environment>[0];\n` +
122
+ `type VaultProps = ConstructorParameters<typeof Vault>[0];\n` +
123
+ `type AgentProps = ConstructorParameters<typeof Agent>[0];\n` +
124
+ body,
125
+ );
126
+ const program = ts.createProgram([join(dir, "probe.ts")], {
127
+ strict: true,
128
+ noEmit: true,
129
+ target: ts.ScriptTarget.ES2022,
130
+ module: ts.ModuleKind.ESNext,
131
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
132
+ types: [],
133
+ });
134
+ return ts
135
+ .getPreEmitDiagnostics(program)
136
+ .map((d) => ts.flattenDiagnosticMessageText(d.messageText, "\n"));
137
+ }
138
+
139
+ it.skipIf(!existsSync(dts))("with no cast", () => {
140
+ const errors = compile(`
141
+ export const env: EnvProps = {
142
+ name: "box",
143
+ setup_timeout_seconds: 900,
144
+ secrets: [{ key: "GITHUB_TOKEN", value: "infisical:///dev/GITHUB_TOKEN" }],
145
+ };
146
+ export const vault: VaultProps = { name: "creds", secrets: [{ key: "NPM_TOKEN", value: "infisical:///dev/NPM_TOKEN" }] };
147
+ // No model: v0.21.0 stopped requiring one, and an acp agent has none.
148
+ export const agent: AgentProps = {
149
+ name: "steward",
150
+ runtime: "acp",
151
+ runtime_command: "chant acp",
152
+ permission_policy: { default: "auto_allow", ask_timeout: 600 },
153
+ };
154
+ `);
155
+ expect(errors).toEqual([]);
156
+ });
157
+
158
+ it.skipIf(!existsSync(dts))("and refuse the wire map in place of the authored list", () => {
159
+ // The probe has to be able to fail, or the case above proves nothing.
160
+ const errors = compile(`export const vault: VaultProps = { name: "creds", secrets: { NPM_TOKEN: "x" } };`);
161
+ expect(errors.join("\n")).toMatch(/NPM_TOKEN.*\{ key: string; value: string; \}\[\]/);
162
+ });
163
+ });