container-superposition 0.1.7 → 0.1.9

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 (242) hide show
  1. package/README.md +24 -15
  2. package/dist/scripts/init.js +1 -1537
  3. package/dist/scripts/init.js.map +1 -1
  4. package/dist/tool/cli/args.d.ts +20 -0
  5. package/dist/tool/cli/args.d.ts.map +1 -0
  6. package/dist/tool/cli/args.js +325 -0
  7. package/dist/tool/cli/args.js.map +1 -0
  8. package/dist/tool/cli/run.d.ts +2 -0
  9. package/dist/tool/cli/run.d.ts.map +1 -0
  10. package/dist/tool/cli/run.js +318 -0
  11. package/dist/tool/cli/run.js.map +1 -0
  12. package/dist/tool/commands/adopt.js +1 -1
  13. package/dist/tool/commands/adopt.js.map +1 -1
  14. package/dist/tool/commands/doctor.d.ts +1 -0
  15. package/dist/tool/commands/doctor.d.ts.map +1 -1
  16. package/dist/tool/commands/doctor.js +1510 -78
  17. package/dist/tool/commands/doctor.js.map +1 -1
  18. package/dist/tool/commands/explain.d.ts.map +1 -1
  19. package/dist/tool/commands/explain.js +9 -0
  20. package/dist/tool/commands/explain.js.map +1 -1
  21. package/dist/tool/commands/migrate.d.ts +7 -0
  22. package/dist/tool/commands/migrate.d.ts.map +1 -0
  23. package/dist/tool/commands/migrate.js +52 -0
  24. package/dist/tool/commands/migrate.js.map +1 -0
  25. package/dist/tool/questionnaire/answers.d.ts +16 -0
  26. package/dist/tool/questionnaire/answers.d.ts.map +1 -0
  27. package/dist/tool/questionnaire/answers.js +102 -0
  28. package/dist/tool/questionnaire/answers.js.map +1 -0
  29. package/dist/tool/questionnaire/composer.d.ts +6 -4
  30. package/dist/tool/questionnaire/composer.d.ts.map +1 -1
  31. package/dist/tool/questionnaire/composer.js +778 -45
  32. package/dist/tool/questionnaire/composer.js.map +1 -1
  33. package/dist/tool/questionnaire/presets.d.ts +60 -0
  34. package/dist/tool/questionnaire/presets.d.ts.map +1 -0
  35. package/dist/tool/questionnaire/presets.js +165 -0
  36. package/dist/tool/questionnaire/presets.js.map +1 -0
  37. package/dist/tool/questionnaire/questionnaire.d.ts +10 -0
  38. package/dist/tool/questionnaire/questionnaire.d.ts.map +1 -0
  39. package/dist/tool/questionnaire/questionnaire.js +582 -0
  40. package/dist/tool/questionnaire/questionnaire.js.map +1 -0
  41. package/dist/tool/schema/manifest-migrations.d.ts +5 -0
  42. package/dist/tool/schema/manifest-migrations.d.ts.map +1 -1
  43. package/dist/tool/schema/manifest-migrations.js +45 -0
  44. package/dist/tool/schema/manifest-migrations.js.map +1 -1
  45. package/dist/tool/schema/overlay-loader.d.ts.map +1 -1
  46. package/dist/tool/schema/overlay-loader.js +24 -0
  47. package/dist/tool/schema/overlay-loader.js.map +1 -1
  48. package/dist/tool/schema/project-config.d.ts +13 -1
  49. package/dist/tool/schema/project-config.d.ts.map +1 -1
  50. package/dist/tool/schema/project-config.js +188 -10
  51. package/dist/tool/schema/project-config.js.map +1 -1
  52. package/dist/tool/schema/target-rules.d.ts +78 -0
  53. package/dist/tool/schema/target-rules.d.ts.map +1 -0
  54. package/dist/tool/schema/target-rules.js +367 -0
  55. package/dist/tool/schema/target-rules.js.map +1 -0
  56. package/dist/tool/schema/types.d.ts +42 -3
  57. package/dist/tool/schema/types.d.ts.map +1 -1
  58. package/dist/tool/utils/parameters.d.ts +76 -0
  59. package/dist/tool/utils/parameters.d.ts.map +1 -0
  60. package/dist/tool/utils/parameters.js +125 -0
  61. package/dist/tool/utils/parameters.js.map +1 -0
  62. package/dist/tool/utils/paths.d.ts +2 -0
  63. package/dist/tool/utils/paths.d.ts.map +1 -0
  64. package/dist/tool/utils/paths.js +31 -0
  65. package/dist/tool/utils/paths.js.map +1 -0
  66. package/docs/deployment-targets.md +88 -56
  67. package/docs/examples.md +20 -17
  68. package/docs/filesystem-contract.md +5 -0
  69. package/docs/minimal-and-editor.md +65 -5
  70. package/docs/overlay-imports.md +92 -14
  71. package/docs/overlays.md +231 -135
  72. package/docs/specs/001-verbose-plan-graph/spec.md +5 -12
  73. package/docs/specs/002-superposition-config-file/spec.md +5 -12
  74. package/docs/specs/003-mkdocs2-overlay/spec.md +2 -9
  75. package/docs/specs/004-doctor-fix/spec.md +1 -8
  76. package/docs/specs/005-cuda-overlay/spec.md +2 -9
  77. package/docs/specs/006-rocm-overlay/spec.md +3 -10
  78. package/docs/specs/007-target-aware-generation/spec.md +119 -0
  79. package/docs/specs/008-project-file-canonical/spec.md +82 -0
  80. package/docs/specs/009-project-env/spec.md +140 -0
  81. package/docs/specs/010-compose-env-materialization/spec.md +123 -0
  82. package/docs/specs/011-overlay-parameters/spec.md +228 -0
  83. package/docs/specs/012-ollama-cli-overlay/spec.md +47 -0
  84. package/docs/specs/013-doctor-dependency-check/spec.md +250 -0
  85. package/docs/specs/014-doctor-compose-port-cross-validation/spec.md +276 -0
  86. package/docs/specs/015-doctor-env-example-drift/spec.md +248 -0
  87. package/docs/specs/016-doctor-reproducibility-check/spec.md +276 -0
  88. package/docs/specs/017-doctor-dry-run/spec.md +276 -0
  89. package/docs/specs/018-init-project-file/spec.md +59 -0
  90. package/docs/specs/taxonomy.md +186 -0
  91. package/overlays/.presets/full-observability.yml +113 -0
  92. package/overlays/.presets/k8s-dev.yml +174 -0
  93. package/overlays/.presets/local-llm.yml +105 -0
  94. package/overlays/.presets/vector-ai.yml +150 -0
  95. package/overlays/.shared/README.md +27 -2
  96. package/overlays/.shared/compose/nvidia-gpu-devcontainer.yml +22 -0
  97. package/overlays/.shared/vscode/js-ts-settings.json +19 -0
  98. package/overlays/.shared/vscode/markdown-extensions.json +8 -0
  99. package/overlays/alertmanager/devcontainer.patch.json +0 -1
  100. package/overlays/alertmanager/docker-compose.yml +8 -0
  101. package/overlays/alertmanager/overlay.yml +1 -0
  102. package/overlays/amp/devcontainer.patch.json +4 -1
  103. package/overlays/bun/devcontainer.patch.json +1 -10
  104. package/overlays/bun/overlay.yml +8 -1
  105. package/overlays/claude-code/devcontainer.patch.json +6 -1
  106. package/overlays/codex/devcontainer.patch.json +5 -0
  107. package/overlays/comfyui/.env.example +34 -0
  108. package/overlays/comfyui/README.md +342 -0
  109. package/overlays/comfyui/devcontainer.patch.json +15 -0
  110. package/overlays/comfyui/docker-compose.yml +40 -0
  111. package/overlays/comfyui/overlay.yml +24 -0
  112. package/overlays/comfyui/setup.sh +36 -0
  113. package/overlays/comfyui/verify.sh +103 -0
  114. package/overlays/commitlint/devcontainer.patch.json +1 -6
  115. package/overlays/docker-sock/overlay.yml +1 -0
  116. package/overlays/dotnet/overlay.yml +4 -1
  117. package/overlays/fuseki/.env.example +5 -0
  118. package/overlays/fuseki/README.md +173 -0
  119. package/overlays/fuseki/devcontainer.patch.json +18 -0
  120. package/overlays/fuseki/docker-compose.yml +29 -0
  121. package/overlays/fuseki/overlay.yml +42 -0
  122. package/overlays/fuseki/verify.sh +58 -0
  123. package/overlays/gemini-cli/devcontainer.patch.json +4 -1
  124. package/overlays/go/overlay.yml +6 -1
  125. package/overlays/grafana/devcontainer.patch.json +0 -1
  126. package/overlays/grafana/docker-compose.yml +8 -2
  127. package/overlays/grafana/overlay.yml +6 -1
  128. package/overlays/jaeger/.env.example +11 -0
  129. package/overlays/jaeger/README.md +33 -4
  130. package/overlays/jaeger/devcontainer.patch.json +9 -1
  131. package/overlays/jaeger/docker-compose.yml +17 -0
  132. package/overlays/jaeger/overlay.yml +1 -12
  133. package/overlays/java/overlay.yml +6 -1
  134. package/overlays/jupyter/docker-compose.yml +1 -0
  135. package/overlays/jupyter/overlay.yml +1 -0
  136. package/overlays/k3d/README.md +201 -0
  137. package/overlays/k3d/devcontainer.patch.json +9 -0
  138. package/overlays/k3d/overlay.yml +19 -0
  139. package/overlays/k3d/setup.sh +34 -0
  140. package/overlays/k3d/verify.sh +38 -0
  141. package/overlays/keycloak/devcontainer.patch.json +0 -1
  142. package/overlays/keycloak/docker-compose.yml +1 -0
  143. package/overlays/keycloak/overlay.yml +15 -0
  144. package/overlays/localstack/docker-compose.yml +1 -0
  145. package/overlays/localstack/overlay.yml +19 -1
  146. package/overlays/loki/devcontainer.patch.json +0 -1
  147. package/overlays/loki/docker-compose.yml +8 -0
  148. package/overlays/loki/overlay.yml +1 -0
  149. package/overlays/mailpit/docker-compose.yml +1 -0
  150. package/overlays/mailpit/overlay.yml +1 -0
  151. package/overlays/minio/devcontainer.patch.json +1 -1
  152. package/overlays/minio/docker-compose.yml +1 -0
  153. package/overlays/minio/overlay.yml +23 -2
  154. package/overlays/mkdocs/devcontainer.patch.json +1 -5
  155. package/overlays/mkdocs/overlay.yml +3 -1
  156. package/overlays/mkdocs2/devcontainer.patch.json +1 -5
  157. package/overlays/mkdocs2/overlay.yml +2 -0
  158. package/overlays/mongodb/docker-compose.yml +2 -0
  159. package/overlays/mongodb/overlay.yml +26 -2
  160. package/overlays/mysql/docker-compose.yml +2 -0
  161. package/overlays/mysql/overlay.yml +36 -2
  162. package/overlays/nats/docker-compose.yml +1 -0
  163. package/overlays/nats/overlay.yml +18 -2
  164. package/overlays/nodejs/devcontainer.patch.json +1 -12
  165. package/overlays/nodejs/overlay.yml +8 -1
  166. package/overlays/ollama/.env.example +14 -0
  167. package/overlays/ollama/README.md +326 -0
  168. package/overlays/ollama/devcontainer.patch.json +14 -0
  169. package/overlays/ollama/docker-compose.yml +25 -0
  170. package/overlays/ollama/overlay.yml +27 -0
  171. package/overlays/ollama/verify.sh +76 -0
  172. package/overlays/ollama-cli/README.md +90 -0
  173. package/overlays/ollama-cli/devcontainer.patch.json +3 -0
  174. package/overlays/ollama-cli/overlay.yml +19 -0
  175. package/overlays/ollama-cli/setup.sh +103 -0
  176. package/overlays/ollama-cli/verify.sh +49 -0
  177. package/overlays/open-webui/.env.example +5 -0
  178. package/overlays/open-webui/README.md +162 -0
  179. package/overlays/open-webui/devcontainer.patch.json +14 -0
  180. package/overlays/open-webui/docker-compose.yml +24 -0
  181. package/overlays/open-webui/overlay.yml +45 -0
  182. package/overlays/opencode/devcontainer.patch.json +4 -1
  183. package/overlays/otel-collector/README.md +4 -0
  184. package/overlays/otel-collector/devcontainer.patch.json +4 -1
  185. package/overlays/otel-collector/docker-compose.yml +8 -4
  186. package/overlays/otel-collector/overlay.yml +1 -0
  187. package/overlays/otel-demo-nodejs/devcontainer.patch.json +0 -1
  188. package/overlays/otel-demo-nodejs/docker-compose.yml +1 -0
  189. package/overlays/otel-demo-nodejs/overlay.yml +9 -1
  190. package/overlays/otel-demo-python/devcontainer.patch.json +0 -1
  191. package/overlays/otel-demo-python/docker-compose.yml +1 -0
  192. package/overlays/otel-demo-python/overlay.yml +6 -1
  193. package/overlays/pandoc/README.md +10 -0
  194. package/overlays/pandoc/devcontainer.patch.json +0 -5
  195. package/overlays/pandoc/overlay.yml +2 -0
  196. package/overlays/pandoc/setup.sh +10 -0
  197. package/overlays/pgvector/.env.example +6 -0
  198. package/overlays/pgvector/README.md +215 -0
  199. package/overlays/pgvector/devcontainer.patch.json +29 -0
  200. package/overlays/pgvector/docker-compose.yml +33 -0
  201. package/overlays/pgvector/overlay.yml +47 -0
  202. package/overlays/playwright/devcontainer.patch.json +0 -5
  203. package/overlays/playwright/overlay.yml +2 -1
  204. package/overlays/postgres/.env.example +5 -5
  205. package/overlays/postgres/devcontainer.patch.json +4 -4
  206. package/overlays/postgres/docker-compose.yml +11 -6
  207. package/overlays/postgres/overlay.yml +23 -2
  208. package/overlays/pre-commit/devcontainer.patch.json +1 -7
  209. package/overlays/prometheus/devcontainer.patch.json +0 -1
  210. package/overlays/prometheus/docker-compose.yml +8 -0
  211. package/overlays/prometheus/overlay.yml +1 -0
  212. package/overlays/promtail/devcontainer.patch.json +1 -2
  213. package/overlays/promtail/docker-compose.yml +8 -0
  214. package/overlays/promtail/overlay.yml +1 -0
  215. package/overlays/qdrant/.env.example +4 -0
  216. package/overlays/qdrant/README.md +216 -0
  217. package/overlays/qdrant/devcontainer.patch.json +20 -0
  218. package/overlays/qdrant/docker-compose.yml +26 -0
  219. package/overlays/qdrant/overlay.yml +44 -0
  220. package/overlays/rabbitmq/docker-compose.yml +1 -0
  221. package/overlays/rabbitmq/overlay.yml +25 -2
  222. package/overlays/redis/docker-compose.yml +7 -0
  223. package/overlays/redis/overlay.yml +15 -1
  224. package/overlays/redpanda/docker-compose.yml +1 -0
  225. package/overlays/redpanda/overlay.yml +15 -3
  226. package/overlays/rocm/overlay.yml +2 -1
  227. package/overlays/rust/overlay.yml +3 -1
  228. package/overlays/skaffold/README.md +256 -0
  229. package/overlays/skaffold/devcontainer.patch.json +9 -0
  230. package/overlays/skaffold/overlay.yml +20 -0
  231. package/overlays/skaffold/setup.sh +33 -0
  232. package/overlays/skaffold/verify.sh +24 -0
  233. package/overlays/sqlserver/docker-compose.yml +1 -0
  234. package/overlays/sqlserver/overlay.yml +17 -0
  235. package/overlays/tempo/devcontainer.patch.json +0 -1
  236. package/overlays/tempo/docker-compose.yml +8 -0
  237. package/overlays/tempo/overlay.yml +1 -0
  238. package/overlays/windsurf-cli/devcontainer.patch.json +4 -1
  239. package/package.json +3 -2
  240. package/tool/schema/config.schema.json +31 -1
  241. package/tool/schema/overlay-manifest.schema.json +33 -0
  242. package/overlays/.shared/otel/otel-base-config.yaml +0 -30
@@ -2,17 +2,23 @@
2
2
  * Doctor command - Environment validation and diagnostics
3
3
  */
4
4
  import * as fs from 'fs';
5
+ import * as os from 'os';
5
6
  import * as path from 'path';
6
7
  import * as net from 'net';
7
8
  import { execSync } from 'child_process';
8
9
  import chalk from 'chalk';
9
10
  import boxen from 'boxen';
11
+ import * as yaml from 'js-yaml';
10
12
  import { loadOverlayManifest } from '../schema/overlay-loader.js';
11
13
  import { detectManifestVersion, isVersionSupported, needsMigration, migrateManifest, CURRENT_MANIFEST_VERSION, } from '../schema/manifest-migrations.js';
12
14
  import { MERGE_STRATEGY } from '../utils/merge.js';
13
15
  import { extractPorts } from '../utils/port-utils.js';
14
16
  import { composeDevContainer } from '../questionnaire/composer.js';
15
- import { loadProjectConfig } from '../schema/project-config.js';
17
+ import { mergeAnswers } from '../questionnaire/answers.js';
18
+ import { loadProjectConfig, buildAnswersFromProjectConfig, writeProjectConfig, } from '../schema/project-config.js';
19
+ import { collectOverlayParameters, resolveParameters, findUnresolvedTokens, } from '../utils/parameters.js';
20
+ import { applyPresetSelections } from '../questionnaire/presets.js';
21
+ import { PRESETS_DIR } from '../questionnaire/questionnaire.js';
16
22
  // ─── Remediation registry ─────────────────────────────────────────────────
17
23
  const REMEDIATION_REGISTRY = new Map([
18
24
  [
@@ -77,6 +83,71 @@ const REMEDIATION_REGISTRY = new Map([
77
83
  ],
78
84
  },
79
85
  ],
86
+ [
87
+ 'parameters-regen',
88
+ {
89
+ key: 'parameters-regen',
90
+ findingId: 'missing-required-parameters',
91
+ safetyClass: 'safe-unattended',
92
+ executionKind: 'regeneration',
93
+ preconditions: [
94
+ 'Project file (.superposition.yml) must exist',
95
+ 'All required parameters must have overlay defaults to fall back to',
96
+ ],
97
+ plannedChanges: [
98
+ 'Add missing parameters with overlay defaults to project file',
99
+ 'Regenerate devcontainer configuration from updated project file',
100
+ ],
101
+ manualFallback: [
102
+ 'Add the missing parameters to the parameters: section in your project file',
103
+ 'Run "cs regen" to regenerate with the updated configuration',
104
+ ],
105
+ },
106
+ ],
107
+ [
108
+ 'dependency-fix',
109
+ {
110
+ key: 'dependency-fix',
111
+ findingId: 'missing-required-overlay',
112
+ safetyClass: 'safe-unattended',
113
+ executionKind: 'regeneration',
114
+ preconditions: ['Project file (.superposition.yml) must exist'],
115
+ plannedChanges: [
116
+ 'Add missing required overlay(s) to project file',
117
+ 'Regenerate devcontainer configuration from updated project file',
118
+ ],
119
+ manualFallback: [
120
+ 'Add the missing required overlays to the overlays: list in your project file',
121
+ 'Run "cs regen" to regenerate',
122
+ ],
123
+ },
124
+ ],
125
+ [
126
+ 'env-example-regen',
127
+ {
128
+ key: 'env-example-regen',
129
+ findingId: 'env-example-drift',
130
+ safetyClass: 'safe-unattended',
131
+ executionKind: 'regeneration',
132
+ preconditions: ['Project file (.superposition.yml) must exist'],
133
+ plannedChanges: ['Regenerate .env.example from current overlay selection'],
134
+ manualFallback: [
135
+ 'Run "cs regen" to regenerate .env.example from the current overlay selection',
136
+ ],
137
+ },
138
+ ],
139
+ [
140
+ 'reproducibility-regen',
141
+ {
142
+ key: 'reproducibility-regen',
143
+ findingId: 'reproducibility',
144
+ safetyClass: 'safe-unattended',
145
+ executionKind: 'regeneration',
146
+ preconditions: ['Project file (.superposition.yml) must exist'],
147
+ plannedChanges: ['Regenerate devcontainer configuration from current project file'],
148
+ manualFallback: ['Run "cs regen" to regenerate the devcontainer configuration'],
149
+ },
150
+ ],
80
151
  ]);
81
152
  /**
82
153
  * Semantic version comparison helper
@@ -418,6 +489,52 @@ function validateOverlayManifest(overlayDir, overlayId) {
418
489
  };
419
490
  }
420
491
  }
492
+ // Validate compose_imports if present
493
+ if (manifest.compose_imports && manifest.compose_imports.length > 0) {
494
+ const overlaysDir = path.dirname(overlayDir);
495
+ const sharedBase = path.resolve(overlaysDir, '.shared');
496
+ const missingImports = [];
497
+ const invalidImports = [];
498
+ const traversalImports = [];
499
+ for (const importPath of manifest.compose_imports) {
500
+ if (!importPath.startsWith('.shared/')) {
501
+ traversalImports.push(`${importPath} (must begin with '.shared/')`);
502
+ continue;
503
+ }
504
+ const resolved = path.resolve(overlaysDir, importPath);
505
+ if (!resolved.startsWith(sharedBase + path.sep) && resolved !== sharedBase) {
506
+ traversalImports.push(`${importPath} (resolves outside '.shared/' directory)`);
507
+ continue;
508
+ }
509
+ const fullImportPath = path.join(overlaysDir, importPath);
510
+ if (!fs.existsSync(fullImportPath)) {
511
+ missingImports.push(importPath);
512
+ continue;
513
+ }
514
+ const ext = path.extname(importPath).toLowerCase();
515
+ if (!['.yaml', '.yml'].includes(ext)) {
516
+ invalidImports.push(`${importPath} (must be .yml or .yaml for compose_imports)`);
517
+ }
518
+ }
519
+ if (traversalImports.length > 0 || missingImports.length > 0 || invalidImports.length > 0) {
520
+ const details = [];
521
+ if (traversalImports.length > 0) {
522
+ details.push(`Path traversal rejected: ${traversalImports.join(', ')}`);
523
+ }
524
+ if (missingImports.length > 0) {
525
+ details.push(`Missing compose_imports: ${missingImports.join(', ')}`);
526
+ }
527
+ if (invalidImports.length > 0) {
528
+ details.push(`Invalid compose_imports: ${invalidImports.join(', ')}`);
529
+ }
530
+ return {
531
+ name: `Overlay: ${overlayId}`,
532
+ status: 'warn',
533
+ message: 'compose_import validation issues',
534
+ details,
535
+ };
536
+ }
537
+ }
421
538
  return {
422
539
  name: `Overlay: ${overlayId}`,
423
540
  status: 'pass',
@@ -737,95 +854,1090 @@ function checkMergeStrategy(outputPath) {
737
854
  });
738
855
  }
739
856
  else {
740
- results.push({
741
- name: 'Port forwarding merge',
742
- status: 'pass',
743
- message: `${uniquePorts.size} unique ports forwarded`,
744
- });
857
+ results.push({
858
+ name: 'Port forwarding merge',
859
+ status: 'pass',
860
+ message: `${uniquePorts.size} unique ports forwarded`,
861
+ });
862
+ }
863
+ }
864
+ }
865
+ catch (error) {
866
+ results.push({
867
+ name: 'DevContainer merge validation',
868
+ status: 'fail',
869
+ message: 'Unable to validate merge strategy',
870
+ details: [`Error: ${error instanceof Error ? error.message : String(error)}`],
871
+ });
872
+ }
873
+ }
874
+ // Check 3: Validate docker-compose.yml if it exists
875
+ const composePath = path.join(outputPath, 'docker-compose.yml');
876
+ if (fs.existsSync(composePath)) {
877
+ try {
878
+ const content = fs.readFileSync(composePath, 'utf8');
879
+ // Basic validation: check if it's parseable YAML
880
+ const yaml = require('js-yaml');
881
+ const compose = yaml.load(content);
882
+ if (compose.services) {
883
+ const serviceNames = Object.keys(compose.services);
884
+ results.push({
885
+ name: 'Compose service merge',
886
+ status: 'pass',
887
+ message: `${serviceNames.length} services merged successfully`,
888
+ });
889
+ // Check depends_on references
890
+ let hasInvalidDependencies = false;
891
+ const serviceNameSet = new Set(serviceNames);
892
+ for (const [serviceName, service] of Object.entries(compose.services)) {
893
+ if (service.depends_on) {
894
+ const deps = Array.isArray(service.depends_on)
895
+ ? service.depends_on
896
+ : Object.keys(service.depends_on);
897
+ for (const dep of deps) {
898
+ if (!serviceNameSet.has(dep)) {
899
+ hasInvalidDependencies = true;
900
+ break;
901
+ }
902
+ }
903
+ }
904
+ }
905
+ if (hasInvalidDependencies) {
906
+ results.push({
907
+ name: 'Service dependencies',
908
+ status: 'warn',
909
+ message: 'Invalid service dependencies detected',
910
+ details: [
911
+ 'Some depends_on references point to non-existent services',
912
+ 'Dependencies should be filtered during merge',
913
+ ],
914
+ });
915
+ }
916
+ else {
917
+ results.push({
918
+ name: 'Service dependencies',
919
+ status: 'pass',
920
+ message: 'All service dependencies are valid',
921
+ });
922
+ }
923
+ }
924
+ }
925
+ catch (error) {
926
+ results.push({
927
+ name: 'Compose merge validation',
928
+ status: 'warn',
929
+ message: 'Unable to validate docker-compose merge',
930
+ details: [`Error: ${error instanceof Error ? error.message : String(error)}`],
931
+ });
932
+ }
933
+ }
934
+ return results;
935
+ }
936
+ /**
937
+ * Check for drift between the project config file and the last generated manifest.
938
+ * Reports a warning when the overlay lists differ so users know to run `regen`.
939
+ */
940
+ function checkProjectFileDrift(overlaysConfig, workingDir, manifestPath) {
941
+ // Load project config — skip silently if not present
942
+ let projectConfig;
943
+ try {
944
+ projectConfig = loadProjectConfig(overlaysConfig, workingDir);
945
+ }
946
+ catch {
947
+ return [];
948
+ }
949
+ if (!projectConfig) {
950
+ return [];
951
+ }
952
+ // Load manifest — if not present while the project file exists, report it informatively
953
+ if (!fs.existsSync(manifestPath)) {
954
+ return [
955
+ {
956
+ name: 'Project file drift',
957
+ status: 'warn',
958
+ message: 'Project file found but no generated manifest — run `cs regen` to generate',
959
+ fixEligibility: 'not-applicable',
960
+ },
961
+ ];
962
+ }
963
+ let manifest;
964
+ try {
965
+ const raw = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
966
+ manifest = needsMigration(raw) ? migrateManifest(raw) : raw;
967
+ }
968
+ catch {
969
+ return [];
970
+ }
971
+ // Compare overlay sets (order-independent).
972
+ // Exclude auto-resolved dependencies from the manifest side — the project file only
973
+ // stores user-selected overlays; auto-resolved ones are re-calculated at generation time.
974
+ const autoResolvedAdded = new Set(manifest.autoResolved?.added ?? []);
975
+ const projectOverlays = new Set(projectConfig.selection.overlays ?? []);
976
+ const manifestBaseOverlays = new Set((manifest.overlays ?? []).filter((o) => !autoResolvedAdded.has(o)));
977
+ const inProjectNotManifest = [...projectOverlays].filter((o) => !manifestBaseOverlays.has(o));
978
+ const inManifestNotProject = [...manifestBaseOverlays].filter((o) => !projectOverlays.has(o));
979
+ if (inProjectNotManifest.length === 0 && inManifestNotProject.length === 0) {
980
+ return [
981
+ {
982
+ name: 'Project file drift',
983
+ status: 'pass',
984
+ message: 'Project file and generated manifest are consistent',
985
+ fixEligibility: 'not-applicable',
986
+ },
987
+ ];
988
+ }
989
+ const details = [];
990
+ if (inProjectNotManifest.length > 0) {
991
+ details.push(`In project file but not in manifest: ${inProjectNotManifest.join(', ')}`);
992
+ }
993
+ if (inManifestNotProject.length > 0) {
994
+ details.push(`In manifest but not in project file: ${inManifestNotProject.join(', ')}`);
995
+ }
996
+ details.push('Run "cs regen" to regenerate with the current project file configuration');
997
+ return [
998
+ {
999
+ name: 'Project file drift',
1000
+ status: 'warn',
1001
+ message: 'Project file and generated manifest have diverged',
1002
+ details,
1003
+ fixEligibility: 'manual-only',
1004
+ },
1005
+ ];
1006
+ }
1007
+ /**
1008
+ * Check overlay parameters: unresolved tokens, secret leakage, missing .env.example,
1009
+ * stale project-file keys, and required parameters not supplied.
1010
+ */
1011
+ function checkParameters(overlaysConfig, outputPath, workingDir) {
1012
+ const results = [];
1013
+ // Load the project config — skip silently if none present
1014
+ let projectConfig;
1015
+ try {
1016
+ projectConfig = loadProjectConfig(overlaysConfig, workingDir);
1017
+ }
1018
+ catch {
1019
+ return [];
1020
+ }
1021
+ if (!projectConfig) {
1022
+ return [];
1023
+ }
1024
+ const selectedOverlays = projectConfig.selection.overlays ?? [];
1025
+ const suppliedParams = projectConfig.selection.parameters ?? {};
1026
+ // Collect all parameter declarations for selected overlays
1027
+ const declared = collectOverlayParameters(selectedOverlays, overlaysConfig.overlays);
1028
+ const declaredCount = Object.keys(declared).length;
1029
+ // ── Check 1: Unresolved {{cs.*}} tokens in generated files ────────────────
1030
+ const filesToScan = [
1031
+ ['devcontainer.json', path.join(outputPath, 'devcontainer.json')],
1032
+ ['docker-compose.yml', path.join(outputPath, 'docker-compose.yml')],
1033
+ ['.env.example', path.join(outputPath, '.env.example')],
1034
+ ];
1035
+ const unresolvedByFile = [];
1036
+ for (const [label, filePath] of filesToScan) {
1037
+ if (!fs.existsSync(filePath))
1038
+ continue;
1039
+ const content = fs.readFileSync(filePath, 'utf8');
1040
+ const tokens = findUnresolvedTokens(content);
1041
+ if (tokens.length > 0) {
1042
+ unresolvedByFile.push(`${label}: ${[...new Set(tokens)].join(', ')}`);
1043
+ }
1044
+ }
1045
+ if (unresolvedByFile.length > 0) {
1046
+ results.push({
1047
+ name: 'Unresolved parameter tokens',
1048
+ status: 'fail',
1049
+ message: `Unsubstituted {{cs.*}} tokens found in generated files`,
1050
+ details: [
1051
+ ...unresolvedByFile,
1052
+ 'Run "cs regen" or add the missing parameters to your project file',
1053
+ ],
1054
+ fixEligibility: 'automatic',
1055
+ remediationKey: 'parameters-regen',
1056
+ fixable: true,
1057
+ });
1058
+ }
1059
+ // ── Check 2: Sensitive params hardcoded in devcontainer.json remoteEnv ───
1060
+ const devcontainerPath = path.join(outputPath, 'devcontainer.json');
1061
+ if (fs.existsSync(devcontainerPath)) {
1062
+ try {
1063
+ const devcontainer = JSON.parse(fs.readFileSync(devcontainerPath, 'utf8'));
1064
+ const remoteEnv = devcontainer.remoteEnv ?? {};
1065
+ const leakedSecrets = [];
1066
+ for (const [key, value] of Object.entries(remoteEnv)) {
1067
+ if (declared[key]?.sensitive && value !== '' && !value.startsWith('${')) {
1068
+ leakedSecrets.push(key);
1069
+ }
1070
+ }
1071
+ if (leakedSecrets.length > 0) {
1072
+ results.push({
1073
+ name: 'Sensitive parameters in devcontainer',
1074
+ status: 'warn',
1075
+ message: `Secret parameter(s) appear as plain text in devcontainer.json remoteEnv: ${leakedSecrets.join(', ')}`,
1076
+ details: [
1077
+ 'Sensitive values should be stored in .env and referenced as ${VAR:-default}',
1078
+ 'Run "cs regen" to regenerate with proper secret handling',
1079
+ ],
1080
+ fixEligibility: 'automatic',
1081
+ remediationKey: 'parameters-regen',
1082
+ fixable: true,
1083
+ });
1084
+ }
1085
+ }
1086
+ catch {
1087
+ // devcontainer.json parse errors are caught by checkManifest — skip here
1088
+ }
1089
+ }
1090
+ // ── Check 3: Missing .env.example for compose stacks with parameters ─────
1091
+ const manifest = (() => {
1092
+ const mPath = path.join(outputPath, 'superposition.json');
1093
+ if (!fs.existsSync(mPath))
1094
+ return null;
1095
+ try {
1096
+ return JSON.parse(fs.readFileSync(mPath, 'utf8'));
1097
+ }
1098
+ catch {
1099
+ return null;
1100
+ }
1101
+ })();
1102
+ const isCompose = manifest?.baseTemplate === 'compose';
1103
+ if (isCompose && declaredCount > 0) {
1104
+ const envExamplePath = path.join(outputPath, '.env.example');
1105
+ if (!fs.existsSync(envExamplePath)) {
1106
+ results.push({
1107
+ name: 'Missing .env.example',
1108
+ status: 'warn',
1109
+ message: `Compose stack with ${declaredCount} parameter(s) has no .env.example`,
1110
+ details: ['Run "cs regen" to generate the .env.example file'],
1111
+ fixEligibility: 'automatic',
1112
+ remediationKey: 'parameters-regen',
1113
+ fixable: true,
1114
+ });
1115
+ }
1116
+ }
1117
+ // ── Check 4: Unknown parameters in project file ───────────────────────────
1118
+ const unknownKeys = Object.keys(suppliedParams).filter((k) => !(k in declared));
1119
+ if (unknownKeys.length > 0) {
1120
+ results.push({
1121
+ name: 'Unknown parameters in project file',
1122
+ status: 'warn',
1123
+ message: `parameters: contains ${unknownKeys.length} key(s) not declared by any selected overlay: ${unknownKeys.join(', ')}`,
1124
+ details: [
1125
+ 'These may be stale entries from a removed overlay',
1126
+ 'Remove them from the parameters: section in your project file',
1127
+ ],
1128
+ fixEligibility: 'manual-only',
1129
+ });
1130
+ }
1131
+ // ── Check 5: Required parameters missing from project file ───────────────
1132
+ const { missingRequired } = resolveParameters(declared, suppliedParams);
1133
+ if (missingRequired.length > 0) {
1134
+ const declaringOverlays = missingRequired
1135
+ .map((k) => `${k} (${declared[k]?.overlayId ?? 'unknown'})`)
1136
+ .join(', ');
1137
+ results.push({
1138
+ name: 'Missing required parameters',
1139
+ status: 'fail',
1140
+ message: `${missingRequired.length} required parameter(s) have no value and no default: ${declaringOverlays}`,
1141
+ details: [
1142
+ 'Add these to the parameters: section in your project file',
1143
+ 'Run "cs regen" (or doctor --fix) to apply defaults and regenerate',
1144
+ ],
1145
+ fixEligibility: 'automatic',
1146
+ remediationKey: 'parameters-regen',
1147
+ fixable: true,
1148
+ });
1149
+ }
1150
+ // ── Pass: all parameters resolved ────────────────────────────────────────
1151
+ if (results.length === 0 && declaredCount > 0) {
1152
+ const { values } = resolveParameters(declared, suppliedParams);
1153
+ const resolvedCount = Object.keys(values).length;
1154
+ results.push({
1155
+ name: 'Parameter resolution',
1156
+ status: 'pass',
1157
+ message: `${resolvedCount} parameter(s) resolved for ${selectedOverlays.length} overlay(s)`,
1158
+ fixEligibility: 'not-applicable',
1159
+ });
1160
+ }
1161
+ return results;
1162
+ }
1163
+ /**
1164
+ * Check overlay dependency consistency: missing required overlays, unknown overlay IDs,
1165
+ * and unsatisfied suggestions.
1166
+ */
1167
+ function checkDependencies(overlaysConfig, workingDir) {
1168
+ const overlayMap = new Map(overlaysConfig.overlays.map((o) => [o.id, o]));
1169
+ // Read raw overlay list from YAML — loadProjectConfig throws for unknown IDs
1170
+ let rawSelectedOverlays = [];
1171
+ try {
1172
+ const projectConfig = loadProjectConfig(overlaysConfig, workingDir);
1173
+ if (!projectConfig)
1174
+ return [];
1175
+ rawSelectedOverlays = (projectConfig.selection.overlays ?? []);
1176
+ }
1177
+ catch {
1178
+ for (const fileName of ['.superposition.yml', 'superposition.yml']) {
1179
+ const filePath = path.join(workingDir, fileName);
1180
+ if (!fs.existsSync(filePath))
1181
+ continue;
1182
+ try {
1183
+ const raw = yaml.load(fs.readFileSync(filePath, 'utf8'));
1184
+ if (Array.isArray(raw?.overlays)) {
1185
+ rawSelectedOverlays = raw.overlays
1186
+ .filter((v) => typeof v === 'string')
1187
+ .map((v) => v);
1188
+ }
1189
+ break;
1190
+ }
1191
+ catch {
1192
+ return [];
1193
+ }
1194
+ }
1195
+ if (rawSelectedOverlays.length === 0)
1196
+ return [];
1197
+ }
1198
+ if (rawSelectedOverlays.length === 0) {
1199
+ return [
1200
+ {
1201
+ name: 'Overlay dependencies',
1202
+ status: 'pass',
1203
+ message: 'No overlays selected',
1204
+ fixEligibility: 'not-applicable',
1205
+ },
1206
+ ];
1207
+ }
1208
+ // The set of overlays explicitly listed in the project file (for requires check)
1209
+ const projectFileSet = new Set(rawSelectedOverlays);
1210
+ const results = [];
1211
+ for (const id of rawSelectedOverlays) {
1212
+ // Unknown overlay
1213
+ if (!overlayMap.has(id)) {
1214
+ results.push({
1215
+ name: `Unknown overlay: ${id}`,
1216
+ status: 'fail',
1217
+ message: `Overlay "${id}" not found in registry — it may have been removed or misspelled`,
1218
+ details: [`Edit .superposition.yml to correct the overlay ID`],
1219
+ fixEligibility: 'manual-only',
1220
+ });
1221
+ continue;
1222
+ }
1223
+ const def = overlayMap.get(id);
1224
+ // Missing required overlays — compare against project file, not auto-resolved set
1225
+ for (const req of def.requires ?? []) {
1226
+ if (!projectFileSet.has(req)) {
1227
+ results.push({
1228
+ name: `Missing required overlay: ${req}`,
1229
+ status: 'fail',
1230
+ message: `Overlay "${id}" requires "${req}" which is not in your project file`,
1231
+ details: [
1232
+ `Add "${req}" to the overlays: list in .superposition.yml`,
1233
+ 'Fixable with --fix flag',
1234
+ ],
1235
+ fixEligibility: 'automatic',
1236
+ remediationKey: 'dependency-fix',
1237
+ fixable: true,
1238
+ });
1239
+ }
1240
+ }
1241
+ // Suggested overlays — compare against project file
1242
+ for (const sug of def.suggests ?? []) {
1243
+ if (!projectFileSet.has(sug)) {
1244
+ results.push({
1245
+ name: `Suggested overlay: ${sug}`,
1246
+ status: 'warn',
1247
+ message: `Overlay "${id}" suggests "${sug}" — consider adding it`,
1248
+ details: [
1249
+ `Add "${sug}" to the overlays: list in .superposition.yml for better functionality`,
1250
+ ],
1251
+ fixEligibility: 'not-applicable',
1252
+ });
1253
+ }
1254
+ }
1255
+ }
1256
+ if (results.length === 0) {
1257
+ results.push({
1258
+ name: 'Overlay dependencies',
1259
+ status: 'pass',
1260
+ message: `${rawSelectedOverlays.length} overlay(s) selected; all dependencies satisfied`,
1261
+ fixEligibility: 'not-applicable',
1262
+ });
1263
+ }
1264
+ return results;
1265
+ }
1266
+ /**
1267
+ * Execute the dependency-fix remediation: add missing required overlays then regenerate.
1268
+ */
1269
+ async function executeDependencyFix(outputPath, overlaysConfig, overlaysDir, workingDir, silent = false) {
1270
+ let projectConfig;
1271
+ try {
1272
+ projectConfig = loadProjectConfig(overlaysConfig, workingDir);
1273
+ }
1274
+ catch (err) {
1275
+ return {
1276
+ findingId: 'missing-required-overlay',
1277
+ remediationKey: 'dependency-fix',
1278
+ attempted: false,
1279
+ outcome: 'requires-manual-action',
1280
+ reason: `Failed to load project file: ${err instanceof Error ? err.message : String(err)}`,
1281
+ rechecked: false,
1282
+ };
1283
+ }
1284
+ if (!projectConfig) {
1285
+ return {
1286
+ findingId: 'missing-required-overlay',
1287
+ remediationKey: 'dependency-fix',
1288
+ attempted: false,
1289
+ outcome: 'requires-manual-action',
1290
+ reason: 'No project file (.superposition.yml) found',
1291
+ rechecked: false,
1292
+ };
1293
+ }
1294
+ const selectedOverlays = [...(projectConfig.selection.overlays ?? [])];
1295
+ const overlayMap = new Map(overlaysConfig.overlays.map((o) => [o.id, o]));
1296
+ // Collect missing required overlays
1297
+ const toAdd = [];
1298
+ const toProcess = [...selectedOverlays];
1299
+ const processed = new Set();
1300
+ const current = new Set(selectedOverlays);
1301
+ while (toProcess.length > 0) {
1302
+ const id = toProcess.shift();
1303
+ if (processed.has(id))
1304
+ continue;
1305
+ processed.add(id);
1306
+ const def = overlayMap.get(id);
1307
+ if (!def?.requires)
1308
+ continue;
1309
+ for (const req of def.requires) {
1310
+ if (!current.has(req)) {
1311
+ current.add(req);
1312
+ toAdd.push(req);
1313
+ toProcess.push(req);
1314
+ }
1315
+ }
1316
+ }
1317
+ if (toAdd.length === 0) {
1318
+ return {
1319
+ findingId: 'missing-required-overlay',
1320
+ remediationKey: 'dependency-fix',
1321
+ attempted: false,
1322
+ outcome: 'already-compliant',
1323
+ reason: 'All required overlays are already present',
1324
+ rechecked: false,
1325
+ };
1326
+ }
1327
+ const updatedSelection = {
1328
+ ...projectConfig.selection,
1329
+ overlays: [...selectedOverlays, ...toAdd],
1330
+ };
1331
+ try {
1332
+ writeProjectConfig(projectConfig.file.path, updatedSelection);
1333
+ }
1334
+ catch (err) {
1335
+ return {
1336
+ findingId: 'missing-required-overlay',
1337
+ remediationKey: 'dependency-fix',
1338
+ attempted: true,
1339
+ outcome: 'requires-manual-action',
1340
+ reason: `Failed to write project file: ${err instanceof Error ? err.message : String(err)}`,
1341
+ rechecked: false,
1342
+ };
1343
+ }
1344
+ let answers;
1345
+ try {
1346
+ const reloadedConfig = loadProjectConfig(overlaysConfig, workingDir);
1347
+ if (!reloadedConfig)
1348
+ throw new Error('Project file not found after write');
1349
+ const baseAnswers = buildAnswersFromProjectConfig(reloadedConfig.selection, overlaysConfig);
1350
+ const withPreset = await applyPresetSelections(baseAnswers, overlaysConfig, PRESETS_DIR);
1351
+ answers = mergeAnswers(withPreset, { outputPath });
1352
+ }
1353
+ catch (err) {
1354
+ return {
1355
+ findingId: 'missing-required-overlay',
1356
+ remediationKey: 'dependency-fix',
1357
+ attempted: true,
1358
+ outcome: 'requires-manual-action',
1359
+ reason: `Failed to build answers for regeneration: ${err instanceof Error ? err.message : String(err)}`,
1360
+ rechecked: false,
1361
+ };
1362
+ }
1363
+ const originalLog = console.log;
1364
+ if (silent)
1365
+ console.log = () => { };
1366
+ try {
1367
+ await composeDevContainer(answers, overlaysDir, { isRegen: true });
1368
+ }
1369
+ catch (err) {
1370
+ if (silent)
1371
+ console.log = originalLog;
1372
+ return {
1373
+ findingId: 'missing-required-overlay',
1374
+ remediationKey: 'dependency-fix',
1375
+ attempted: true,
1376
+ outcome: 'requires-manual-action',
1377
+ reason: `Regeneration failed: ${err instanceof Error ? err.message : String(err)}`,
1378
+ rechecked: false,
1379
+ };
1380
+ }
1381
+ finally {
1382
+ if (silent)
1383
+ console.log = originalLog;
1384
+ }
1385
+ return {
1386
+ findingId: 'missing-required-overlay',
1387
+ remediationKey: 'dependency-fix',
1388
+ attempted: true,
1389
+ outcome: 'fixed',
1390
+ reason: `Added missing required overlay(s): ${toAdd.join(', ')} and regenerated devcontainer`,
1391
+ changedFiles: [projectConfig.file.path],
1392
+ rechecked: true,
1393
+ };
1394
+ }
1395
+ /**
1396
+ * Normalise a ports: or forwardPorts: entry to the integer container-side port.
1397
+ * Returns null for unparseable entries.
1398
+ */
1399
+ function parseContainerPort(entry) {
1400
+ if (typeof entry === 'number')
1401
+ return entry > 0 ? entry : null;
1402
+ if (typeof entry === 'object' && entry !== null) {
1403
+ return typeof entry.target === 'number' && entry.target > 0 ? entry.target : null;
1404
+ }
1405
+ if (typeof entry !== 'string')
1406
+ return null;
1407
+ // Strip protocol suffix: "5432/tcp" → "5432"
1408
+ const withoutProto = entry.replace(/\/[a-z]+$/i, '');
1409
+ // Handle "host:container" and "ip:host:container" forms
1410
+ const parts = withoutProto.split(':');
1411
+ const portStr = parts[parts.length - 1] ?? '';
1412
+ const port = parseInt(portStr, 10);
1413
+ return Number.isFinite(port) && port > 0 ? port : null;
1414
+ }
1415
+ /**
1416
+ * Cross-validate forwardPorts in devcontainer.json against ports exposed by compose services.
1417
+ */
1418
+ function checkPortCrossValidation(outputPath) {
1419
+ const composePath = path.join(outputPath, 'docker-compose.yml');
1420
+ if (!fs.existsSync(composePath)) {
1421
+ return [
1422
+ {
1423
+ name: 'Port cross-validation',
1424
+ status: 'pass',
1425
+ message: 'No compose stack — port cross-validation skipped',
1426
+ fixEligibility: 'not-applicable',
1427
+ },
1428
+ ];
1429
+ }
1430
+ // Parse devcontainer.json forwardPorts
1431
+ const devcontainerPath = path.join(outputPath, 'devcontainer.json');
1432
+ let forwardedPorts = new Set();
1433
+ if (fs.existsSync(devcontainerPath)) {
1434
+ try {
1435
+ const dc = JSON.parse(fs.readFileSync(devcontainerPath, 'utf8'));
1436
+ for (const entry of dc.forwardPorts ?? []) {
1437
+ const port = parseContainerPort(entry);
1438
+ if (port !== null)
1439
+ forwardedPorts.add(port);
1440
+ }
1441
+ }
1442
+ catch {
1443
+ // parse errors handled by checkManifest
1444
+ }
1445
+ }
1446
+ // Parse docker-compose.yml ports and expose blocks
1447
+ let boundPorts = new Set();
1448
+ let exposedPorts = new Set();
1449
+ try {
1450
+ const raw = fs.readFileSync(composePath, 'utf8');
1451
+ const doc = yaml.load(raw);
1452
+ const services = doc?.services ?? {};
1453
+ for (const svc of Object.values(services)) {
1454
+ const service = svc;
1455
+ for (const entry of service.ports ?? []) {
1456
+ const port = parseContainerPort(entry);
1457
+ if (port !== null)
1458
+ boundPorts.add(port);
1459
+ }
1460
+ for (const entry of service.expose ?? []) {
1461
+ const port = parseContainerPort(entry);
1462
+ if (port !== null)
1463
+ exposedPorts.add(port);
1464
+ }
1465
+ }
1466
+ }
1467
+ catch {
1468
+ return [
1469
+ {
1470
+ name: 'Port cross-validation',
1471
+ status: 'fail',
1472
+ message: 'Could not parse docker-compose.yml for port cross-validation — file may be malformed',
1473
+ fixEligibility: 'manual-only',
1474
+ },
1475
+ ];
1476
+ }
1477
+ const allComposePorts = new Set([...boundPorts, ...exposedPorts]);
1478
+ const results = [];
1479
+ // forwardPorts entries with no backing compose service
1480
+ for (const port of forwardedPorts) {
1481
+ if (!allComposePorts.has(port)) {
1482
+ results.push({
1483
+ name: `Port ${port} not exposed by any service`,
1484
+ status: 'fail',
1485
+ message: `Port ${port} is listed in forwardPorts but is not exposed by any compose service`,
1486
+ details: [`Remove port ${port} from forwardPorts or add it to a compose service`],
1487
+ fixEligibility: 'manual-only',
1488
+ });
1489
+ }
1490
+ }
1491
+ // Bound compose ports not in forwardPorts
1492
+ for (const port of boundPorts) {
1493
+ if (!forwardedPorts.has(port)) {
1494
+ results.push({
1495
+ name: `Port ${port} not forwarded`,
1496
+ status: 'warn',
1497
+ message: `Port ${port} is bound by a compose service but is not in forwardPorts — it may be inaccessible from the host`,
1498
+ details: [
1499
+ `Add ${port} to forwardPorts in your overlay's devcontainer.patch.json, then run cs regen`,
1500
+ ],
1501
+ fixEligibility: 'manual-only',
1502
+ });
1503
+ }
1504
+ }
1505
+ if (results.length === 0) {
1506
+ results.push({
1507
+ name: 'Port cross-validation',
1508
+ status: 'pass',
1509
+ message: `${forwardedPorts.size} forwarded port(s) all match compose service declarations`,
1510
+ fixEligibility: 'not-applicable',
1511
+ });
1512
+ }
1513
+ return results;
1514
+ }
1515
+ /**
1516
+ * Check whether .env.example is in sync with the current overlay parameter declarations.
1517
+ */
1518
+ function checkEnvExampleDrift(overlaysConfig, outputPath, workingDir) {
1519
+ let projectConfig;
1520
+ try {
1521
+ projectConfig = loadProjectConfig(overlaysConfig, workingDir);
1522
+ }
1523
+ catch {
1524
+ return [];
1525
+ }
1526
+ if (!projectConfig)
1527
+ return [];
1528
+ const envExamplePath = path.join(outputPath, '.env.example');
1529
+ if (!fs.existsSync(envExamplePath)) {
1530
+ return [
1531
+ {
1532
+ name: '.env.example drift',
1533
+ status: 'pass',
1534
+ message: 'No .env.example present — skipping drift check',
1535
+ fixEligibility: 'not-applicable',
1536
+ },
1537
+ ];
1538
+ }
1539
+ const selectedOverlays = projectConfig.selection.overlays ?? [];
1540
+ const declared = collectOverlayParameters(selectedOverlays, overlaysConfig.overlays);
1541
+ const declaredKeys = new Set(Object.keys(declared));
1542
+ // Parse .env.example keys (skip comments and blanks)
1543
+ const envContent = fs.readFileSync(envExamplePath, 'utf8');
1544
+ const exampleKeys = new Set();
1545
+ for (const line of envContent.split('\n')) {
1546
+ const trimmed = line.trim();
1547
+ if (!trimmed || trimmed.startsWith('#'))
1548
+ continue;
1549
+ const key = trimmed.split('=')[0]?.trim();
1550
+ if (key)
1551
+ exampleKeys.add(key);
1552
+ }
1553
+ const results = [];
1554
+ // Keys declared by overlays but missing from .env.example
1555
+ for (const [key, decl] of Object.entries(declared)) {
1556
+ if (!exampleKeys.has(key)) {
1557
+ results.push({
1558
+ name: `Missing .env.example key: ${key}`,
1559
+ status: 'fail',
1560
+ message: `Parameter "${key}" declared by overlay "${decl.overlayId}" is missing from .env.example`,
1561
+ details: [
1562
+ 'Run cs regen or use --fix to regenerate .env.example',
1563
+ 'Fixable with --fix flag',
1564
+ ],
1565
+ fixEligibility: 'automatic',
1566
+ remediationKey: 'env-example-regen',
1567
+ fixable: true,
1568
+ });
1569
+ }
1570
+ }
1571
+ // Keys in .env.example not declared by any overlay
1572
+ for (const key of exampleKeys) {
1573
+ if (!declaredKeys.has(key)) {
1574
+ results.push({
1575
+ name: `Stale .env.example key: ${key}`,
1576
+ status: 'warn',
1577
+ message: `Key "${key}" in .env.example is not declared by any selected overlay — it may be stale`,
1578
+ details: [
1579
+ `Remove "${key}" from .env.example or run --fix to regenerate`,
1580
+ 'Fixable with --fix flag',
1581
+ ],
1582
+ fixEligibility: 'automatic',
1583
+ remediationKey: 'env-example-regen',
1584
+ fixable: true,
1585
+ });
1586
+ }
1587
+ }
1588
+ if (results.length === 0) {
1589
+ results.push({
1590
+ name: '.env.example drift',
1591
+ status: 'pass',
1592
+ message: `.env.example is in sync with ${declaredKeys.size} declared parameter(s)`,
1593
+ fixEligibility: 'not-applicable',
1594
+ });
1595
+ }
1596
+ return results;
1597
+ }
1598
+ /**
1599
+ * Execute the env-example-regen remediation: full regen which regenerates .env.example.
1600
+ */
1601
+ async function executeEnvExampleRegen(outputPath, overlaysConfig, overlaysDir, workingDir, silent = false) {
1602
+ let projectConfig;
1603
+ try {
1604
+ projectConfig = loadProjectConfig(overlaysConfig, workingDir);
1605
+ }
1606
+ catch (err) {
1607
+ return {
1608
+ findingId: 'env-example-drift',
1609
+ remediationKey: 'env-example-regen',
1610
+ attempted: false,
1611
+ outcome: 'requires-manual-action',
1612
+ reason: `Failed to load project file: ${err instanceof Error ? err.message : String(err)}`,
1613
+ rechecked: false,
1614
+ };
1615
+ }
1616
+ if (!projectConfig) {
1617
+ return {
1618
+ findingId: 'env-example-drift',
1619
+ remediationKey: 'env-example-regen',
1620
+ attempted: false,
1621
+ outcome: 'requires-manual-action',
1622
+ reason: 'No project file (.superposition.yml) found',
1623
+ rechecked: false,
1624
+ };
1625
+ }
1626
+ let answers;
1627
+ try {
1628
+ const baseAnswers = buildAnswersFromProjectConfig(projectConfig.selection, overlaysConfig);
1629
+ const withPreset = await applyPresetSelections(baseAnswers, overlaysConfig, PRESETS_DIR);
1630
+ answers = mergeAnswers(withPreset, { outputPath });
1631
+ }
1632
+ catch (err) {
1633
+ return {
1634
+ findingId: 'env-example-drift',
1635
+ remediationKey: 'env-example-regen',
1636
+ attempted: true,
1637
+ outcome: 'requires-manual-action',
1638
+ reason: `Failed to build answers: ${err instanceof Error ? err.message : String(err)}`,
1639
+ rechecked: false,
1640
+ };
1641
+ }
1642
+ const originalLog = console.log;
1643
+ if (silent)
1644
+ console.log = () => { };
1645
+ try {
1646
+ await composeDevContainer(answers, overlaysDir, { isRegen: true });
1647
+ }
1648
+ catch (err) {
1649
+ if (silent)
1650
+ console.log = originalLog;
1651
+ return {
1652
+ findingId: 'env-example-drift',
1653
+ remediationKey: 'env-example-regen',
1654
+ attempted: true,
1655
+ outcome: 'requires-manual-action',
1656
+ reason: `Regeneration failed: ${err instanceof Error ? err.message : String(err)}`,
1657
+ rechecked: false,
1658
+ };
1659
+ }
1660
+ finally {
1661
+ if (silent)
1662
+ console.log = originalLog;
1663
+ }
1664
+ return {
1665
+ findingId: 'env-example-drift',
1666
+ remediationKey: 'env-example-regen',
1667
+ attempted: true,
1668
+ outcome: 'fixed',
1669
+ reason: 'Regenerated .env.example from current overlay selection',
1670
+ changedFiles: [path.join(outputPath, '.env.example')],
1671
+ rechecked: true,
1672
+ };
1673
+ }
1674
+ /**
1675
+ * Dry-compose the devcontainer to a temp directory and compare files against the output directory.
1676
+ */
1677
+ async function checkReproducibility(overlaysConfig, outputPath, overlaysDir, workingDir) {
1678
+ let projectConfig;
1679
+ try {
1680
+ projectConfig = loadProjectConfig(overlaysConfig, workingDir);
1681
+ }
1682
+ catch {
1683
+ return [];
1684
+ }
1685
+ if (!projectConfig)
1686
+ return [];
1687
+ // Skip when output directory or devcontainer.json are absent — regen hasn't run yet,
1688
+ // or the environment check surfaces the missing dir already.
1689
+ if (!fs.existsSync(outputPath) || !fs.existsSync(path.join(outputPath, 'devcontainer.json'))) {
1690
+ return [];
1691
+ }
1692
+ let tmpDir;
1693
+ try {
1694
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cs-doctor-repro-'));
1695
+ let answers;
1696
+ try {
1697
+ const baseAnswers = buildAnswersFromProjectConfig(projectConfig.selection, overlaysConfig);
1698
+ const withPreset = await applyPresetSelections(baseAnswers, overlaysConfig, PRESETS_DIR);
1699
+ answers = mergeAnswers(withPreset, { outputPath: tmpDir });
1700
+ }
1701
+ catch (err) {
1702
+ return [
1703
+ {
1704
+ name: 'Reproducibility',
1705
+ status: 'fail',
1706
+ message: `Failed to build answers for dry compose: ${err instanceof Error ? err.message : String(err)}`,
1707
+ fixEligibility: 'manual-only',
1708
+ },
1709
+ ];
1710
+ }
1711
+ const originalLog = console.log;
1712
+ console.log = () => { };
1713
+ try {
1714
+ // Copy the existing custom/ directory to tmpDir so applyCustomPatches()
1715
+ // inside composeDevContainer applies the same user patches that produced
1716
+ // the current outputPath — without this, the dry-compose output would
1717
+ // always differ whenever .devcontainer/custom/ contains any overrides.
1718
+ const srcCustom = path.join(outputPath, 'custom');
1719
+ if (fs.existsSync(srcCustom)) {
1720
+ const destCustom = path.join(tmpDir, 'custom');
1721
+ fs.cpSync(srcCustom, destCustom, { recursive: true });
1722
+ }
1723
+ await composeDevContainer(answers, overlaysDir, { isRegen: false });
1724
+ }
1725
+ catch (err) {
1726
+ return [
1727
+ {
1728
+ name: 'Reproducibility',
1729
+ status: 'fail',
1730
+ message: `Dry compose failed: ${err instanceof Error ? err.message : String(err)}`,
1731
+ fixEligibility: 'automatic',
1732
+ remediationKey: 'reproducibility-regen',
1733
+ fixable: true,
1734
+ },
1735
+ ];
1736
+ }
1737
+ finally {
1738
+ console.log = originalLog;
1739
+ }
1740
+ const GENERATION_HEADERS = [
1741
+ '# Generated by container-superposition',
1742
+ '// Generated by container-superposition',
1743
+ ];
1744
+ function isGeneratedFile(filePath) {
1745
+ try {
1746
+ const firstLine = fs.readFileSync(filePath, 'utf8').split('\n')[0] ?? '';
1747
+ return GENERATION_HEADERS.some((h) => firstLine.startsWith(h));
1748
+ }
1749
+ catch {
1750
+ return false;
1751
+ }
1752
+ }
1753
+ function listFiles(dir) {
1754
+ const result = [];
1755
+ if (!fs.existsSync(dir))
1756
+ return result;
1757
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
1758
+ const full = path.join(dir, entry.name);
1759
+ if (entry.isDirectory()) {
1760
+ result.push(...listFiles(full).map((f) => path.join(entry.name, f)));
1761
+ }
1762
+ else {
1763
+ result.push(entry.name);
745
1764
  }
746
1765
  }
747
- }
748
- catch (error) {
749
- results.push({
750
- name: 'DevContainer merge validation',
751
- status: 'fail',
752
- message: 'Unable to validate merge strategy',
753
- details: [`Error: ${error instanceof Error ? error.message : String(error)}`],
754
- });
755
- }
756
- }
757
- // Check 3: Validate docker-compose.yml if it exists
758
- const composePath = path.join(outputPath, 'docker-compose.yml');
759
- if (fs.existsSync(composePath)) {
760
- try {
761
- const content = fs.readFileSync(composePath, 'utf8');
762
- // Basic validation: check if it's parseable YAML
763
- const yaml = require('js-yaml');
764
- const compose = yaml.load(content);
765
- if (compose.services) {
766
- const serviceNames = Object.keys(compose.services);
767
- results.push({
768
- name: 'Compose service merge',
769
- status: 'pass',
770
- message: `${serviceNames.length} services merged successfully`,
771
- });
772
- // Check depends_on references
773
- let hasInvalidDependencies = false;
774
- const serviceNameSet = new Set(serviceNames);
775
- for (const [serviceName, service] of Object.entries(compose.services)) {
776
- if (service.depends_on) {
777
- const deps = Array.isArray(service.depends_on)
778
- ? service.depends_on
779
- : Object.keys(service.depends_on);
780
- for (const dep of deps) {
781
- if (!serviceNameSet.has(dep)) {
782
- hasInvalidDependencies = true;
783
- break;
784
- }
785
- }
1766
+ return result;
1767
+ }
1768
+ function normalise(content, rel) {
1769
+ const normalised = content.replace(/\r\n/g, '\n');
1770
+ if (rel === 'superposition.json') {
1771
+ // Parse the manifest as JSON and strip the fields that legitimately
1772
+ // differ between runs (wall-clock timestamp, output-path-dependent
1773
+ // location) before re-serialising for a structural comparison.
1774
+ try {
1775
+ const obj = JSON.parse(normalised);
1776
+ delete obj['generated'];
1777
+ if (obj['customizations'] && typeof obj['customizations'] === 'object') {
1778
+ delete obj['customizations']['location'];
786
1779
  }
1780
+ return JSON.stringify(obj, null, 4);
787
1781
  }
788
- if (hasInvalidDependencies) {
789
- results.push({
790
- name: 'Service dependencies',
791
- status: 'warn',
792
- message: 'Invalid service dependencies detected',
793
- details: [
794
- 'Some depends_on references point to non-existent services',
795
- 'Dependencies should be filtered during merge',
796
- ],
797
- });
798
- }
799
- else {
800
- results.push({
801
- name: 'Service dependencies',
802
- status: 'pass',
803
- message: 'All service dependencies are valid',
804
- });
1782
+ catch {
1783
+ // Unparseable JSON — fall through to character comparison
805
1784
  }
806
1785
  }
1786
+ return normalised;
1787
+ }
1788
+ const tmpFiles = new Set(listFiles(tmpDir));
1789
+ const results = [];
1790
+ // Files produced by regen but absent or different in outputPath
1791
+ for (const rel of tmpFiles) {
1792
+ const actual = path.join(outputPath, rel);
1793
+ const expected = path.join(tmpDir, rel);
1794
+ if (!fs.existsSync(actual)) {
1795
+ results.push({
1796
+ name: `Missing generated file: ${rel}`,
1797
+ status: 'fail',
1798
+ message: `File "${rel}" would be created by cs regen but does not exist`,
1799
+ details: ['Run cs regen or use --fix to synchronise'],
1800
+ fixEligibility: 'automatic',
1801
+ remediationKey: 'reproducibility-regen',
1802
+ fixable: true,
1803
+ });
1804
+ }
1805
+ else if (normalise(fs.readFileSync(actual, 'utf8'), rel) !==
1806
+ normalise(fs.readFileSync(expected, 'utf8'), rel)) {
1807
+ results.push({
1808
+ name: `Out-of-date generated file: ${rel}`,
1809
+ status: 'fail',
1810
+ message: `File "${rel}" differs from what cs regen would produce — it may have been manually edited or is out of date`,
1811
+ details: ['Run cs regen or use --fix to synchronise'],
1812
+ fixEligibility: 'automatic',
1813
+ remediationKey: 'reproducibility-regen',
1814
+ fixable: true,
1815
+ });
1816
+ }
807
1817
  }
808
- catch (error) {
1818
+ // Generated files in outputPath that regen would not produce (stale)
1819
+ for (const rel of listFiles(outputPath)) {
1820
+ if (tmpFiles.has(rel))
1821
+ continue;
1822
+ const actualPath = path.join(outputPath, rel);
1823
+ if (isGeneratedFile(actualPath)) {
1824
+ results.push({
1825
+ name: `Stale generated file: ${rel}`,
1826
+ status: 'warn',
1827
+ message: `File "${rel}" exists but cs regen would not produce it — it may be stale`,
1828
+ fixEligibility: 'manual-only',
1829
+ });
1830
+ }
1831
+ }
1832
+ if (results.length === 0) {
809
1833
  results.push({
810
- name: 'Compose merge validation',
811
- status: 'warn',
812
- message: 'Unable to validate docker-compose merge',
813
- details: [`Error: ${error instanceof Error ? error.message : String(error)}`],
1834
+ name: 'Reproducibility',
1835
+ status: 'pass',
1836
+ message: 'Generated output matches current project configuration',
1837
+ fixEligibility: 'not-applicable',
814
1838
  });
815
1839
  }
1840
+ return results;
1841
+ }
1842
+ finally {
1843
+ if (tmpDir) {
1844
+ try {
1845
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1846
+ }
1847
+ catch {
1848
+ // best-effort cleanup
1849
+ }
1850
+ }
816
1851
  }
817
- return results;
818
1852
  }
819
1853
  /**
820
- * Generate doctor report
1854
+ * Execute the reproducibility-regen remediation: call composeDevContainer with isRegen: true.
821
1855
  */
822
- function generateReport(environmentChecks, overlayChecks, manifestChecks, mergeChecks, portChecks) {
1856
+ async function executeReproducibilityRegen(outputPath, overlaysConfig, overlaysDir, workingDir, silent = false) {
1857
+ let projectConfig;
1858
+ try {
1859
+ projectConfig = loadProjectConfig(overlaysConfig, workingDir);
1860
+ }
1861
+ catch (err) {
1862
+ return {
1863
+ findingId: 'reproducibility',
1864
+ remediationKey: 'reproducibility-regen',
1865
+ attempted: false,
1866
+ outcome: 'requires-manual-action',
1867
+ reason: `Failed to load project file: ${err instanceof Error ? err.message : String(err)}`,
1868
+ rechecked: false,
1869
+ };
1870
+ }
1871
+ if (!projectConfig) {
1872
+ return {
1873
+ findingId: 'reproducibility',
1874
+ remediationKey: 'reproducibility-regen',
1875
+ attempted: false,
1876
+ outcome: 'requires-manual-action',
1877
+ reason: 'No project file (.superposition.yml) found',
1878
+ rechecked: false,
1879
+ };
1880
+ }
1881
+ let answers;
1882
+ try {
1883
+ const baseAnswers = buildAnswersFromProjectConfig(projectConfig.selection, overlaysConfig);
1884
+ const withPreset = await applyPresetSelections(baseAnswers, overlaysConfig, PRESETS_DIR);
1885
+ answers = mergeAnswers(withPreset, { outputPath });
1886
+ }
1887
+ catch (err) {
1888
+ return {
1889
+ findingId: 'reproducibility',
1890
+ remediationKey: 'reproducibility-regen',
1891
+ attempted: true,
1892
+ outcome: 'requires-manual-action',
1893
+ reason: `Failed to build answers: ${err instanceof Error ? err.message : String(err)}`,
1894
+ rechecked: false,
1895
+ };
1896
+ }
1897
+ const originalLog = console.log;
1898
+ if (silent)
1899
+ console.log = () => { };
1900
+ try {
1901
+ await composeDevContainer(answers, overlaysDir, { isRegen: true });
1902
+ }
1903
+ catch (err) {
1904
+ if (silent)
1905
+ console.log = originalLog;
1906
+ return {
1907
+ findingId: 'reproducibility',
1908
+ remediationKey: 'reproducibility-regen',
1909
+ attempted: true,
1910
+ outcome: 'requires-manual-action',
1911
+ reason: `Regeneration failed: ${err instanceof Error ? err.message : String(err)}`,
1912
+ rechecked: false,
1913
+ };
1914
+ }
1915
+ finally {
1916
+ if (silent)
1917
+ console.log = originalLog;
1918
+ }
1919
+ return {
1920
+ findingId: 'reproducibility',
1921
+ remediationKey: 'reproducibility-regen',
1922
+ attempted: true,
1923
+ outcome: 'fixed',
1924
+ reason: 'Regenerated devcontainer configuration from current project file',
1925
+ rechecked: true,
1926
+ };
1927
+ }
1928
+ function generateReport(environmentChecks, overlayChecks, manifestChecks, mergeChecks, portChecks, driftChecks = [], parametersChecks = [], dependenciesChecks = [], portCrossValidationChecks = [], envExampleDriftChecks = [], reproducibilityChecks = []) {
823
1929
  const allChecks = [
824
1930
  ...environmentChecks,
825
1931
  ...overlayChecks,
826
1932
  ...manifestChecks,
827
1933
  ...mergeChecks,
828
1934
  ...portChecks,
1935
+ ...driftChecks,
1936
+ ...parametersChecks,
1937
+ ...dependenciesChecks,
1938
+ ...portCrossValidationChecks,
1939
+ ...envExampleDriftChecks,
1940
+ ...reproducibilityChecks,
829
1941
  ];
830
1942
  const passed = allChecks.filter((c) => c.status === 'pass').length;
831
1943
  const warnings = allChecks.filter((c) => c.status === 'warn').length;
@@ -837,6 +1949,12 @@ function generateReport(environmentChecks, overlayChecks, manifestChecks, mergeC
837
1949
  manifest: manifestChecks,
838
1950
  merge: mergeChecks,
839
1951
  ports: portChecks,
1952
+ drift: driftChecks,
1953
+ parameters: parametersChecks,
1954
+ dependencies: dependenciesChecks,
1955
+ portCrossValidation: portCrossValidationChecks,
1956
+ envExampleDrift: envExampleDriftChecks,
1957
+ reproducibility: reproducibilityChecks,
840
1958
  summary: {
841
1959
  passed,
842
1960
  warnings,
@@ -913,6 +2031,79 @@ function formatAsText(report) {
913
2031
  lines.push(formatCheckResult(check));
914
2032
  }
915
2033
  }
2034
+ // Drift section
2035
+ if (report.drift.length > 0) {
2036
+ const failedDrift = report.drift.filter((c) => c.status !== 'pass');
2037
+ if (failedDrift.length > 0) {
2038
+ lines.push(chalk.bold('\nProject File:'));
2039
+ for (const check of failedDrift) {
2040
+ lines.push(formatCheckResult(check));
2041
+ }
2042
+ }
2043
+ else {
2044
+ lines.push(chalk.bold('\nProject File:'));
2045
+ lines.push(` ${chalk.green('✓')} ${chalk.white('Project file and manifest are consistent')}`);
2046
+ }
2047
+ }
2048
+ // Parameters section
2049
+ if (report.parameters.length > 0) {
2050
+ const failedParams = report.parameters.filter((c) => c.status !== 'pass');
2051
+ if (failedParams.length > 0) {
2052
+ lines.push(chalk.bold('\nParameters:'));
2053
+ for (const check of failedParams) {
2054
+ lines.push(formatCheckResult(check));
2055
+ }
2056
+ }
2057
+ else {
2058
+ lines.push(chalk.bold('\nParameters:'));
2059
+ lines.push(` ${chalk.green('✓')} ${chalk.white(report.parameters[0]?.message ?? 'All parameters resolved')}`);
2060
+ }
2061
+ }
2062
+ // Dependencies section
2063
+ if (report.dependencies.length > 0) {
2064
+ const nonPass = report.dependencies.filter((c) => c.status !== 'pass');
2065
+ const hasFail = nonPass.some((c) => c.status === 'fail');
2066
+ lines.push(chalk.bold('\nDependencies:'));
2067
+ if (nonPass.length > 0) {
2068
+ for (const check of nonPass) {
2069
+ lines.push(formatCheckResult(check));
2070
+ }
2071
+ }
2072
+ if (!hasFail) {
2073
+ const passCheck = report.dependencies.find((c) => c.status === 'pass');
2074
+ lines.push(` ${chalk.green('✓')} ${chalk.white(passCheck?.message ?? 'All overlay dependencies satisfied')}`);
2075
+ }
2076
+ }
2077
+ // Port cross-validation section
2078
+ if (report.portCrossValidation.length > 0) {
2079
+ const failed = report.portCrossValidation.filter((c) => c.status !== 'pass');
2080
+ if (failed.length > 0) {
2081
+ lines.push(chalk.bold('\nPort Cross-Validation:'));
2082
+ for (const check of failed) {
2083
+ lines.push(formatCheckResult(check));
2084
+ }
2085
+ }
2086
+ }
2087
+ // .env.example drift section
2088
+ if (report.envExampleDrift.length > 0) {
2089
+ const failed = report.envExampleDrift.filter((c) => c.status !== 'pass');
2090
+ if (failed.length > 0) {
2091
+ lines.push(chalk.bold('\n.env.example Drift:'));
2092
+ for (const check of failed) {
2093
+ lines.push(formatCheckResult(check));
2094
+ }
2095
+ }
2096
+ }
2097
+ // Reproducibility section
2098
+ if (report.reproducibility.length > 0) {
2099
+ const failed = report.reproducibility.filter((c) => c.status !== 'pass');
2100
+ if (failed.length > 0) {
2101
+ lines.push(chalk.bold('\nReproducibility:'));
2102
+ for (const check of failed) {
2103
+ lines.push(formatCheckResult(check));
2104
+ }
2105
+ }
2106
+ }
916
2107
  // Summary
917
2108
  lines.push(chalk.bold('\nSummary:'));
918
2109
  lines.push(` ${chalk.green('✓')} ${report.summary.passed} passed`);
@@ -960,6 +2151,12 @@ function reportToFindings(report) {
960
2151
  ...checksToFindings(report.manifest, 'manifest', 'manifest'),
961
2152
  ...checksToFindings(report.merge, 'merge', 'devcontainer'),
962
2153
  ...checksToFindings(report.ports, 'ports', 'environment'),
2154
+ ...checksToFindings(report.drift, 'manifest', 'manifest'),
2155
+ ...checksToFindings(report.parameters, 'manifest', 'full'),
2156
+ ...checksToFindings(report.dependencies, 'manifest', 'full'),
2157
+ ...checksToFindings(report.portCrossValidation, 'ports', 'full'),
2158
+ ...checksToFindings(report.envExampleDrift, 'manifest', 'full'),
2159
+ ...checksToFindings(report.reproducibility, 'manifest', 'full'),
963
2160
  ];
964
2161
  }
965
2162
  /**
@@ -969,8 +2166,12 @@ function orderFindingsForRemediation(findings) {
969
2166
  const PRIORITY = {
970
2167
  'manifest-migration': 1,
971
2168
  'devcontainer-regeneration': 2,
972
- 'node-version-fix': 3,
973
- 'docker-repair': 4,
2169
+ 'dependency-fix': 3,
2170
+ 'parameters-regen': 4,
2171
+ 'env-example-regen': 5,
2172
+ 'reproducibility-regen': 6,
2173
+ 'node-version-fix': 7,
2174
+ 'docker-repair': 8,
974
2175
  };
975
2176
  return [...findings].sort((a, b) => {
976
2177
  const pa = PRIORITY[a.remediationKey ?? ''] ?? 99;
@@ -1026,6 +2227,7 @@ function buildAnswersFromManifest(manifest, manifestDir, overlaysConfig) {
1026
2227
  language.push(id);
1027
2228
  break;
1028
2229
  case 'database':
2230
+ case 'messaging':
1029
2231
  database.push(id);
1030
2232
  break;
1031
2233
  case 'observability':
@@ -1202,7 +2404,7 @@ async function executeRegeneration(outputPath, overlaysConfig, overlaysDir, sile
1202
2404
  rechecked: false,
1203
2405
  };
1204
2406
  }
1205
- const answers = buildAnswersFromManifest(manifest, outputPath, overlaysConfig);
2407
+ const answers = mergeAnswers(buildAnswersFromManifest(manifest, outputPath, overlaysConfig));
1206
2408
  // Suppress console output during regeneration when in JSON mode
1207
2409
  const originalLog = console.log;
1208
2410
  if (silent) {
@@ -1362,15 +2564,163 @@ function executeNodeVersionFix() {
1362
2564
  rechecked: true,
1363
2565
  };
1364
2566
  }
2567
+ /**
2568
+ * Execute parameters-regen fix: add missing parameter defaults to the project file
2569
+ * then regenerate the devcontainer.
2570
+ */
2571
+ async function executeParametersRegen(outputPath, overlaysConfig, overlaysDir, workingDir, silent = false) {
2572
+ let projectConfig;
2573
+ try {
2574
+ projectConfig = loadProjectConfig(overlaysConfig, workingDir);
2575
+ }
2576
+ catch (err) {
2577
+ return {
2578
+ findingId: 'missing-required-parameters',
2579
+ remediationKey: 'parameters-regen',
2580
+ attempted: false,
2581
+ outcome: 'requires-manual-action',
2582
+ reason: `Failed to load project file: ${err instanceof Error ? err.message : String(err)}`,
2583
+ rechecked: false,
2584
+ };
2585
+ }
2586
+ if (!projectConfig) {
2587
+ return {
2588
+ findingId: 'missing-required-parameters',
2589
+ remediationKey: 'parameters-regen',
2590
+ attempted: false,
2591
+ outcome: 'requires-manual-action',
2592
+ reason: 'No project file (.superposition.yml) found — run "cs init" first',
2593
+ rechecked: false,
2594
+ };
2595
+ }
2596
+ const selectedOverlays = projectConfig.selection.overlays ?? [];
2597
+ const declared = collectOverlayParameters(selectedOverlays, overlaysConfig.overlays);
2598
+ const supplied = { ...(projectConfig.selection.parameters ?? {}) };
2599
+ // Fill in defaults for missing required parameters
2600
+ const { missingRequired } = resolveParameters(declared, supplied);
2601
+ const needsManual = [];
2602
+ for (const key of missingRequired) {
2603
+ const def = declared[key];
2604
+ if (def?.default !== undefined) {
2605
+ supplied[key] = def.default;
2606
+ }
2607
+ else {
2608
+ needsManual.push(key);
2609
+ }
2610
+ }
2611
+ if (needsManual.length > 0) {
2612
+ return {
2613
+ findingId: 'missing-required-parameters',
2614
+ remediationKey: 'parameters-regen',
2615
+ attempted: false,
2616
+ outcome: 'requires-manual-action',
2617
+ reason: `Cannot auto-fix: ${needsManual.join(', ')} have no default value — add them manually to parameters: in your project file`,
2618
+ rechecked: false,
2619
+ };
2620
+ }
2621
+ // Write updated project file with filled-in parameters
2622
+ const updatedSelection = { ...projectConfig.selection, parameters: supplied };
2623
+ const projectFilePath = projectConfig.file.path;
2624
+ try {
2625
+ writeProjectConfig(projectFilePath, updatedSelection);
2626
+ }
2627
+ catch (err) {
2628
+ return {
2629
+ findingId: 'missing-required-parameters',
2630
+ remediationKey: 'parameters-regen',
2631
+ attempted: true,
2632
+ outcome: 'requires-manual-action',
2633
+ reason: `Failed to write project file: ${err instanceof Error ? err.message : String(err)}`,
2634
+ rechecked: false,
2635
+ };
2636
+ }
2637
+ // Rebuild answers from updated project file and regenerate
2638
+ let answers;
2639
+ try {
2640
+ const reloadedConfig = loadProjectConfig(overlaysConfig, workingDir);
2641
+ if (!reloadedConfig)
2642
+ throw new Error('Project file not found after write');
2643
+ const baseAnswers = buildAnswersFromProjectConfig(reloadedConfig.selection, overlaysConfig);
2644
+ const withPreset = await applyPresetSelections(baseAnswers, overlaysConfig, PRESETS_DIR);
2645
+ answers = mergeAnswers(withPreset, { outputPath });
2646
+ }
2647
+ catch (err) {
2648
+ return {
2649
+ findingId: 'missing-required-parameters',
2650
+ remediationKey: 'parameters-regen',
2651
+ attempted: true,
2652
+ outcome: 'requires-manual-action',
2653
+ reason: `Failed to build answers for regeneration: ${err instanceof Error ? err.message : String(err)}`,
2654
+ rechecked: false,
2655
+ };
2656
+ }
2657
+ const originalLog = console.log;
2658
+ if (silent)
2659
+ console.log = () => { };
2660
+ try {
2661
+ await composeDevContainer(answers, overlaysDir, { isRegen: true });
2662
+ }
2663
+ catch (err) {
2664
+ if (silent)
2665
+ console.log = originalLog;
2666
+ return {
2667
+ findingId: 'missing-required-parameters',
2668
+ remediationKey: 'parameters-regen',
2669
+ attempted: true,
2670
+ outcome: 'requires-manual-action',
2671
+ reason: `Regeneration failed: ${err instanceof Error ? err.message : String(err)}`,
2672
+ rechecked: false,
2673
+ };
2674
+ }
2675
+ finally {
2676
+ if (silent)
2677
+ console.log = originalLog;
2678
+ }
2679
+ // Re-check: ensure no unresolved tokens remain
2680
+ const filesToScan = [
2681
+ ['devcontainer.json', path.join(outputPath, 'devcontainer.json')],
2682
+ ['docker-compose.yml', path.join(outputPath, 'docker-compose.yml')],
2683
+ ['.env.example', path.join(outputPath, '.env.example')],
2684
+ ];
2685
+ const stillUnresolved = [];
2686
+ for (const [, filePath] of filesToScan) {
2687
+ if (!fs.existsSync(filePath))
2688
+ continue;
2689
+ const tokens = findUnresolvedTokens(fs.readFileSync(filePath, 'utf8'));
2690
+ stillUnresolved.push(...tokens);
2691
+ }
2692
+ const addedKeys = Object.keys(supplied).filter((k) => !(projectConfig.selection.parameters ?? {})[k]);
2693
+ return {
2694
+ findingId: 'missing-required-parameters',
2695
+ remediationKey: 'parameters-regen',
2696
+ attempted: true,
2697
+ outcome: stillUnresolved.length === 0 ? 'fixed' : 'requires-manual-action',
2698
+ reason: stillUnresolved.length === 0
2699
+ ? addedKeys.length > 0
2700
+ ? `Added defaults for ${addedKeys.join(', ')} and regenerated devcontainer`
2701
+ : 'Regenerated devcontainer from project file'
2702
+ : `Regeneration ran but unresolved tokens remain: ${[...new Set(stillUnresolved)].join(', ')}`,
2703
+ changedFiles: [projectFilePath],
2704
+ rechecked: true,
2705
+ };
2706
+ }
1365
2707
  /**
1366
2708
  * Execute a single remediation action and return its execution record.
1367
2709
  */
1368
- async function executeSingleFix(finding, outputPath, overlaysConfig, overlaysDir, silent = false, explicitManifestPath) {
2710
+ async function executeSingleFix(finding, outputPath, overlaysConfig, overlaysDir, silent = false, explicitManifestPath, workingDir = process.cwd()) {
1369
2711
  switch (finding.remediationKey) {
1370
2712
  case 'manifest-migration':
1371
2713
  return executeManifestMigration(outputPath, explicitManifestPath);
1372
2714
  case 'devcontainer-regeneration':
1373
2715
  return executeRegeneration(outputPath, overlaysConfig, overlaysDir, silent, explicitManifestPath);
2716
+ case 'parameters-regen':
2717
+ return executeParametersRegen(outputPath, overlaysConfig, overlaysDir, workingDir, silent);
2718
+ case 'dependency-fix':
2719
+ return executeDependencyFix(outputPath, overlaysConfig, overlaysDir, workingDir, silent);
2720
+ case 'env-example-regen':
2721
+ return executeEnvExampleRegen(outputPath, overlaysConfig, overlaysDir, workingDir, silent);
2722
+ case 'reproducibility-regen':
2723
+ return executeReproducibilityRegen(outputPath, overlaysConfig, overlaysDir, workingDir, silent);
1374
2724
  case 'node-version-fix':
1375
2725
  return executeNodeVersionFix();
1376
2726
  case 'docker-repair': {
@@ -1439,7 +2789,7 @@ function determineExitDisposition(summary, finalFindings) {
1439
2789
  /**
1440
2790
  * Run the full fix flow: diagnose → narrate → remediate → re-check → summarise.
1441
2791
  */
1442
- async function executeFixRun(report, outputPath, overlaysConfig, overlaysDir, requestedJson, explicitManifestPath) {
2792
+ async function executeFixRun(report, outputPath, overlaysConfig, overlaysDir, requestedJson, explicitManifestPath, workingDir = process.cwd()) {
1443
2793
  const initialFindings = reportToFindings(report);
1444
2794
  // Separate automatic and manual-only fixable findings
1445
2795
  const autoFixable = initialFindings.filter((f) => f.fixEligibility === 'automatic' && f.status !== 'pass');
@@ -1471,7 +2821,7 @@ async function executeFixRun(report, outputPath, overlaysConfig, overlaysDir, re
1471
2821
  }
1472
2822
  }
1473
2823
  }
1474
- const execution = await executeSingleFix(finding, outputPath, overlaysConfig, overlaysDir, requestedJson, explicitManifestPath);
2824
+ const execution = await executeSingleFix(finding, outputPath, overlaysConfig, overlaysDir, requestedJson, explicitManifestPath, workingDir);
1475
2825
  executions.push(execution);
1476
2826
  if (finding.remediationKey === 'manifest-migration' &&
1477
2827
  execution.outcome !== 'fixed' &&
@@ -1500,12 +2850,24 @@ async function executeFixRun(report, outputPath, overlaysConfig, overlaysDir, re
1500
2850
  const overlayChecks = checkOverlays(overlaysDir);
1501
2851
  const finalManifestPath = explicitManifestPath ?? path.join(outputPath, 'superposition.json');
1502
2852
  const portChecks = checkPorts(overlaysConfig, finalManifestPath);
2853
+ const finalDriftChecks = checkProjectFileDrift(overlaysConfig, workingDir, finalManifestPath);
2854
+ const finalParamChecks = checkParameters(overlaysConfig, outputPath, workingDir);
2855
+ const finalDepChecks = checkDependencies(overlaysConfig, workingDir);
2856
+ const finalPortCrossChecks = checkPortCrossValidation(outputPath);
2857
+ const finalEnvDriftChecks = checkEnvExampleDrift(overlaysConfig, outputPath, workingDir);
2858
+ const finalReproChecks = await checkReproducibility(overlaysConfig, outputPath, overlaysDir, workingDir);
1503
2859
  const finalFindings = [
1504
2860
  ...checksToFindings(envChecks, 'environment', 'environment'),
1505
2861
  ...checksToFindings(manifestChecks, 'manifest', 'manifest'),
1506
2862
  ...checksToFindings(mergeChecks, 'merge', 'devcontainer'),
1507
2863
  ...checksToFindings(overlayChecks, 'overlay', 'full'),
1508
2864
  ...checksToFindings(portChecks, 'ports', 'environment'),
2865
+ ...checksToFindings(finalDriftChecks, 'manifest', 'manifest'),
2866
+ ...checksToFindings(finalParamChecks, 'manifest', 'full'),
2867
+ ...checksToFindings(finalDepChecks, 'manifest', 'full'),
2868
+ ...checksToFindings(finalPortCrossChecks, 'ports', 'full'),
2869
+ ...checksToFindings(finalEnvDriftChecks, 'manifest', 'full'),
2870
+ ...checksToFindings(finalReproChecks, 'manifest', 'full'),
1509
2871
  ];
1510
2872
  const summary = buildOutcomeSummary(executions);
1511
2873
  const exitDisposition = determineExitDisposition(summary, finalFindings);
@@ -1671,6 +3033,11 @@ export async function doctorCommand(overlaysConfig, overlaysDir, options) {
1671
3033
  borderStyle: 'round',
1672
3034
  }));
1673
3035
  }
3036
+ // ── Validate --dry-run flag ───────────────────────────────────────────────
3037
+ if (options.dryRun && !options.fix) {
3038
+ console.error(chalk.red('✗ Error: --dry-run requires --fix. Use: cs doctor --fix --dry-run'));
3039
+ process.exit(1);
3040
+ }
1674
3041
  // Run all checks
1675
3042
  const environmentChecks = checkEnvironment(outputPath, explicitManifestPath);
1676
3043
  const overlayChecks = checkOverlays(overlaysDir);
@@ -1678,15 +3045,80 @@ export async function doctorCommand(overlaysConfig, overlaysDir, options) {
1678
3045
  const mergeChecks = checkMergeStrategy(outputPath);
1679
3046
  const manifestPath = explicitManifestPath ?? path.join(outputPath, 'superposition.json');
1680
3047
  const portChecks = checkPorts(overlaysConfig, manifestPath);
3048
+ const driftChecks = checkProjectFileDrift(overlaysConfig, workingDir, manifestPath);
3049
+ const parametersChecks = checkParameters(overlaysConfig, outputPath, workingDir);
3050
+ const dependenciesChecks = checkDependencies(overlaysConfig, workingDir);
3051
+ const portCrossValidationChecks = checkPortCrossValidation(outputPath);
3052
+ const envExampleDriftChecks = checkEnvExampleDrift(overlaysConfig, outputPath, workingDir);
3053
+ const reproducibilityChecks = await checkReproducibility(overlaysConfig, outputPath, overlaysDir, workingDir);
1681
3054
  // Generate report
1682
- const report = generateReport(environmentChecks, overlayChecks, manifestChecks, mergeChecks, portChecks);
3055
+ const report = generateReport(environmentChecks, overlayChecks, manifestChecks, mergeChecks, portChecks, driftChecks, parametersChecks, dependenciesChecks, portCrossValidationChecks, envExampleDriftChecks, reproducibilityChecks);
1683
3056
  if (options.fix) {
1684
3057
  // ── Fix flow ──────────────────────────────────────────────────────────
1685
3058
  if (!options.json) {
1686
3059
  // Print diagnostic findings first (as normal)
1687
3060
  console.log(formatAsText(report));
1688
3061
  }
1689
- const fixRun = await executeFixRun(report, outputPath, overlaysConfig, overlaysDir, options.json ?? false, explicitManifestPath);
3062
+ // ── Dry-run branch ────────────────────────────────────────────────────
3063
+ if (options.dryRun) {
3064
+ const allFindings = reportToFindings(report);
3065
+ const autoFixable = allFindings.filter((f) => f.fixEligibility === 'automatic' && f.status !== 'pass');
3066
+ const manualOnly = allFindings.filter((f) => f.fixEligibility === 'manual-only' && f.status !== 'pass');
3067
+ if (options.json) {
3068
+ const plannedActions = autoFixable.map((f) => {
3069
+ const action = REMEDIATION_REGISTRY.get(f.remediationKey ?? '');
3070
+ return {
3071
+ findingName: f.name,
3072
+ remediationKey: f.remediationKey ?? '',
3073
+ plannedChanges: action?.plannedChanges ?? [],
3074
+ safetyClass: action?.safetyClass ?? 'safe-unattended',
3075
+ };
3076
+ });
3077
+ console.log(JSON.stringify({
3078
+ dryRun: true,
3079
+ plannedActions,
3080
+ manualFindings: manualOnly.map((f) => ({
3081
+ name: f.name,
3082
+ message: f.message,
3083
+ })),
3084
+ }, null, 2));
3085
+ }
3086
+ else {
3087
+ if (autoFixable.length === 0) {
3088
+ console.log(chalk.green('\nDoctor dry-run — no auto-fixable findings. Nothing to apply.'));
3089
+ }
3090
+ else {
3091
+ console.log(chalk.bold('\nDoctor dry-run — changes that --fix would apply:'));
3092
+ console.log(chalk.dim('══════════════════════════════════════════════════'));
3093
+ autoFixable.forEach((f, i) => {
3094
+ const action = REMEDIATION_REGISTRY.get(f.remediationKey ?? '');
3095
+ console.log(`\n [${i + 1}] ${chalk.cyan(f.remediationKey ?? '')} (${chalk.dim(action?.safetyClass ?? '')})`);
3096
+ console.log(` Finding: ${chalk.white(`"${f.message}"`)}`);
3097
+ console.log(` Would:`);
3098
+ for (const change of action?.plannedChanges ?? []) {
3099
+ console.log(` ${chalk.dim('•')} ${chalk.dim(change)}`);
3100
+ }
3101
+ });
3102
+ console.log(chalk.dim('\n──────────────────────────────────────────────────'));
3103
+ console.log(` ${chalk.yellow(`${autoFixable.length} fix action(s) would be applied.`)} Run without --dry-run to apply.`);
3104
+ }
3105
+ if (manualOnly.length > 0) {
3106
+ console.log(chalk.bold('\nFindings that require manual action (not auto-fixable):'));
3107
+ for (const f of manualOnly) {
3108
+ console.log(` ${chalk.red('✗')} ${chalk.white(f.name)} — ${chalk.gray(f.message)}`);
3109
+ const action = REMEDIATION_REGISTRY.get(f.remediationKey ?? '');
3110
+ if (action?.manualFallback.length) {
3111
+ for (const step of action.manualFallback) {
3112
+ console.log(` ${chalk.dim('→')} ${chalk.dim(step)}`);
3113
+ }
3114
+ }
3115
+ }
3116
+ }
3117
+ }
3118
+ const hasAnyFindings = autoFixable.length > 0 || manualOnly.length > 0;
3119
+ process.exit(hasAnyFindings ? 1 : 0);
3120
+ }
3121
+ const fixRun = await executeFixRun(report, outputPath, overlaysConfig, overlaysDir, options.json ?? false, explicitManifestPath, workingDir);
1690
3122
  if (options.json) {
1691
3123
  console.log(JSON.stringify(fixRun, null, 2));
1692
3124
  }