@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,169 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { buildOkfBundle, okfConformanceProblems, splitFrontmatter, OKF_VERSION, type OkfFile } from "./okf";
5
+ import { DECLARABLE_MARKER, type Declarable } from "./declarable";
6
+ import { setProvenance } from "./provenance";
7
+ import { parseYAML } from "./yaml";
8
+ import { discover } from "./discovery/index";
9
+
10
+ function decl<T extends object>(base: T): Declarable & T {
11
+ return { [DECLARABLE_MARKER]: true, ...base } as Declarable & T;
12
+ }
13
+
14
+ function fileMap(bundle: OkfFile[]): Map<string, string> {
15
+ return new Map(bundle.map((f) => [f.path, f.content]));
16
+ }
17
+
18
+ /**
19
+ * Assert the four OKF v0.2 conformance criteria (spec §11) over a bundle via
20
+ * the shared checker (`okfConformanceProblems`) — the same one the lexicon
21
+ * bundle tests (#1060) run — plus the version pin our own emitter guarantees.
22
+ */
23
+ function assertConformant(bundle: OkfFile[]): void {
24
+ expect(okfConformanceProblems(bundle)).toEqual([]);
25
+ const index = bundle.find((f) => f.path === "index.md");
26
+ expect(index).toBeDefined();
27
+ const split = splitFrontmatter(index!.content);
28
+ expect(split).toBeDefined();
29
+ expect(parseYAML(split!.frontmatter)).toEqual({ okf_version: OKF_VERSION });
30
+ }
31
+
32
+ describe("buildOkfBundle", () => {
33
+ test("emits one concept per entity with parseable frontmatter and a non-empty type", () => {
34
+ const bucket = decl({ lexicon: "aws", entityType: "AWS::S3::Bucket", kind: "resource" as const });
35
+ setProvenance(bucket, { sourceFile: "/proj/src/storage.ts" });
36
+ const vpc = decl({ lexicon: "gcp", entityType: "Vpc" });
37
+ const entities = new Map<string, Declarable>([
38
+ ["myBucket", bucket],
39
+ ["vpc", vpc],
40
+ ]);
41
+
42
+ const bundle = buildOkfBundle({ entities, dependencies: new Map() }, "/proj");
43
+ const files = fileMap(bundle);
44
+ expect([...files.keys()]).toEqual(["aws/myBucket.md", "gcp/vpc.md", "index.md"]);
45
+
46
+ const concept = splitFrontmatter(files.get("aws/myBucket.md")!)!;
47
+ const front = parseYAML(concept.frontmatter);
48
+ expect(front.type).toBe("AWS::S3::Bucket");
49
+ expect(front.title).toBe("myBucket");
50
+ expect(front.lexicon).toBe("aws");
51
+ expect(front.kind).toBe("resource");
52
+ expect(front.source).toBe("src/storage.ts");
53
+ expect(concept.body).toContain("Declared in `src/storage.ts`.");
54
+ });
55
+
56
+ test("dependency edges become bundle-relative markdown links, both directions", () => {
57
+ const vpc = decl({ lexicon: "gcp", entityType: "Vpc" });
58
+ const subnet = decl({ lexicon: "gcp", entityType: "Subnet" });
59
+ const entities = new Map<string, Declarable>([
60
+ ["vpc", vpc],
61
+ ["subnet", subnet],
62
+ ]);
63
+ const dependencies = new Map([["subnet", new Set(["vpc"])]]);
64
+
65
+ const files = fileMap(buildOkfBundle({ entities, dependencies }));
66
+ expect(files.get("gcp/subnet.md")).toContain("## Depends on");
67
+ expect(files.get("gcp/subnet.md")).toContain("- [vpc](/gcp/vpc.md)");
68
+ expect(files.get("gcp/vpc.md")).toContain("## Referenced by");
69
+ expect(files.get("gcp/vpc.md")).toContain("- [subnet](/gcp/subnet.md)");
70
+ });
71
+
72
+ test("an unresolved dependency emits a link rather than failing — broken links are permitted", () => {
73
+ const app = decl({ lexicon: "k8s", entityType: "Deployment" });
74
+ const entities = new Map<string, Declarable>([["app", app]]);
75
+ const dependencies = new Map([["app", new Set(["ghost"])]]);
76
+
77
+ const bundle = buildOkfBundle({ entities, dependencies });
78
+ expect(fileMap(bundle).get("k8s/app.md")).toContain("- [ghost](/ghost.md)");
79
+ assertConformant(bundle);
80
+ });
81
+
82
+ test("index.md groups entities into per-lexicon sections with okf_version frontmatter", () => {
83
+ const bucket = decl({ lexicon: "aws", entityType: "AWS::S3::Bucket" });
84
+ const job = decl({ lexicon: "gitlab", entityType: "GitLab::Job" });
85
+ const entities = new Map<string, Declarable>([
86
+ ["assets", bucket],
87
+ ["buildJob", job],
88
+ ]);
89
+
90
+ const index = fileMap(buildOkfBundle({ entities, dependencies: new Map() })).get("index.md")!;
91
+ expect(index).toContain(`okf_version: '${OKF_VERSION}'`);
92
+ expect(index).toContain("# Lexicon: aws");
93
+ expect(index).toContain("# Lexicon: gitlab");
94
+ expect(index).toContain("* [assets](/aws/assets.md) - aws resource of type AWS::S3::Bucket");
95
+ expect(index).toContain("* [buildJob](/gitlab/buildJob.md) - gitlab resource of type GitLab::Job");
96
+ });
97
+
98
+ test("composite provenance rides along as extra frontmatter keys", () => {
99
+ const dep = decl({ lexicon: "k8s", entityType: "K8s::Apps::Deployment" });
100
+ setProvenance(dep, { sourceFile: "/proj/src/web.ts", composite: "WebApp", compositeInstance: "prodApp" });
101
+ const entities = new Map<string, Declarable>([["prodAppDeployment", dep]]);
102
+
103
+ const front = parseYAML(
104
+ splitFrontmatter(fileMap(buildOkfBundle({ entities, dependencies: new Map() }, "/proj")).get("k8s/prodAppDeployment.md")!)!.frontmatter,
105
+ );
106
+ expect(front.composite).toBe("WebApp");
107
+ expect(front.composite_instance).toBe("prodApp");
108
+ });
109
+
110
+ test("entity names that slug to the same path are deduped, never overwritten", () => {
111
+ const a = decl({ lexicon: "aws", entityType: "AWS::S3::Bucket" });
112
+ const b = decl({ lexicon: "aws", entityType: "AWS::S3::Bucket" });
113
+ const entities = new Map<string, Declarable>([
114
+ ["my/bucket", a],
115
+ ["my-bucket", b],
116
+ ]);
117
+
118
+ const paths = buildOkfBundle({ entities, dependencies: new Map() }).map((f) => f.path);
119
+ expect(new Set(paths).size).toBe(paths.length);
120
+ expect(paths).toContain("aws/my-bucket.md");
121
+ expect(paths).toContain("aws/my-bucket-2.md");
122
+ });
123
+
124
+ test("an empty project still emits a conformant bundle (just the index)", () => {
125
+ const bundle = buildOkfBundle({ entities: new Map(), dependencies: new Map() });
126
+ expect(bundle.map((f) => f.path)).toEqual(["index.md"]);
127
+ assertConformant(bundle);
128
+ });
129
+
130
+ test("deterministic: the same input yields byte-identical files", () => {
131
+ const vpc = decl({ lexicon: "gcp", entityType: "Vpc" });
132
+ const subnet = decl({ lexicon: "gcp", entityType: "Subnet" });
133
+ const entities = new Map<string, Declarable>([
134
+ ["vpc", vpc],
135
+ ["subnet", subnet],
136
+ ]);
137
+ const dependencies = new Map([["subnet", new Set(["vpc"])]]);
138
+
139
+ expect(buildOkfBundle({ entities, dependencies })).toEqual(buildOkfBundle({ entities, dependencies }));
140
+ });
141
+ });
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Shipped examples, spanning lexicons (#1058 acceptance): each bundle is
145
+ // asserted conformant against the four v0.2 criteria and snapshot-tested.
146
+ // ---------------------------------------------------------------------------
147
+
148
+ const repoRoot = resolve(fileURLToPath(import.meta.url), "../../../..");
149
+
150
+ const exampleCases: Array<[string, string]> = [
151
+ ["k8s layered-config", "lexicons/k8s/examples/layered-config/src"],
152
+ ["aws lambda-s3", "lexicons/aws/examples/lambda-s3/src"],
153
+ ["gitlab node-pipeline", "lexicons/gitlab/examples/node-pipeline/src"],
154
+ ];
155
+
156
+ describe("buildOkfBundle over shipped examples", () => {
157
+ for (const [name, rel] of exampleCases) {
158
+ test(`${name} emits a conformant, snapshot-stable bundle`, async () => {
159
+ const projectPath = resolve(repoRoot, rel);
160
+ const result = await discover(projectPath);
161
+ expect(result.errors).toEqual([]);
162
+ expect(result.entities.size).toBeGreaterThan(0);
163
+
164
+ const bundle = buildOkfBundle(result, projectPath);
165
+ assertConformant(bundle);
166
+ expect(bundle).toMatchSnapshot();
167
+ });
168
+ }
169
+ });
package/src/okf.ts ADDED
@@ -0,0 +1,308 @@
1
+ import { relative, isAbsolute } from "node:path";
2
+ import type { Declarable } from "./declarable";
3
+ import { buildGraphIr } from "./graph-ir";
4
+ import { isLexiconOutput, type LexiconOutput } from "./lexicon-output";
5
+ import { getProvenance } from "./provenance";
6
+ import { emitYAML, parseYAML } from "./yaml";
7
+
8
+ /**
9
+ * OKF bundle emitter (#1058, epic #1057) — projects a discovered entity graph
10
+ * into an Open Knowledge Format v0.2 bundle: a directory of markdown files
11
+ * with YAML frontmatter, per the spec at
12
+ * https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md
13
+ *
14
+ * One concept document per entity, `type` carrying the entity's resource type
15
+ * string, dependency edges as bundle-relative markdown links (the form the
16
+ * spec recommends for stability), and a root `index.md` grouping concepts into
17
+ * per-lexicon sections. Emit-only: OKF as project *input* is #1059.
18
+ */
19
+
20
+ export const OKF_VERSION = "0.2";
21
+
22
+ /** One file of an OKF bundle: a bundle-relative path and its full content. */
23
+ export interface OkfFile {
24
+ /** Bundle-relative path, e.g. "aws/myBucket.md" or "index.md". */
25
+ path: string;
26
+ content: string;
27
+ }
28
+
29
+ /** The subset of a discovery result the emitter reads. */
30
+ export interface OkfBundleInput {
31
+ entities: Map<string, Declarable>;
32
+ dependencies: Map<string, Set<string>>;
33
+ }
34
+
35
+ /** Make an entity or lexicon name safe as a single path segment. */
36
+ export function slug(name: string): string {
37
+ const cleaned = name.replace(/[^A-Za-z0-9._-]/g, "-").replace(/^\.+/, "");
38
+ return cleaned === "" ? "unnamed" : cleaned;
39
+ }
40
+
41
+ function relFile(file: string | undefined, projectPath?: string): string | undefined {
42
+ if (!file) return undefined;
43
+ if (projectPath && isAbsolute(file)) {
44
+ const rel = relative(projectPath, file);
45
+ return rel.startsWith("..") ? file : rel;
46
+ }
47
+ return file;
48
+ }
49
+
50
+ /**
51
+ * Render a flat frontmatter mapping as a YAML block. Keys with undefined
52
+ * values are dropped; scalar quoting is `emitYAML`'s.
53
+ */
54
+ export function frontmatter(fields: Record<string, string | undefined>): string {
55
+ const lines: string[] = ["---"];
56
+ for (const [key, value] of Object.entries(fields)) {
57
+ if (value === undefined) continue;
58
+ lines.push(`${key}: ${emitYAML(value, 0)}`);
59
+ }
60
+ lines.push("---");
61
+ return lines.join("\n");
62
+ }
63
+
64
+ /**
65
+ * Split a concept document into its frontmatter block and body. Returns
66
+ * undefined when the document does not start with a `---` delimited block.
67
+ * Exported for the conformance tests (and any future OKF consumer, #1059).
68
+ */
69
+ export function splitFrontmatter(content: string): { frontmatter: string; body: string } | undefined {
70
+ const lines = content.split("\n");
71
+ if (lines[0] !== "---") return undefined;
72
+ const end = lines.indexOf("---", 1);
73
+ if (end === -1) return undefined;
74
+ return {
75
+ frontmatter: lines.slice(1, end).join("\n"),
76
+ body: lines.slice(end + 1).join("\n"),
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Check a bundle against the OKF v0.2 conformance criteria (spec §11) and
82
+ * return every violation found: parseable frontmatter on every non-reserved
83
+ * `.md`, a non-empty `type` everywhere, a well-formed root `index.md`, and
84
+ * generated cross-link entries that are bundle-absolute (or external URLs).
85
+ * Empty result means conformant. Shared by the project-bundle tests (#1058)
86
+ * and every lexicon's bundle tests (#1060).
87
+ */
88
+ export function okfConformanceProblems(files: OkfFile[]): string[] {
89
+ const problems: string[] = [];
90
+ const reserved = new Set(["index.md", "log.md"]);
91
+ const linkTarget = /\]\(([^)]+)\)/g;
92
+ const externalOrAbsolute = (target: string): boolean =>
93
+ target.startsWith("/") || /^[a-z][a-z0-9+.-]*:/i.test(target);
94
+
95
+ for (const file of files) {
96
+ if (!file.path.endsWith(".md")) problems.push(`${file.path}: not a markdown file`);
97
+ if (file.path.startsWith("/") || file.path.includes("..")) {
98
+ problems.push(`${file.path}: path is not bundle-relative`);
99
+ }
100
+ if (reserved.has(file.path.split("/").pop()!)) continue;
101
+
102
+ const split = splitFrontmatter(file.content);
103
+ if (!split) {
104
+ problems.push(`${file.path}: no frontmatter block`);
105
+ continue;
106
+ }
107
+ let parsed: unknown;
108
+ try {
109
+ parsed = parseYAML(split.frontmatter);
110
+ } catch {
111
+ problems.push(`${file.path}: unparseable frontmatter`);
112
+ continue;
113
+ }
114
+ if (typeof parsed !== "object" || parsed === null) {
115
+ problems.push(`${file.path}: frontmatter is not a mapping`);
116
+ continue;
117
+ }
118
+ const type = (parsed as Record<string, unknown>).type;
119
+ if (typeof type !== "string" || type.length === 0) {
120
+ problems.push(`${file.path}: frontmatter has no non-empty type`);
121
+ }
122
+ // Cross-link entries (the "- [name](target)" lines the emitters generate)
123
+ // must point inside the bundle or at an external URL. Broken targets are
124
+ // permitted (not-yet-written knowledge); malformed ones are not. Free-text
125
+ // knowledge bodies may carry arbitrary links and are not checked.
126
+ for (const line of split.body.split("\n")) {
127
+ if (!line.trimStart().startsWith("- [")) continue;
128
+ for (const match of line.matchAll(linkTarget)) {
129
+ if (!externalOrAbsolute(match[1])) {
130
+ problems.push(`${file.path}: link target "${match[1]}" is neither bundle-absolute nor external`);
131
+ }
132
+ }
133
+ }
134
+ }
135
+
136
+ const index = files.find((f) => f.path === "index.md");
137
+ if (!index) {
138
+ problems.push("bundle has no root index.md");
139
+ return problems;
140
+ }
141
+ const split = splitFrontmatter(index.content);
142
+ if (!split) {
143
+ problems.push("index.md: no frontmatter block");
144
+ return problems;
145
+ }
146
+ const parsed = parseYAML(split.frontmatter);
147
+ for (const key of Object.keys(parsed ?? {})) {
148
+ if (key !== "okf_version") problems.push(`index.md: frontmatter carries "${key}" — okf_version is the only key an index is allowed`);
149
+ }
150
+ for (const line of split.body.split("\n")) {
151
+ if (line.startsWith("*") && !/^\* \[[^\]]+\]\([^)]+\) - .+$/.test(line)) {
152
+ problems.push(`index.md: malformed entry line: ${line}`);
153
+ }
154
+ }
155
+ return problems;
156
+ }
157
+
158
+ interface Concept {
159
+ name: string;
160
+ entity: Declarable;
161
+ /** Bundle-relative concept path, e.g. "aws/myBucket.md". */
162
+ path: string;
163
+ lexicon: string;
164
+ /** The frontmatter `type` — the entity's resource type string. */
165
+ type: string;
166
+ kind: string;
167
+ }
168
+
169
+ /**
170
+ * The `type`/`lexicon`/`kind` a concept carries. Most entities state all
171
+ * three; a `LexiconOutput` (an `output(...)` bridge) carries none of them
172
+ * directly, so its facts come from its producing side — `type` must be
173
+ * non-empty for the bundle to conform, never blank.
174
+ */
175
+ function conceptFacts(entity: Declarable): { type: string; lexicon: string; kind: string } {
176
+ if (isLexiconOutput(entity)) {
177
+ const out = entity as unknown as LexiconOutput;
178
+ return { type: "Output", lexicon: out.sourceLexicon || "unknown", kind: "output" };
179
+ }
180
+ return {
181
+ type: entity.entityType || "Entity",
182
+ lexicon: entity.lexicon || "unknown",
183
+ kind: entity.kind ?? "resource",
184
+ };
185
+ }
186
+
187
+ /**
188
+ * Build the OKF bundle for a discovered graph. Pure and deterministic: the
189
+ * same entities yield byte-identical files, so bundles are diffable and
190
+ * snapshot-testable. `projectPath` relativizes source-file paths so the
191
+ * bundle is portable.
192
+ */
193
+ export function buildOkfBundle(input: OkfBundleInput, projectPath?: string): OkfFile[] {
194
+ // Assign each entity a stable concept path: <lexicon>/<name>.md, deduped
195
+ // when slugging collides two distinct names.
196
+ const concepts = new Map<string, Concept>();
197
+ const takenPaths = new Set<string>();
198
+ const names = [...input.entities.keys()].sort((a, b) => a.localeCompare(b));
199
+ for (const name of names) {
200
+ const entity = input.entities.get(name)!;
201
+ const facts = conceptFacts(entity);
202
+ const base = `${slug(facts.lexicon)}/${slug(name)}`;
203
+ let path = `${base}.md`;
204
+ for (let n = 2; takenPaths.has(path); n++) path = `${base}-${n}.md`;
205
+ takenPaths.add(path);
206
+ concepts.set(name, { name, entity, path, ...facts });
207
+ }
208
+
209
+ // Dependency edges: the graph IR's AttrRef/Ref-derived edges (the same
210
+ // derivation `chant graph --format ir` ships) unioned with discovery's
211
+ // declared dependency map — the map alone misses references that live only
212
+ // in attribute values.
213
+ const dependsOn = new Map<string, Set<string>>();
214
+ const addEdge = (from: string, to: string): void => {
215
+ if (!dependsOn.has(from)) dependsOn.set(from, new Set());
216
+ dependsOn.get(from)!.add(to);
217
+ };
218
+ for (const edge of buildGraphIr(input.entities, projectPath).edges) addEdge(edge.from, edge.to);
219
+ for (const [from, deps] of input.dependencies) for (const to of deps) addEdge(from, to);
220
+
221
+ // Reverse edges: who references each entity.
222
+ const referencedBy = new Map<string, string[]>();
223
+ for (const [from, deps] of dependsOn) {
224
+ for (const to of deps) {
225
+ if (!referencedBy.has(to)) referencedBy.set(to, []);
226
+ referencedBy.get(to)!.push(from);
227
+ }
228
+ }
229
+
230
+ const files: OkfFile[] = [];
231
+
232
+ for (const name of names) {
233
+ const concept = concepts.get(name)!;
234
+ const { entity } = concept;
235
+ const prov = getProvenance(entity);
236
+ const source = relFile(prov?.sourceFile, projectPath);
237
+
238
+ const head = frontmatter({
239
+ type: concept.type,
240
+ title: name,
241
+ description: `${concept.lexicon} ${concept.kind} of type ${concept.type}`,
242
+ name,
243
+ lexicon: concept.lexicon,
244
+ kind: concept.kind,
245
+ source,
246
+ composite: prov?.composite,
247
+ composite_instance: prov?.compositeInstance,
248
+ });
249
+
250
+ const body: string[] = [""];
251
+ body.push(source ? `Declared in \`${source}\`.` : `Declared by the ${concept.lexicon} lexicon.`);
252
+
253
+ // Cross-resource references become bundle-relative links. A dependency
254
+ // whose target is not in the bundle still emits a link — the spec permits
255
+ // broken links (not-yet-written knowledge), so unresolved never fails.
256
+ const deps = [...(dependsOn.get(name) ?? [])].sort((a, b) => a.localeCompare(b));
257
+ if (deps.length > 0) {
258
+ body.push("", "## Depends on", "");
259
+ for (const dep of deps) {
260
+ const target = concepts.get(dep)?.path ?? `${slug(dep)}.md`;
261
+ body.push(`- [${dep}](/${target})`);
262
+ }
263
+ }
264
+
265
+ const consumers = (referencedBy.get(name) ?? []).sort((a, b) => a.localeCompare(b));
266
+ if (consumers.length > 0) {
267
+ body.push("", "## Referenced by", "");
268
+ for (const consumer of consumers) {
269
+ const target = concepts.get(consumer)?.path ?? `${slug(consumer)}.md`;
270
+ body.push(`- [${consumer}](/${target})`);
271
+ }
272
+ }
273
+
274
+ body.push("");
275
+ files.push({ path: concept.path, content: head + body.join("\n") });
276
+ }
277
+
278
+ files.push({ path: "index.md", content: buildIndex(concepts, names) });
279
+ files.sort((a, b) => a.path.localeCompare(b.path));
280
+ return files;
281
+ }
282
+
283
+ /**
284
+ * The bundle-root `index.md` (spec §8): one section per lexicon, each entry a
285
+ * bundle-relative link plus the concept's description — the spec's
286
+ * progressive-disclosure mechanism. Root indexes may carry `okf_version`
287
+ * frontmatter (spec §12), the only frontmatter an index is allowed.
288
+ */
289
+ function buildIndex(concepts: Map<string, Concept>, names: string[]): string {
290
+ const byLexicon = new Map<string, Concept[]>();
291
+ for (const name of names) {
292
+ const concept = concepts.get(name)!;
293
+ if (!byLexicon.has(concept.lexicon)) byLexicon.set(concept.lexicon, []);
294
+ byLexicon.get(concept.lexicon)!.push(concept);
295
+ }
296
+
297
+ const lines: string[] = ["---", `okf_version: '${OKF_VERSION}'`, "---", ""];
298
+ const lexicons = [...byLexicon.keys()].sort((a, b) => a.localeCompare(b));
299
+ for (const lexicon of lexicons) {
300
+ lines.push(`# Lexicon: ${lexicon}`, "");
301
+ for (const concept of byLexicon.get(lexicon)!) {
302
+ lines.push(`* [${concept.name}](/${concept.path}) - ${lexicon} ${concept.kind} of type ${concept.type}`);
303
+ }
304
+ lines.push("");
305
+ }
306
+ if (lexicons.length === 0) lines.push("No entities discovered.", "");
307
+ return lines.join("\n");
308
+ }
package/src/reconcile.ts CHANGED
@@ -19,6 +19,8 @@
19
19
  * only part that drives I/O (through the provider's `Cycle` implementations).
20
20
  */
21
21
 
22
+ import { GOVERNANCE_VERBS, type GovernanceVerb } from "./governance.js";
23
+
22
24
  // ---------------------------------------------------------------------------
23
25
  // Change-set model
24
26
  // ---------------------------------------------------------------------------
@@ -38,6 +40,12 @@ export interface ChangeSetEntry {
38
40
  kind: ChangeKind;
39
41
  /** High-level resource category (e.g. "team", "member", "branch-protection"). */
40
42
  resourceType: string;
43
+ /**
44
+ * Cross-provider governance category (#790). `resourceType` stays the
45
+ * provider-specific display string; the verb is the shared grammar SCM and
46
+ * cloud plans group by. Stamped by `runReconcile` from the cycle's `verb`.
47
+ */
48
+ verb?: GovernanceVerb;
41
49
  /**
42
50
  * Unique key identifying this resource within its type.
43
51
  * - For top-level resources: a single name (team slug, member login, …).
@@ -211,8 +219,17 @@ export function renderChangeSet(cs: ChangeSet): string {
211
219
 
212
220
  const ORDER: ChangeKind[] = ["create", "update", "delete"];
213
221
  for (const kind of ORDER) {
214
- const group = byKind[kind];
222
+ let group = byKind[kind];
215
223
  if (group.length === 0) continue;
224
+ // Verb-aware grouping (#790): when entries carry governance verbs, order
225
+ // each section by verb (vocabulary order, unverbed entries last) so mixed
226
+ // plans read category-by-category. Stable, and line format is unchanged —
227
+ // a verbless change set renders exactly as before.
228
+ if (group.some((e) => e.verb)) {
229
+ const rank = (e: ChangeSetEntry): number =>
230
+ e.verb ? GOVERNANCE_VERBS.indexOf(e.verb) : GOVERNANCE_VERBS.length;
231
+ group = [...group].sort((a, b) => rank(a) - rank(b));
232
+ }
216
233
  lines.push(`\n${kind.toUpperCase()}:`);
217
234
  for (const e of group) {
218
235
  lines.push(` [${e.resourceType}] ${e.key}`);
@@ -399,6 +416,12 @@ class MutableRateBudget implements RateBudget {
399
416
  export interface Cycle<TClient, TConfig, TLive, TScope = unknown> {
400
417
  /** Human-readable name, e.g. "branch-protection". */
401
418
  name: string;
419
+ /**
420
+ * Cross-provider governance category this cycle reconciles (#790). Every
421
+ * SCM warden cycle stamps one; cloud cycles (epic #787 C2) must. Optional
422
+ * only so provider-external Cycle implementations don't break.
423
+ */
424
+ verb?: GovernanceVerb;
402
425
  fetchLive(client: TClient, scopeId: string, scope: TScope, budget: RateBudget): Promise<TLive>;
403
426
  buildDesired(config: TConfig, scopeId: string, scope: TScope): TConfig;
404
427
  apply(
@@ -413,6 +436,8 @@ export interface Cycle<TClient, TConfig, TLive, TScope = unknown> {
413
436
  /** Per-cycle outcome recorded in the run result. */
414
437
  export interface CycleResult {
415
438
  name: string;
439
+ /** The cycle's governance verb (#790), when it stamps one. */
440
+ verb?: GovernanceVerb;
416
441
  /** Scope id this result is for (e.g. an org login). */
417
442
  org: string;
418
443
  counts: { create: number; update: number; delete: number };
@@ -535,6 +560,11 @@ export async function runReconcile<TClient, TConfig, TLive, TScope = unknown>(
535
560
  }
536
561
 
537
562
  const changeSet = diffFn(scopeId, desired, live, diffOptions);
563
+ // Stamp the cycle's governance verb (#790) onto entries that don't
564
+ // carry one, so provider diffs stay verb-unaware.
565
+ if (cycle.verb) {
566
+ for (const e of changeSet.entries) e.verb ??= cycle.verb;
567
+ }
538
568
  const guardrailResult = guardrails(changeSet, live);
539
569
 
540
570
  const counts = { create: 0, update: 0, delete: 0 };
@@ -542,6 +572,7 @@ export async function runReconcile<TClient, TConfig, TLive, TScope = unknown>(
542
572
 
543
573
  const cycleResult: CycleResult = {
544
574
  name: cycle.name,
575
+ verb: cycle.verb,
545
576
  org: scopeId,
546
577
  counts,
547
578
  guardrails: guardrailResult,
@@ -0,0 +1,25 @@
1
+ import { buildGraph, collectExpressions, type ExpressionRefs } from "../graph";
2
+ import type { Hcl2JsonTree, TfGraph } from "../types";
3
+
4
+ /**
5
+ * Test-only stand-in for the hcl2json expression AST (`parse.ts`'s
6
+ * `resolveExpressionRefs`). Fixture expressions are simple `${<accessor>}`
7
+ * templates, so each one's accessor list is just its interpolation bodies.
8
+ * Production never takes this path — `parseTerraformDir` resolves every
9
+ * expression through `getReferencesInExpression`.
10
+ */
11
+ export function fixtureExprRefs(tree: Hcl2JsonTree): ExpressionRefs {
12
+ const refs = new Map<string, string[]>();
13
+ for (const expr of collectExpressions(tree)) {
14
+ refs.set(
15
+ expr,
16
+ [...expr.matchAll(/\$\{([^}]+)\}/g)].map((m) => m[1].trim()),
17
+ );
18
+ }
19
+ return refs;
20
+ }
21
+
22
+ /** `buildGraph` over a hand-written fixture tree, accessors derived per {@link fixtureExprRefs}. */
23
+ export function buildFixtureGraph(tree: Hcl2JsonTree): TfGraph {
24
+ return buildGraph(tree, fixtureExprRefs(tree));
25
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, test, expect } from "vitest";
2
- import { adoptFromState, canAdoptFromState, supportedStateAdoptionTypes } from "./adopt-state";
2
+ import { adoptFromState, canAdoptFromState, supportedStateAdoptionTypes, type DeferredParam } from "./adopt-state";
3
3
  import type { StateResource } from "./state";
4
4
 
5
5
  describe("adoptFromState", () => {
@@ -45,6 +45,39 @@ describe("adoptFromState", () => {
45
45
  expect(out.content).toContain("RetentionInDays: 30");
46
46
  });
47
47
 
48
+ test("a deferred input renders as a params reference, not the state literal (#998)", () => {
49
+ const subnet: StateResource = {
50
+ type: "aws_subnet",
51
+ name: "a",
52
+ attributes: { id: "subnet-0aa", vpc_id: "vpc-0abc", cidr_block: "10.0.1.0/24" },
53
+ };
54
+ const params: DeferredParam[] = [
55
+ { name: "vpc_id", tfAttr: "vpc_id", survivor: "aws_vpc.main", attrs: ["id"], default: "vpc-0abc" },
56
+ ];
57
+ const out = adoptFromState(subnet, params)!;
58
+ expect(out.parameterized).toEqual(["vpc_id"]);
59
+ expect(out.content).toContain('import { params } from "@intentius/chant/params";');
60
+ expect(out.content).toContain("VpcId: params.vpc_id as string,");
61
+ expect(out.content).not.toContain('VpcId: "vpc-0abc"');
62
+ // Non-deferred props keep their state literals.
63
+ expect(out.content).toContain('CidrBlock: "10.0.1.0/24"');
64
+ });
65
+
66
+ test("a deferred input on an unmapped attribute leaves the source alone", () => {
67
+ const lambda: StateResource = {
68
+ type: "aws_lambda_function",
69
+ name: "api",
70
+ attributes: { id: "myapp-api", function_name: "myapp-api", environment: [{ variables: { B: "b" } }] },
71
+ };
72
+ const params: DeferredParam[] = [
73
+ { name: "environment", tfAttr: "environment", survivor: "aws_s3_bucket.assets", attrs: ["bucket"] },
74
+ ];
75
+ const out = adoptFromState(lambda, params)!;
76
+ expect(out.parameterized).toEqual([]);
77
+ expect(out.content).not.toContain("@intentius/chant/params");
78
+ expect(out.content).toContain('FunctionName: "myapp-api"');
79
+ });
80
+
48
81
  test("canAdoptFromState gates on a known native constructor", () => {
49
82
  expect(canAdoptFromState("aws_s3_bucket")).toBe(true);
50
83
  expect(canAdoptFromState("random_pet")).toBe(false);