container-superposition 0.1.8 → 0.1.10

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 (184) hide show
  1. package/README.md +3 -0
  2. package/dist/tool/cli/args.d.ts.map +1 -1
  3. package/dist/tool/cli/args.js +1 -1
  4. package/dist/tool/cli/args.js.map +1 -1
  5. package/dist/tool/commands/adopt.d.ts.map +1 -1
  6. package/dist/tool/commands/adopt.js +15 -21
  7. package/dist/tool/commands/adopt.js.map +1 -1
  8. package/dist/tool/commands/doctor.d.ts +1 -0
  9. package/dist/tool/commands/doctor.d.ts.map +1 -1
  10. package/dist/tool/commands/doctor.js +1370 -73
  11. package/dist/tool/commands/doctor.js.map +1 -1
  12. package/dist/tool/questionnaire/composer.d.ts +3 -1
  13. package/dist/tool/questionnaire/composer.d.ts.map +1 -1
  14. package/dist/tool/questionnaire/composer.js +273 -20
  15. package/dist/tool/questionnaire/composer.js.map +1 -1
  16. package/dist/tool/questionnaire/presets.d.ts.map +1 -1
  17. package/dist/tool/questionnaire/presets.js +1 -0
  18. package/dist/tool/questionnaire/presets.js.map +1 -1
  19. package/dist/tool/questionnaire/questionnaire.d.ts.map +1 -1
  20. package/dist/tool/questionnaire/questionnaire.js +3 -1
  21. package/dist/tool/questionnaire/questionnaire.js.map +1 -1
  22. package/dist/tool/schema/project-config.d.ts.map +1 -1
  23. package/dist/tool/schema/project-config.js +174 -1
  24. package/dist/tool/schema/project-config.js.map +1 -1
  25. package/dist/tool/schema/types.d.ts +53 -2
  26. package/dist/tool/schema/types.d.ts.map +1 -1
  27. package/docs/README.md +1 -0
  28. package/docs/overlays.md +188 -147
  29. package/docs/specs/001-verbose-plan-graph/spec.md +5 -12
  30. package/docs/specs/002-superposition-config-file/spec.md +5 -12
  31. package/docs/specs/003-mkdocs2-overlay/spec.md +2 -9
  32. package/docs/specs/004-doctor-fix/spec.md +1 -8
  33. package/docs/specs/005-cuda-overlay/spec.md +2 -9
  34. package/docs/specs/006-rocm-overlay/spec.md +3 -10
  35. package/docs/specs/007-target-aware-generation/spec.md +4 -11
  36. package/docs/specs/008-project-file-canonical/spec.md +7 -8
  37. package/docs/specs/009-project-env/spec.md +3 -10
  38. package/docs/specs/010-compose-env-materialization/spec.md +3 -10
  39. package/docs/specs/011-overlay-parameters/spec.md +2 -9
  40. package/docs/specs/012-ollama-cli-overlay/spec.md +47 -0
  41. package/docs/specs/013-doctor-dependency-check/spec.md +250 -0
  42. package/docs/specs/014-doctor-compose-port-cross-validation/spec.md +276 -0
  43. package/docs/specs/015-doctor-env-example-drift/spec.md +248 -0
  44. package/docs/specs/016-doctor-reproducibility-check/spec.md +276 -0
  45. package/docs/specs/017-doctor-dry-run/spec.md +276 -0
  46. package/docs/specs/{007-init-project-file → 018-init-project-file}/spec.md +2 -9
  47. package/docs/specs/019-project-mounts/spec.md +176 -0
  48. package/docs/specs/taxonomy.md +186 -0
  49. package/docs/superposition-yml.md +467 -0
  50. package/overlays/.presets/full-observability.yml +113 -0
  51. package/overlays/.presets/k8s-dev.yml +174 -0
  52. package/overlays/.presets/local-llm.yml +105 -0
  53. package/overlays/.presets/vector-ai.yml +150 -0
  54. package/overlays/.shared/vscode/js-ts-settings.json +19 -0
  55. package/overlays/.shared/vscode/markdown-extensions.json +8 -0
  56. package/overlays/alertmanager/devcontainer.patch.json +0 -1
  57. package/overlays/alertmanager/docker-compose.yml +8 -0
  58. package/overlays/alertmanager/overlay.yml +1 -0
  59. package/overlays/amp/devcontainer.patch.json +4 -1
  60. package/overlays/ansible/README.md +163 -0
  61. package/overlays/ansible/devcontainer.patch.json +14 -0
  62. package/overlays/ansible/overlay.yml +18 -0
  63. package/overlays/argocd/README.md +158 -0
  64. package/overlays/argocd/devcontainer.patch.json +9 -0
  65. package/overlays/argocd/overlay.yml +17 -0
  66. package/overlays/argocd/setup.sh +29 -0
  67. package/overlays/argocd/verify.sh +14 -0
  68. package/overlays/bun/devcontainer.patch.json +1 -10
  69. package/overlays/bun/overlay.yml +8 -1
  70. package/overlays/claude-code/devcontainer.patch.json +6 -1
  71. package/overlays/codex/devcontainer.patch.json +5 -0
  72. package/overlays/comfyui/docker-compose.yml +1 -0
  73. package/overlays/comfyui/overlay.yml +4 -0
  74. package/overlays/commitlint/devcontainer.patch.json +1 -6
  75. package/overlays/docker-sock/overlay.yml +1 -0
  76. package/overlays/dotnet/overlay.yml +4 -1
  77. package/overlays/fuseki/.env.example +5 -0
  78. package/overlays/fuseki/README.md +173 -0
  79. package/overlays/fuseki/devcontainer.patch.json +18 -0
  80. package/overlays/fuseki/docker-compose.yml +29 -0
  81. package/overlays/fuseki/overlay.yml +42 -0
  82. package/overlays/fuseki/verify.sh +58 -0
  83. package/overlays/gemini-cli/devcontainer.patch.json +4 -1
  84. package/overlays/go/overlay.yml +6 -1
  85. package/overlays/grafana/devcontainer.patch.json +0 -1
  86. package/overlays/grafana/docker-compose.yml +8 -2
  87. package/overlays/grafana/overlay.yml +6 -1
  88. package/overlays/jaeger/.env.example +11 -0
  89. package/overlays/jaeger/README.md +33 -4
  90. package/overlays/jaeger/devcontainer.patch.json +9 -1
  91. package/overlays/jaeger/docker-compose.yml +17 -0
  92. package/overlays/jaeger/overlay.yml +1 -12
  93. package/overlays/java/overlay.yml +6 -1
  94. package/overlays/jupyter/docker-compose.yml +1 -0
  95. package/overlays/jupyter/overlay.yml +1 -0
  96. package/overlays/keycloak/devcontainer.patch.json +0 -1
  97. package/overlays/keycloak/docker-compose.yml +1 -0
  98. package/overlays/keycloak/overlay.yml +15 -0
  99. package/overlays/localstack/docker-compose.yml +1 -0
  100. package/overlays/localstack/overlay.yml +19 -1
  101. package/overlays/loki/devcontainer.patch.json +0 -1
  102. package/overlays/loki/docker-compose.yml +8 -0
  103. package/overlays/loki/overlay.yml +1 -0
  104. package/overlays/mailpit/docker-compose.yml +1 -0
  105. package/overlays/mailpit/overlay.yml +1 -0
  106. package/overlays/minio/devcontainer.patch.json +1 -1
  107. package/overlays/minio/docker-compose.yml +1 -0
  108. package/overlays/minio/overlay.yml +23 -2
  109. package/overlays/mkdocs/devcontainer.patch.json +1 -5
  110. package/overlays/mkdocs/overlay.yml +3 -1
  111. package/overlays/mkdocs2/devcontainer.patch.json +1 -5
  112. package/overlays/mkdocs2/overlay.yml +2 -0
  113. package/overlays/mongodb/docker-compose.yml +2 -0
  114. package/overlays/mongodb/overlay.yml +26 -2
  115. package/overlays/mysql/docker-compose.yml +2 -0
  116. package/overlays/mysql/overlay.yml +36 -2
  117. package/overlays/nats/docker-compose.yml +1 -0
  118. package/overlays/nats/overlay.yml +18 -2
  119. package/overlays/nodejs/devcontainer.patch.json +1 -12
  120. package/overlays/nodejs/overlay.yml +8 -1
  121. package/overlays/ollama/README.md +4 -3
  122. package/overlays/ollama/docker-compose.yml +1 -0
  123. package/overlays/ollama/overlay.yml +6 -1
  124. package/overlays/ollama/verify.sh +5 -28
  125. package/overlays/ollama-cli/README.md +90 -0
  126. package/overlays/ollama-cli/devcontainer.patch.json +3 -0
  127. package/overlays/ollama-cli/overlay.yml +19 -0
  128. package/overlays/{ollama → ollama-cli}/setup.sh +7 -10
  129. package/overlays/ollama-cli/verify.sh +49 -0
  130. package/overlays/open-webui/docker-compose.yml +1 -0
  131. package/overlays/open-webui/overlay.yml +8 -1
  132. package/overlays/opencode/devcontainer.patch.json +4 -1
  133. package/overlays/otel-collector/README.md +4 -0
  134. package/overlays/otel-collector/devcontainer.patch.json +4 -1
  135. package/overlays/otel-collector/docker-compose.yml +8 -4
  136. package/overlays/otel-collector/overlay.yml +1 -0
  137. package/overlays/otel-demo-nodejs/devcontainer.patch.json +0 -1
  138. package/overlays/otel-demo-nodejs/docker-compose.yml +1 -0
  139. package/overlays/otel-demo-nodejs/overlay.yml +9 -1
  140. package/overlays/otel-demo-python/devcontainer.patch.json +0 -1
  141. package/overlays/otel-demo-python/docker-compose.yml +1 -0
  142. package/overlays/otel-demo-python/overlay.yml +6 -1
  143. package/overlays/pandoc/README.md +10 -0
  144. package/overlays/pandoc/devcontainer.patch.json +0 -5
  145. package/overlays/pandoc/overlay.yml +2 -0
  146. package/overlays/pandoc/setup.sh +10 -0
  147. package/overlays/pgvector/devcontainer.patch.json +11 -5
  148. package/overlays/pgvector/docker-compose.yml +1 -0
  149. package/overlays/pgvector/overlay.yml +3 -0
  150. package/overlays/playwright/devcontainer.patch.json +0 -5
  151. package/overlays/playwright/overlay.yml +2 -1
  152. package/overlays/postgres/docker-compose.yml +1 -0
  153. package/overlays/postgres/overlay.yml +4 -1
  154. package/overlays/pre-commit/devcontainer.patch.json +1 -7
  155. package/overlays/prometheus/devcontainer.patch.json +0 -1
  156. package/overlays/prometheus/docker-compose.yml +8 -0
  157. package/overlays/prometheus/overlay.yml +1 -0
  158. package/overlays/promtail/devcontainer.patch.json +1 -2
  159. package/overlays/promtail/docker-compose.yml +8 -0
  160. package/overlays/promtail/overlay.yml +1 -0
  161. package/overlays/qdrant/docker-compose.yml +1 -0
  162. package/overlays/qdrant/overlay.yml +5 -1
  163. package/overlays/rabbitmq/docker-compose.yml +1 -0
  164. package/overlays/rabbitmq/overlay.yml +25 -2
  165. package/overlays/redis/docker-compose.yml +7 -0
  166. package/overlays/redis/overlay.yml +15 -1
  167. package/overlays/redpanda/docker-compose.yml +1 -0
  168. package/overlays/redpanda/overlay.yml +15 -3
  169. package/overlays/rocm/overlay.yml +2 -1
  170. package/overlays/rust/overlay.yml +3 -1
  171. package/overlays/sqlserver/docker-compose.yml +1 -0
  172. package/overlays/sqlserver/overlay.yml +17 -0
  173. package/overlays/task/README.md +47 -0
  174. package/overlays/task/devcontainer.patch.json +9 -0
  175. package/overlays/task/overlay.yml +16 -0
  176. package/overlays/task/setup.sh +29 -0
  177. package/overlays/task/verify.sh +14 -0
  178. package/overlays/tempo/devcontainer.patch.json +0 -1
  179. package/overlays/tempo/docker-compose.yml +8 -0
  180. package/overlays/tempo/overlay.yml +1 -0
  181. package/overlays/windsurf-cli/devcontainer.patch.json +4 -1
  182. package/package.json +1 -1
  183. package/tool/schema/config.schema.json +74 -1
  184. package/overlays/.shared/otel/otel-base-config.yaml +0 -30
@@ -2,18 +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
17
  import { mergeAnswers } from '../questionnaire/answers.js';
16
- import { loadProjectConfig } from '../schema/project-config.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';
17
22
  // ─── Remediation registry ─────────────────────────────────────────────────
18
23
  const REMEDIATION_REGISTRY = new Map([
19
24
  [
@@ -78,6 +83,71 @@ const REMEDIATION_REGISTRY = new Map([
78
83
  ],
79
84
  },
80
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
+ ],
81
151
  ]);
82
152
  /**
83
153
  * Semantic version comparison helper
@@ -844,97 +914,1018 @@ function checkMergeStrategy(outputPath) {
844
914
  });
845
915
  }
846
916
  else {
847
- results.push({
848
- name: 'Service dependencies',
849
- status: 'pass',
850
- message: 'All service dependencies are valid',
851
- });
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);
1764
+ }
1765
+ }
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'];
1779
+ }
1780
+ return JSON.stringify(obj, null, 4);
852
1781
  }
1782
+ catch {
1783
+ // Unparseable JSON — fall through to character comparison
1784
+ }
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
+ });
853
1816
  }
854
1817
  }
855
- 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) {
856
1833
  results.push({
857
- name: 'Compose merge validation',
858
- status: 'warn',
859
- message: 'Unable to validate docker-compose merge',
860
- 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',
861
1838
  });
862
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
+ }
863
1851
  }
864
- return results;
865
1852
  }
866
1853
  /**
867
- * Check for drift between the project config file and the last generated manifest.
868
- * Reports a warning when the overlay lists differ so users know to run `regen`.
1854
+ * Execute the reproducibility-regen remediation: call composeDevContainer with isRegen: true.
869
1855
  */
870
- function checkProjectFileDrift(overlaysConfig, workingDir, manifestPath) {
871
- // Load project config — skip silently if not present
1856
+ async function executeReproducibilityRegen(outputPath, overlaysConfig, overlaysDir, workingDir, silent = false) {
872
1857
  let projectConfig;
873
1858
  try {
874
1859
  projectConfig = loadProjectConfig(overlaysConfig, workingDir);
875
1860
  }
876
- catch {
877
- return [];
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
+ };
878
1870
  }
879
1871
  if (!projectConfig) {
880
- return [];
881
- }
882
- // Load manifest — if not present while the project file exists, report it informatively
883
- if (!fs.existsSync(manifestPath)) {
884
- return [
885
- {
886
- name: 'Project file drift',
887
- status: 'warn',
888
- message: 'Project file found but no generated manifest — run `cs regen` to generate',
889
- fixEligibility: 'not-applicable',
890
- },
891
- ];
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
+ };
892
1880
  }
893
- let manifest;
1881
+ let answers;
894
1882
  try {
895
- const raw = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
896
- manifest = needsMigration(raw) ? migrateManifest(raw) : raw;
1883
+ const baseAnswers = buildAnswersFromProjectConfig(projectConfig.selection, overlaysConfig);
1884
+ const withPreset = await applyPresetSelections(baseAnswers, overlaysConfig, PRESETS_DIR);
1885
+ answers = mergeAnswers(withPreset, { outputPath });
897
1886
  }
898
- catch {
899
- return [];
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
+ };
900
1896
  }
901
- // Compare overlay sets (order-independent).
902
- // Exclude auto-resolved dependencies from the manifest side — the project file only
903
- // stores user-selected overlays; auto-resolved ones are re-calculated at generation time.
904
- const autoResolvedAdded = new Set(manifest.autoResolved?.added ?? []);
905
- const projectOverlays = new Set(projectConfig.selection.overlays ?? []);
906
- const manifestBaseOverlays = new Set((manifest.overlays ?? []).filter((o) => !autoResolvedAdded.has(o)));
907
- const inProjectNotManifest = [...projectOverlays].filter((o) => !manifestBaseOverlays.has(o));
908
- const inManifestNotProject = [...manifestBaseOverlays].filter((o) => !projectOverlays.has(o));
909
- if (inProjectNotManifest.length === 0 && inManifestNotProject.length === 0) {
910
- return [
911
- {
912
- name: 'Project file drift',
913
- status: 'pass',
914
- message: 'Project file and generated manifest are consistent',
915
- fixEligibility: 'not-applicable',
916
- },
917
- ];
1897
+ const originalLog = console.log;
1898
+ if (silent)
1899
+ console.log = () => { };
1900
+ try {
1901
+ await composeDevContainer(answers, overlaysDir, { isRegen: true });
918
1902
  }
919
- const details = [];
920
- if (inProjectNotManifest.length > 0) {
921
- details.push(`In project file but not in manifest: ${inProjectNotManifest.join(', ')}`);
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
+ };
922
1914
  }
923
- if (inManifestNotProject.length > 0) {
924
- details.push(`In manifest but not in project file: ${inManifestNotProject.join(', ')}`);
1915
+ finally {
1916
+ if (silent)
1917
+ console.log = originalLog;
925
1918
  }
926
- details.push('Run "cs regen" to regenerate with the current project file configuration');
927
- return [
928
- {
929
- name: 'Project file drift',
930
- status: 'warn',
931
- message: 'Project file and generated manifest have diverged',
932
- details,
933
- fixEligibility: 'manual-only',
934
- },
935
- ];
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
+ };
936
1927
  }
937
- function generateReport(environmentChecks, overlayChecks, manifestChecks, mergeChecks, portChecks, driftChecks = []) {
1928
+ function generateReport(environmentChecks, overlayChecks, manifestChecks, mergeChecks, portChecks, driftChecks = [], parametersChecks = [], dependenciesChecks = [], portCrossValidationChecks = [], envExampleDriftChecks = [], reproducibilityChecks = []) {
938
1929
  const allChecks = [
939
1930
  ...environmentChecks,
940
1931
  ...overlayChecks,
@@ -942,6 +1933,11 @@ function generateReport(environmentChecks, overlayChecks, manifestChecks, mergeC
942
1933
  ...mergeChecks,
943
1934
  ...portChecks,
944
1935
  ...driftChecks,
1936
+ ...parametersChecks,
1937
+ ...dependenciesChecks,
1938
+ ...portCrossValidationChecks,
1939
+ ...envExampleDriftChecks,
1940
+ ...reproducibilityChecks,
945
1941
  ];
946
1942
  const passed = allChecks.filter((c) => c.status === 'pass').length;
947
1943
  const warnings = allChecks.filter((c) => c.status === 'warn').length;
@@ -954,6 +1950,11 @@ function generateReport(environmentChecks, overlayChecks, manifestChecks, mergeC
954
1950
  merge: mergeChecks,
955
1951
  ports: portChecks,
956
1952
  drift: driftChecks,
1953
+ parameters: parametersChecks,
1954
+ dependencies: dependenciesChecks,
1955
+ portCrossValidation: portCrossValidationChecks,
1956
+ envExampleDrift: envExampleDriftChecks,
1957
+ reproducibility: reproducibilityChecks,
957
1958
  summary: {
958
1959
  passed,
959
1960
  warnings,
@@ -1044,6 +2045,65 @@ function formatAsText(report) {
1044
2045
  lines.push(` ${chalk.green('✓')} ${chalk.white('Project file and manifest are consistent')}`);
1045
2046
  }
1046
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
+ }
1047
2107
  // Summary
1048
2108
  lines.push(chalk.bold('\nSummary:'));
1049
2109
  lines.push(` ${chalk.green('✓')} ${report.summary.passed} passed`);
@@ -1092,6 +2152,11 @@ function reportToFindings(report) {
1092
2152
  ...checksToFindings(report.merge, 'merge', 'devcontainer'),
1093
2153
  ...checksToFindings(report.ports, 'ports', 'environment'),
1094
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'),
1095
2160
  ];
1096
2161
  }
1097
2162
  /**
@@ -1101,8 +2166,12 @@ function orderFindingsForRemediation(findings) {
1101
2166
  const PRIORITY = {
1102
2167
  'manifest-migration': 1,
1103
2168
  'devcontainer-regeneration': 2,
1104
- 'node-version-fix': 3,
1105
- '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,
1106
2175
  };
1107
2176
  return [...findings].sort((a, b) => {
1108
2177
  const pa = PRIORITY[a.remediationKey ?? ''] ?? 99;
@@ -1158,6 +2227,7 @@ function buildAnswersFromManifest(manifest, manifestDir, overlaysConfig) {
1158
2227
  language.push(id);
1159
2228
  break;
1160
2229
  case 'database':
2230
+ case 'messaging':
1161
2231
  database.push(id);
1162
2232
  break;
1163
2233
  case 'observability':
@@ -1494,15 +2564,163 @@ function executeNodeVersionFix() {
1494
2564
  rechecked: true,
1495
2565
  };
1496
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
+ }
1497
2707
  /**
1498
2708
  * Execute a single remediation action and return its execution record.
1499
2709
  */
1500
- async function executeSingleFix(finding, outputPath, overlaysConfig, overlaysDir, silent = false, explicitManifestPath) {
2710
+ async function executeSingleFix(finding, outputPath, overlaysConfig, overlaysDir, silent = false, explicitManifestPath, workingDir = process.cwd()) {
1501
2711
  switch (finding.remediationKey) {
1502
2712
  case 'manifest-migration':
1503
2713
  return executeManifestMigration(outputPath, explicitManifestPath);
1504
2714
  case 'devcontainer-regeneration':
1505
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);
1506
2724
  case 'node-version-fix':
1507
2725
  return executeNodeVersionFix();
1508
2726
  case 'docker-repair': {
@@ -1603,7 +2821,7 @@ async function executeFixRun(report, outputPath, overlaysConfig, overlaysDir, re
1603
2821
  }
1604
2822
  }
1605
2823
  }
1606
- const execution = await executeSingleFix(finding, outputPath, overlaysConfig, overlaysDir, requestedJson, explicitManifestPath);
2824
+ const execution = await executeSingleFix(finding, outputPath, overlaysConfig, overlaysDir, requestedJson, explicitManifestPath, workingDir);
1607
2825
  executions.push(execution);
1608
2826
  if (finding.remediationKey === 'manifest-migration' &&
1609
2827
  execution.outcome !== 'fixed' &&
@@ -1633,6 +2851,11 @@ async function executeFixRun(report, outputPath, overlaysConfig, overlaysDir, re
1633
2851
  const finalManifestPath = explicitManifestPath ?? path.join(outputPath, 'superposition.json');
1634
2852
  const portChecks = checkPorts(overlaysConfig, finalManifestPath);
1635
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);
1636
2859
  const finalFindings = [
1637
2860
  ...checksToFindings(envChecks, 'environment', 'environment'),
1638
2861
  ...checksToFindings(manifestChecks, 'manifest', 'manifest'),
@@ -1640,6 +2863,11 @@ async function executeFixRun(report, outputPath, overlaysConfig, overlaysDir, re
1640
2863
  ...checksToFindings(overlayChecks, 'overlay', 'full'),
1641
2864
  ...checksToFindings(portChecks, 'ports', 'environment'),
1642
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'),
1643
2871
  ];
1644
2872
  const summary = buildOutcomeSummary(executions);
1645
2873
  const exitDisposition = determineExitDisposition(summary, finalFindings);
@@ -1805,6 +3033,11 @@ export async function doctorCommand(overlaysConfig, overlaysDir, options) {
1805
3033
  borderStyle: 'round',
1806
3034
  }));
1807
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
+ }
1808
3041
  // Run all checks
1809
3042
  const environmentChecks = checkEnvironment(outputPath, explicitManifestPath);
1810
3043
  const overlayChecks = checkOverlays(overlaysDir);
@@ -1813,14 +3046,78 @@ export async function doctorCommand(overlaysConfig, overlaysDir, options) {
1813
3046
  const manifestPath = explicitManifestPath ?? path.join(outputPath, 'superposition.json');
1814
3047
  const portChecks = checkPorts(overlaysConfig, manifestPath);
1815
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);
1816
3054
  // Generate report
1817
- const report = generateReport(environmentChecks, overlayChecks, manifestChecks, mergeChecks, portChecks, driftChecks);
3055
+ const report = generateReport(environmentChecks, overlayChecks, manifestChecks, mergeChecks, portChecks, driftChecks, parametersChecks, dependenciesChecks, portCrossValidationChecks, envExampleDriftChecks, reproducibilityChecks);
1818
3056
  if (options.fix) {
1819
3057
  // ── Fix flow ──────────────────────────────────────────────────────────
1820
3058
  if (!options.json) {
1821
3059
  // Print diagnostic findings first (as normal)
1822
3060
  console.log(formatAsText(report));
1823
3061
  }
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
+ }
1824
3121
  const fixRun = await executeFixRun(report, outputPath, overlaysConfig, overlaysDir, options.json ?? false, explicitManifestPath, workingDir);
1825
3122
  if (options.json) {
1826
3123
  console.log(JSON.stringify(fixRun, null, 2));