@intentius/chant 0.41.20 → 0.44.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 (144) hide show
  1. package/dist/build.d.ts +15 -1
  2. package/dist/build.d.ts.map +1 -1
  3. package/dist/cli/commands/build.d.ts.map +1 -1
  4. package/dist/cli/commands/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/init.d.ts +2 -0
  11. package/dist/cli/commands/init.d.ts.map +1 -1
  12. package/dist/cli/handlers/carve-apply.d.ts.map +1 -1
  13. package/dist/cli/handlers/components.d.ts.map +1 -1
  14. package/dist/cli/handlers/explain.d.ts +11 -0
  15. package/dist/cli/handlers/explain.d.ts.map +1 -0
  16. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  17. package/dist/cli/main.d.ts.map +1 -1
  18. package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
  19. package/dist/cli/plugins.d.ts +8 -0
  20. package/dist/cli/plugins.d.ts.map +1 -1
  21. package/dist/cli/registry.d.ts +2 -0
  22. package/dist/cli/registry.d.ts.map +1 -1
  23. package/dist/codegen/docs-rule-scanning.d.ts +11 -0
  24. package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
  25. package/dist/codegen/okf-lexicon.d.ts +36 -0
  26. package/dist/codegen/okf-lexicon.d.ts.map +1 -0
  27. package/dist/codegen/package.d.ts +2 -1
  28. package/dist/codegen/package.d.ts.map +1 -1
  29. package/dist/components/deploy-units.d.ts.map +1 -1
  30. package/dist/components/verbs/sbom.d.ts +1 -1
  31. package/dist/components/verbs/sbom.d.ts.map +1 -1
  32. package/dist/components/verbs/vuln-gate.d.ts +15 -4
  33. package/dist/components/verbs/vuln-gate.d.ts.map +1 -1
  34. package/dist/components/verbs/vuln-scan.d.ts +18 -6
  35. package/dist/components/verbs/vuln-scan.d.ts.map +1 -1
  36. package/dist/config.d.ts +12 -0
  37. package/dist/config.d.ts.map +1 -1
  38. package/dist/deep-observation.d.ts +13 -0
  39. package/dist/deep-observation.d.ts.map +1 -1
  40. package/dist/lexicon.d.ts +47 -0
  41. package/dist/lexicon.d.ts.map +1 -1
  42. package/dist/lifecycle/observe.d.ts.map +1 -1
  43. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  44. package/dist/okf.d.ts +57 -0
  45. package/dist/okf.d.ts.map +1 -0
  46. package/dist/terraform/__fixtures__/build-graph.d.ts +13 -0
  47. package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -0
  48. package/dist/terraform/adopt-state.d.ts +33 -1
  49. package/dist/terraform/adopt-state.d.ts.map +1 -1
  50. package/dist/terraform/aws-resources.d.ts.map +1 -1
  51. package/dist/terraform/bridge.d.ts +7 -1
  52. package/dist/terraform/bridge.d.ts.map +1 -1
  53. package/dist/terraform/carve.d.ts +12 -0
  54. package/dist/terraform/carve.d.ts.map +1 -1
  55. package/dist/terraform/excise.d.ts +31 -0
  56. package/dist/terraform/excise.d.ts.map +1 -0
  57. package/dist/terraform/graduate.d.ts +12 -0
  58. package/dist/terraform/graduate.d.ts.map +1 -1
  59. package/dist/terraform/graph.d.ts +35 -5
  60. package/dist/terraform/graph.d.ts.map +1 -1
  61. package/dist/terraform/manifest.d.ts +89 -0
  62. package/dist/terraform/manifest.d.ts.map +1 -0
  63. package/dist/terraform/parse.d.ts +9 -4
  64. package/dist/terraform/parse.d.ts.map +1 -1
  65. package/dist/terraform/tier-map.d.ts +4 -3
  66. package/dist/terraform/tier-map.d.ts.map +1 -1
  67. package/dist/terraform/types.d.ts +7 -0
  68. package/dist/terraform/types.d.ts.map +1 -1
  69. package/dist/terraform/unified-diff.d.ts +11 -0
  70. package/dist/terraform/unified-diff.d.ts.map +1 -0
  71. package/package.json +1 -1
  72. package/src/__snapshots__/okf.test.ts.snap +381 -0
  73. package/src/audit/rules-doc.ts +1 -1
  74. package/src/build.test.ts +95 -0
  75. package/src/build.ts +46 -1
  76. package/src/cli/commands/build.ts +9 -1
  77. package/src/cli/commands/carve-apply.test.ts +94 -2
  78. package/src/cli/commands/carve-apply.ts +62 -11
  79. package/src/cli/commands/carve-bridge.test.ts +82 -4
  80. package/src/cli/commands/carve-bridge.ts +79 -11
  81. package/src/cli/commands/carve-emit-state.test.ts +110 -1
  82. package/src/cli/commands/carve-emit.ts +184 -8
  83. package/src/cli/commands/init.ts +1 -1
  84. package/src/cli/handlers/carve-apply.ts +1 -0
  85. package/src/cli/handlers/components.ts +4 -2
  86. package/src/cli/handlers/explain.test.ts +93 -0
  87. package/src/cli/handlers/explain.ts +60 -0
  88. package/src/cli/handlers/lifecycle.ts +16 -9
  89. package/src/cli/main.ts +21 -5
  90. package/src/cli/mcp/server.test.ts +17 -0
  91. package/src/cli/mcp/tools/explain.ts +15 -3
  92. package/src/cli/plugins.ts +17 -0
  93. package/src/cli/registry.ts +2 -0
  94. package/src/codegen/__snapshots__/okf-lexicon.test.ts.snap +170 -0
  95. package/src/codegen/docs-rule-scanning.ts +35 -14
  96. package/src/codegen/okf-lexicon.test.ts +248 -0
  97. package/src/codegen/okf-lexicon.ts +303 -0
  98. package/src/codegen/package.ts +29 -4
  99. package/src/components/config-defaults.test.ts +30 -0
  100. package/src/components/deploy-units.test.ts +13 -0
  101. package/src/components/deploy-units.ts +5 -0
  102. package/src/components/verbs/__fixtures__/grype-with-kev-epss.json +1 -0
  103. package/src/components/verbs/__fixtures__/trivy-with-kev-epss.json +631 -0
  104. package/src/components/verbs/exploitability-roundtrip.test.ts +190 -0
  105. package/src/components/verbs/sbom.ts +1 -1
  106. package/src/components/verbs/vuln-gate.test.ts +144 -1
  107. package/src/components/verbs/vuln-gate.ts +61 -10
  108. package/src/components/verbs/vuln-scan.test.ts +94 -0
  109. package/src/components/verbs/vuln-scan.ts +44 -9
  110. package/src/config.test.ts +21 -0
  111. package/src/config.ts +18 -0
  112. package/src/deep-observation.test.ts +19 -0
  113. package/src/deep-observation.ts +32 -13
  114. package/src/lexicon.ts +50 -0
  115. package/src/lifecycle/observe.test.ts +18 -0
  116. package/src/lifecycle/observe.ts +10 -3
  117. package/src/lifecycle/snapshot.ts +5 -4
  118. package/src/okf.test.ts +169 -0
  119. package/src/okf.ts +308 -0
  120. package/src/terraform/__fixtures__/build-graph.ts +25 -0
  121. package/src/terraform/adopt-state.test.ts +34 -1
  122. package/src/terraform/adopt-state.ts +69 -2
  123. package/src/terraform/aws-resources.test.ts +52 -2
  124. package/src/terraform/aws-resources.ts +95 -4
  125. package/src/terraform/bridge.test.ts +38 -14
  126. package/src/terraform/bridge.ts +48 -14
  127. package/src/terraform/carve.test.ts +9 -7
  128. package/src/terraform/carve.ts +0 -0
  129. package/src/terraform/excise.test.ts +92 -0
  130. package/src/terraform/excise.ts +139 -0
  131. package/src/terraform/graduate.test.ts +73 -6
  132. package/src/terraform/graduate.ts +49 -0
  133. package/src/terraform/graph.test.ts +73 -10
  134. package/src/terraform/graph.ts +124 -47
  135. package/src/terraform/manifest.test.ts +105 -0
  136. package/src/terraform/manifest.ts +162 -0
  137. package/src/terraform/parse.test.ts +39 -1
  138. package/src/terraform/parse.ts +36 -9
  139. package/src/terraform/score.test.ts +8 -8
  140. package/src/terraform/state.test.ts +4 -4
  141. package/src/terraform/tier-map.ts +60 -8
  142. package/src/terraform/types.ts +7 -0
  143. package/src/terraform/unified-diff.test.ts +77 -0
  144. package/src/terraform/unified-diff.ts +146 -0
package/src/cli/main.ts CHANGED
@@ -26,6 +26,7 @@ import { runCarveApply } from "./handlers/carve-apply";
26
26
  import { runLifecycleSnapshot, runLifecycleShow, runLifecycleDiff, runLifecycleRollback, runLifecyclePlan, runLifecycleAffected, runLifecycleLog, runLifecycleUnknown } from "./handlers/lifecycle";
27
27
  import { runComponentsStatus, runComponentsReleaseRecord, runComponentsUnknown } from "./handlers/components";
28
28
  import { runGraph } from "./handlers/graph";
29
+ import { runExplain } from "./handlers/explain";
29
30
  import { runSearch } from "./handlers/search";
30
31
  import { runOp, runOpList, runOpStatus, runOpSignal, runOpCancel, runOpLog } from "./handlers/run";
31
32
  import { runEmulator } from "./handlers/emulator";
@@ -212,6 +213,8 @@ export function parseArgs(args: string[]): ParsedArgs {
212
213
  result.carveStack = args[++i];
213
214
  } else if (arg === "--write") {
214
215
  result.write = true;
216
+ } else if (arg === "--write-source") {
217
+ result.writeSource = true;
215
218
  } else if (arg === "--to") {
216
219
  result.migrateTo = args[++i];
217
220
  } else if (arg === "--emit") {
@@ -385,6 +388,10 @@ Commands:
385
388
  lint Check specifications for issues
386
389
  list List discovered entities
387
390
  describe Show the effective config for one component
391
+ explain Summarize discovered entities (--format markdown|json|okf;
392
+ okf emits an OKF v0.2 knowledge bundle — one markdown
393
+ concept per entity + index.md; -o <dir> writes the
394
+ bundle tree, otherwise JSON path→content on stdout)
388
395
  vendor Pull pinned, checksummed patterns into your repo
389
396
  import Import external template into TypeScript
390
397
  audit [path|url] Audit a repo's CI YAML for security issues
@@ -402,14 +409,22 @@ Commands:
402
409
  --select <addr> (recommended for TF-managed resources); --env
403
410
  --state|--env <env> adopts via live cloud import (--live-name
404
411
  <logical-id> narrows a multi-resource stack).
412
+ Persists a carve manifest bridge/apply compose
413
+ with; scaffolds the output dir into a buildable
414
+ chant project (src/ + config + package.json).
405
415
  carve bridge Generate the surviving-TF patch (data sources + rewired
406
- --from <tf-dir> refs) + deferred inputs + reversible runbook.
407
- --select <addr> Writes proposals for review; --apply-rewrites
408
- edits the .tf in place.
416
+ --from <tf-dir> refs) + deferred inputs + reversible runbook,
417
+ [--select <addr>] plus one git-applyable .patch for the whole
418
+ edit. Writes proposals for review;
419
+ --apply-rewrites edits the .tf in place.
420
+ --select is optional when the output dir
421
+ holds one carve manifest.
409
422
  carve apply Apply graduation: ownership marker + finalized apply
410
423
  --from <tf-dir> runbook (dial-turn observe→apply). BYOL —
411
- --select <addr> no cloud call; --write saves the doc.
412
- --env <env>
424
+ [--select <addr>] no cloud call; --write saves the doc. --select
425
+ --env <env> is optional with a carve manifest present.
426
+ --write-source stamps the ownership marker
427
+ into the emitted chant source.
413
428
 
414
429
  Ops:
415
430
  run <name> Start an Op workflow (spawns worker + submits to Temporal)
@@ -705,6 +720,7 @@ const registry: CommandDef[] = [
705
720
  { name: "lint", handler: runLint },
706
721
  { name: "list", handler: runList },
707
722
  { name: "describe", handler: runDescribe },
723
+ { name: "explain", handler: runExplain },
708
724
  { name: "search", handler: runSearch },
709
725
  { name: "import", handler: runImport },
710
726
  { name: "audit", handler: runAudit },
@@ -327,6 +327,23 @@ describe("McpServer", () => {
327
327
  expect(parsed.sourceFiles).toBeDefined();
328
328
  });
329
329
 
330
+ test("calls explain tool with okf format (#1058)", async () => {
331
+ const response = await server.handleRequest({
332
+ jsonrpc: "2.0",
333
+ id: 1,
334
+ method: "tools/call",
335
+ params: { name: "explain", arguments: { path: testDir, format: "okf" } },
336
+ });
337
+
338
+ expect(response.error).toBeUndefined();
339
+ const result = response.result as { content: Array<{ text: string }> };
340
+ const parsed = JSON.parse(result.content[0].text);
341
+ expect(parsed.okf_version).toBe("0.2");
342
+ // Even an empty project yields a bundle with a root index.md.
343
+ expect(Object.keys(parsed.files)).toEqual(["index.md"]);
344
+ expect(parsed.files["index.md"]).toContain("okf_version: '0.2'");
345
+ });
346
+
330
347
  test("calls scaffold tool with generic fallback", async () => {
331
348
  const response = await server.handleRequest({
332
349
  jsonrpc: "2.0",
@@ -1,5 +1,6 @@
1
1
  import { resolve } from "path";
2
2
  import { discover } from "../../../discovery/index";
3
+ import { buildOkfBundle, OKF_VERSION } from "../../../okf";
3
4
 
4
5
  /**
5
6
  * Explain tool definition for MCP
@@ -16,8 +17,8 @@ export const explainTool = {
16
17
  },
17
18
  format: {
18
19
  type: "string",
19
- enum: ["markdown", "json"],
20
- description: "Output format (default: markdown)",
20
+ enum: ["markdown", "json", "okf"],
21
+ description: "Output format (default: markdown). okf: an OKF v0.2 knowledge bundle — one markdown concept per entity plus an index.md (#1058)",
21
22
  },
22
23
  },
23
24
  required: ["path"],
@@ -29,11 +30,22 @@ export const explainTool = {
29
30
  */
30
31
  export async function handleExplain(params: Record<string, unknown>): Promise<unknown> {
31
32
  const path = params.path as string;
32
- const format = (params.format as "markdown" | "json") ?? "markdown";
33
+ const format = (params.format as "markdown" | "json" | "okf") ?? "markdown";
33
34
 
34
35
  const infraPath = resolve(path);
35
36
  const result = await discover(infraPath);
36
37
 
38
+ // OKF knowledge bundle (#1058): one concept document per entity plus a root
39
+ // index.md, returned as bundle-relative path → content. The CLI path writes
40
+ // the same files to a directory.
41
+ if (format === "okf") {
42
+ return {
43
+ okf_version: OKF_VERSION,
44
+ files: Object.fromEntries(buildOkfBundle(result, infraPath).map((f) => [f.path, f.content])),
45
+ errors: result.errors.map((e) => e.message),
46
+ };
47
+ }
48
+
37
49
  // Group entities by lexicon and kind
38
50
  const byLexicon = new Map<string, { resources: string[]; properties: string[] }>();
39
51
 
@@ -95,6 +95,23 @@ export function resolveLexiconVersions(lexiconNames: readonly string[]): Record<
95
95
  return versions;
96
96
  }
97
97
 
98
+ /**
99
+ * Bind each loaded plugin's `buildRoots` hook (#1548 piece 3) to this
100
+ * invocation's config and project root, producing the closures
101
+ * `BuildOptions.buildRoots` takes — the same extract-then-thread shape the
102
+ * CLI uses for `intrinsics`. Plugins without the hook contribute nothing;
103
+ * an empty array is the common case and `build()` treats it as absent.
104
+ */
105
+ export function collectBuildRootContributors(
106
+ plugins: readonly LexiconPlugin[] | undefined,
107
+ config: Record<string, unknown>,
108
+ projectRoot: string,
109
+ ): Array<() => Promise<import("../lexicon").BuildRootContribution>> {
110
+ return (plugins ?? [])
111
+ .filter((plugin) => typeof plugin.buildRoots === "function")
112
+ .map((plugin) => () => plugin.buildRoots!({ projectRoot, config }));
113
+ }
114
+
98
115
  /**
99
116
  * Load plugins for all detected lexicon names.
100
117
  * Calls `init()` on each plugin if present.
@@ -45,6 +45,8 @@ export interface ParsedArgs {
45
45
  carveStack?: string;
46
46
  /** `chant carve apply --write` — save the graduation doc */
47
47
  write?: boolean;
48
+ /** `chant carve apply --write-source` — stamp the ownership marker into the emitted source */
49
+ writeSource?: boolean;
48
50
  /** `chant migrate --to <name>` (default "gitlab") */
49
51
  migrateTo?: string;
50
52
  /** `chant migrate --emit yaml|ts` */
@@ -0,0 +1,170 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`buildLexiconOkfBundle > registry-style and dts-style bundles snapshot stably 1`] = `
4
+ [
5
+ {
6
+ "content": "---
7
+ okf_version: '0.2'
8
+ ---
9
+
10
+ # Resource types
11
+
12
+ * [Service](/types/Service.md) - Mock::Compose::Service
13
+ * [Volume](/types/Volume.md) - Mock::Compose::Volume
14
+
15
+ # Rules
16
+
17
+ * [MCK001](/rules/MCK001.md) - Avoid :latest image references on a Service
18
+ * [MCK010](/rules/MCK010.md) - A declared Volume must be mounted somewhere
19
+ ",
20
+ "path": "index.md",
21
+ },
22
+ {
23
+ "content": "---
24
+ type: lint-rule
25
+ title: MCK001
26
+ description: Avoid :latest image references on a Service
27
+ id: MCK001
28
+ severity: warning
29
+ category: correctness
30
+ lexicon: mockdocker
31
+ docs: https://intentius.io/chant/lexicons/mockdocker/rules/
32
+ ---
33
+ Avoid :latest image references on a Service
34
+
35
+ ## Applies to
36
+
37
+ - [Service](/types/Service.md)
38
+ ",
39
+ "path": "rules/MCK001.md",
40
+ },
41
+ {
42
+ "content": "---
43
+ type: post-synth-check
44
+ title: MCK010
45
+ description: A declared Volume must be mounted somewhere
46
+ id: MCK010
47
+ severity: error
48
+ category: post-synth
49
+ lexicon: mockdocker
50
+ docs: https://intentius.io/chant/lexicons/mockdocker/rules/
51
+ ---
52
+ A declared Volume must be mounted somewhere
53
+
54
+ ## Applies to
55
+
56
+ - [Volume](/types/Volume.md)
57
+ ",
58
+ "path": "rules/MCK010.md",
59
+ },
60
+ {
61
+ "content": "---
62
+ type: resource-type
63
+ title: Service
64
+ description: A containerized service definition
65
+ name: Service
66
+ lexicon: mockdocker
67
+ resource_type: Mock::Compose::Service
68
+ ---
69
+ A containerized service definition
70
+
71
+ ## Properties
72
+
73
+ - \`image\` (\`string\`): Container image to use
74
+ - \`ports\` (\`string[]\`): Published ports
75
+
76
+ ## Governed by
77
+
78
+ - [MCK001](/rules/MCK001.md): Avoid :latest image references on a Service
79
+ ",
80
+ "path": "types/Service.md",
81
+ },
82
+ {
83
+ "content": "---
84
+ type: resource-type
85
+ title: Volume
86
+ description: A named volume
87
+ name: Volume
88
+ lexicon: mockdocker
89
+ resource_type: Mock::Compose::Volume
90
+ ---
91
+ A named volume
92
+
93
+ ## Properties
94
+
95
+ - \`driver\` (\`string\`): Volume driver
96
+
97
+ ## Governed by
98
+
99
+ - [MCK010](/rules/MCK010.md): A declared Volume must be mounted somewhere
100
+ ",
101
+ "path": "types/Volume.md",
102
+ },
103
+ ]
104
+ `;
105
+
106
+ exports[`buildLexiconOkfBundle > registry-style and dts-style bundles snapshot stably 2`] = `
107
+ [
108
+ {
109
+ "content": "---
110
+ okf_version: '0.2'
111
+ ---
112
+
113
+ # Resource types
114
+
115
+ * [Bucket](/types/Bucket.md) - Mock::S3::Bucket
116
+
117
+ # Rules
118
+
119
+ * [MAW006](/rules/MAW006.md) - Detects Bucket creation without encryption
120
+ ",
121
+ "path": "index.md",
122
+ },
123
+ {
124
+ "content": "---
125
+ type: lint-rule
126
+ title: MAW006
127
+ description: Detects Bucket creation without encryption
128
+ id: MAW006
129
+ severity: warning
130
+ category: security
131
+ lexicon: mockaws
132
+ docs: https://intentius.io/chant/lexicons/mockaws/rules/
133
+ ---
134
+ Detects Bucket creation without encryption
135
+
136
+ ## Applies to
137
+
138
+ - [Bucket](/types/Bucket.md)
139
+ ",
140
+ "path": "rules/MAW006.md",
141
+ },
142
+ {
143
+ "content": "---
144
+ type: resource-type
145
+ title: Bucket
146
+ description: mockaws resource type Mock::S3::Bucket
147
+ name: Bucket
148
+ lexicon: mockaws
149
+ resource_type: Mock::S3::Bucket
150
+ ---
151
+ \`Mock::S3::Bucket\`, a resource type of the mockaws lexicon.
152
+
153
+ ## Properties
154
+
155
+ - \`BucketName\` (\`string\`, optional): A name for the bucket.
156
+ - \`VersioningConfiguration\` (\`Bucket_VersioningConfiguration\`, optional): The versioning state.
157
+
158
+ ## Attributes
159
+
160
+ - \`Arn\`
161
+ - \`DomainName\`
162
+
163
+ ## Governed by
164
+
165
+ - [MAW006](/rules/MAW006.md): Detects Bucket creation without encryption
166
+ ",
167
+ "path": "types/Bucket.md",
168
+ },
169
+ ]
170
+ `;
@@ -11,12 +11,27 @@ import { join } from "path";
11
11
  import { escapeMdx } from "./docs-file-markers";
12
12
  import type { DocsConfig, RuleMeta } from "./docs-types";
13
13
 
14
+ /** A scanned rule: its extracted metadata plus the source it came from. */
15
+ export interface ScannedRule {
16
+ meta: RuleMeta;
17
+ source: string;
18
+ }
19
+
14
20
  /**
15
21
  * Scan lint rule and post-synth check source files to extract metadata.
16
22
  * Uses regex to find id, severity, category, and description from source.
17
23
  */
18
24
  export function scanRules(srcDir: string): RuleMeta[] {
19
- const rules: RuleMeta[] = [];
25
+ return scanRulesWithSources(srcDir).map((r) => r.meta);
26
+ }
27
+
28
+ /**
29
+ * Like {@link scanRules}, but keeps each rule's source text alongside its
30
+ * metadata — the OKF bundle builder (#1060) reads the source to associate a
31
+ * rule with the resource types it mentions.
32
+ */
33
+ export function scanRulesWithSources(srcDir: string): ScannedRule[] {
34
+ const rules: ScannedRule[] = [];
20
35
 
21
36
  // Scan lint rules
22
37
  scanDir(join(srcDir, "lint", "rules"), "lint", rules);
@@ -27,7 +42,7 @@ export function scanRules(srcDir: string): RuleMeta[] {
27
42
  return rules;
28
43
  }
29
44
 
30
- function scanDir(dir: string, type: "lint" | "post-synth", out: RuleMeta[]): void {
45
+ function scanDir(dir: string, type: "lint" | "post-synth", out: ScannedRule[]): void {
31
46
  let entries: string[];
32
47
  try {
33
48
  entries = readdirSync(dir);
@@ -62,13 +77,16 @@ function scanDir(dir: string, type: "lint" | "post-synth", out: RuleMeta[]): voi
62
77
  );
63
78
 
64
79
  out.push({
65
- id: idMatch[1],
66
- severity: severityMatch?.[1] ?? "warning",
67
- category: categoryMatch?.[1] ?? "general",
68
- description:
69
- descMatch?.[1]?.trim() ??
70
- extractDescriptionFromComment(content, idMatch[1]),
71
- type: "lint",
80
+ meta: {
81
+ id: idMatch[1],
82
+ severity: severityMatch?.[1] ?? "warning",
83
+ category: categoryMatch?.[1] ?? "general",
84
+ description:
85
+ descMatch?.[1]?.trim() ??
86
+ extractDescriptionFromComment(content, idMatch[1]),
87
+ type: "lint",
88
+ },
89
+ source: content,
72
90
  });
73
91
  }
74
92
  } else {
@@ -78,11 +96,14 @@ function scanDir(dir: string, type: "lint" | "post-synth", out: RuleMeta[]): voi
78
96
 
79
97
  if (idMatch) {
80
98
  out.push({
81
- id: idMatch[1],
82
- severity: "error",
83
- category: "post-synth",
84
- description: descMatch?.[1] ?? idMatch[1],
85
- type: "post-synth",
99
+ meta: {
100
+ id: idMatch[1],
101
+ severity: "error",
102
+ category: "post-synth",
103
+ description: descMatch?.[1] ?? idMatch[1],
104
+ type: "post-synth",
105
+ },
106
+ source: content,
86
107
  });
87
108
  }
88
109
  }
@@ -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
+ });