@intentius/chant-lexicon-fountain 0.45.0 → 0.49.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.
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Parse-to-graph for `chant audit` (#1567).
3
+ *
4
+ * Audit discovery classifies standalone fountain manifests (#1566); this
5
+ * module turns one classified file's content back into the entity graph the
6
+ * FTN post-synth checks read (`ctx.entities`), so the same graph-reading
7
+ * rules that fire on `chant build` fire on an audit of hand-written
8
+ * `fountain apply` YAML — one implementation per rule, no output-reading
9
+ * variants to drift.
10
+ *
11
+ * Tolerant by contract: the audit runs against any repo, so a malformed
12
+ * document contributes no entities instead of throwing, and documents are
13
+ * parsed one at a time so one bad document doesn't take down the file's
14
+ * others. A name declared twice keeps both declarations in the map (the
15
+ * second gets a `#n`-suffixed key), so nothing is silently dropped and
16
+ * FTN017's duplicate-name detection still sees the collision.
17
+ */
18
+ import type { Declarable } from "@intentius/chant";
19
+ /** Parse standalone fountain YAML (or a fountain-plan.json) into `ctx.entities`. */
20
+ export declare function fountainAuditEntities(content: string): Map<string, Declarable>;
21
+ //# sourceMappingURL=audit-entities.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audit-entities.d.ts","sourceRoot":"","sources":["../src/audit-entities.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAGnD,oFAAoF;AACpF,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,EAAE,UAAU,CAAC,CAoB9E"}
@@ -65,6 +65,13 @@ export declare function computeFountainCoverage(specJSON: string | Buffer, surfa
65
65
  export declare function formatSummary(report: FountainCoverageReport): string;
66
66
  export declare function formatVerbose(report: FountainCoverageReport): string;
67
67
  /** Run coverage analysis for the fountain lexicon. */
68
+ /**
69
+ * The offline path behind `coverageReport()` (#1330): the same computation
70
+ * `coverage.test.ts` runs, over the committed spec snapshot and surface
71
+ * baseline. Never `fetchSchemas()` — check-lexicon runs on every PR and must
72
+ * not do network I/O.
73
+ */
74
+ export declare function coverageReportFromSnapshots(basePath?: string): FountainCoverageReport;
68
75
  export declare function analyzeFountainCoverage(opts?: {
69
76
  basePath?: string;
70
77
  verbose?: boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"coverage.d.ts","sourceRoot":"","sources":["../src/coverage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAQH,uDAAuD;AACvD,eAAO,MAAM,cAAc,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAQjD,CAAC;AAEF;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,qBAAqB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAUxD,CAAC;AAEF,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;IACrB,yEAAyE;IACzE,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,yEAAyE;IACzE,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,MAAM,WAAW,sBAAsB;IACrC,KAAK,EAAE,YAAY,EAAE,CAAC;IACtB,8CAA8C;IAC9C,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,yDAAyD;IACzD,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC,iEAAiE;IACjE,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,+EAA+E;IAC/E,oBAAoB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC7C,yEAAyE;IACzE,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,UAAU,eAAe;IACvB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CAAC;CAC7D;AAOD,wBAAgB,uBAAuB,CACrC,QAAQ,EAAE,MAAM,GAAG,MAAM,EACzB,OAAO,EAAE,eAAe,GACvB,sBAAsB,CAkDxB;AAED,wBAAgB,aAAa,CAAC,MAAM,EAAE,sBAAsB,GAAG,MAAM,CAGpE;AAED,wBAAgB,aAAa,CAAC,MAAM,EAAE,sBAAsB,GAAG,MAAM,CAkCpE;AAED,sDAAsD;AACtD,wBAAsB,uBAAuB,CAAC,IAAI,CAAC,EAAE;IACnD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,GAAG,OAAO,CAAC,sBAAsB,CAAC,CAsBlC"}
1
+ {"version":3,"file":"coverage.d.ts","sourceRoot":"","sources":["../src/coverage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAQH,uDAAuD;AACvD,eAAO,MAAM,cAAc,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAQjD,CAAC;AAEF;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,qBAAqB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAUxD,CAAC;AAEF,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;IACrB,yEAAyE;IACzE,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,yEAAyE;IACzE,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,MAAM,WAAW,sBAAsB;IACrC,KAAK,EAAE,YAAY,EAAE,CAAC;IACtB,8CAA8C;IAC9C,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,yDAAyD;IACzD,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC,iEAAiE;IACjE,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,+EAA+E;IAC/E,oBAAoB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC7C,yEAAyE;IACzE,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,UAAU,eAAe;IACvB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CAAC;CAC7D;AAOD,wBAAgB,uBAAuB,CACrC,QAAQ,EAAE,MAAM,GAAG,MAAM,EACzB,OAAO,EAAE,eAAe,GACvB,sBAAsB,CAkDxB;AAED,wBAAgB,aAAa,CAAC,MAAM,EAAE,sBAAsB,GAAG,MAAM,CAGpE;AAED,wBAAgB,aAAa,CAAC,MAAM,EAAE,sBAAsB,GAAG,MAAM,CAkCpE;AAED,sDAAsD;AACtD;;;;;GAKG;AACH,wBAAgB,2BAA2B,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,sBAAsB,CASrF;AAED,wBAAsB,uBAAuB,CAAC,IAAI,CAAC,EAAE;IACnD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,GAAG,OAAO,CAAC,sBAAsB,CAAC,CAsBlC"}
package/dist/detect.d.ts CHANGED
@@ -5,6 +5,11 @@
5
5
  export declare function isFountainManifest(doc: unknown): boolean;
6
6
  /** The serializer's fountain-plan.json shape: entity name → { kind, spec }. */
7
7
  export declare function isFountainPlan(data: unknown): boolean;
8
+ /**
9
+ * Canonical `detectTemplate` name under `@…/detect` — what edge callers
10
+ * (and core's detect-bundle guard) import for content detection.
11
+ */
12
+ export { detectFountainTemplate as detectTemplate };
8
13
  /** Template detection for the plugin: raw string input (YAML or JSON). */
9
14
  export declare function detectFountainTemplate(data: unknown): boolean;
10
15
  //# sourceMappingURL=detect.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"detect.d.ts","sourceRoot":"","sources":["../src/detect.ts"],"names":[],"mappings":"AAAA;;GAEG;AAIH,sFAAsF;AACtF,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAIxD;AAED,+EAA+E;AAC/E,wBAAgB,cAAc,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CASrD;AAED,0EAA0E;AAC1E,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAU7D"}
1
+ {"version":3,"file":"detect.d.ts","sourceRoot":"","sources":["../src/detect.ts"],"names":[],"mappings":"AAAA;;GAEG;AAIH,sFAAsF;AACtF,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAIxD;AAED,+EAA+E;AAC/E,wBAAgB,cAAc,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CASrD;AAED;;;GAGG;AACH,OAAO,EAAE,sBAAsB,IAAI,cAAc,EAAE,CAAC;AAEpD,0EAA0E;AAC1E,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAU7D"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "28dd49dfa56314218e8f0679b05ad176cd7996e751444eb51e6887633c04a4ba",
4
+ "manifest.json": "c4e4aef153956f7e33b8732485af8e2bf16c7e57be9e99574982d2b56c7c461c",
5
5
  "meta.json": "6666b7a77db9210a329219c6a5e107e5ae8794a34542f7310ce16d9bf95c64f5",
6
6
  "types/index.d.ts": "1dfdcba184fffcf464dac7d1d71ef7abed2ae02a8d638c0de50e0b0317b7eb01",
7
7
  "rules/ftn001-no-secret-literals.ts": "897a4ce1ec790b3c1540d32892603bd33ff4bf30eb6ba4cd2565dee356d4962e",
@@ -17,5 +17,5 @@
17
17
  "skills/chant-fountain-secrets.md": "27e349a91589510a92e518c7d7824a4a322cab5ef242cf5799373a55cbfcd1cd",
18
18
  "skills/chant-fountain-locked-sandboxes.md": "de82f06cb3a08ba6bf3ae45fb9869e21d6da18b9ebe0fc769da8aebaceea7dd1"
19
19
  },
20
- "composite": "b7579f9b30c25525a5424d24868d39e12858de8ea2da23773c3760edca697810"
20
+ "composite": "6bd8f5f7bab081c4e3f249f92ac6bfa9a6dd3a94718bba6142046e5e00d8d9f3"
21
21
  }
@@ -2,13 +2,17 @@
2
2
  * The fountain lexicon's chant audit catalog — metadata for the FTN rules,
3
3
  * contributed via `fountainPlugin.auditCatalog()` (#687).
4
4
  *
5
- * Every entry carries `yamlBased: false`, which is not an oversight: all of
6
- * fountain's checks read the chant model (`ctx.entities`), not the emitted
7
- * manifests (`ctx.outputs`), because the facts they needwhich Environment
8
- * an Agent references, which keys a Vault shadows live in the typed graph
9
- * and are flattened by the time YAML exists. So they fire on `chant build`
10
- * and cannot fire on an audit of standalone fountain YAML. `auditRule()`
11
- * hardcodes `yamlBased: true`, so these are constructed directly.
5
+ * All of fountain's checks read the chant model (`ctx.entities`), not the
6
+ * emitted manifests (`ctx.outputs`), because the facts they need — which
7
+ * Environment an Agent references, which keys a Vault shadowslive in the
8
+ * typed graph and are flattened by the time YAML exists. They still fire on
9
+ * an audit of standalone fountain YAML: the audit parses classified
10
+ * `fountain.dev/v1` documents back into the entity graph via the plugin's
11
+ * `auditEntities` (parse-to-graph, #1567), so the post-synth entries are
12
+ * `yamlBased: true` — one rule implementation serves both paths. The one
13
+ * exception is FTN001, a declarative lint rule over TypeScript source that
14
+ * the audit never runs; it stays `yamlBased: false`. `auditRule()` hardcodes
15
+ * `yamlBased: true`, so these are constructed directly.
12
16
  */
13
17
  import type { RuleMeta } from "@intentius/chant/audit/catalog";
14
18
  export declare const fountainAuditCatalog: Record<string, RuleMeta>;
@@ -1 +1 @@
1
- {"version":3,"file":"audit-catalog.d.ts","sourceRoot":"","sources":["../../src/lint/audit-catalog.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAa,QAAQ,EAAE,MAAM,gCAAgC,CAAC;AAwB1E,eAAO,MAAM,oBAAoB,EAAE,MAAM,CAAC,MAAM,EAAE,QAAQ,CAqEzD,CAAC"}
1
+ {"version":3,"file":"audit-catalog.d.ts","sourceRoot":"","sources":["../../src/lint/audit-catalog.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,KAAK,EAAa,QAAQ,EAAE,MAAM,gCAAgC,CAAC;AA6B1E,eAAO,MAAM,oBAAoB,EAAE,MAAM,CAAC,MAAM,EAAE,QAAQ,CAsEzD,CAAC"}
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fountain",
3
- "version": "0.45.0",
3
+ "version": "0.49.0",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "Fountain",
6
6
  "specVersion": "v0.3.0"
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,0BAA0B,CAAC;AAsB7F;;;;GAIG;AACH,eAAO,MAAM,cAAc,EAAE,aA8I5B,CAAC"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,0BAA0B,CAAC;AAuB7F;;;;GAIG;AACH,eAAO,MAAM,cAAc,EAAE,aA0J5B,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-fountain",
3
- "version": "0.45.0",
3
+ "version": "0.49.0",
4
4
  "type": "module",
5
5
  "description": "Fountain lexicon for chant — sandboxed agent environments, vaults, and agents as typed estate",
6
6
  "license": "Apache-2.0",
@@ -50,7 +50,7 @@
50
50
  "bundle": "tsx src/package-cli.ts"
51
51
  },
52
52
  "peerDependencies": {
53
- "@intentius/chant": "^0.45.0",
53
+ "@intentius/chant": "^0.49.0",
54
54
  "typescript": "^5.9.3"
55
55
  },
56
56
  "devDependencies": {
@@ -0,0 +1,157 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { Declarable } from "@intentius/chant";
3
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
4
+ import { fountainAuditEntities } from "./audit-entities";
5
+ import { fountainSerializer } from "./serializer";
6
+ import { postSynthChecks } from "./lint/post-synth";
7
+ import { propsOf } from "./entity-props";
8
+
9
+ function ctxOf(entities: Map<string, Declarable>): PostSynthContext {
10
+ return {
11
+ outputs: new Map(),
12
+ entities,
13
+ buildResult: { outputs: new Map(), entities, warnings: [], errors: [], sourceFileCount: 0 },
14
+ } as unknown as PostSynthContext;
15
+ }
16
+
17
+ const runAll = (entities: Map<string, Declarable>) => postSynthChecks.flatMap((c) => c.check(ctxOf(entities)));
18
+
19
+ const DIRTY = `apiVersion: fountain.dev/v1
20
+ kind: Environment
21
+ metadata:
22
+ name: dev
23
+ spec:
24
+ networking_type: unrestricted
25
+ env_vars:
26
+ API_URL: https://api.example.com
27
+ AWS_SECRET_ACCESS_KEY: not-a-real-secret
28
+ ---
29
+ apiVersion: fountain.dev/v1
30
+ kind: Vault
31
+ metadata:
32
+ name: staging
33
+ spec:
34
+ secrets:
35
+ - key: API_URL
36
+ value: https://staging.example.com
37
+ `;
38
+
39
+ const CLEAN = `apiVersion: fountain.dev/v1
40
+ kind: Environment
41
+ metadata:
42
+ name: dev
43
+ spec:
44
+ networking_type: limited
45
+ networking_config:
46
+ allowed_hosts:
47
+ - github.com
48
+ env_vars:
49
+ API_URL: https://api.example.com
50
+ ---
51
+ apiVersion: fountain.dev/v1
52
+ kind: Agent
53
+ metadata:
54
+ name: researcher
55
+ spec:
56
+ model: anthropic/claude-sonnet-4-6
57
+ runtime: claude
58
+ environment: dev
59
+ `;
60
+
61
+ describe("fountainAuditEntities (parse-to-graph, #1567)", () => {
62
+ it("parses manifests into entities keyed by metadata.name, props under .props", () => {
63
+ const entities = fountainAuditEntities(DIRTY);
64
+ expect([...entities.keys()].sort()).toEqual(["dev", "staging"]);
65
+ const dev = entities.get("dev")!;
66
+ expect(dev.entityType).toBe("Fountain::V1::Environment");
67
+ expect(propsOf(dev).networking_type).toBe("unrestricted");
68
+ expect(propsOf(dev).name).toBe("dev");
69
+ expect(entities.get("staging")!.entityType).toBe("Fountain::V1::Vault");
70
+ });
71
+
72
+ it("the existing FTN checks fire unchanged over the parsed graph", () => {
73
+ const diags = runAll(fountainAuditEntities(DIRTY));
74
+ const ids = diags.map((d) => d.checkId).sort();
75
+ expect(ids).toContain("FTN011"); // unrestricted networking
76
+ expect(ids).toContain("FTN012"); // credential-shaped env_vars key
77
+ expect(ids).toContain("FTN014"); // vault shadows API_URL
78
+ expect(diags.find((d) => d.checkId === "FTN012")!.severity).toBe("error");
79
+ });
80
+
81
+ it("a clean manifest set yields no findings", () => {
82
+ expect(runAll(fountainAuditEntities(CLEAN))).toEqual([]);
83
+ });
84
+
85
+ it("a name declared twice keeps both entities (suffixed key), so FTN017 sees the collision", () => {
86
+ const twice = `apiVersion: fountain.dev/v1
87
+ kind: Environment
88
+ metadata:
89
+ name: dev
90
+ spec:
91
+ networking_type: limited
92
+ ---
93
+ apiVersion: fountain.dev/v1
94
+ kind: Environment
95
+ metadata:
96
+ name: dev
97
+ spec:
98
+ networking_type: limited
99
+ `;
100
+ const entities = fountainAuditEntities(twice);
101
+ expect([...entities.keys()].sort()).toEqual(["dev", "dev#2"]);
102
+ expect(runAll(entities).map((d) => d.checkId)).toContain("FTN017");
103
+ });
104
+
105
+ it("a malformed document contributes nothing; the rest of the file still parses", () => {
106
+ const mixed = `{not yaml [
107
+ ---
108
+ apiVersion: fountain.dev/v1
109
+ kind: Environment
110
+ metadata:
111
+ name: dev
112
+ spec:
113
+ networking_type: limited
114
+ `;
115
+ const entities = fountainAuditEntities(mixed);
116
+ expect([...entities.keys()]).toEqual(["dev"]);
117
+ });
118
+
119
+ it("non-fountain documents in the file are skipped, not misread", () => {
120
+ const entities = fountainAuditEntities("apiVersion: v1\nkind: Pod\nmetadata:\n name: p\n");
121
+ expect(entities.size).toBe(0);
122
+ });
123
+
124
+ it("round-trips: serialize -> parse -> same entities (guards the #1286 class)", () => {
125
+ const entity = (entityType: string, props: Record<string, unknown>): Declarable =>
126
+ ({ entityType, lexicon: "fountain", props }) as unknown as Declarable;
127
+ const src = new Map<string, Declarable>([
128
+ [
129
+ "dev",
130
+ entity("Fountain::V1::Environment", {
131
+ name: "dev",
132
+ networking_type: "limited",
133
+ networking_config: { allowed_hosts: ["github.com"] },
134
+ env_vars: { API_URL: "https://api.example.com" },
135
+ }),
136
+ ],
137
+ [
138
+ "researcher",
139
+ entity("Fountain::V1::Agent", {
140
+ name: "researcher",
141
+ runtime: "claude",
142
+ model: "anthropic/claude-sonnet-4-6",
143
+ environment: "dev",
144
+ }),
145
+ ],
146
+ ]);
147
+ const parsed = fountainAuditEntities(fountainSerializer.serialize(src) as string);
148
+ expect([...parsed.keys()].sort()).toEqual(["dev", "researcher"]);
149
+ for (const [name, before] of src) {
150
+ const after = parsed.get(name)!;
151
+ expect(after.entityType).toBe(before.entityType);
152
+ expect(propsOf(after)).toEqual(propsOf(before));
153
+ }
154
+ // and the parsed graph is as quiet as the declared one
155
+ expect(runAll(parsed)).toEqual([]);
156
+ });
157
+ });
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Parse-to-graph for `chant audit` (#1567).
3
+ *
4
+ * Audit discovery classifies standalone fountain manifests (#1566); this
5
+ * module turns one classified file's content back into the entity graph the
6
+ * FTN post-synth checks read (`ctx.entities`), so the same graph-reading
7
+ * rules that fire on `chant build` fire on an audit of hand-written
8
+ * `fountain apply` YAML — one implementation per rule, no output-reading
9
+ * variants to drift.
10
+ *
11
+ * Tolerant by contract: the audit runs against any repo, so a malformed
12
+ * document contributes no entities instead of throwing, and documents are
13
+ * parsed one at a time so one bad document doesn't take down the file's
14
+ * others. A name declared twice keeps both declarations in the map (the
15
+ * second gets a `#n`-suffixed key), so nothing is silently dropped and
16
+ * FTN017's duplicate-name detection still sees the collision.
17
+ */
18
+
19
+ import type { Declarable } from "@intentius/chant";
20
+ import { FountainParser } from "./import/parser";
21
+
22
+ /** Parse standalone fountain YAML (or a fountain-plan.json) into `ctx.entities`. */
23
+ export function fountainAuditEntities(content: string): Map<string, Declarable> {
24
+ const entities = new Map<string, Declarable>();
25
+ const parser = new FountainParser();
26
+ for (const docText of content.split(/^---\s*$/m)) {
27
+ if (!docText.trim()) continue;
28
+ let resources;
29
+ try {
30
+ resources = parser.parse(docText).resources;
31
+ } catch {
32
+ continue; // malformed document — no entities, never a crash
33
+ }
34
+ for (const r of resources) {
35
+ let key = r.logicalId;
36
+ for (let n = 2; entities.has(key); n++) key = `${r.logicalId}#${n}`;
37
+ // The plain-object entity shape the checks' `propsOf` reads: authored
38
+ // props under `.props`, mirroring real createResource instances.
39
+ entities.set(key, { lexicon: "fountain", entityType: r.type, props: r.properties } as unknown as Declarable);
40
+ }
41
+ }
42
+ return entities;
43
+ }
@@ -45,7 +45,7 @@ export const helper = new Agent({
45
45
 
46
46
  ## The loop
47
47
 
48
- 1. \`chant build\` — synthesize and lint. The FTN rules catch open networking, credential literals, and unresolvable \`\${VAR}\` references before review.
48
+ 1. \`chant build\` — synthesize and lint. The FTN rules catch open networking, credential literals, and unresolvable \`\${VAR}\` references before review. The same rules run via [\`chant audit\`](/chant/cli/audit/) over a repo of hand-written \`fountain apply\` manifests — no chant project needed; the documents are parsed back into the entity graph.
49
49
  2. \`chant run <apply op>\` or call \`fountainApply\` — reconcile against the API. Idempotent by name.
50
50
  3. \`chant lifecycle diff --live\` — drift. A UI edit to an owned Environment shows up here.
51
51
  4. \`chant import --from\` — adopt UI-built resources into typed files.
@@ -1,8 +1,15 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { describe, expect, it, vi } from "vitest";
2
2
  import { readFileSync } from "fs";
3
3
  import { join, dirname } from "path";
4
4
  import { fileURLToPath } from "url";
5
- import { computeFountainCoverage, formatVerbose, EXCLUDED_KINDS, UNSPECIFIED_ENDPOINTS } from "./coverage";
5
+ import {
6
+ computeFountainCoverage,
7
+ coverageReportFromSnapshots,
8
+ formatVerbose,
9
+ EXCLUDED_KINDS,
10
+ UNSPECIFIED_ENDPOINTS,
11
+ } from "./coverage";
12
+ import { fountainPlugin } from "./plugin";
6
13
  import { fetchSchemas } from "./spec/fetch";
7
14
 
8
15
  const srcDir = dirname(fileURLToPath(import.meta.url));
@@ -63,6 +70,30 @@ describe("fountain coverage", () => {
63
70
  });
64
71
  });
65
72
 
73
+ // chant #1330 — the same accounting, exposed to check-lexicon through the
74
+ // plugin contract. It must agree with the direct computation above and must
75
+ // stay offline: check-lexicon runs on every PR.
76
+ describe("coverageReport plugin contract", () => {
77
+ it("reports no unaccounted kinds over the committed snapshots", async () => {
78
+ const report = await fountainPlugin.coverageReport!();
79
+ expect(report.unaccountedKinds).toEqual([]);
80
+ expect(report.unaccountedKinds).toEqual(
81
+ computeFountainCoverage(spec, surface).unaccountedKinds,
82
+ );
83
+ });
84
+
85
+ it("does no network I/O", async () => {
86
+ const spy = vi.spyOn(globalThis, "fetch");
87
+ try {
88
+ await fountainPlugin.coverageReport!();
89
+ coverageReportFromSnapshots();
90
+ expect(spy).not.toHaveBeenCalled();
91
+ } finally {
92
+ spy.mockRestore();
93
+ }
94
+ });
95
+ });
96
+
66
97
  describe("unspecified upstream endpoints", () => {
67
98
  it("records a decision for every endpoint the spec cannot describe", () => {
68
99
  // The point of the list is the reasoning, not the route string. An entry
package/src/coverage.ts CHANGED
@@ -190,6 +190,23 @@ export function formatVerbose(report: FountainCoverageReport): string {
190
190
  }
191
191
 
192
192
  /** Run coverage analysis for the fountain lexicon. */
193
+ /**
194
+ * The offline path behind `coverageReport()` (#1330): the same computation
195
+ * `coverage.test.ts` runs, over the committed spec snapshot and surface
196
+ * baseline. Never `fetchSchemas()` — check-lexicon runs on every PR and must
197
+ * not do network I/O.
198
+ */
199
+ export function coverageReportFromSnapshots(basePath?: string): FountainCoverageReport {
200
+ const base = basePath ?? dirname(dirname(fileURLToPath(import.meta.url)));
201
+
202
+ const spec = readFileSync(join(base, "src", "spec", "fountain-openapi.snapshot.json"), "utf-8");
203
+ const surface = JSON.parse(
204
+ readFileSync(join(base, "surface.snapshot.json"), "utf-8"),
205
+ ) as SurfaceSnapshot;
206
+
207
+ return computeFountainCoverage(spec, surface);
208
+ }
209
+
193
210
  export async function analyzeFountainCoverage(opts?: {
194
211
  basePath?: string;
195
212
  verbose?: boolean;
package/src/detect.ts CHANGED
@@ -23,6 +23,12 @@ export function isFountainPlan(data: unknown): boolean {
23
23
  });
24
24
  }
25
25
 
26
+ /**
27
+ * Canonical `detectTemplate` name under `@…/detect` — what edge callers
28
+ * (and core's detect-bundle guard) import for content detection.
29
+ */
30
+ export { detectFountainTemplate as detectTemplate };
31
+
26
32
  /** Template detection for the plugin: raw string input (YAML or JSON). */
27
33
  export function detectFountainTemplate(data: unknown): boolean {
28
34
  if (typeof data === "string") {
@@ -2,13 +2,17 @@
2
2
  * The fountain lexicon's chant audit catalog — metadata for the FTN rules,
3
3
  * contributed via `fountainPlugin.auditCatalog()` (#687).
4
4
  *
5
- * Every entry carries `yamlBased: false`, which is not an oversight: all of
6
- * fountain's checks read the chant model (`ctx.entities`), not the emitted
7
- * manifests (`ctx.outputs`), because the facts they needwhich Environment
8
- * an Agent references, which keys a Vault shadows live in the typed graph
9
- * and are flattened by the time YAML exists. So they fire on `chant build`
10
- * and cannot fire on an audit of standalone fountain YAML. `auditRule()`
11
- * hardcodes `yamlBased: true`, so these are constructed directly.
5
+ * All of fountain's checks read the chant model (`ctx.entities`), not the
6
+ * emitted manifests (`ctx.outputs`), because the facts they need — which
7
+ * Environment an Agent references, which keys a Vault shadowslive in the
8
+ * typed graph and are flattened by the time YAML exists. They still fire on
9
+ * an audit of standalone fountain YAML: the audit parses classified
10
+ * `fountain.dev/v1` documents back into the entity graph via the plugin's
11
+ * `auditEntities` (parse-to-graph, #1567), so the post-synth entries are
12
+ * `yamlBased: true` — one rule implementation serves both paths. The one
13
+ * exception is FTN001, a declarative lint rule over TypeScript source that
14
+ * the audit never runs; it stays `yamlBased: false`. `auditRule()` hardcodes
15
+ * `yamlBased: true`, so these are constructed directly.
12
16
  */
13
17
 
14
18
  import type { Authority, RuleMeta } from "@intentius/chant/audit/catalog";
@@ -23,7 +27,11 @@ const OWASP_LLM_INJECTION: Authority = {
23
27
  url: "https://owasp.org/www-project-top-10-for-large-language-model-applications/",
24
28
  };
25
29
 
26
- /** Entity-based rule: everything fountain ships. */
30
+ /**
31
+ * Entity-based rule. `yamlBased` defaults to true: the post-synth checks fire
32
+ * on audited standalone YAML via parse-to-graph (#1567). Pass false only for
33
+ * a rule the audit genuinely cannot run (FTN001, a source-level lint rule).
34
+ */
27
35
  function rule(
28
36
  id: string,
29
37
  tier: RuleMeta["tier"],
@@ -31,8 +39,9 @@ function rule(
31
39
  title: string,
32
40
  remediation: string,
33
41
  authority?: Authority[],
42
+ yamlBased = true,
34
43
  ): RuleMeta {
35
- return { id, tier, fixKind: "guidance", category, title, remediation, authority, yamlBased: false };
44
+ return { id, tier, fixKind: "guidance", category, title, remediation, authority, yamlBased };
36
45
  }
37
46
 
38
47
  export const fountainAuditCatalog: Record<string, RuleMeta> = {
@@ -43,6 +52,7 @@ export const fountainAuditCatalog: Record<string, RuleMeta> = {
43
52
  "Literal credential in a fountain declaration",
44
53
  "Use a ${VAR} substitution reference or an environment secret; never a literal in source.",
45
54
  [OWASP_LLM_INJECTION],
55
+ false, // lint rule over TypeScript source — the audit never runs it
46
56
  ),
47
57
  FTN010: rule(
48
58
  "FTN010",
@@ -47,11 +47,24 @@ describe("fountain plugin", () => {
47
47
  expect(ruleIds.sort()).toEqual(Object.keys(catalog).sort());
48
48
  });
49
49
 
50
- it("marks its checks entity-based so audit does not claim they fire on YAML", () => {
50
+ it("marks exactly the checks the audit can run as yamlBased (#1567)", () => {
51
51
  const catalog = fountainPlugin.auditCatalog?.() ?? {};
52
- // Every fountain check reads ctx.entities, so none can fire against
53
- // standalone fountain YAML. Flipping one to true would misreport it.
54
- expect(Object.values(catalog).every((m) => m.yamlBased === false)).toBe(true);
52
+ // Every post-synth check fires on standalone fountain YAML via
53
+ // parse-to-graph (auditEntities), so their entries are yamlBased. FTN001
54
+ // is a lint rule over TypeScript source the audit never runs — claiming
55
+ // it fires on YAML would misreport `chant audit --rules`.
56
+ const postSynthIds = new Set((fountainPlugin.postSynthChecks?.() ?? []).map((c) => c.id));
57
+ for (const [id, meta] of Object.entries(catalog)) {
58
+ expect(meta.yamlBased, id).toBe(postSynthIds.has(id));
59
+ }
60
+ });
61
+
62
+ it("parses standalone manifests into the entity graph via auditEntities (#1567)", () => {
63
+ const entities = fountainPlugin.auditEntities?.(
64
+ "apiVersion: fountain.dev/v1\nkind: Environment\nmetadata:\n name: dev\nspec:\n networking_type: limited\n",
65
+ );
66
+ expect(entities?.size).toBe(1);
67
+ expect(entities?.get("dev")?.entityType).toBe("Fountain::V1::Environment");
55
68
  });
56
69
 
57
70
  it("scaffolds a closed sandbox by default", () => {
package/src/plugin.ts CHANGED
@@ -12,6 +12,7 @@ import { fountainSerializer } from "./serializer";
12
12
  import { rules } from "./lint/rules";
13
13
  import { postSynthChecks } from "./lint/post-synth";
14
14
  import { fountainAuditCatalog } from "./lint/audit-catalog";
15
+ import { fountainAuditEntities } from "./audit-entities";
15
16
  import { fountainReferenceCatalog } from "./reference-catalog";
16
17
  import { detectFountainTemplate } from "./detect";
17
18
  import { fountainInitTemplates } from "./init-templates";
@@ -63,6 +64,14 @@ export const fountainPlugin: LexiconPlugin = {
63
64
 
64
65
  // ── Optional extensions ────────────────────────────────────
65
66
 
67
+ // #1330 — the fact check-lexicon gates on, computed offline from the
68
+ // committed spec snapshot + surface baseline. Never fetchSchemas():
69
+ // check-lexicon runs per-PR, no network.
70
+ async coverageReport(): Promise<{ unaccountedKinds?: string[] }> {
71
+ const { coverageReportFromSnapshots } = await import("./coverage");
72
+ return { unaccountedKinds: coverageReportFromSnapshots().unaccountedKinds };
73
+ },
74
+
66
75
  lintRules() {
67
76
  return rules;
68
77
  },
@@ -73,6 +82,10 @@ export const fountainPlugin: LexiconPlugin = {
73
82
 
74
83
  auditCatalog: () => fountainAuditCatalog,
75
84
 
85
+ auditEntities(content: string) {
86
+ return fountainAuditEntities(content);
87
+ },
88
+
76
89
  skills: createSkillsLoader(import.meta.url, [
77
90
  {
78
91
  file: "chant-fountain.md",