@intentius/chant-lexicon-cpln 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (266) hide show
  1. package/README.md +85 -0
  2. package/dist/api.d.ts +77 -0
  3. package/dist/api.d.ts.map +1 -0
  4. package/dist/codegen/docs-cli.d.ts +3 -0
  5. package/dist/codegen/docs-cli.d.ts.map +1 -0
  6. package/dist/codegen/docs.d.ts +17 -0
  7. package/dist/codegen/docs.d.ts.map +1 -0
  8. package/dist/codegen/generate-cli.d.ts +6 -0
  9. package/dist/codegen/generate-cli.d.ts.map +1 -0
  10. package/dist/codegen/generate-lexicon.d.ts +36 -0
  11. package/dist/codegen/generate-lexicon.d.ts.map +1 -0
  12. package/dist/codegen/generate-typescript.d.ts +11 -0
  13. package/dist/codegen/generate-typescript.d.ts.map +1 -0
  14. package/dist/codegen/generate.d.ts +15 -0
  15. package/dist/codegen/generate.d.ts.map +1 -0
  16. package/dist/codegen/naming.d.ts +30 -0
  17. package/dist/codegen/naming.d.ts.map +1 -0
  18. package/dist/codegen/package.d.ts +17 -0
  19. package/dist/codegen/package.d.ts.map +1 -0
  20. package/dist/composites/cron-job.d.ts +60 -0
  21. package/dist/composites/cron-job.d.ts.map +1 -0
  22. package/dist/composites/gvc-environment.d.ts +47 -0
  23. package/dist/composites/gvc-environment.d.ts.map +1 -0
  24. package/dist/composites/index.d.ts +13 -0
  25. package/dist/composites/index.d.ts.map +1 -0
  26. package/dist/composites/public-domain.d.ts +68 -0
  27. package/dist/composites/public-domain.d.ts.map +1 -0
  28. package/dist/composites/secret-access.d.ts +78 -0
  29. package/dist/composites/secret-access.d.ts.map +1 -0
  30. package/dist/composites/serverless-service.d.ts +78 -0
  31. package/dist/composites/serverless-service.d.ts.map +1 -0
  32. package/dist/composites/stateful-service.d.ts +79 -0
  33. package/dist/composites/stateful-service.d.ts.map +1 -0
  34. package/dist/coverage.d.ts +59 -0
  35. package/dist/coverage.d.ts.map +1 -0
  36. package/dist/describe-resources.d.ts +71 -0
  37. package/dist/describe-resources.d.ts.map +1 -0
  38. package/dist/detect.d.ts +14 -0
  39. package/dist/detect.d.ts.map +1 -0
  40. package/dist/entity-props.d.ts +34 -0
  41. package/dist/entity-props.d.ts.map +1 -0
  42. package/dist/generated/index.d.ts +151 -0
  43. package/dist/generated/index.d.ts.map +1 -0
  44. package/dist/generated/runtime.d.ts +5 -0
  45. package/dist/generated/runtime.d.ts.map +1 -0
  46. package/dist/index.d.ts +8 -0
  47. package/dist/index.d.ts.map +1 -0
  48. package/dist/init-templates.d.ts +16 -0
  49. package/dist/init-templates.d.ts.map +1 -0
  50. package/dist/integrity.json +35 -0
  51. package/dist/kinds.d.ts +70 -0
  52. package/dist/kinds.d.ts.map +1 -0
  53. package/dist/lint/audit-catalog.d.ts +19 -0
  54. package/dist/lint/audit-catalog.d.ts.map +1 -0
  55. package/dist/lint/post-synth/cpl010-unrestricted-outbound.d.ts +12 -0
  56. package/dist/lint/post-synth/cpl010-unrestricted-outbound.d.ts.map +1 -0
  57. package/dist/lint/post-synth/cpl011-internal-firewall-scope.d.ts +11 -0
  58. package/dist/lint/post-synth/cpl011-internal-firewall-scope.d.ts.map +1 -0
  59. package/dist/lint/post-synth/cpl012-no-literal-credential-env.d.ts +11 -0
  60. package/dist/lint/post-synth/cpl012-no-literal-credential-env.d.ts.map +1 -0
  61. package/dist/lint/post-synth/cpl013-qualified-identity-principal.d.ts +11 -0
  62. package/dist/lint/post-synth/cpl013-qualified-identity-principal.d.ts.map +1 -0
  63. package/dist/lint/post-synth/cpl014-qualified-secret-ref.d.ts +14 -0
  64. package/dist/lint/post-synth/cpl014-qualified-secret-ref.d.ts.map +1 -0
  65. package/dist/lint/post-synth/cpl020-serverless-single-port.d.ts +10 -0
  66. package/dist/lint/post-synth/cpl020-serverless-single-port.d.ts.map +1 -0
  67. package/dist/lint/post-synth/cpl021-cron-job-shape.d.ts +11 -0
  68. package/dist/lint/post-synth/cpl021-cron-job-shape.d.ts.map +1 -0
  69. package/dist/lint/post-synth/cpl022-container-ports.d.ts +10 -0
  70. package/dist/lint/post-synth/cpl022-container-ports.d.ts.map +1 -0
  71. package/dist/lint/post-synth/cpl023-container-resources.d.ts +12 -0
  72. package/dist/lint/post-synth/cpl023-container-resources.d.ts.map +1 -0
  73. package/dist/lint/post-synth/cpl024-probe-handler.d.ts +10 -0
  74. package/dist/lint/post-synth/cpl024-probe-handler.d.ts.map +1 -0
  75. package/dist/lint/post-synth/cpl025-autoscaling-shape.d.ts +11 -0
  76. package/dist/lint/post-synth/cpl025-autoscaling-shape.d.ts.map +1 -0
  77. package/dist/lint/post-synth/cpl026-scale-to-zero.d.ts +11 -0
  78. package/dist/lint/post-synth/cpl026-scale-to-zero.d.ts.map +1 -0
  79. package/dist/lint/post-synth/cpl027-capacity-ai-conflict.d.ts +13 -0
  80. package/dist/lint/post-synth/cpl027-capacity-ai-conflict.d.ts.map +1 -0
  81. package/dist/lint/post-synth/cpl028-volume-set.d.ts +3 -0
  82. package/dist/lint/post-synth/cpl028-volume-set.d.ts.map +1 -0
  83. package/dist/lint/post-synth/cpl029-link-targets.d.ts +3 -0
  84. package/dist/lint/post-synth/cpl029-link-targets.d.ts.map +1 -0
  85. package/dist/lint/post-synth/cpl030-domain-routing.d.ts +3 -0
  86. package/dist/lint/post-synth/cpl030-domain-routing.d.ts.map +1 -0
  87. package/dist/lint/post-synth/cpl040-pinned-image.d.ts +11 -0
  88. package/dist/lint/post-synth/cpl040-pinned-image.d.ts.map +1 -0
  89. package/dist/lint/post-synth/cpl041-image-reference-form.d.ts +12 -0
  90. package/dist/lint/post-synth/cpl041-image-reference-form.d.ts.map +1 -0
  91. package/dist/lint/post-synth/cpl042-gvc-placement.d.ts +10 -0
  92. package/dist/lint/post-synth/cpl042-gvc-placement.d.ts.map +1 -0
  93. package/dist/lint/post-synth/cpl043-policy-scope.d.ts +16 -0
  94. package/dist/lint/post-synth/cpl043-policy-scope.d.ts.map +1 -0
  95. package/dist/lint/post-synth/helpers.d.ts +45 -0
  96. package/dist/lint/post-synth/helpers.d.ts.map +1 -0
  97. package/dist/lint/post-synth/index.d.ts +3 -0
  98. package/dist/lint/post-synth/index.d.ts.map +1 -0
  99. package/dist/lint/rules/cpl001-no-secret-literals.d.ts +3 -0
  100. package/dist/lint/rules/cpl001-no-secret-literals.d.ts.map +1 -0
  101. package/dist/lint/rules/cpl002-prefer-resource-reference.d.ts +3 -0
  102. package/dist/lint/rules/cpl002-prefer-resource-reference.d.ts.map +1 -0
  103. package/dist/lint/rules/index.d.ts +6 -0
  104. package/dist/lint/rules/index.d.ts.map +1 -0
  105. package/dist/lsp/completions.d.ts +7 -0
  106. package/dist/lsp/completions.d.ts.map +1 -0
  107. package/dist/lsp/hover.d.ts +4 -0
  108. package/dist/lsp/hover.d.ts.map +1 -0
  109. package/dist/manifest.json +6 -0
  110. package/dist/meta.json +2110 -0
  111. package/dist/okf/index.md +39 -0
  112. package/dist/okf/rules/CPL001.md +22 -0
  113. package/dist/okf/rules/CPL002.md +22 -0
  114. package/dist/okf/rules/CPL010.md +15 -0
  115. package/dist/okf/rules/CPL011.md +15 -0
  116. package/dist/okf/rules/CPL012.md +15 -0
  117. package/dist/okf/rules/CPL013.md +15 -0
  118. package/dist/okf/rules/CPL014.md +16 -0
  119. package/dist/okf/rules/CPL020.md +11 -0
  120. package/dist/okf/rules/CPL021.md +15 -0
  121. package/dist/okf/rules/CPL022.md +15 -0
  122. package/dist/okf/rules/CPL023.md +15 -0
  123. package/dist/okf/rules/CPL024.md +15 -0
  124. package/dist/okf/rules/CPL025.md +15 -0
  125. package/dist/okf/rules/CPL026.md +15 -0
  126. package/dist/okf/rules/CPL027.md +15 -0
  127. package/dist/okf/rules/CPL028.md +15 -0
  128. package/dist/okf/rules/CPL029.md +15 -0
  129. package/dist/okf/rules/CPL030.md +16 -0
  130. package/dist/okf/rules/CPL040.md +15 -0
  131. package/dist/okf/rules/CPL041.md +15 -0
  132. package/dist/okf/rules/CPL042.md +11 -0
  133. package/dist/okf/rules/CPL043.md +15 -0
  134. package/dist/okf/types/Domain.md +22 -0
  135. package/dist/okf/types/Gvc.md +21 -0
  136. package/dist/okf/types/Identity.md +29 -0
  137. package/dist/okf/types/IpSet.md +21 -0
  138. package/dist/okf/types/Policy.md +28 -0
  139. package/dist/okf/types/Secret.md +23 -0
  140. package/dist/okf/types/VolumeSet.md +22 -0
  141. package/dist/okf/types/Workload.md +38 -0
  142. package/dist/ownership.d.ts +23 -0
  143. package/dist/ownership.d.ts.map +1 -0
  144. package/dist/package-cli.d.ts +3 -0
  145. package/dist/package-cli.d.ts.map +1 -0
  146. package/dist/plugin.d.ts +3 -0
  147. package/dist/plugin.d.ts.map +1 -0
  148. package/dist/reference-catalog.d.ts +24 -0
  149. package/dist/reference-catalog.d.ts.map +1 -0
  150. package/dist/rules/cpl001-no-secret-literals.ts +83 -0
  151. package/dist/rules/cpl002-prefer-resource-reference.ts +71 -0
  152. package/dist/rules/cpl010-unrestricted-outbound.ts +41 -0
  153. package/dist/rules/cpl011-internal-firewall-scope.ts +38 -0
  154. package/dist/rules/cpl012-no-literal-credential-env.ts +58 -0
  155. package/dist/rules/cpl013-qualified-identity-principal.ts +45 -0
  156. package/dist/rules/cpl014-qualified-secret-ref.ts +54 -0
  157. package/dist/rules/cpl020-serverless-single-port.ts +58 -0
  158. package/dist/rules/cpl021-cron-job-shape.ts +64 -0
  159. package/dist/rules/cpl022-container-ports.ts +64 -0
  160. package/dist/rules/cpl023-container-resources.ts +87 -0
  161. package/dist/rules/cpl024-probe-handler.ts +51 -0
  162. package/dist/rules/cpl025-autoscaling-shape.ts +100 -0
  163. package/dist/rules/cpl026-scale-to-zero.ts +67 -0
  164. package/dist/rules/cpl027-capacity-ai-conflict.ts +78 -0
  165. package/dist/rules/cpl028-volume-set.ts +183 -0
  166. package/dist/rules/cpl029-link-targets.ts +155 -0
  167. package/dist/rules/cpl030-domain-routing.ts +137 -0
  168. package/dist/rules/cpl040-pinned-image.ts +48 -0
  169. package/dist/rules/cpl041-image-reference-form.ts +60 -0
  170. package/dist/rules/cpl042-gvc-placement.ts +57 -0
  171. package/dist/rules/cpl043-policy-scope.ts +0 -0
  172. package/dist/rules/helpers.ts +114 -0
  173. package/dist/serializer.d.ts +69 -0
  174. package/dist/serializer.d.ts.map +1 -0
  175. package/dist/skills/chant-cpln-secrets.md +110 -0
  176. package/dist/skills/chant-cpln-workloads.md +78 -0
  177. package/dist/skills/chant-cpln.md +103 -0
  178. package/dist/spec/fetch.d.ts +43 -0
  179. package/dist/spec/fetch.d.ts.map +1 -0
  180. package/dist/spec/parse.d.ts +79 -0
  181. package/dist/spec/parse.d.ts.map +1 -0
  182. package/dist/spec/snapshot-cli.d.ts +18 -0
  183. package/dist/spec/snapshot-cli.d.ts.map +1 -0
  184. package/dist/types/index.d.ts +1649 -0
  185. package/dist/validate-cli.d.ts +3 -0
  186. package/dist/validate-cli.d.ts.map +1 -0
  187. package/dist/validate.d.ts +26 -0
  188. package/dist/validate.d.ts.map +1 -0
  189. package/package.json +74 -0
  190. package/src/api.ts +134 -0
  191. package/src/codegen/docs-cli.ts +7 -0
  192. package/src/codegen/docs.ts +94 -0
  193. package/src/codegen/generate-cli.ts +34 -0
  194. package/src/codegen/generate-lexicon.ts +88 -0
  195. package/src/codegen/generate-typescript.ts +71 -0
  196. package/src/codegen/generate.ts +110 -0
  197. package/src/codegen/naming.ts +61 -0
  198. package/src/codegen/package.ts +35 -0
  199. package/src/composites/composites.test.ts +252 -0
  200. package/src/composites/cron-job.ts +124 -0
  201. package/src/composites/gvc-environment.ts +104 -0
  202. package/src/composites/index.ts +17 -0
  203. package/src/composites/public-domain.ts +144 -0
  204. package/src/composites/secret-access.ts +136 -0
  205. package/src/composites/serverless-service.ts +143 -0
  206. package/src/composites/stateful-service.ts +179 -0
  207. package/src/coverage.ts +149 -0
  208. package/src/describe-resources.test.ts +252 -0
  209. package/src/describe-resources.ts +310 -0
  210. package/src/detect.ts +58 -0
  211. package/src/entity-props.ts +98 -0
  212. package/src/generated/index.d.ts +1649 -0
  213. package/src/generated/index.ts +156 -0
  214. package/src/generated/lexicon-cpln.json +2110 -0
  215. package/src/generated/runtime.ts +4 -0
  216. package/src/index.ts +31 -0
  217. package/src/init-templates.ts +131 -0
  218. package/src/kinds.ts +173 -0
  219. package/src/lint/audit-catalog.ts +230 -0
  220. package/src/lint/post-synth/cpl010-unrestricted-outbound.ts +41 -0
  221. package/src/lint/post-synth/cpl011-internal-firewall-scope.ts +38 -0
  222. package/src/lint/post-synth/cpl012-no-literal-credential-env.ts +58 -0
  223. package/src/lint/post-synth/cpl013-qualified-identity-principal.ts +45 -0
  224. package/src/lint/post-synth/cpl014-qualified-secret-ref.ts +54 -0
  225. package/src/lint/post-synth/cpl020-serverless-single-port.ts +58 -0
  226. package/src/lint/post-synth/cpl021-cron-job-shape.ts +64 -0
  227. package/src/lint/post-synth/cpl022-container-ports.ts +64 -0
  228. package/src/lint/post-synth/cpl023-container-resources.ts +87 -0
  229. package/src/lint/post-synth/cpl024-probe-handler.ts +51 -0
  230. package/src/lint/post-synth/cpl025-autoscaling-shape.ts +100 -0
  231. package/src/lint/post-synth/cpl026-scale-to-zero.ts +67 -0
  232. package/src/lint/post-synth/cpl027-capacity-ai-conflict.ts +78 -0
  233. package/src/lint/post-synth/cpl028-volume-set.ts +183 -0
  234. package/src/lint/post-synth/cpl029-link-targets.ts +155 -0
  235. package/src/lint/post-synth/cpl030-domain-routing.ts +137 -0
  236. package/src/lint/post-synth/cpl040-pinned-image.ts +48 -0
  237. package/src/lint/post-synth/cpl041-image-reference-form.ts +60 -0
  238. package/src/lint/post-synth/cpl042-gvc-placement.ts +57 -0
  239. package/src/lint/post-synth/cpl043-policy-scope.ts +0 -0
  240. package/src/lint/post-synth/helpers.ts +114 -0
  241. package/src/lint/post-synth/index.ts +45 -0
  242. package/src/lint/post-synth/post-synth.test.ts +737 -0
  243. package/src/lint/rules/cpl001-no-secret-literals.ts +83 -0
  244. package/src/lint/rules/cpl002-prefer-resource-reference.ts +71 -0
  245. package/src/lint/rules/index.ts +9 -0
  246. package/src/lint/rules/rules.test.ts +108 -0
  247. package/src/lsp/completions.test.ts +44 -0
  248. package/src/lsp/completions.ts +27 -0
  249. package/src/lsp/hover.test.ts +53 -0
  250. package/src/lsp/hover.ts +130 -0
  251. package/src/ownership.ts +28 -0
  252. package/src/package-cli.ts +25 -0
  253. package/src/plugin.test.ts +131 -0
  254. package/src/plugin.ts +187 -0
  255. package/src/reference-catalog.ts +110 -0
  256. package/src/serializer.test.ts +233 -0
  257. package/src/serializer.ts +253 -0
  258. package/src/skills/chant-cpln-secrets.md +110 -0
  259. package/src/skills/chant-cpln-workloads.md +78 -0
  260. package/src/skills/chant-cpln.md +103 -0
  261. package/src/spec/cpln-openapi.snapshot.json +10651 -0
  262. package/src/spec/fetch.ts +77 -0
  263. package/src/spec/parse.ts +451 -0
  264. package/src/spec/snapshot-cli.ts +71 -0
  265. package/src/validate-cli.ts +8 -0
  266. package/src/validate.ts +102 -0
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Coverage analysis for the cpln lexicon.
3
+ *
4
+ * The shared `computeCoverage` measures CloudFormation-shaped dimensions
5
+ * (lifecycle flags, return attributes, extension constraints) that an
6
+ * eight-kind OpenAPI lexicon has no analog for — it would report 0% on
7
+ * everything and mean nothing. What cpln needs to know is two different things:
8
+ *
9
+ * 1. **Kind coverage** — which of the API's kinds are modelled as declarables
10
+ * and which are deliberately not, with the reason, so an unmodelled kind
11
+ * is a decision on record rather than an omission nobody noticed.
12
+ * 2. **Property coverage** — for each modelled kind, how much of its writable
13
+ * surface is typed rather than loosened to `Record<string, unknown>`. A
14
+ * drop here means upstream reshaped something and the parser quietly
15
+ * stopped following it.
16
+ *
17
+ * Both are measured against the spec the generator actually reads, so running
18
+ * this after `just generate` compares like with like.
19
+ */
20
+
21
+ import { fetchSchemas, SPEC_KEY } from "./spec/fetch";
22
+ import { parseCplnOpenAPI } from "./spec/parse";
23
+ import { KINDS } from "./kinds";
24
+
25
+ /**
26
+ * Kinds the API exposes that this lexicon does not model, and why.
27
+ *
28
+ * "First pass" is a real reason, not a placeholder: each of these is a row in
29
+ * `kinds.ts` away, and listing them keeps the gap visible instead of leaving a
30
+ * reader to infer it from the absence.
31
+ */
32
+ export const UNMODELLED_KINDS: Record<string, string> = {
33
+ agent: "first pass — org infrastructure, not the workload surface",
34
+ auditctx: "first pass — governance",
35
+ cloudaccount: "first pass — org infrastructure",
36
+ group: "first pass — principals",
37
+ serviceaccount: "first pass — principals",
38
+ user: "first pass — principals; membership is invite-driven, not declarative",
39
+ image: "read-mostly — images are produced by `cpln image build`, not declared",
40
+ location: "read-mostly — locations are platform-provided; only `patch` is writable",
41
+ org: "not declarable — an org is immutable and cannot be deleted",
42
+ quota: "read-only",
43
+ mk8s: "first pass — a very large nested spec that deserves its own pass",
44
+ task: "read-only — an operation record, not estate",
45
+ deployment: "read-only — a workload's runtime status",
46
+ };
47
+
48
+ export interface KindCoverage {
49
+ kind: string;
50
+ /** Writable top-level properties on the resource. */
51
+ properties: number;
52
+ /** Property types generated beneath it. */
53
+ propertyTypes: number;
54
+ /** Properties resolving to a loose map rather than a typed shape. */
55
+ loose: number;
56
+ }
57
+
58
+ export interface CplnCoverage {
59
+ modelled: number;
60
+ unmodelled: number;
61
+ propertyTypes: number;
62
+ kinds: KindCoverage[];
63
+ /** Share of writable properties across all kinds that are typed. */
64
+ typedRatio: number;
65
+ }
66
+
67
+ const LOOSE = /^Record<string, (unknown|any)>/;
68
+
69
+ /** Compute coverage from the spec the generator reads. */
70
+ export async function computeCplnCoverage(options?: { force?: boolean }): Promise<CplnCoverage> {
71
+ const schemas = await fetchSchemas({ force: options?.force });
72
+ const raw = schemas.get(SPEC_KEY);
73
+ if (!raw) throw new Error(`spec fetch returned no ${SPEC_KEY} entry`);
74
+
75
+ const results = parseCplnOpenAPI(raw);
76
+ const resources = results.filter((r) => !r.isProperty);
77
+ const propertyTypes = results.filter((r) => r.isProperty);
78
+
79
+ const kinds: KindCoverage[] = [];
80
+ let typed = 0;
81
+ let total = 0;
82
+
83
+ for (const kind of KINDS) {
84
+ const resource = resources.find((r) => r.resource.typeName === kind.typeName);
85
+ if (!resource) continue;
86
+
87
+ const properties = resource.resource.properties;
88
+ const loose = properties.filter((p) => LOOSE.test(p.tsType)).length;
89
+ // Property types whose name is derived from this kind's class name.
90
+ const owned = propertyTypes.filter((p) =>
91
+ p.resource.typeName.split("::").pop()!.startsWith(kind.className),
92
+ ).length;
93
+
94
+ kinds.push({ kind: kind.kind, properties: properties.length, propertyTypes: owned, loose });
95
+ typed += properties.length - loose;
96
+ total += properties.length;
97
+ }
98
+
99
+ return {
100
+ modelled: resources.length,
101
+ unmodelled: Object.keys(UNMODELLED_KINDS).length,
102
+ propertyTypes: propertyTypes.length,
103
+ kinds,
104
+ typedRatio: total === 0 ? 0 : typed / total,
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Print the coverage report. Exits non-zero when `minOverall` is set and the
110
+ * typed ratio falls below it, so CI can gate on the surface not silently
111
+ * loosening.
112
+ */
113
+ export async function analyzeCplnCoverage(options?: {
114
+ verbose?: boolean;
115
+ force?: boolean;
116
+ minOverall?: number;
117
+ }): Promise<CplnCoverage> {
118
+ const coverage = await computeCplnCoverage({ force: options?.force });
119
+
120
+ console.error(
121
+ `cpln coverage: ${coverage.modelled} kinds modelled, ${coverage.unmodelled} not, ` +
122
+ `${coverage.propertyTypes} property types, ${(coverage.typedRatio * 100).toFixed(0)}% of writable ` +
123
+ `properties typed`,
124
+ );
125
+
126
+ for (const kind of coverage.kinds) {
127
+ const detail = kind.loose > 0 ? ` (${kind.loose} loose)` : "";
128
+ console.error(
129
+ ` ${kind.kind.padEnd(11)} ${String(kind.properties).padStart(2)} properties, ` +
130
+ `${String(kind.propertyTypes).padStart(3)} property types${detail}`,
131
+ );
132
+ }
133
+
134
+ if (options?.verbose) {
135
+ console.error("\nNot modelled:");
136
+ for (const [kind, reason] of Object.entries(UNMODELLED_KINDS)) {
137
+ console.error(` ${kind.padEnd(15)} ${reason}`);
138
+ }
139
+ }
140
+
141
+ if (options?.minOverall !== undefined && coverage.typedRatio * 100 < options.minOverall) {
142
+ console.error(
143
+ `\nTyped ratio ${(coverage.typedRatio * 100).toFixed(0)}% is below the ${options.minOverall}% minimum.`,
144
+ );
145
+ process.exitCode = 1;
146
+ }
147
+
148
+ return coverage;
149
+ }
@@ -0,0 +1,252 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { normalizeObservation } from "@intentius/chant/observation";
3
+ import { describeResources, gvcOf, referenceAttributes } from "./describe-resources";
4
+ import { authorization, type CplnHttp, type CplnResource } from "./api";
5
+ import { kindByName } from "./kinds";
6
+ import { CPLN_TAG_OWNERSHIP_KEYS } from "./ownership";
7
+
8
+ const OWNED = { [CPLN_TAG_OWNERSHIP_KEYS.managedBy]: "chant", [CPLN_TAG_OWNERSHIP_KEYS.stack]: "demo" };
9
+
10
+ /** A stub transport backed by a path → body map. Records what was requested. */
11
+ function stubHttp(routes: Record<string, unknown>): CplnHttp & { calls: string[] } {
12
+ const calls: string[] = [];
13
+ return {
14
+ calls,
15
+ async get(path: string) {
16
+ calls.push(path);
17
+ return routes[path];
18
+ },
19
+ };
20
+ }
21
+
22
+ const config = { endpoint: "https://api.cpln.io", org: "acme", token: "sa-key" };
23
+
24
+ function declared(entries: Array<[string, string, Record<string, unknown>]>) {
25
+ return {
26
+ environment: "prod",
27
+ buildOutput: "",
28
+ entityNames: entries.map(([name]) => name),
29
+ entities: new Map(entries.map(([name, entityType, props]) => [name, { entityType, props }])),
30
+ };
31
+ }
32
+
33
+ describe("describeResources", () => {
34
+ it("reports a live resource as present and owned", async () => {
35
+ const http = stubHttp({
36
+ "/org/acme/gvc": { items: [{ id: "abc", name: "prod", tags: OWNED, lastModified: "2026-01-01T00:00:00Z" }] },
37
+ });
38
+
39
+ const result = await describeResources({
40
+ ...declared([["gvc", "Cpln::Core::Gvc", { name: "prod" }]]),
41
+ config,
42
+ http,
43
+ });
44
+
45
+ const { resources, unobserved } = normalizeObservation(result);
46
+ expect(unobserved).toEqual({});
47
+ expect(resources.gvc.type).toBe("Cpln::Core::Gvc");
48
+ expect(resources.gvc.physicalId).toBe("abc");
49
+ expect(resources.gvc.ownership).toBe("owned");
50
+ expect(resources.gvc.lastUpdated).toBe("2026-01-01T00:00:00Z");
51
+ });
52
+
53
+ it("reports an unmarked resource as foreign, not owned", async () => {
54
+ const http = stubHttp({ "/org/acme/gvc": { items: [{ name: "prod" }] } });
55
+ const result = await describeResources({
56
+ ...declared([["gvc", "Cpln::Core::Gvc", { name: "prod" }]]),
57
+ config,
58
+ http,
59
+ });
60
+
61
+ expect(normalizeObservation(result).resources.gvc.ownership).toBe("foreign");
62
+ });
63
+
64
+ it("filters a foreign resource to a typed NOT-OBSERVED under `owned`", async () => {
65
+ // Not absent — absence would classify as a create, which is exactly the
66
+ // wrong answer for something that exists and belongs to someone else.
67
+ const http = stubHttp({ "/org/acme/gvc": { items: [{ name: "prod" }] } });
68
+ const result = await describeResources({
69
+ ...declared([["gvc", "Cpln::Core::Gvc", { name: "prod" }]]),
70
+ owned: true,
71
+ config,
72
+ http,
73
+ });
74
+
75
+ const { resources, unobserved } = normalizeObservation(result);
76
+ expect(resources).toEqual({});
77
+ expect(unobserved.gvc.reason).toBe("filtered");
78
+ expect(unobserved.gvc.detail).toContain(CPLN_TAG_OWNERSHIP_KEYS.managedBy);
79
+ });
80
+
81
+ it("reports a missing resource as absent", async () => {
82
+ const http = stubHttp({ "/org/acme/gvc": { items: [] } });
83
+ const result = await describeResources({
84
+ ...declared([["gvc", "Cpln::Core::Gvc", { name: "prod" }]]),
85
+ config,
86
+ http,
87
+ });
88
+
89
+ const { resources, unobserved } = normalizeObservation(result);
90
+ expect(resources).toEqual({});
91
+ expect(unobserved).toEqual({});
92
+ });
93
+
94
+ it("lists each kind once no matter how many entities of it are declared", async () => {
95
+ const http = stubHttp({
96
+ "/org/acme/workload": {
97
+ items: [
98
+ { name: "a", links: [{ rel: "gvc", href: "/org/acme/gvc/prod" }] },
99
+ { name: "b", links: [{ rel: "gvc", href: "/org/acme/gvc/prod" }] },
100
+ { name: "c", links: [{ rel: "gvc", href: "/org/acme/gvc/prod" }] },
101
+ ],
102
+ },
103
+ });
104
+
105
+ await describeResources({
106
+ ...declared([
107
+ ["a", "Cpln::Core::Workload", { name: "a", gvc: "prod" }],
108
+ ["b", "Cpln::Core::Workload", { name: "b", gvc: "prod" }],
109
+ ["c", "Cpln::Core::Workload", { name: "c", gvc: "prod" }],
110
+ ]),
111
+ config,
112
+ http,
113
+ });
114
+
115
+ expect(http.calls).toEqual(["/org/acme/workload"]);
116
+ });
117
+
118
+ it("does not match a same-named resource in another GVC", async () => {
119
+ // The org-wide rollup is what makes one request per kind possible, and it
120
+ // is also what makes this collision reachable.
121
+ const http = stubHttp({
122
+ "/org/acme/workload": { items: [{ name: "web", links: [{ rel: "gvc", href: "/org/acme/gvc/staging" }] }] },
123
+ });
124
+
125
+ const result = await describeResources({
126
+ ...declared([["web", "Cpln::Core::Workload", { name: "web", gvc: "prod" }]]),
127
+ config,
128
+ http,
129
+ });
130
+
131
+ expect(normalizeObservation(result).resources).toEqual({});
132
+ });
133
+
134
+ it("reports missing credentials as no-credentials, not read-failed", async () => {
135
+ const result = await describeResources({
136
+ ...declared([["gvc", "Cpln::Core::Gvc", { name: "prod" }]]),
137
+ config: { org: "", token: "" },
138
+ http: stubHttp({}),
139
+ });
140
+
141
+ expect(normalizeObservation(result).unobserved.gvc.reason).toBe("no-credentials");
142
+ });
143
+
144
+ it("degrades a read failure to read-failed rather than a silent absence", async () => {
145
+ const http: CplnHttp = {
146
+ async get() {
147
+ throw new Error("502 Bad Gateway");
148
+ },
149
+ };
150
+
151
+ const result = await describeResources({
152
+ ...declared([["gvc", "Cpln::Core::Gvc", { name: "prod" }]]),
153
+ config,
154
+ http,
155
+ });
156
+
157
+ const { unobserved } = normalizeObservation(result);
158
+ expect(unobserved.gvc.reason).toBe("read-failed");
159
+ expect(unobserved.gvc.detail).toContain("502");
160
+ });
161
+
162
+ it("marks an unmodelled entity type unsupported-kind", async () => {
163
+ const result = await describeResources({
164
+ ...declared([["x", "Cpln::Core::Group", { name: "x" }]]),
165
+ config,
166
+ http: stubHttp({}),
167
+ });
168
+
169
+ expect(normalizeObservation(result).unobserved.x.reason).toBe("unsupported-kind");
170
+ });
171
+ });
172
+
173
+ describe("gvcOf", () => {
174
+ it("reads the GVC from the links relation", () => {
175
+ expect(gvcOf({ links: [{ rel: "gvc", href: "/org/acme/gvc/prod" }] })).toBe("prod");
176
+ });
177
+
178
+ it("falls back to a string gvc field", () => {
179
+ expect(gvcOf({ gvc: "prod" } as CplnResource)).toBe("prod");
180
+ });
181
+
182
+ it("falls back to an object gvc field", () => {
183
+ expect(gvcOf({ gvc: { name: "prod" } } as CplnResource)).toBe("prod");
184
+ });
185
+
186
+ it("returns undefined when there is nothing to read", () => {
187
+ expect(gvcOf({})).toBeUndefined();
188
+ });
189
+ });
190
+
191
+ describe("referenceAttributes", () => {
192
+ it("resolves links down to the names the graph matches on", () => {
193
+ const refs = referenceAttributes(kindByName("workload")!, {
194
+ name: "web",
195
+ links: [{ rel: "gvc", href: "/org/acme/gvc/prod" }],
196
+ spec: {
197
+ identityLink: "//gvc/prod/identity/api",
198
+ containers: [{ name: "m", volumes: [{ uri: "cpln://volumeset/data", path: "/data" }] }],
199
+ },
200
+ });
201
+
202
+ expect(refs.gvc).toBe("prod");
203
+ expect(refs.identity).toBe("api");
204
+ expect(refs.volumeSets).toEqual(["data"]);
205
+ });
206
+
207
+ it("collects a domain's route targets", () => {
208
+ const refs = referenceAttributes(kindByName("domain")!, {
209
+ name: "api.example.com",
210
+ spec: {
211
+ ports: [
212
+ {
213
+ number: 443,
214
+ routes: [
215
+ { prefix: "/a", workloadLink: "//gvc/prod/workload/a" },
216
+ { prefix: "/b", workloadLink: "//gvc/prod/workload/b" },
217
+ ],
218
+ },
219
+ ],
220
+ },
221
+ });
222
+
223
+ expect(refs.workloads).toEqual(["a", "b"]);
224
+ });
225
+
226
+ it("resolves a GVC's pull secrets", () => {
227
+ const refs = referenceAttributes(kindByName("gvc")!, {
228
+ name: "prod",
229
+ spec: { pullSecretLinks: ["//secret/a", "//secret/b"] },
230
+ });
231
+
232
+ expect(refs.pullSecrets).toEqual(["a", "b"]);
233
+ });
234
+
235
+ it("omits relations that are absent rather than emitting empties", () => {
236
+ expect(referenceAttributes(kindByName("secret")!, { name: "db" })).toEqual({});
237
+ });
238
+ });
239
+
240
+ describe("authorization", () => {
241
+ it("sends a service account key bare", () => {
242
+ expect(authorization("abc123")).toBe("abc123");
243
+ });
244
+
245
+ it("sends a JWT as a bearer token", () => {
246
+ expect(authorization("eyJhbGciOi.payload.sig")).toBe("Bearer eyJhbGciOi.payload.sig");
247
+ });
248
+
249
+ it("passes an already-prefixed value through", () => {
250
+ expect(authorization("Bearer abc")).toBe("Bearer abc");
251
+ });
252
+ });