@intentius/chant 0.41.19 → 0.42.1

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 (150) hide show
  1. package/dist/cli/build-params-cli.d.ts +7 -4
  2. package/dist/cli/build-params-cli.d.ts.map +1 -1
  3. package/dist/cli/commands/build.d.ts.map +1 -1
  4. package/dist/cli/commands/carve-apply.d.ts +9 -0
  5. package/dist/cli/commands/carve-apply.d.ts.map +1 -1
  6. package/dist/cli/commands/carve-bridge.d.ts +7 -0
  7. package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
  8. package/dist/cli/commands/carve-emit.d.ts +7 -0
  9. package/dist/cli/commands/carve-emit.d.ts.map +1 -1
  10. package/dist/cli/commands/import.d.ts +16 -0
  11. package/dist/cli/commands/import.d.ts.map +1 -1
  12. package/dist/cli/commands/init.d.ts +2 -0
  13. package/dist/cli/commands/init.d.ts.map +1 -1
  14. package/dist/cli/handlers/carve-apply.d.ts.map +1 -1
  15. package/dist/cli/handlers/components.d.ts.map +1 -1
  16. package/dist/cli/handlers/explain.d.ts +11 -0
  17. package/dist/cli/handlers/explain.d.ts.map +1 -0
  18. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  19. package/dist/cli/handlers/misc.d.ts.map +1 -1
  20. package/dist/cli/main.d.ts.map +1 -1
  21. package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
  22. package/dist/cli/registry.d.ts +4 -0
  23. package/dist/cli/registry.d.ts.map +1 -1
  24. package/dist/codegen/docs-rule-scanning.d.ts +11 -0
  25. package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
  26. package/dist/codegen/okf-lexicon.d.ts +36 -0
  27. package/dist/codegen/okf-lexicon.d.ts.map +1 -0
  28. package/dist/codegen/package.d.ts +2 -1
  29. package/dist/codegen/package.d.ts.map +1 -1
  30. package/dist/components/deploy-units.d.ts.map +1 -1
  31. package/dist/components/verbs/sbom.d.ts +1 -1
  32. package/dist/components/verbs/sbom.d.ts.map +1 -1
  33. package/dist/components/verbs/vuln-gate.d.ts +15 -4
  34. package/dist/components/verbs/vuln-gate.d.ts.map +1 -1
  35. package/dist/components/verbs/vuln-scan.d.ts +18 -6
  36. package/dist/components/verbs/vuln-scan.d.ts.map +1 -1
  37. package/dist/config.d.ts +12 -0
  38. package/dist/config.d.ts.map +1 -1
  39. package/dist/deep-observation.d.ts +13 -0
  40. package/dist/deep-observation.d.ts.map +1 -1
  41. package/dist/governance.d.ts +37 -0
  42. package/dist/governance.d.ts.map +1 -0
  43. package/dist/lexicon.d.ts +3 -0
  44. package/dist/lexicon.d.ts.map +1 -1
  45. package/dist/lifecycle/observe.d.ts.map +1 -1
  46. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  47. package/dist/okf.d.ts +57 -0
  48. package/dist/okf.d.ts.map +1 -0
  49. package/dist/reconcile.d.ts +15 -0
  50. package/dist/reconcile.d.ts.map +1 -1
  51. package/dist/terraform/__fixtures__/build-graph.d.ts +13 -0
  52. package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -0
  53. package/dist/terraform/adopt-state.d.ts +33 -1
  54. package/dist/terraform/adopt-state.d.ts.map +1 -1
  55. package/dist/terraform/bridge.d.ts +7 -1
  56. package/dist/terraform/bridge.d.ts.map +1 -1
  57. package/dist/terraform/carve.d.ts +12 -0
  58. package/dist/terraform/carve.d.ts.map +1 -1
  59. package/dist/terraform/excise.d.ts +31 -0
  60. package/dist/terraform/excise.d.ts.map +1 -0
  61. package/dist/terraform/graduate.d.ts +12 -0
  62. package/dist/terraform/graduate.d.ts.map +1 -1
  63. package/dist/terraform/graph.d.ts +35 -5
  64. package/dist/terraform/graph.d.ts.map +1 -1
  65. package/dist/terraform/manifest.d.ts +89 -0
  66. package/dist/terraform/manifest.d.ts.map +1 -0
  67. package/dist/terraform/parse.d.ts +9 -4
  68. package/dist/terraform/parse.d.ts.map +1 -1
  69. package/dist/terraform/types.d.ts +7 -0
  70. package/dist/terraform/types.d.ts.map +1 -1
  71. package/dist/terraform/unified-diff.d.ts +11 -0
  72. package/dist/terraform/unified-diff.d.ts.map +1 -0
  73. package/package.json +1 -1
  74. package/src/__snapshots__/okf.test.ts.snap +381 -0
  75. package/src/cli/build-params-cli.test.ts +47 -3
  76. package/src/cli/build-params-cli.ts +22 -5
  77. package/src/cli/commands/build.test.ts +44 -0
  78. package/src/cli/commands/build.ts +15 -2
  79. package/src/cli/commands/carve-apply.test.ts +94 -2
  80. package/src/cli/commands/carve-apply.ts +62 -11
  81. package/src/cli/commands/carve-bridge.test.ts +82 -4
  82. package/src/cli/commands/carve-bridge.ts +79 -11
  83. package/src/cli/commands/carve-emit-state.test.ts +110 -1
  84. package/src/cli/commands/carve-emit.ts +184 -8
  85. package/src/cli/commands/import.ts +53 -5
  86. package/src/cli/commands/init.ts +1 -1
  87. package/src/cli/handlers/build.test.ts +3 -1
  88. package/src/cli/handlers/carve-apply.ts +1 -0
  89. package/src/cli/handlers/components.ts +4 -2
  90. package/src/cli/handlers/explain.test.ts +93 -0
  91. package/src/cli/handlers/explain.ts +60 -0
  92. package/src/cli/handlers/lifecycle.ts +9 -7
  93. package/src/cli/handlers/misc.ts +40 -1
  94. package/src/cli/handlers/run.test.ts +3 -1
  95. package/src/cli/main.ts +25 -5
  96. package/src/cli/mcp/server.test.ts +17 -0
  97. package/src/cli/mcp/tools/explain.ts +15 -3
  98. package/src/cli/registry.ts +4 -0
  99. package/src/codegen/__snapshots__/okf-lexicon.test.ts.snap +170 -0
  100. package/src/codegen/docs-rule-scanning.ts +35 -14
  101. package/src/codegen/okf-lexicon.test.ts +248 -0
  102. package/src/codegen/okf-lexicon.ts +303 -0
  103. package/src/codegen/package.ts +29 -4
  104. package/src/codegen/publish-order.test.ts +1 -1
  105. package/src/codegen/release-wiring.test.ts +7 -3
  106. package/src/components/config-defaults.test.ts +30 -0
  107. package/src/components/deploy-units.ts +3 -0
  108. package/src/components/verbs/__fixtures__/grype-with-kev-epss.json +1 -0
  109. package/src/components/verbs/__fixtures__/trivy-with-kev-epss.json +631 -0
  110. package/src/components/verbs/exploitability-roundtrip.test.ts +190 -0
  111. package/src/components/verbs/sbom.ts +1 -1
  112. package/src/components/verbs/vuln-gate.test.ts +144 -1
  113. package/src/components/verbs/vuln-gate.ts +61 -10
  114. package/src/components/verbs/vuln-scan.test.ts +94 -0
  115. package/src/components/verbs/vuln-scan.ts +44 -9
  116. package/src/config.test.ts +21 -0
  117. package/src/config.ts +18 -0
  118. package/src/deep-observation.test.ts +19 -0
  119. package/src/deep-observation.ts +32 -13
  120. package/src/governance.test.ts +72 -0
  121. package/src/governance.ts +54 -0
  122. package/src/lexicon.ts +3 -0
  123. package/src/lifecycle/observe.test.ts +18 -0
  124. package/src/lifecycle/observe.ts +10 -3
  125. package/src/lifecycle/snapshot.ts +5 -4
  126. package/src/okf.test.ts +169 -0
  127. package/src/okf.ts +308 -0
  128. package/src/reconcile.ts +32 -1
  129. package/src/terraform/__fixtures__/build-graph.ts +25 -0
  130. package/src/terraform/adopt-state.test.ts +34 -1
  131. package/src/terraform/adopt-state.ts +69 -2
  132. package/src/terraform/bridge.test.ts +38 -14
  133. package/src/terraform/bridge.ts +48 -14
  134. package/src/terraform/carve.test.ts +9 -7
  135. package/src/terraform/carve.ts +0 -0
  136. package/src/terraform/excise.test.ts +92 -0
  137. package/src/terraform/excise.ts +139 -0
  138. package/src/terraform/graduate.test.ts +73 -6
  139. package/src/terraform/graduate.ts +49 -0
  140. package/src/terraform/graph.test.ts +56 -10
  141. package/src/terraform/graph.ts +112 -45
  142. package/src/terraform/manifest.test.ts +105 -0
  143. package/src/terraform/manifest.ts +162 -0
  144. package/src/terraform/parse.test.ts +39 -1
  145. package/src/terraform/parse.ts +36 -9
  146. package/src/terraform/score.test.ts +8 -8
  147. package/src/terraform/state.test.ts +4 -4
  148. package/src/terraform/types.ts +7 -0
  149. package/src/terraform/unified-diff.test.ts +77 -0
  150. package/src/terraform/unified-diff.ts +146 -0
@@ -0,0 +1,248 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { buildLexiconOkfBundle, type LexiconOkfInput } from "./okf-lexicon";
3
+ import { okfConformanceProblems, splitFrontmatter, OKF_VERSION, type OkfFile } from "../okf";
4
+ import { parseYAML } from "../yaml";
5
+
6
+ function fileMap(bundle: OkfFile[]): Map<string, string> {
7
+ return new Map(bundle.map((f) => [f.path, f.content]));
8
+ }
9
+
10
+ /** A docker-style registry: descriptions carried in the registry itself. */
11
+ const registryStyleInput: LexiconOkfInput = {
12
+ name: "mockdocker",
13
+ registry: JSON.stringify({
14
+ Service: {
15
+ resourceType: "Mock::Compose::Service",
16
+ kind: "resource",
17
+ description: "A containerized service definition",
18
+ properties: {
19
+ image: { type: "string", description: "Container image to use" },
20
+ ports: { type: "string[]", description: "Published ports" },
21
+ },
22
+ },
23
+ Volume: {
24
+ resourceType: "Mock::Compose::Volume",
25
+ kind: "resource",
26
+ description: "A named volume",
27
+ properties: { driver: { type: "string", description: "Volume driver" } },
28
+ },
29
+ Service_Healthcheck: {
30
+ resourceType: "Mock::Compose::Service.Healthcheck",
31
+ kind: "property",
32
+ },
33
+ }),
34
+ typesDTS: "",
35
+ rules: [
36
+ {
37
+ meta: {
38
+ id: "MCK001",
39
+ severity: "warning",
40
+ category: "correctness",
41
+ description: "Avoid :latest image references on a Service",
42
+ type: "lint",
43
+ },
44
+ source: `// Checks each Service's image prop\nexport const rule = { id: "MCK001" };`,
45
+ },
46
+ {
47
+ meta: {
48
+ id: "MCK010",
49
+ severity: "error",
50
+ category: "post-synth",
51
+ description: "A declared Volume must be mounted somewhere",
52
+ type: "post-synth",
53
+ },
54
+ source: `export const check = { id: "MCK010", description: "..." }; // walks Volume mounts`,
55
+ },
56
+ ],
57
+ };
58
+
59
+ /** A CFN-style registry: no descriptions, JSDoc lives in the declarations. */
60
+ const dtsStyleInput: LexiconOkfInput = {
61
+ name: "mockaws",
62
+ registry: JSON.stringify({
63
+ Bucket: {
64
+ resourceType: "Mock::S3::Bucket",
65
+ kind: "resource",
66
+ lexicon: "mockaws",
67
+ attrs: { Arn: "Arn", DomainName: "DomainName" },
68
+ },
69
+ // An alias entry for the same resource type must not mint a second concept.
70
+ S3Bucket: {
71
+ resourceType: "Mock::S3::Bucket",
72
+ kind: "resource",
73
+ lexicon: "mockaws",
74
+ attrs: { Arn: "Arn", DomainName: "DomainName" },
75
+ },
76
+ Bucket_VersioningConfiguration: {
77
+ resourceType: "Mock::S3::Bucket.VersioningConfiguration",
78
+ kind: "property",
79
+ lexicon: "mockaws",
80
+ },
81
+ }),
82
+ typesDTS: [
83
+ "export declare class Bucket {",
84
+ " constructor(props: {",
85
+ " /** A name for the bucket. */",
86
+ " BucketName?: string;",
87
+ " /** The versioning state. */",
88
+ " VersioningConfiguration?: Bucket_VersioningConfiguration;",
89
+ " }, attributes?: Record<string, unknown>);",
90
+ " readonly Arn: string;",
91
+ "}",
92
+ ].join("\n"),
93
+ rules: [
94
+ {
95
+ meta: {
96
+ id: "MAW006",
97
+ severity: "warning",
98
+ category: "security",
99
+ description: "Detects Bucket creation without encryption",
100
+ type: "lint",
101
+ },
102
+ source: `if (expression.text === "Bucket") { /* Mock::S3::Bucket */ }`,
103
+ },
104
+ ],
105
+ };
106
+
107
+ describe("buildLexiconOkfBundle", () => {
108
+ test("emits a conformant bundle from a registry-described lexicon", () => {
109
+ const bundle = buildLexiconOkfBundle(registryStyleInput);
110
+ expect(okfConformanceProblems(bundle)).toEqual([]);
111
+ expect(bundle.map((f) => f.path)).toEqual([
112
+ "index.md",
113
+ "rules/MCK001.md",
114
+ "rules/MCK010.md",
115
+ "types/Service.md",
116
+ "types/Volume.md",
117
+ ]);
118
+ });
119
+
120
+ test("concept type is the category, the resource type rides in resource_type", () => {
121
+ const files = fileMap(buildLexiconOkfBundle(registryStyleInput));
122
+ const front = parseYAML(splitFrontmatter(files.get("types/Service.md")!)!.frontmatter);
123
+ expect(front.type).toBe("resource-type");
124
+ expect(front.resource_type).toBe("Mock::Compose::Service");
125
+ expect(front.title).toBe("Service");
126
+ expect(front.lexicon).toBe("mockdocker");
127
+ expect(front.description).toBe("A containerized service definition");
128
+ });
129
+
130
+ test("registry-carried property descriptions land in the concept body", () => {
131
+ const files = fileMap(buildLexiconOkfBundle(registryStyleInput));
132
+ const body = splitFrontmatter(files.get("types/Service.md")!)!.body;
133
+ expect(body).toContain("## Properties");
134
+ expect(body).toContain("- `image` (`string`): Container image to use");
135
+ expect(body).toContain("- `ports` (`string[]`): Published ports");
136
+ });
137
+
138
+ test("declaration JSDoc fills in property descriptions when the registry has none", () => {
139
+ const files = fileMap(buildLexiconOkfBundle(dtsStyleInput));
140
+ const body = splitFrontmatter(files.get("types/Bucket.md")!)!.body;
141
+ expect(body).toContain("- `BucketName` (`string`, optional): A name for the bucket.");
142
+ expect(body).toContain("- `VersioningConfiguration` (`Bucket_VersioningConfiguration`, optional): The versioning state.");
143
+ expect(body).toContain("## Attributes");
144
+ expect(body).toContain("- `Arn`");
145
+ });
146
+
147
+ test("rules and resource types cross-link in both directions", () => {
148
+ const files = fileMap(buildLexiconOkfBundle(registryStyleInput));
149
+ expect(splitFrontmatter(files.get("types/Service.md")!)!.body).toContain(
150
+ "- [MCK001](/rules/MCK001.md): Avoid :latest image references on a Service",
151
+ );
152
+ expect(splitFrontmatter(files.get("rules/MCK001.md")!)!.body).toContain("- [Service](/types/Service.md)");
153
+ // MCK010 mentions Volume, not Service.
154
+ expect(splitFrontmatter(files.get("rules/MCK010.md")!)!.body).toContain("- [Volume](/types/Volume.md)");
155
+ expect(splitFrontmatter(files.get("rules/MCK010.md")!)!.body).not.toContain("types/Service.md");
156
+ expect(splitFrontmatter(files.get("types/Volume.md")!)!.body).toContain("- [MCK010](/rules/MCK010.md)");
157
+ });
158
+
159
+ test("rule concepts carry id, severity, category, and a docs page", () => {
160
+ const files = fileMap(buildLexiconOkfBundle(registryStyleInput));
161
+ const lint = parseYAML(splitFrontmatter(files.get("rules/MCK001.md")!)!.frontmatter);
162
+ expect(lint.type).toBe("lint-rule");
163
+ expect(lint.id).toBe("MCK001");
164
+ expect(lint.severity).toBe("warning");
165
+ expect(lint.category).toBe("correctness");
166
+ expect(lint.docs).toBe("https://intentius.io/chant/lexicons/mockdocker/rules/");
167
+
168
+ const postSynth = parseYAML(splitFrontmatter(files.get("rules/MCK010.md")!)!.frontmatter);
169
+ expect(postSynth.type).toBe("post-synth-check");
170
+ expect(postSynth.severity).toBe("error");
171
+ });
172
+
173
+ test("alias registry entries collapse into one concept per resource type", () => {
174
+ const bundle = buildLexiconOkfBundle(dtsStyleInput);
175
+ const typePaths = bundle.map((f) => f.path).filter((p) => p.startsWith("types/"));
176
+ expect(typePaths).toEqual(["types/Bucket.md"]);
177
+ expect(okfConformanceProblems(bundle)).toEqual([]);
178
+ });
179
+
180
+ test("property-kind registry entries do not become concepts", () => {
181
+ const files = fileMap(buildLexiconOkfBundle(registryStyleInput));
182
+ expect([...files.keys()].some((p) => p.includes("Healthcheck"))).toBe(false);
183
+ });
184
+
185
+ test("index.md sections resource types and rules with okf_version its only frontmatter", () => {
186
+ const index = fileMap(buildLexiconOkfBundle(registryStyleInput)).get("index.md")!;
187
+ expect(index).toContain(`okf_version: '${OKF_VERSION}'`);
188
+ expect(index).toContain("# Resource types");
189
+ expect(index).toContain("* [Service](/types/Service.md) - Mock::Compose::Service");
190
+ expect(index).toContain("# Rules");
191
+ expect(index).toContain("* [MCK001](/rules/MCK001.md) - Avoid :latest image references on a Service");
192
+ });
193
+
194
+ test("an empty lexicon still emits a conformant bundle", () => {
195
+ const bundle = buildLexiconOkfBundle({ name: "bare", registry: "{}", typesDTS: "", rules: [] });
196
+ expect(bundle.map((f) => f.path)).toEqual(["index.md"]);
197
+ expect(okfConformanceProblems(bundle)).toEqual([]);
198
+ });
199
+
200
+ test("deterministic: the same input yields byte-identical files", () => {
201
+ expect(buildLexiconOkfBundle(registryStyleInput)).toEqual(buildLexiconOkfBundle(registryStyleInput));
202
+ });
203
+
204
+ test("multi-line spec descriptions collapse to a single frontmatter line", () => {
205
+ const input: LexiconOkfInput = {
206
+ name: "mock",
207
+ registry: JSON.stringify({
208
+ Thing: {
209
+ resourceType: "Mock::A::Thing",
210
+ kind: "resource",
211
+ description: "First line.\nSecond line with detail.",
212
+ },
213
+ }),
214
+ typesDTS: "",
215
+ rules: [],
216
+ };
217
+ const bundle = buildLexiconOkfBundle(input);
218
+ expect(okfConformanceProblems(bundle)).toEqual([]);
219
+ const front = parseYAML(splitFrontmatter(fileMap(bundle).get("types/Thing.md")!)!.frontmatter);
220
+ expect(front.description).toBe("First line. Second line with detail.");
221
+ });
222
+
223
+ test("registry-style and dts-style bundles snapshot stably", () => {
224
+ expect(buildLexiconOkfBundle(registryStyleInput)).toMatchSnapshot();
225
+ expect(buildLexiconOkfBundle(dtsStyleInput)).toMatchSnapshot();
226
+ });
227
+ });
228
+
229
+ describe("okfConformanceProblems", () => {
230
+ test("flags a concept without frontmatter and a bundle without an index", () => {
231
+ const problems = okfConformanceProblems([{ path: "types/Broken.md", content: "no frontmatter here" }]);
232
+ expect(problems).toContain("types/Broken.md: no frontmatter block");
233
+ expect(problems).toContain("bundle has no root index.md");
234
+ });
235
+
236
+ test("flags an empty type and a relative cross-link", () => {
237
+ const bundle: OkfFile[] = [
238
+ {
239
+ path: "types/Bad.md",
240
+ content: "---\ntype: ''\n---\n\n- [x](x.md)\n",
241
+ },
242
+ { path: "index.md", content: `---\nokf_version: '${OKF_VERSION}'\n---\n` },
243
+ ];
244
+ const problems = okfConformanceProblems(bundle);
245
+ expect(problems.some((p) => p.includes("no non-empty type"))).toBe(true);
246
+ expect(problems.some((p) => p.includes('link target "x.md"'))).toBe(true);
247
+ });
248
+ });
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Lexicon OKF knowledge bundle (#1060, epic #1057) — projects a packaged
3
+ * lexicon's resource-type registry and lint rules into an Open Knowledge
4
+ * Format v0.2 bundle, emitted alongside the other build artifacts
5
+ * (`dist/meta.json`, `dist/types/`) as `dist/okf/`.
6
+ *
7
+ * Vocabulary, resolved against the project bundle (#1058, ../okf.ts): there a
8
+ * concept's `type` IS the entity's resource type; here `type` is the concept
9
+ * *category* — `resource-type`, `lint-rule`, `post-synth-check` — and the
10
+ * resource type string rides in `resource_type`. A consumer tells the two
11
+ * bundle kinds apart by exactly that.
12
+ *
13
+ * Cross-links run both directions: a resource concept lists the rules that
14
+ * govern it under "Governed by", a rule concept lists the types it applies to
15
+ * under "Applies to". The association is derived from the rule's source text
16
+ * (rules match on identifiers, not declared type lists), so it is a textual
17
+ * over-approximation — acceptable for knowledge, wrong for enforcement.
18
+ */
19
+
20
+ import * as ts from "typescript";
21
+ import { frontmatter, slug, OKF_VERSION, type OkfFile } from "../okf";
22
+ import type { ScannedRule } from "./docs-rule-scanning";
23
+
24
+ export interface LexiconOkfInput {
25
+ /** Lexicon name as the manifest states it, e.g. "aws". */
26
+ name: string;
27
+ /** The registry JSON — the exact content of `dist/meta.json`. */
28
+ registry: string;
29
+ /** The generated declarations — the content of `dist/types/index.d.ts`. */
30
+ typesDTS: string;
31
+ /** Scanned rules with their sources, for metadata and type association. */
32
+ rules: ScannedRule[];
33
+ }
34
+
35
+ interface RegistryEntry {
36
+ resourceType?: unknown;
37
+ kind?: unknown;
38
+ description?: unknown;
39
+ properties?: Record<string, { type?: string; description?: string }>;
40
+ attrs?: Record<string, string>;
41
+ }
42
+
43
+ interface ConceptProperty {
44
+ name: string;
45
+ type?: string;
46
+ required?: boolean;
47
+ description?: string;
48
+ }
49
+
50
+ interface TypeConcept {
51
+ className: string;
52
+ resourceType: string;
53
+ description?: string;
54
+ properties: ConceptProperty[];
55
+ attributes: string[];
56
+ path: string;
57
+ }
58
+
59
+ /** Collapse whitespace so a description fits a frontmatter scalar or a list line. */
60
+ function oneLine(text: string): string {
61
+ return text.replace(/\s+/g, " ").trim();
62
+ }
63
+
64
+ function truncate(text: string, max: number): string {
65
+ return text.length <= max ? text : `${text.slice(0, max - 3).trimEnd()}...`;
66
+ }
67
+
68
+ /**
69
+ * Per-class constructor property docs, parsed out of the generated
70
+ * declarations. This is where CloudFormation- and OpenAPI-derived lexicons
71
+ * carry their spec-sourced property descriptions (the registry carries them
72
+ * only for hand-authored lexicons like docker).
73
+ */
74
+ function parseDtsProperties(typesDTS: string): Map<string, ConceptProperty[]> {
75
+ const out = new Map<string, ConceptProperty[]>();
76
+ if (!typesDTS.includes("constructor")) return out;
77
+ const sf = ts.createSourceFile("types.d.ts", typesDTS, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS);
78
+ for (const stmt of sf.statements) {
79
+ if (!ts.isClassDeclaration(stmt) || !stmt.name) continue;
80
+ const ctor = stmt.members.find(ts.isConstructorDeclaration);
81
+ const param = ctor?.parameters[0];
82
+ if (!param?.type || !ts.isTypeLiteralNode(param.type)) continue;
83
+ const props: ConceptProperty[] = [];
84
+ for (const member of param.type.members) {
85
+ if (!ts.isPropertySignature(member) || !member.name) continue;
86
+ const name =
87
+ ts.isIdentifier(member.name) || ts.isStringLiteral(member.name)
88
+ ? member.name.text
89
+ : member.name.getText(sf);
90
+ const raw = (member as unknown as { jsDoc?: ts.JSDoc[] }).jsDoc?.[0]?.comment;
91
+ const doc = typeof raw === "string" ? raw : raw ? ts.getTextOfJSDocComment(raw) : undefined;
92
+ props.push({
93
+ name,
94
+ type: member.type?.getText(sf),
95
+ required: member.questionToken === undefined,
96
+ // Undo the `*\/` escaping generate-typescript applies inside JSDoc.
97
+ description: doc?.replace(/\*\\\//g, "*/"),
98
+ });
99
+ }
100
+ if (props.length > 0) out.set(stmt.name.text, props);
101
+ }
102
+ return out;
103
+ }
104
+
105
+ /**
106
+ * The resource types a rule's source mentions — by class name as a whole
107
+ * word, or by the resource type string anywhere.
108
+ */
109
+ function ruleAppliesTo(source: string, concepts: TypeConcept[]): TypeConcept[] {
110
+ const words = new Set(source.match(/[A-Za-z0-9_$]+/g) ?? []);
111
+ return concepts.filter((c) => words.has(c.className) || source.includes(c.resourceType));
112
+ }
113
+
114
+ function propertyLine(prop: ConceptProperty): string {
115
+ const facts: string[] = [];
116
+ if (prop.type) facts.push(`\`${oneLine(prop.type)}\``);
117
+ if (prop.required === true) facts.push("required");
118
+ if (prop.required === false) facts.push("optional");
119
+ const head = facts.length > 0 ? `- \`${prop.name}\` (${facts.join(", ")})` : `- \`${prop.name}\``;
120
+ return prop.description ? `${head}: ${oneLine(prop.description)}` : head;
121
+ }
122
+
123
+ /**
124
+ * Build the OKF bundle for a packaged lexicon. Pure and deterministic: the
125
+ * same registry, declarations, and rules yield byte-identical files.
126
+ */
127
+ export function buildLexiconOkfBundle(input: LexiconOkfInput): OkfFile[] {
128
+ let registry: Record<string, RegistryEntry>;
129
+ try {
130
+ registry = JSON.parse(input.registry) as Record<string, RegistryEntry>;
131
+ } catch {
132
+ registry = {};
133
+ }
134
+ const dtsProps = parseDtsProperties(input.typesDTS);
135
+
136
+ // One concept per resource type: alias entries share a resourceType, the
137
+ // first class name in sorted order names the concept.
138
+ const byType = new Map<string, { className: string; entry: RegistryEntry }>();
139
+ for (const className of Object.keys(registry).sort((a, b) => a.localeCompare(b))) {
140
+ const entry = registry[className];
141
+ if (entry?.kind !== "resource" || typeof entry.resourceType !== "string") continue;
142
+ if (!byType.has(entry.resourceType)) byType.set(entry.resourceType, { className, entry });
143
+ }
144
+
145
+ const concepts: TypeConcept[] = [];
146
+ const takenPaths = new Set<string>();
147
+ for (const { className, entry } of [...byType.values()].sort((a, b) => a.className.localeCompare(b.className))) {
148
+ const base = `types/${slug(className)}`;
149
+ let path = `${base}.md`;
150
+ for (let n = 2; takenPaths.has(path); n++) path = `${base}-${n}.md`;
151
+ takenPaths.add(path);
152
+
153
+ // Registry-carried property docs (hand-authored lexicons) win; the
154
+ // generated declarations fill in for spec-derived lexicons.
155
+ let properties: ConceptProperty[];
156
+ if (entry.properties && Object.keys(entry.properties).length > 0) {
157
+ properties = Object.entries(entry.properties).map(([name, p]) => ({
158
+ name,
159
+ type: p.type,
160
+ description: p.description,
161
+ }));
162
+ } else {
163
+ properties = dtsProps.get(className) ?? [];
164
+ }
165
+
166
+ concepts.push({
167
+ className,
168
+ resourceType: entry.resourceType as string,
169
+ description: typeof entry.description === "string" ? entry.description : undefined,
170
+ properties,
171
+ attributes: entry.attrs ? Object.values(entry.attrs).sort((a, b) => a.localeCompare(b)) : [],
172
+ path,
173
+ });
174
+ }
175
+
176
+ // One concept per rule id, first scan wins.
177
+ const rulesById = new Map<string, ScannedRule>();
178
+ for (const rule of input.rules) {
179
+ if (!rulesById.has(rule.meta.id)) rulesById.set(rule.meta.id, rule);
180
+ }
181
+ const rules = [...rulesById.values()].sort((a, b) => a.meta.id.localeCompare(b.meta.id));
182
+ const rulePath = (id: string): string => `rules/${slug(id)}.md`;
183
+
184
+ // Association, both directions.
185
+ const governedBy = new Map<string, ScannedRule[]>();
186
+ const appliesTo = new Map<string, TypeConcept[]>();
187
+ for (const rule of rules) {
188
+ const targets = ruleAppliesTo(rule.source, concepts);
189
+ appliesTo.set(rule.meta.id, targets);
190
+ for (const target of targets) {
191
+ if (!governedBy.has(target.resourceType)) governedBy.set(target.resourceType, []);
192
+ governedBy.get(target.resourceType)!.push(rule);
193
+ }
194
+ }
195
+
196
+ const docsUrl = `https://intentius.io/chant/lexicons/${input.name}/rules/`;
197
+ const files: OkfFile[] = [];
198
+
199
+ for (const concept of concepts) {
200
+ const head = frontmatter({
201
+ type: "resource-type",
202
+ title: concept.className,
203
+ description: truncate(
204
+ oneLine(concept.description ?? `${input.name} resource type ${concept.resourceType}`),
205
+ 200,
206
+ ),
207
+ name: concept.className,
208
+ lexicon: input.name,
209
+ resource_type: concept.resourceType,
210
+ });
211
+
212
+ const body: string[] = [""];
213
+ body.push(
214
+ concept.description
215
+ ? oneLine(concept.description)
216
+ : `\`${concept.resourceType}\`, a resource type of the ${input.name} lexicon.`,
217
+ );
218
+
219
+ if (concept.properties.length > 0) {
220
+ body.push("", "## Properties", "");
221
+ for (const prop of concept.properties) body.push(propertyLine(prop));
222
+ }
223
+
224
+ if (concept.attributes.length > 0) {
225
+ body.push("", "## Attributes", "");
226
+ for (const attr of concept.attributes) body.push(`- \`${attr}\``);
227
+ }
228
+
229
+ const governing = governedBy.get(concept.resourceType) ?? [];
230
+ if (governing.length > 0) {
231
+ body.push("", "## Governed by", "");
232
+ for (const rule of governing) {
233
+ body.push(`- [${rule.meta.id}](/${rulePath(rule.meta.id)}): ${oneLine(rule.meta.description)}`);
234
+ }
235
+ }
236
+
237
+ body.push("");
238
+ files.push({ path: concept.path, content: head + body.join("\n") });
239
+ }
240
+
241
+ for (const rule of rules) {
242
+ const head = frontmatter({
243
+ type: rule.meta.type === "post-synth" ? "post-synth-check" : "lint-rule",
244
+ title: rule.meta.id,
245
+ description: truncate(oneLine(rule.meta.description), 200),
246
+ id: rule.meta.id,
247
+ severity: rule.meta.severity,
248
+ category: rule.meta.category,
249
+ lexicon: input.name,
250
+ docs: docsUrl,
251
+ });
252
+
253
+ const body: string[] = ["", oneLine(rule.meta.description)];
254
+ const targets = appliesTo.get(rule.meta.id) ?? [];
255
+ if (targets.length > 0) {
256
+ body.push("", "## Applies to", "");
257
+ for (const target of targets) {
258
+ body.push(`- [${target.className}](/${target.path})`);
259
+ }
260
+ }
261
+ body.push("");
262
+ files.push({ path: rulePath(rule.meta.id), content: head + body.join("\n") });
263
+ }
264
+
265
+ files.push({ path: "index.md", content: buildIndex(input.name, concepts, rules, rulePath) });
266
+ files.sort((a, b) => a.path.localeCompare(b.path));
267
+ return files;
268
+ }
269
+
270
+ /**
271
+ * The bundle-root `index.md`: resource types and rules as sections of linked
272
+ * entries, `okf_version` its only frontmatter (spec §12).
273
+ */
274
+ function buildIndex(
275
+ name: string,
276
+ concepts: TypeConcept[],
277
+ rules: ScannedRule[],
278
+ rulePath: (id: string) => string,
279
+ ): string {
280
+ const lines: string[] = ["---", `okf_version: '${OKF_VERSION}'`, "---", ""];
281
+
282
+ if (concepts.length > 0) {
283
+ lines.push("# Resource types", "");
284
+ for (const concept of concepts) {
285
+ lines.push(`* [${concept.className}](/${concept.path}) - ${concept.resourceType}`);
286
+ }
287
+ lines.push("");
288
+ }
289
+
290
+ if (rules.length > 0) {
291
+ lines.push("# Rules", "");
292
+ for (const rule of rules) {
293
+ lines.push(`* [${rule.meta.id}](/${rulePath(rule.meta.id)}) - ${truncate(oneLine(rule.meta.description), 160)}`);
294
+ }
295
+ lines.push("");
296
+ }
297
+
298
+ if (concepts.length === 0 && rules.length === 0) {
299
+ lines.push(`The ${name} lexicon declares no resource types or rules.`, "");
300
+ }
301
+
302
+ return lines.join("\n");
303
+ }
@@ -5,11 +5,13 @@
5
5
  * assemble BundleSpec → compute integrity → attach metadata.
6
6
  */
7
7
 
8
- import { readFileSync, readdirSync, writeFileSync, mkdirSync } from "fs";
9
- import { join } from "path";
8
+ import { readFileSync, readdirSync, writeFileSync, mkdirSync, rmSync } from "fs";
9
+ import { dirname, join } from "path";
10
10
  import type { BundleSpec, LexiconManifest } from "../lexicon";
11
11
  import { computeIntegrity } from "../lexicon-integrity";
12
12
  import type { GenerateResult } from "./generate";
13
+ import { scanRulesWithSources } from "./docs-rule-scanning";
14
+ import { buildLexiconOkfBundle } from "./okf-lexicon";
13
15
 
14
16
  // ── Types ──────────────────────────────────────────────────────────
15
17
 
@@ -73,13 +75,24 @@ export async function packagePipeline(
73
75
  log("Collecting skills...");
74
76
  const skills = config.collectSkills();
75
77
 
76
- // Step 5: Assemble BundleSpec
78
+ // Step 5: Assemble BundleSpec, with the OKF knowledge bundle over the same
79
+ // artifacts (#1060) — derived here so it cannot drift from the registry and
80
+ // rules it describes.
81
+ log("Building OKF knowledge bundle...");
82
+ const okf = buildLexiconOkfBundle({
83
+ name: manifest.name,
84
+ registry: result.lexiconJSON,
85
+ typesDTS: result.typesDTS,
86
+ rules: scanRulesWithSources(config.srcDir),
87
+ });
88
+
77
89
  const spec: BundleSpec = {
78
90
  manifest,
79
91
  registry: result.lexiconJSON,
80
92
  typesDTS: result.typesDTS,
81
93
  rules,
82
94
  skills,
95
+ okf,
83
96
  };
84
97
 
85
98
  // Step 6: Compute integrity
@@ -151,7 +164,8 @@ export function collectRules(
151
164
  * Write a BundleSpec to the given dist directory.
152
165
  *
153
166
  * Creates the directory structure and writes all artifacts:
154
- * manifest.json, meta.json, types/index.d.ts, rules/*, skills/*, integrity.json.
167
+ * manifest.json, meta.json, types/index.d.ts, rules/*, skills/*, okf/*,
168
+ * integrity.json.
155
169
  */
156
170
  export function writeBundleSpec(spec: BundleSpec, distDir: string): void {
157
171
  mkdirSync(join(distDir, "types"), { recursive: true });
@@ -169,6 +183,17 @@ export function writeBundleSpec(spec: BundleSpec, distDir: string): void {
169
183
  writeFileSync(join(distDir, "skills", name), content);
170
184
  }
171
185
 
186
+ if (spec.okf) {
187
+ // Replace rather than merge — a concept whose resource type was removed
188
+ // upstream must not survive as a stale file.
189
+ rmSync(join(distDir, "okf"), { recursive: true, force: true });
190
+ for (const file of spec.okf) {
191
+ const target = join(distDir, "okf", file.path);
192
+ mkdirSync(dirname(target), { recursive: true });
193
+ writeFileSync(target, file.content);
194
+ }
195
+ }
196
+
172
197
  if (spec.integrity) {
173
198
  writeFileSync(join(distDir, "integrity.json"), JSON.stringify(spec.integrity, null, 2));
174
199
  }
@@ -48,7 +48,7 @@ describe("publish order", () => {
48
48
  // same stranding by a different route.
49
49
  const all = execFileSync(
50
50
  "bash",
51
- ["-c", 'for d in packages/*/ lexicons/*/; do [ -f "$d/package.json" ] && echo "${d%/}"; done'],
51
+ ["-c", 'for d in packages/*/ lexicons/*/ wardens/*/; do [ -f "$d/package.json" ] && echo "${d%/}"; done'],
52
52
  { cwd: REPO, encoding: "utf8" },
53
53
  )
54
54
  .split("\n")
@@ -117,10 +117,14 @@ function recipeBody(name: string): string {
117
117
  * which is why this asserts the mechanism, not a shared literal.
118
118
  */
119
119
  describe("release wiring: peer ranges stay in lockstep (#1255)", () => {
120
- it("the whole-repo release rewrites both @intentius peer ranges", () => {
120
+ it("the whole-repo release rewrites every @intentius peer range", () => {
121
+ // Since #788 the rewrite is generic (`with_entries` over every
122
+ // `@intentius/*` peer) rather than two named keys: the wardens added
123
+ // `@intentius/warden-core` as a third in-repo peer, and a hand-listed
124
+ // rewrite goes stale the same way the hand-listed publish steps did.
121
125
  const body = recipeBody("release");
122
- expect(body).toMatch(/peerDependencies\["@intentius\/chant"\]/);
123
- expect(body).toMatch(/peerDependencies\["@intentius\/chant-lexicon-github"\]/);
126
+ expect(body).toMatch(/\.peerDependencies\s*\|=\s*with_entries/);
127
+ expect(body).toMatch(/startswith\("@intentius\/"\)/);
124
128
  });
125
129
 
126
130
  it("the single-lexicon release rewrites them too", () => {
@@ -200,6 +200,36 @@ describe("applyConfigDefaults", () => {
200
200
  expect(step.policy?.warnSeverity).toBe("medium");
201
201
  });
202
202
 
203
+ test("vuln-gate: exploitability fields (#1466) reach a step with no per-step policy written", () => {
204
+ const config: ChantConfig = { vulnPolicy: { failOnKev: true, failEpssAtOrAbove: 0.1, warnEpssAtOrAbove: 0.01, exploitabilityFixableOnly: false } };
205
+ const comp = component([
206
+ { phase: "Gate", steps: [{ kind: "vuln-gate", sbom: { bytes: "", mediaType: "", packageCount: 0, generator: "x", format: "spdx" } }] },
207
+ ]);
208
+
209
+ const result = applyConfigDefaults(comp, config);
210
+ const step = result.deploy[0]!.steps[0] as { policy?: { failOnKev?: boolean; failEpssAtOrAbove?: number; warnEpssAtOrAbove?: number; exploitabilityFixableOnly?: boolean } };
211
+ expect(step.policy).toEqual({ failOnKev: true, failEpssAtOrAbove: 0.1, warnEpssAtOrAbove: 0.01, exploitabilityFixableOnly: false });
212
+ });
213
+
214
+ test("vuln-gate: a per-step failOnKev: false beats a config-level true, field-by-field", () => {
215
+ const config: ChantConfig = { vulnPolicy: { failOnKev: true, failEpssAtOrAbove: 0.1 } };
216
+ const comp = component([
217
+ {
218
+ phase: "Gate",
219
+ steps: [{
220
+ kind: "vuln-gate",
221
+ sbom: { bytes: "", mediaType: "", packageCount: 0, generator: "x", format: "spdx" },
222
+ policy: { failOnKev: false },
223
+ }],
224
+ },
225
+ ]);
226
+
227
+ const result = applyConfigDefaults(comp, config);
228
+ const step = result.deploy[0]!.steps[0] as { policy?: { failOnKev?: boolean; failEpssAtOrAbove?: number } };
229
+ expect(step.policy?.failOnKev).toBe(false);
230
+ expect(step.policy?.failEpssAtOrAbove).toBe(0.1);
231
+ });
232
+
203
233
  test("recurses into nested fan-out phases and onFailure compensation phases", () => {
204
234
  const config: ChantConfig = { sbom: { format: "cyclonedx" } };
205
235
  const comp = component([