@intentius/chant 0.41.20 → 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 (125) hide show
  1. package/dist/cli/commands/carve-apply.d.ts +9 -0
  2. package/dist/cli/commands/carve-apply.d.ts.map +1 -1
  3. package/dist/cli/commands/carve-bridge.d.ts +7 -0
  4. package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
  5. package/dist/cli/commands/carve-emit.d.ts +7 -0
  6. package/dist/cli/commands/carve-emit.d.ts.map +1 -1
  7. package/dist/cli/commands/init.d.ts +2 -0
  8. package/dist/cli/commands/init.d.ts.map +1 -1
  9. package/dist/cli/handlers/carve-apply.d.ts.map +1 -1
  10. package/dist/cli/handlers/components.d.ts.map +1 -1
  11. package/dist/cli/handlers/explain.d.ts +11 -0
  12. package/dist/cli/handlers/explain.d.ts.map +1 -0
  13. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  14. package/dist/cli/main.d.ts.map +1 -1
  15. package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
  16. package/dist/cli/registry.d.ts +2 -0
  17. package/dist/cli/registry.d.ts.map +1 -1
  18. package/dist/codegen/docs-rule-scanning.d.ts +11 -0
  19. package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
  20. package/dist/codegen/okf-lexicon.d.ts +36 -0
  21. package/dist/codegen/okf-lexicon.d.ts.map +1 -0
  22. package/dist/codegen/package.d.ts +2 -1
  23. package/dist/codegen/package.d.ts.map +1 -1
  24. package/dist/components/verbs/sbom.d.ts +1 -1
  25. package/dist/components/verbs/sbom.d.ts.map +1 -1
  26. package/dist/components/verbs/vuln-gate.d.ts +15 -4
  27. package/dist/components/verbs/vuln-gate.d.ts.map +1 -1
  28. package/dist/components/verbs/vuln-scan.d.ts +18 -6
  29. package/dist/components/verbs/vuln-scan.d.ts.map +1 -1
  30. package/dist/config.d.ts +12 -0
  31. package/dist/config.d.ts.map +1 -1
  32. package/dist/deep-observation.d.ts +13 -0
  33. package/dist/deep-observation.d.ts.map +1 -1
  34. package/dist/lexicon.d.ts +3 -0
  35. package/dist/lexicon.d.ts.map +1 -1
  36. package/dist/lifecycle/observe.d.ts.map +1 -1
  37. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  38. package/dist/okf.d.ts +57 -0
  39. package/dist/okf.d.ts.map +1 -0
  40. package/dist/terraform/__fixtures__/build-graph.d.ts +13 -0
  41. package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -0
  42. package/dist/terraform/adopt-state.d.ts +33 -1
  43. package/dist/terraform/adopt-state.d.ts.map +1 -1
  44. package/dist/terraform/bridge.d.ts +7 -1
  45. package/dist/terraform/bridge.d.ts.map +1 -1
  46. package/dist/terraform/carve.d.ts +12 -0
  47. package/dist/terraform/carve.d.ts.map +1 -1
  48. package/dist/terraform/excise.d.ts +31 -0
  49. package/dist/terraform/excise.d.ts.map +1 -0
  50. package/dist/terraform/graduate.d.ts +12 -0
  51. package/dist/terraform/graduate.d.ts.map +1 -1
  52. package/dist/terraform/graph.d.ts +35 -5
  53. package/dist/terraform/graph.d.ts.map +1 -1
  54. package/dist/terraform/manifest.d.ts +89 -0
  55. package/dist/terraform/manifest.d.ts.map +1 -0
  56. package/dist/terraform/parse.d.ts +9 -4
  57. package/dist/terraform/parse.d.ts.map +1 -1
  58. package/dist/terraform/types.d.ts +7 -0
  59. package/dist/terraform/types.d.ts.map +1 -1
  60. package/dist/terraform/unified-diff.d.ts +11 -0
  61. package/dist/terraform/unified-diff.d.ts.map +1 -0
  62. package/package.json +1 -1
  63. package/src/__snapshots__/okf.test.ts.snap +381 -0
  64. package/src/cli/commands/carve-apply.test.ts +94 -2
  65. package/src/cli/commands/carve-apply.ts +62 -11
  66. package/src/cli/commands/carve-bridge.test.ts +82 -4
  67. package/src/cli/commands/carve-bridge.ts +79 -11
  68. package/src/cli/commands/carve-emit-state.test.ts +110 -1
  69. package/src/cli/commands/carve-emit.ts +184 -8
  70. package/src/cli/commands/init.ts +1 -1
  71. package/src/cli/handlers/carve-apply.ts +1 -0
  72. package/src/cli/handlers/components.ts +4 -2
  73. package/src/cli/handlers/explain.test.ts +93 -0
  74. package/src/cli/handlers/explain.ts +60 -0
  75. package/src/cli/handlers/lifecycle.ts +9 -7
  76. package/src/cli/main.ts +21 -5
  77. package/src/cli/mcp/server.test.ts +17 -0
  78. package/src/cli/mcp/tools/explain.ts +15 -3
  79. package/src/cli/registry.ts +2 -0
  80. package/src/codegen/__snapshots__/okf-lexicon.test.ts.snap +170 -0
  81. package/src/codegen/docs-rule-scanning.ts +35 -14
  82. package/src/codegen/okf-lexicon.test.ts +248 -0
  83. package/src/codegen/okf-lexicon.ts +303 -0
  84. package/src/codegen/package.ts +29 -4
  85. package/src/components/config-defaults.test.ts +30 -0
  86. package/src/components/verbs/__fixtures__/grype-with-kev-epss.json +1 -0
  87. package/src/components/verbs/__fixtures__/trivy-with-kev-epss.json +631 -0
  88. package/src/components/verbs/exploitability-roundtrip.test.ts +190 -0
  89. package/src/components/verbs/sbom.ts +1 -1
  90. package/src/components/verbs/vuln-gate.test.ts +144 -1
  91. package/src/components/verbs/vuln-gate.ts +61 -10
  92. package/src/components/verbs/vuln-scan.test.ts +94 -0
  93. package/src/components/verbs/vuln-scan.ts +44 -9
  94. package/src/config.test.ts +21 -0
  95. package/src/config.ts +18 -0
  96. package/src/deep-observation.test.ts +19 -0
  97. package/src/deep-observation.ts +32 -13
  98. package/src/lexicon.ts +3 -0
  99. package/src/lifecycle/observe.test.ts +18 -0
  100. package/src/lifecycle/observe.ts +10 -3
  101. package/src/lifecycle/snapshot.ts +5 -4
  102. package/src/okf.test.ts +169 -0
  103. package/src/okf.ts +308 -0
  104. package/src/terraform/__fixtures__/build-graph.ts +25 -0
  105. package/src/terraform/adopt-state.test.ts +34 -1
  106. package/src/terraform/adopt-state.ts +69 -2
  107. package/src/terraform/bridge.test.ts +38 -14
  108. package/src/terraform/bridge.ts +48 -14
  109. package/src/terraform/carve.test.ts +9 -7
  110. package/src/terraform/carve.ts +0 -0
  111. package/src/terraform/excise.test.ts +92 -0
  112. package/src/terraform/excise.ts +139 -0
  113. package/src/terraform/graduate.test.ts +73 -6
  114. package/src/terraform/graduate.ts +49 -0
  115. package/src/terraform/graph.test.ts +56 -10
  116. package/src/terraform/graph.ts +112 -45
  117. package/src/terraform/manifest.test.ts +105 -0
  118. package/src/terraform/manifest.ts +162 -0
  119. package/src/terraform/parse.test.ts +39 -1
  120. package/src/terraform/parse.ts +36 -9
  121. package/src/terraform/score.test.ts +8 -8
  122. package/src/terraform/state.test.ts +4 -4
  123. package/src/terraform/types.ts +7 -0
  124. package/src/terraform/unified-diff.test.ts +77 -0
  125. package/src/terraform/unified-diff.ts +146 -0
@@ -9,10 +9,10 @@
9
9
  * digest) over re-scanning the image: deterministic (same SBOM -> same
10
10
  * findings), fast, and reuses work already done. Real backends shell out to
11
11
  * `grype`/`trivy` through the injectable `ProcessRunner` (./process-runner.ts),
12
- * exactly like ./tool-sbom-generator.ts's deep-scan backend — tests inject a
13
- * `MockVulnScanner` (./__tests__/mock-vuln-scanner.ts) and never invoke a real
14
- * scanner, network, or vuln DB. The scanner's vuln DB currency is the tool's
15
- * job, not chant's.
12
+ * exactly like ./tool-sbom-generator.ts's deep-scan backend — tests inject an
13
+ * inline fake `VulnScanner` (an object literal with a canned `scan()`, see
14
+ * ./vuln-gate.test.ts) and never invoke a real scanner, network, or vuln DB.
15
+ * The scanner's vuln DB currency is the tool's job, not chant's.
16
16
  */
17
17
 
18
18
  import { writeFileSync } from "node:fs";
@@ -57,6 +57,18 @@ export interface VulnFinding {
57
57
  fixedVersion?: string;
58
58
  /** True when a fix exists (upgradeable) — the beginner-safe default gate blocks only fixable findings, since an unfixable one can't be actioned by bumping. */
59
59
  fixable: boolean;
60
+ /** EPSS score (0.0–1.0): probability of exploitation in the next 30 days. Absent when the scanner did not report one. */
61
+ epss?: number;
62
+ /** EPSS percentile (0.0–1.0) — rank against all scored CVEs. */
63
+ epssPercentile?: number;
64
+ /** Present in CISA's Known Exploited Vulnerabilities catalog. `undefined` means the scanner did not report KEV membership at all — NOT the same conclusion as a reported `false`. Never default this. */
65
+ inKev?: boolean;
66
+ /** When the CVE entered the KEV catalog (ISO date, as the source reports it). */
67
+ kevDateAdded?: string;
68
+ /** KEV remediation due date (ISO date). */
69
+ kevDueDate?: string;
70
+ /** Known use in a ransomware campaign, per KEV. */
71
+ kevRansomware?: boolean;
60
72
  }
61
73
 
62
74
  // ── injectable scanner boundary ──────────────────────────────────────────────
@@ -72,8 +84,8 @@ export interface ScanInput {
72
84
  * Injectable vulnerability-scan boundary — the scan-side analogue of
73
85
  * `SbomGenerator` (./sbom-generator.ts) and `CloudExecutor`
74
86
  * (./cloud-executor.ts). A real implementation shells out to `grype`/`trivy`;
75
- * tests substitute `MockVulnScanner` (./__tests__/mock-vuln-scanner.ts) and
76
- * never touch a real tool, network, or vuln DB.
87
+ * tests substitute an inline fake (an object literal with a canned `scan()`)
88
+ * and never touch a real tool, network, or vuln DB.
77
89
  */
78
90
  export interface VulnScanner {
79
91
  /** Scan an SBOM, returning every known vulnerability it surfaces. */
@@ -83,15 +95,26 @@ export interface VulnScanner {
83
95
  /** Which real CLI scanner a `ProcessRunner`-backed scanner shells out to. */
84
96
  export type ScannerTool = "grype" | "trivy";
85
97
 
86
- /** grype `-o json` output shape (the subset we read). */
98
+ /** grype `-o json` output shape (the subset we read). `epss` and `knownExploited` are omitted (not empty) for a vuln grype has no data on, so absence maps to `undefined`, never `false`/`0`. grype's composite `risk` score is deliberately not read — policy gates on the inputs (EPSS, KEV), not one tool's weighting of them. */
87
99
  interface GrypeOutput {
88
100
  matches?: Array<{
89
- vulnerability?: { id?: string; severity?: string; fix?: { versions?: string[]; state?: string } };
101
+ vulnerability?: {
102
+ id?: string;
103
+ severity?: string;
104
+ fix?: { versions?: string[]; state?: string };
105
+ epss?: Array<{ cve?: string; epss?: number; percentile?: number; date?: string }>;
106
+ knownExploited?: Array<{
107
+ cve?: string;
108
+ dateAdded?: string;
109
+ dueDate?: string;
110
+ knownRansomwareCampaignUse?: string;
111
+ }>;
112
+ };
90
113
  artifact?: { name?: string; version?: string };
91
114
  }>;
92
115
  }
93
116
 
94
- /** trivy `--format json` output shape (the subset we read). */
117
+ /** trivy `--format json` output shape (the subset we read). Trivy (v0.73, __fixtures__/trivy-with-kev-epss.json) reports no KEV/EPSS data in its JSON output, so a trivy-backed finding carries every exploitability field as `undefined` — the honest "not reported" state, not `false`. */
95
118
  interface TrivyOutput {
96
119
  Results?: Array<{
97
120
  Vulnerabilities?: Array<{
@@ -109,6 +132,8 @@ export function parseGrypeOutput(stdout: string): VulnFinding[] {
109
132
  const doc = JSON.parse(stdout) as GrypeOutput;
110
133
  return (doc.matches ?? []).map((m) => {
111
134
  const fixVersions = m.vulnerability?.fix?.versions ?? [];
135
+ const epss = m.vulnerability?.epss?.[0];
136
+ const kev = m.vulnerability?.knownExploited?.[0];
112
137
  return {
113
138
  cveId: m.vulnerability?.id ?? "UNKNOWN",
114
139
  severity: normalizeSeverity(m.vulnerability?.severity),
@@ -116,6 +141,16 @@ export function parseGrypeOutput(stdout: string): VulnFinding[] {
116
141
  installedVersion: m.artifact?.version ?? "",
117
142
  fixedVersion: fixVersions[0],
118
143
  fixable: m.vulnerability?.fix?.state === "fixed" || fixVersions.length > 0,
144
+ epss: epss?.epss,
145
+ epssPercentile: epss?.percentile,
146
+ // grype omits `knownExploited` for a non-KEV vuln, so `undefined` here
147
+ // means "no KEV annotation reported" — never coerced to `false`.
148
+ inKev: kev ? true : undefined,
149
+ kevDateAdded: kev?.dateAdded,
150
+ kevDueDate: kev?.dueDate,
151
+ // KEV's ransomware field is "known"/"unknown" — "unknown" is not "no",
152
+ // so only an explicit "known" becomes `true`; everything else stays unset.
153
+ kevRansomware: kev?.knownRansomwareCampaignUse?.toLowerCase() === "known" ? true : undefined,
119
154
  } satisfies VulnFinding;
120
155
  });
121
156
  }
@@ -239,6 +239,27 @@ describe("environments (#1166 — string or { name, endpoint })", () => {
239
239
  });
240
240
  });
241
241
 
242
+ describe("vulnPolicy exploitability fields (#1466)", () => {
243
+ test("loads the exploitability fields, zero thresholds included", async () => {
244
+ writeFileSync(
245
+ join(TEST_DIR, "chant.config.json"),
246
+ JSON.stringify({ vulnPolicy: { failOnKev: true, failEpssAtOrAbove: 0, warnEpssAtOrAbove: 0.01, exploitabilityFixableOnly: false } }),
247
+ );
248
+
249
+ const result = await loadChantConfig(TEST_DIR);
250
+ expect(result.config.vulnPolicy).toEqual({ failOnKev: true, failEpssAtOrAbove: 0, warnEpssAtOrAbove: 0.01, exploitabilityFixableOnly: false });
251
+ });
252
+
253
+ test("rejects an EPSS threshold outside 0.0–1.0", async () => {
254
+ writeFileSync(
255
+ join(TEST_DIR, "chant.config.json"),
256
+ JSON.stringify({ vulnPolicy: { failEpssAtOrAbove: 1.5 } }),
257
+ );
258
+
259
+ await expect(loadChantConfig(TEST_DIR)).rejects.toThrow(/failEpssAtOrAbove/);
260
+ });
261
+ });
262
+
242
263
  describe("environmentName / environmentNames / environmentEndpoint (#1166)", () => {
243
264
  test("environmentName reduces either form to its name", () => {
244
265
  expect(environmentName("prod")).toBe("prod");
package/src/config.ts CHANGED
@@ -106,6 +106,10 @@ export const ChantConfigSchema = z.object({
106
106
  warnSeverity: z.enum(["critical", "high", "medium", "low", "negligible", "unknown"]).optional(),
107
107
  failOnLicense: z.boolean().optional(),
108
108
  failOnUnknownSeverity: z.boolean().optional(),
109
+ failOnKev: z.boolean().optional(),
110
+ failEpssAtOrAbove: z.number().min(0).max(1).optional(),
111
+ warnEpssAtOrAbove: z.number().min(0).max(1).optional(),
112
+ exploitabilityFixableOnly: z.boolean().optional(),
109
113
  license: z.object({
110
114
  allow: z.array(z.string()).optional(),
111
115
  deny: z.array(z.string()).optional(),
@@ -308,6 +312,14 @@ export interface ChantConfig {
308
312
  failOnLicense?: boolean;
309
313
  /** Block on an `unknown`-severity finding (always warned regardless). Default `false`. */
310
314
  failOnUnknownSeverity?: boolean;
315
+ /** Block any finding in the CISA KEV catalog, regardless of severity. Default `false`. */
316
+ failOnKev?: boolean;
317
+ /** Block when EPSS is at or above this (0.0–1.0). Omit to ignore EPSS. */
318
+ failEpssAtOrAbove?: number;
319
+ /** Warn (not block) at or above this EPSS. Omit to ignore. */
320
+ warnEpssAtOrAbove?: number;
321
+ /** Apply `fixableOnly` to exploitability blocks too. Default `true`. */
322
+ exploitabilityFixableOnly?: boolean;
311
323
  /** License allow/deny lists evaluated against the SBOM's declared licenses. */
312
324
  license?: { allow?: string[]; deny?: string[] };
313
325
  /** Which real scanner a `ProcessRunner`-backed `vuln-gate`/`scan-vulnerabilities` shells out to. Default `"grype"`. Read where the capability/scanner is constructed. */
@@ -560,6 +572,12 @@ export function resolveVulnPolicy(config: ChantConfig): Partial<VulnPolicy> {
560
572
  if (v.warnSeverity) out.warnSeverity = v.warnSeverity;
561
573
  if (v.failOnLicense !== undefined) out.failOnLicense = v.failOnLicense;
562
574
  if (v.failOnUnknownSeverity !== undefined) out.failOnUnknownSeverity = v.failOnUnknownSeverity;
575
+ // `!== undefined`, never truthiness: `failEpssAtOrAbove: 0` (match any scored
576
+ // finding) and an explicit `failOnKev: false` override must both survive.
577
+ if (v.failOnKev !== undefined) out.failOnKev = v.failOnKev;
578
+ if (v.failEpssAtOrAbove !== undefined) out.failEpssAtOrAbove = v.failEpssAtOrAbove;
579
+ if (v.warnEpssAtOrAbove !== undefined) out.warnEpssAtOrAbove = v.warnEpssAtOrAbove;
580
+ if (v.exploitabilityFixableOnly !== undefined) out.exploitabilityFixableOnly = v.exploitabilityFixableOnly;
563
581
  if (v.license) out.license = v.license;
564
582
  return out;
565
583
  }
@@ -75,6 +75,25 @@ describe("normalizeDeepProperties", () => {
75
75
  expect(out.List).toEqual(["x", "y"]);
76
76
  });
77
77
 
78
+ test("a value the unresolved hook claims collapses to UNRESOLVED, and only that value", () => {
79
+ const hooks: DeepNormalizationHooks = {
80
+ unresolved: (n) =>
81
+ n.side === "declared" && typeof n.value === "string" && n.value.startsWith("[") && n.value.endsWith("]"),
82
+ };
83
+ const declared = normalizeDeepProperties(
84
+ { ref: { id: "[resourceId('T', 'x')]" }, name: "n" },
85
+ { entityType: "T", side: "declared", hooks },
86
+ );
87
+ expect(declared).toEqual({ ref: { id: UNRESOLVED }, name: "n" });
88
+ // The hook sees the side, so the live tree — where the same shape is an
89
+ // evaluated value — is untouched.
90
+ const live = normalizeDeepProperties(
91
+ { ref: { id: "[literal]" }, name: "n" },
92
+ { entityType: "T", side: "live", hooks },
93
+ );
94
+ expect(live).toEqual({ ref: { id: "[literal]" }, name: "n" });
95
+ });
96
+
78
97
  test("prunes by hook, and prunes the whole subtree", () => {
79
98
  const hooks: DeepNormalizationHooks = { prune: (n) => n.pattern === "Status" };
80
99
  const out = normalizeDeepProperties(
@@ -253,6 +253,19 @@ export interface DeepNormalizationHooks {
253
253
  * the order the provider returned it.
254
254
  */
255
255
  orderKey?(element: DeepArrayElement): string | undefined;
256
+ /**
257
+ * Return true when this value cannot be known without deploying — it is
258
+ * collapsed to {@link UNRESOLVED}, and the diff skips any path whose declared
259
+ * value is UNRESOLVED, exactly as it already does for a non-JSON intrinsic.
260
+ *
261
+ * The pass collapses class-instance intrinsics (`Fn::Sub`, `Ref`) on its own;
262
+ * this hook exists for the lexicons whose unevaluated references are plain
263
+ * JSON data the pass cannot recognize — an ARM template's
264
+ * `"[resourceId(...)]"` expression string is the motivating case (#1213).
265
+ * Left alone, every such reference diffs against its evaluated live value and
266
+ * reads as permanent drift.
267
+ */
268
+ unresolved?(node: DeepNode): boolean;
256
269
  }
257
270
 
258
271
  /** Everything the pass needs besides the tree itself. */
@@ -371,25 +384,31 @@ export function normalizeDeepProperties(
371
384
  ): Record<string, unknown> {
372
385
  const { entityType, side, hooks, counterpartPaths } = options;
373
386
 
387
+ const nodeOf = (path: string, pattern: string, key: string, value: unknown): DeepNode => ({
388
+ entityType,
389
+ path,
390
+ pattern,
391
+ key,
392
+ value,
393
+ side,
394
+ counterpart: !counterpartPaths
395
+ ? "unknown"
396
+ : counterpartPaths.has(path) || counterpartPaths.has(pattern)
397
+ ? "present"
398
+ : "absent",
399
+ });
400
+
374
401
  const prune = (path: string, pattern: string, key: string, value: unknown): boolean => {
375
402
  if (!hooks?.prune) return false;
376
- return hooks.prune({
377
- entityType,
378
- path,
379
- pattern,
380
- key,
381
- value,
382
- side,
383
- counterpart: !counterpartPaths
384
- ? "unknown"
385
- : counterpartPaths.has(path) || counterpartPaths.has(pattern)
386
- ? "present"
387
- : "absent",
388
- });
403
+ return hooks.prune(nodeOf(path, pattern, key, value));
389
404
  };
390
405
 
391
406
  const normalizeValue = (value: unknown, path: string, pattern: string, key: string): unknown => {
392
407
  if (isSensitiveKey(key)) return MASKED;
408
+ // A value the lexicon says cannot be known without deploying — an
409
+ // expression-string reference — collapses exactly like a class-instance
410
+ // intrinsic does below.
411
+ if (hooks?.unresolved?.(nodeOf(path, pattern, key, value))) return UNRESOLVED;
393
412
  if (isJsonPrimitive(value)) return value;
394
413
 
395
414
  // A PROPERTY-kind Declarable is authored data wearing a class, not an
package/src/lexicon.ts CHANGED
@@ -5,6 +5,7 @@ import type { PostSynthCheck } from "./lint/post-synth";
5
5
  import type { TemplateParser, TemplateIR } from "./import/parser";
6
6
  import type { TypeScriptGenerator } from "./import/generator";
7
7
  import type { ArtifactIntegrity } from "./lexicon-integrity";
8
+ import type { OkfFile } from "./okf";
8
9
  import type { CompletionContext, CompletionItem, HoverContext, HoverInfo, CodeActionContext, CodeAction } from "./lsp/types";
9
10
  import type { McpToolContribution, McpResourceContribution } from "./mcp/types";
10
11
  import type { DriverComponent } from "./components/driver";
@@ -122,6 +123,8 @@ export interface BundleSpec {
122
123
  typesDTS: string;
123
124
  rules: Map<string, string>;
124
125
  skills: Map<string, string>;
126
+ /** OKF knowledge bundle over the registry and rules (#1060), written to dist/okf/. */
127
+ okf?: OkfFile[];
125
128
  integrity?: ArtifactIntegrity;
126
129
  metadata?: PackageMetadata;
127
130
  }
@@ -64,6 +64,24 @@ describe("observeResources", () => {
64
64
  expect(names).toEqual(["web-vpc"]);
65
65
  });
66
66
 
67
+ it("keeps non-resource declarables out of the observation universe", async () => {
68
+ // Outputs, parameters and serializer directives (gcp's defaultAnnotations)
69
+ // have no `props` and no live counterpart — a declared name the reader can
70
+ // never resolve would read as unobserved or missing forever.
71
+ const buildResult = {
72
+ outputs: new Map<string, string>([["aws", "{}"]]),
73
+ entities: new Map<string, unknown>([
74
+ ["web-vpc", { lexicon: "aws", entityType: "AWS::EC2::VPC", props: {} }],
75
+ ["annotations", { lexicon: "aws", entityType: "chant:aws:directive" }],
76
+ ]),
77
+ errors: [],
78
+ } as unknown as BuildResult;
79
+ let names: string[] = [];
80
+ const plugins = [awsPlugin(({ entityNames }) => { names = entityNames; return {}; })];
81
+ await observeResources("prod", plugins, buildResult);
82
+ expect(names).toEqual(["web-vpc"]);
83
+ });
84
+
67
85
  it("collects a throwing plugin into errors instead of failing the whole graph, and reports its entities unobserved (#1089)", async () => {
68
86
  const plugins = [
69
87
  awsPlugin(() => { throw new Error("access denied"); }),
@@ -25,6 +25,7 @@ import {
25
25
  } from "../observation";
26
26
  import { zeroResourcesWarning } from "../live-endpoint";
27
27
  import { unqualifiedKey } from "./identity";
28
+ import { isResourceDeclarable } from "../declarable";
28
29
 
29
30
  export interface ObserveResult {
30
31
  observations: LiveObservation[];
@@ -123,10 +124,16 @@ export async function observeResources(
123
124
  const entities = new Map<string, { entityType: string; props: Record<string, unknown> }>();
124
125
  for (const [name, entity] of buildResult.entities) {
125
126
  if (entity.lexicon !== plugin.name) continue;
127
+ // Only resource declarables have a live counterpart to observe. Outputs,
128
+ // parameters and serializer directives (gcp's `defaultAnnotations`) are
129
+ // build-time inputs — declared, but with nothing in any cloud to compare
130
+ // against, so keeping them in the universe makes every diff report a
131
+ // hole (or worse, a deletion) for a resource that cannot exist.
132
+ if (!isResourceDeclarable(entity)) continue;
126
133
  entityNames.push(name);
127
134
  entities.set(name, {
128
135
  entityType: entity.entityType,
129
- props: ("props" in entity && entity.props != null ? entity.props : {}) as Record<string, unknown>,
136
+ props: (entity.props != null ? entity.props : {}) as Record<string, unknown>,
130
137
  });
131
138
  }
132
139
 
@@ -145,11 +152,11 @@ export async function observeResources(
145
152
  stackEntityNames = [];
146
153
  stackEntities = new Map();
147
154
  for (const [name, entity] of sb.entities) {
148
- if (entity.lexicon !== plugin.name) continue;
155
+ if (entity.lexicon !== plugin.name || !isResourceDeclarable(entity)) continue;
149
156
  stackEntityNames.push(name);
150
157
  stackEntities.set(name, {
151
158
  entityType: entity.entityType,
152
- props: ("props" in entity && entity.props != null ? entity.props : {}) as Record<string, unknown>,
159
+ props: (entity.props != null ? entity.props : {}) as Record<string, unknown>,
153
160
  });
154
161
  }
155
162
  const raw = sb.outputs.get(plugin.name);
@@ -18,6 +18,7 @@ import { formatUnobserved, normalizeObservation, unobservedAll, type UnobservedE
18
18
  // property tree is ever rendered or committed. Two lists would eventually
19
19
  // disagree about what counts as a secret.
20
20
  import { isSensitiveKey } from "../deep-observation";
21
+ import { isResourceDeclarable } from "../declarable";
21
22
 
22
23
  /**
23
24
  * Check for potential sensitive data in resource attributes and return warnings.
@@ -125,13 +126,13 @@ export async function takeSnapshot(
125
126
  const entityNames: string[] = [];
126
127
  const entities = new Map<string, { entityType: string; props: Record<string, unknown> }>();
127
128
  for (const [name, entity] of buildResult.entities) {
128
- if (entity.lexicon === plugin.name) {
129
+ // Resource declarables only — outputs, parameters and serializer
130
+ // directives have no live counterpart (see lifecycle/observe.ts).
131
+ if (entity.lexicon === plugin.name && isResourceDeclarable(entity)) {
129
132
  entityNames.push(name);
130
133
  entities.set(name, {
131
134
  entityType: entity.entityType,
132
- props: ("props" in entity && entity.props != null
133
- ? entity.props
134
- : {}) as Record<string, unknown>,
135
+ props: (entity.props != null ? entity.props : {}) as Record<string, unknown>,
135
136
  });
136
137
  }
137
138
  }
@@ -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
+ });