@aws/ml-container-creator 0.2.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 (143) hide show
  1. package/LICENSE +202 -0
  2. package/LICENSE-THIRD-PARTY +68620 -0
  3. package/NOTICE +2 -0
  4. package/README.md +106 -0
  5. package/bin/cli.js +365 -0
  6. package/config/defaults.json +32 -0
  7. package/config/presets/transformers-djl.json +26 -0
  8. package/config/presets/transformers-gpu.json +24 -0
  9. package/config/presets/transformers-lmi.json +27 -0
  10. package/package.json +129 -0
  11. package/servers/README.md +419 -0
  12. package/servers/base-image-picker/catalogs/model-servers.json +1191 -0
  13. package/servers/base-image-picker/catalogs/python-slim.json +38 -0
  14. package/servers/base-image-picker/catalogs/triton-backends.json +51 -0
  15. package/servers/base-image-picker/catalogs/triton.json +38 -0
  16. package/servers/base-image-picker/index.js +495 -0
  17. package/servers/base-image-picker/manifest.json +17 -0
  18. package/servers/base-image-picker/package.json +15 -0
  19. package/servers/hyperpod-cluster-picker/LICENSE +202 -0
  20. package/servers/hyperpod-cluster-picker/index.js +424 -0
  21. package/servers/hyperpod-cluster-picker/manifest.json +14 -0
  22. package/servers/hyperpod-cluster-picker/package.json +17 -0
  23. package/servers/instance-recommender/LICENSE +202 -0
  24. package/servers/instance-recommender/catalogs/instances.json +852 -0
  25. package/servers/instance-recommender/index.js +284 -0
  26. package/servers/instance-recommender/manifest.json +16 -0
  27. package/servers/instance-recommender/package.json +15 -0
  28. package/servers/lib/LICENSE +202 -0
  29. package/servers/lib/bedrock-client.js +160 -0
  30. package/servers/lib/custom-validators.js +46 -0
  31. package/servers/lib/dynamic-resolver.js +36 -0
  32. package/servers/lib/package.json +11 -0
  33. package/servers/lib/schemas/image-catalog.schema.json +185 -0
  34. package/servers/lib/schemas/instances.schema.json +124 -0
  35. package/servers/lib/schemas/manifest.schema.json +64 -0
  36. package/servers/lib/schemas/model-catalog.schema.json +91 -0
  37. package/servers/lib/schemas/regions.schema.json +26 -0
  38. package/servers/lib/schemas/triton-backends.schema.json +51 -0
  39. package/servers/model-picker/catalogs/jumpstart-public.json +66 -0
  40. package/servers/model-picker/catalogs/popular-diffusors.json +88 -0
  41. package/servers/model-picker/catalogs/popular-transformers.json +226 -0
  42. package/servers/model-picker/index.js +1693 -0
  43. package/servers/model-picker/manifest.json +18 -0
  44. package/servers/model-picker/package.json +20 -0
  45. package/servers/region-picker/LICENSE +202 -0
  46. package/servers/region-picker/catalogs/regions.json +263 -0
  47. package/servers/region-picker/index.js +230 -0
  48. package/servers/region-picker/manifest.json +16 -0
  49. package/servers/region-picker/package.json +15 -0
  50. package/src/app.js +1007 -0
  51. package/src/copy-tpl.js +77 -0
  52. package/src/lib/accelerator-validator.js +39 -0
  53. package/src/lib/asset-manager.js +385 -0
  54. package/src/lib/aws-profile-parser.js +181 -0
  55. package/src/lib/bootstrap-command-handler.js +1647 -0
  56. package/src/lib/bootstrap-config.js +238 -0
  57. package/src/lib/ci-register-helpers.js +124 -0
  58. package/src/lib/ci-report-helpers.js +158 -0
  59. package/src/lib/ci-stage-helpers.js +268 -0
  60. package/src/lib/cli-handler.js +529 -0
  61. package/src/lib/comment-generator.js +544 -0
  62. package/src/lib/community-reports-validator.js +91 -0
  63. package/src/lib/config-manager.js +2106 -0
  64. package/src/lib/configuration-exporter.js +204 -0
  65. package/src/lib/configuration-manager.js +695 -0
  66. package/src/lib/configuration-matcher.js +221 -0
  67. package/src/lib/cpu-validator.js +36 -0
  68. package/src/lib/cuda-validator.js +57 -0
  69. package/src/lib/deployment-config-resolver.js +103 -0
  70. package/src/lib/deployment-entry-schema.js +125 -0
  71. package/src/lib/deployment-registry.js +598 -0
  72. package/src/lib/docker-introspection-validator.js +51 -0
  73. package/src/lib/engine-prefix-resolver.js +60 -0
  74. package/src/lib/huggingface-client.js +172 -0
  75. package/src/lib/key-value-parser.js +37 -0
  76. package/src/lib/known-flags-validator.js +200 -0
  77. package/src/lib/manifest-cli.js +280 -0
  78. package/src/lib/mcp-client.js +303 -0
  79. package/src/lib/mcp-command-handler.js +532 -0
  80. package/src/lib/neuron-validator.js +80 -0
  81. package/src/lib/parameter-schema-validator.js +284 -0
  82. package/src/lib/prompt-runner.js +1349 -0
  83. package/src/lib/prompts.js +1138 -0
  84. package/src/lib/registry-command-handler.js +519 -0
  85. package/src/lib/registry-loader.js +198 -0
  86. package/src/lib/rocm-validator.js +80 -0
  87. package/src/lib/schema-validator.js +157 -0
  88. package/src/lib/sensitive-redactor.js +59 -0
  89. package/src/lib/template-engine.js +156 -0
  90. package/src/lib/template-manager.js +341 -0
  91. package/src/lib/validation-engine.js +314 -0
  92. package/src/prompt-adapter.js +63 -0
  93. package/templates/Dockerfile +300 -0
  94. package/templates/IAM_PERMISSIONS.md +84 -0
  95. package/templates/MIGRATION.md +488 -0
  96. package/templates/PROJECT_README.md +439 -0
  97. package/templates/TEMPLATE_SYSTEM.md +243 -0
  98. package/templates/buildspec.yml +64 -0
  99. package/templates/code/chat_template.jinja +1 -0
  100. package/templates/code/flask/gunicorn_config.py +35 -0
  101. package/templates/code/flask/wsgi.py +10 -0
  102. package/templates/code/model_handler.py +387 -0
  103. package/templates/code/serve +300 -0
  104. package/templates/code/serve.py +175 -0
  105. package/templates/code/serving.properties +105 -0
  106. package/templates/code/start_server.py +39 -0
  107. package/templates/code/start_server.sh +39 -0
  108. package/templates/diffusors/Dockerfile +72 -0
  109. package/templates/diffusors/patch_image_api.py +35 -0
  110. package/templates/diffusors/serve +115 -0
  111. package/templates/diffusors/start_server.sh +114 -0
  112. package/templates/do/.gitkeep +1 -0
  113. package/templates/do/README.md +541 -0
  114. package/templates/do/build +83 -0
  115. package/templates/do/ci +681 -0
  116. package/templates/do/clean +811 -0
  117. package/templates/do/config +260 -0
  118. package/templates/do/deploy +1560 -0
  119. package/templates/do/export +306 -0
  120. package/templates/do/logs +319 -0
  121. package/templates/do/manifest +12 -0
  122. package/templates/do/push +119 -0
  123. package/templates/do/register +580 -0
  124. package/templates/do/run +113 -0
  125. package/templates/do/submit +417 -0
  126. package/templates/do/test +1147 -0
  127. package/templates/hyperpod/configmap.yaml +24 -0
  128. package/templates/hyperpod/deployment.yaml +71 -0
  129. package/templates/hyperpod/pvc.yaml +42 -0
  130. package/templates/hyperpod/service.yaml +17 -0
  131. package/templates/nginx-diffusors.conf +74 -0
  132. package/templates/nginx-predictors.conf +47 -0
  133. package/templates/nginx-tensorrt.conf +74 -0
  134. package/templates/requirements.txt +61 -0
  135. package/templates/sample_model/test_inference.py +123 -0
  136. package/templates/sample_model/train_abalone.py +252 -0
  137. package/templates/test/test_endpoint.sh +79 -0
  138. package/templates/test/test_local_image.sh +80 -0
  139. package/templates/test/test_model_handler.py +180 -0
  140. package/templates/triton/Dockerfile +128 -0
  141. package/templates/triton/config.pbtxt +163 -0
  142. package/templates/triton/model.py +130 -0
  143. package/templates/triton/requirements.txt +11 -0
@@ -0,0 +1,238 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * Bootstrap Configuration
6
+ *
7
+ * Handles reading, writing, and managing the bootstrap configuration file
8
+ * at ~/.ml-container-creator/config.json. Supports named profiles for
9
+ * multiple AWS environment configurations.
10
+ *
11
+ * Config file format:
12
+ * {
13
+ * "activeProfile": "default",
14
+ * "profiles": {
15
+ * "default": {
16
+ * "awsProfile": "default",
17
+ * "awsRegion": "us-east-1",
18
+ * "accountId": "111111111111",
19
+ * "roleArn": "arn:aws:iam::111111111111:role/mlcc-sagemaker-execution-role",
20
+ * "ecrRepositoryName": "ml-container-creator",
21
+ * "asyncS3Bucket": "...",
22
+ * "batchS3Bucket": "...",
23
+ * "ciInfraProvisioned": false,
24
+ * "ciTableName": "mlcc-ci-table"
25
+ * }
26
+ * }
27
+ * }
28
+ *
29
+ * Optional CI fields (added by bootstrap --ci):
30
+ * - ciInfraProvisioned (boolean): Whether CI harness infrastructure has been deployed. Defaults to false.
31
+ * - ciTableName (string): Name of the DynamoDB CI table. Defaults to "mlcc-ci-table".
32
+ */
33
+
34
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
35
+ import { dirname, join } from 'node:path'
36
+ import { homedir } from 'node:os'
37
+
38
+ export default class BootstrapConfig {
39
+ /**
40
+ * @param {string} [configPath] - Absolute path to the config JSON file.
41
+ * Defaults to ~/.ml-container-creator/config.json
42
+ */
43
+ constructor(configPath) {
44
+ this.configPath = configPath || join(homedir(), '.ml-container-creator', 'config.json')
45
+ }
46
+
47
+ /**
48
+ * Read the config file and return the parsed config object.
49
+ *
50
+ * @returns {{ activeProfile: string, profiles: Object }|null} The config object, or null if file doesn't exist
51
+ */
52
+ read() {
53
+ if (!existsSync(this.configPath)) {
54
+ return null
55
+ }
56
+
57
+ const raw = readFileSync(this.configPath, 'utf8')
58
+ return JSON.parse(raw)
59
+ }
60
+
61
+ /**
62
+ * Write a config object to the config file.
63
+ * Creates the parent directory if it doesn't exist.
64
+ * Uses 2-space indentation and a trailing newline.
65
+ *
66
+ * @param {Object} config - The config object to write
67
+ */
68
+ write(config) {
69
+ const dir = dirname(this.configPath)
70
+ if (!existsSync(dir)) {
71
+ mkdirSync(dir, { recursive: true })
72
+ }
73
+
74
+ writeFileSync(this.configPath, JSON.stringify(config, null, 2) + '\n')
75
+ }
76
+
77
+ /**
78
+ * Check whether the config file exists.
79
+ *
80
+ * @returns {boolean} true if the config file exists
81
+ */
82
+ exists() {
83
+ return existsSync(this.configPath)
84
+ }
85
+
86
+ /**
87
+ * Get the active profile name and its config.
88
+ *
89
+ * @returns {{ name: string, config: Object }|null} The active profile, or null if no active profile
90
+ */
91
+ getActiveProfile() {
92
+ const config = this.read()
93
+ if (!config || !config.activeProfile || !config.profiles) {
94
+ return null
95
+ }
96
+
97
+ const profileConfig = config.profiles[config.activeProfile]
98
+ if (!profileConfig) {
99
+ return null
100
+ }
101
+
102
+ return { name: config.activeProfile, config: profileConfig }
103
+ }
104
+
105
+ /**
106
+ * Get a specific profile's config by name.
107
+ *
108
+ * @param {string} name - The profile name
109
+ * @returns {Object|null} The profile config object, or null if not found
110
+ */
111
+ getProfile(name) {
112
+ const config = this.read()
113
+ if (!config || !config.profiles) {
114
+ return null
115
+ }
116
+
117
+ return config.profiles[name] || null
118
+ }
119
+
120
+ /**
121
+ * Get a specific profile's config with CI defaults applied.
122
+ * Ensures profiles created before CI integration still work by
123
+ * providing graceful defaults for missing CI fields.
124
+ *
125
+ * @param {string} name - The profile name
126
+ * @returns {Object|null} The profile config with CI defaults, or null if not found
127
+ */
128
+ getProfileWithDefaults(name) {
129
+ const profile = this.getProfile(name)
130
+ if (!profile) {
131
+ return null
132
+ }
133
+
134
+ return {
135
+ ciInfraProvisioned: false,
136
+ ciTableName: 'mlcc-ci-table',
137
+ ...profile
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Get the active profile with CI defaults applied.
143
+ * Ensures profiles created before CI integration still work by
144
+ * providing graceful defaults for missing CI fields.
145
+ *
146
+ * @returns {{ name: string, config: Object }|null} The active profile with CI defaults, or null
147
+ */
148
+ getActiveProfileWithDefaults() {
149
+ const active = this.getActiveProfile()
150
+ if (!active) {
151
+ return null
152
+ }
153
+
154
+ return {
155
+ name: active.name,
156
+ config: {
157
+ ciInfraProvisioned: false,
158
+ ciTableName: 'mlcc-ci-table',
159
+ ...active.config
160
+ }
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Create or update a profile in the config.
166
+ * Sets the given profile as the active profile and writes the config.
167
+ *
168
+ * @param {string} name - The profile name
169
+ * @param {Object} profileData - The profile configuration data
170
+ */
171
+ setProfile(name, profileData) {
172
+ let config = this.read()
173
+ if (!config) {
174
+ config = { activeProfile: null, profiles: {} }
175
+ }
176
+ if (!config.profiles) {
177
+ config.profiles = {}
178
+ }
179
+
180
+ config.profiles[name] = profileData
181
+ config.activeProfile = name
182
+ this.write(config)
183
+ }
184
+
185
+ /**
186
+ * Remove a profile from the config.
187
+ * If the removed profile was active, sets activeProfile to the first
188
+ * remaining profile or null if no profiles remain.
189
+ *
190
+ * @param {string} name - The profile name to remove
191
+ * @returns {boolean} true if the profile was removed, false if not found
192
+ */
193
+ removeProfile(name) {
194
+ const config = this.read()
195
+ if (!config || !config.profiles || !config.profiles[name]) {
196
+ return false
197
+ }
198
+
199
+ delete config.profiles[name]
200
+
201
+ if (config.activeProfile === name) {
202
+ const remaining = Object.keys(config.profiles)
203
+ config.activeProfile = remaining.length > 0 ? remaining[0] : null
204
+ }
205
+
206
+ this.write(config)
207
+ return true
208
+ }
209
+
210
+ /**
211
+ * List all profile names in the config.
212
+ *
213
+ * @returns {string[]} Array of profile name strings
214
+ */
215
+ listProfiles() {
216
+ const config = this.read()
217
+ if (!config || !config.profiles) {
218
+ return []
219
+ }
220
+
221
+ return Object.keys(config.profiles)
222
+ }
223
+
224
+ /**
225
+ * Set the active profile by name and write the config.
226
+ *
227
+ * @param {string} name - The profile name to set as active
228
+ */
229
+ setActiveProfile(name) {
230
+ const config = this.read()
231
+ if (!config) {
232
+ return
233
+ }
234
+
235
+ config.activeProfile = name
236
+ this.write(config)
237
+ }
238
+ }
@@ -0,0 +1,124 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * CI Register Helpers
6
+ *
7
+ * Extracted logic from the `do/register` bash template into testable
8
+ * JavaScript functions. These functions mirror the bash implementations
9
+ * for configId hashing, CI record building, and record default handling.
10
+ *
11
+ * Used by unit and property-based tests to validate CI registration
12
+ * behavior without executing the bash template directly.
13
+ */
14
+
15
+ import { createHash } from 'node:crypto'
16
+
17
+ /**
18
+ * Compute a deterministic configId from canonical deployment fields.
19
+ *
20
+ * Mirrors the bash logic:
21
+ * echo -n "${DEPLOYMENT_CONFIG}:${MODEL_NAME:-none}:${INSTANCE_TYPE}:${AWS_REGION}:${DEPLOYMENT_TARGET}" \
22
+ * | sha256sum | cut -c1-16
23
+ *
24
+ * @param {string} deploymentConfig - e.g. "transformers-vllm"
25
+ * @param {string} modelName - e.g. "meta-llama/Llama-2-7b-chat-hf", defaults to "none"
26
+ * @param {string} instanceType - e.g. "ml.g5.xlarge"
27
+ * @param {string} region - e.g. "us-east-1"
28
+ * @param {string} deploymentTarget - e.g. "managed-inference"
29
+ * @returns {string} 16-character lowercase hex string
30
+ */
31
+ export function computeConfigId(deploymentConfig, modelName, instanceType, region, deploymentTarget) {
32
+ const input = `${deploymentConfig}:${modelName || 'none'}:${instanceType}:${region}:${deploymentTarget}`
33
+ const hash = createHash('sha256').update(input).digest('hex')
34
+ return hash.slice(0, 16)
35
+ }
36
+
37
+ /**
38
+ * Build a CI DynamoDB record structure from registration inputs.
39
+ *
40
+ * Mirrors the `write_ci_record` function in the bash template.
41
+ *
42
+ * @param {string} configId - The computed configId (16-char hex)
43
+ * @param {string} configJson - Compact JSON string of the full configuration
44
+ * @param {object} promotedAttrs - Promoted top-level attributes
45
+ * @param {string} promotedAttrs.deploymentConfig - e.g. "transformers-vllm"
46
+ * @param {string} promotedAttrs.baseImage - e.g. "vllm/vllm-openai:v0.8.5"
47
+ * @param {string} promotedAttrs.baseImageVersion - e.g. "v0.8.5"
48
+ * @param {string} promotedAttrs.projectName - e.g. "test-vllm"
49
+ * @returns {object} DynamoDB item structure (plain JS object, not DynamoDB JSON)
50
+ */
51
+ export function buildCiRecord(configId, configJson, promotedAttrs) {
52
+ const createdAt = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')
53
+ return {
54
+ configId,
55
+ schemaVersion: 1,
56
+ configJson,
57
+ testStatus: 'untested',
58
+ lastTestTimestamp: '1970-01-01T00:00:00Z',
59
+ deploymentConfig: promotedAttrs.deploymentConfig || '',
60
+ baseImage: promotedAttrs.baseImage || '',
61
+ baseImageVersion: promotedAttrs.baseImageVersion || '',
62
+ projectName: promotedAttrs.projectName || '',
63
+ createdAt
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Apply default values for missing attributes on a CI record.
69
+ *
70
+ * Ensures consumers handle records written under older schemaVersions
71
+ * gracefully (Requirement 2.7).
72
+ *
73
+ * @param {object} record - A CI record that may have missing attributes
74
+ * @returns {object} The record with defaults applied (mutates and returns same object)
75
+ */
76
+ export function applyRecordDefaults(record) {
77
+ if (record.schemaVersion === undefined || record.schemaVersion === null) {
78
+ record.schemaVersion = 1
79
+ }
80
+ if (!record.testStatus) {
81
+ record.testStatus = 'untested'
82
+ }
83
+ if (!record.lastTestTimestamp) {
84
+ record.lastTestTimestamp = '1970-01-01T00:00:00Z'
85
+ }
86
+ if (!record.buildStrategy) {
87
+ record.buildStrategy = 'codebuild-submit'
88
+ }
89
+ if (!record.stageResults) {
90
+ record.stageResults = {}
91
+ }
92
+ if (!record.errorMessage && record.errorMessage !== '') {
93
+ record.errorMessage = ''
94
+ }
95
+ if (!record.deploymentConfig) {
96
+ record.deploymentConfig = ''
97
+ }
98
+ if (!record.baseImage) {
99
+ record.baseImage = ''
100
+ }
101
+ if (!record.baseImageVersion) {
102
+ record.baseImageVersion = ''
103
+ }
104
+ if (!record.projectName) {
105
+ record.projectName = ''
106
+ }
107
+ return record
108
+ }
109
+
110
+ /**
111
+ * Extract the baseImageVersion from a base image string.
112
+ *
113
+ * Mirrors the bash logic:
114
+ * case "${promoted_base_image}" in *:*) promoted_base_image_version="${promoted_base_image##*:}" ;; esac
115
+ *
116
+ * @param {string} baseImage - e.g. "vllm/vllm-openai:v0.8.5"
117
+ * @returns {string} The version tag, or empty string if no tag present
118
+ */
119
+ export function extractBaseImageVersion(baseImage) {
120
+ if (!baseImage || !baseImage.includes(':')) {
121
+ return ''
122
+ }
123
+ return baseImage.split(':').pop()
124
+ }
@@ -0,0 +1,158 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * CI Report Helpers
6
+ *
7
+ * Extracted logic from the `do/ci report` bash template into testable
8
+ * JavaScript functions. These functions mirror the bash implementations
9
+ * for coverage report generation, regression detection, and coverage
10
+ * arithmetic.
11
+ *
12
+ * Used by unit and property-based tests to validate CI report behavior
13
+ * without executing the bash template directly.
14
+ */
15
+
16
+ /**
17
+ * The 15 known deployment configurations across 4 architectures.
18
+ */
19
+ export const KNOWN_DEPLOYMENT_CONFIGS = [
20
+ 'transformers-vllm',
21
+ 'transformers-sglang',
22
+ 'transformers-lmi',
23
+ 'transformers-djl',
24
+ 'transformers-tensorrt-llm',
25
+ 'http-flask',
26
+ 'http-fastapi',
27
+ 'http-nginx',
28
+ 'triton-fil',
29
+ 'triton-python',
30
+ 'triton-onnx',
31
+ 'triton-tensorrt',
32
+ 'diffusors-vllm',
33
+ 'diffusors-sglang',
34
+ 'diffusors-comfyui'
35
+ ]
36
+
37
+ /**
38
+ * Group an array of CI records by their deploymentConfig field.
39
+ *
40
+ * @param {object[]} records - Array of CI_Record objects
41
+ * @returns {Map<string, object[]>} Map from deploymentConfig to array of records
42
+ */
43
+ export function groupByDeploymentConfig(records) {
44
+ const groups = new Map()
45
+ for (const record of records) {
46
+ const key = record.deploymentConfig || ''
47
+ if (!groups.has(key)) {
48
+ groups.set(key, [])
49
+ }
50
+ groups.get(key).push(record)
51
+ }
52
+ return groups
53
+ }
54
+
55
+ /**
56
+ * Detect regressions — deployment configs that transitioned from pass to fail-*.
57
+ *
58
+ * A regression is defined as a record whose testStatus starts with 'fail-'
59
+ * AND whose previousTestStatus was 'pass'. Records that were never 'pass'
60
+ * or that transition from one failure to another are NOT regressions.
61
+ *
62
+ * @param {object[]} records - Array of CI_Record objects, each with testStatus and optionally previousTestStatus
63
+ * @returns {object[]} Array of records that are regressions
64
+ */
65
+ export function detectRegressions(records) {
66
+ return records.filter(record => {
67
+ const current = record.testStatus || ''
68
+ const previous = record.previousTestStatus || ''
69
+ return current.startsWith('fail-') && previous === 'pass'
70
+ })
71
+ }
72
+
73
+ /**
74
+ * Compute a full coverage report from CI records and the known config list.
75
+ *
76
+ * @param {object[]} records - Array of CI_Record objects from the CI_Table
77
+ * @param {string[]} knownConfigs - Array of known deployment configuration names
78
+ * @returns {object} Coverage report with summary statistics
79
+ * @returns {number} return.total - Total number of known configs
80
+ * @returns {number} return.tested - Number of configs with at least one CI_Record
81
+ * @returns {number} return.passing - Number of configs where latest testStatus is 'pass'
82
+ * @returns {number} return.failing - Number of configs where latest testStatus starts with 'fail-'
83
+ * @returns {number} return.untested - Number of known configs with no CI_Record
84
+ * @returns {number} return.coveragePercent - (tested / total) * 100, rounded to 1 decimal
85
+ * @returns {object[]} return.configurations - Per-config status details
86
+ * @returns {object[]} return.regressions - Records flagged as regressions
87
+ * @returns {string[]} return.untestedConfigs - Known configs with no CI_Record
88
+ */
89
+ export function computeCoverageReport(records, knownConfigs) {
90
+ const grouped = groupByDeploymentConfig(records)
91
+
92
+ // Determine which known configs have been tested
93
+ const testedConfigSet = new Set()
94
+ const passingSet = new Set()
95
+ const failingSet = new Set()
96
+
97
+ const configurations = []
98
+
99
+ for (const config of knownConfigs) {
100
+ const configRecords = grouped.get(config) || []
101
+ if (configRecords.length === 0) {
102
+ configurations.push({
103
+ deploymentConfig: config,
104
+ status: 'untested',
105
+ recordCount: 0
106
+ })
107
+ continue
108
+ }
109
+
110
+ testedConfigSet.add(config)
111
+
112
+ // Use the most recent record (by lastTestTimestamp) to determine status
113
+ const sorted = [...configRecords].sort((a, b) => {
114
+ const tsA = a.lastTestTimestamp || '1970-01-01T00:00:00Z'
115
+ const tsB = b.lastTestTimestamp || '1970-01-01T00:00:00Z'
116
+ return tsB.localeCompare(tsA)
117
+ })
118
+ const latest = sorted[0]
119
+ const status = latest.testStatus || 'untested'
120
+
121
+ if (status === 'pass') {
122
+ passingSet.add(config)
123
+ } else if (status.startsWith('fail-')) {
124
+ failingSet.add(config)
125
+ }
126
+
127
+ configurations.push({
128
+ deploymentConfig: config,
129
+ status,
130
+ recordCount: configRecords.length,
131
+ latestRecord: latest
132
+ })
133
+ }
134
+
135
+ const total = knownConfigs.length
136
+ const tested = testedConfigSet.size
137
+ const untested = total - tested
138
+ const passing = passingSet.size
139
+ const failing = failingSet.size
140
+ const coveragePercent = total > 0
141
+ ? Math.round((tested / total) * 1000) / 10
142
+ : 0
143
+
144
+ const untestedConfigs = knownConfigs.filter(c => !testedConfigSet.has(c))
145
+ const regressions = detectRegressions(records)
146
+
147
+ return {
148
+ total,
149
+ tested,
150
+ passing,
151
+ failing,
152
+ untested,
153
+ coveragePercent,
154
+ configurations,
155
+ regressions,
156
+ untestedConfigs
157
+ }
158
+ }