container-superposition 0.1.8 → 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.
- package/dist/tool/commands/adopt.js +1 -1
- package/dist/tool/commands/adopt.js.map +1 -1
- package/dist/tool/commands/doctor.d.ts +1 -0
- package/dist/tool/commands/doctor.d.ts.map +1 -1
- package/dist/tool/commands/doctor.js +1370 -73
- package/dist/tool/commands/doctor.js.map +1 -1
- package/dist/tool/questionnaire/composer.d.ts +3 -1
- package/dist/tool/questionnaire/composer.d.ts.map +1 -1
- package/dist/tool/questionnaire/composer.js +87 -18
- package/dist/tool/questionnaire/composer.js.map +1 -1
- package/dist/tool/questionnaire/presets.d.ts.map +1 -1
- package/dist/tool/questionnaire/presets.js +1 -0
- package/dist/tool/questionnaire/presets.js.map +1 -1
- package/dist/tool/questionnaire/questionnaire.d.ts.map +1 -1
- package/dist/tool/questionnaire/questionnaire.js +3 -1
- package/dist/tool/questionnaire/questionnaire.js.map +1 -1
- package/dist/tool/schema/project-config.d.ts.map +1 -1
- package/dist/tool/schema/project-config.js +5 -1
- package/dist/tool/schema/project-config.js.map +1 -1
- package/dist/tool/schema/types.d.ts +4 -2
- package/dist/tool/schema/types.d.ts.map +1 -1
- package/docs/overlays.md +158 -147
- package/docs/specs/001-verbose-plan-graph/spec.md +5 -12
- package/docs/specs/002-superposition-config-file/spec.md +5 -12
- package/docs/specs/003-mkdocs2-overlay/spec.md +2 -9
- package/docs/specs/004-doctor-fix/spec.md +1 -8
- package/docs/specs/005-cuda-overlay/spec.md +2 -9
- package/docs/specs/006-rocm-overlay/spec.md +3 -10
- package/docs/specs/007-target-aware-generation/spec.md +4 -11
- package/docs/specs/008-project-file-canonical/spec.md +7 -8
- package/docs/specs/009-project-env/spec.md +3 -10
- package/docs/specs/010-compose-env-materialization/spec.md +3 -10
- package/docs/specs/011-overlay-parameters/spec.md +2 -9
- package/docs/specs/012-ollama-cli-overlay/spec.md +47 -0
- package/docs/specs/013-doctor-dependency-check/spec.md +250 -0
- package/docs/specs/014-doctor-compose-port-cross-validation/spec.md +276 -0
- package/docs/specs/015-doctor-env-example-drift/spec.md +248 -0
- package/docs/specs/016-doctor-reproducibility-check/spec.md +276 -0
- package/docs/specs/017-doctor-dry-run/spec.md +276 -0
- package/docs/specs/{007-init-project-file → 018-init-project-file}/spec.md +2 -9
- package/docs/specs/taxonomy.md +186 -0
- package/overlays/.presets/full-observability.yml +113 -0
- package/overlays/.presets/k8s-dev.yml +174 -0
- package/overlays/.presets/local-llm.yml +105 -0
- package/overlays/.presets/vector-ai.yml +150 -0
- package/overlays/.shared/vscode/js-ts-settings.json +19 -0
- package/overlays/.shared/vscode/markdown-extensions.json +8 -0
- package/overlays/alertmanager/devcontainer.patch.json +0 -1
- package/overlays/alertmanager/docker-compose.yml +8 -0
- package/overlays/alertmanager/overlay.yml +1 -0
- package/overlays/amp/devcontainer.patch.json +4 -1
- package/overlays/bun/devcontainer.patch.json +1 -10
- package/overlays/bun/overlay.yml +8 -1
- package/overlays/claude-code/devcontainer.patch.json +6 -1
- package/overlays/codex/devcontainer.patch.json +5 -0
- package/overlays/comfyui/docker-compose.yml +1 -0
- package/overlays/comfyui/overlay.yml +4 -0
- package/overlays/commitlint/devcontainer.patch.json +1 -6
- package/overlays/docker-sock/overlay.yml +1 -0
- package/overlays/dotnet/overlay.yml +4 -1
- package/overlays/fuseki/.env.example +5 -0
- package/overlays/fuseki/README.md +173 -0
- package/overlays/fuseki/devcontainer.patch.json +18 -0
- package/overlays/fuseki/docker-compose.yml +29 -0
- package/overlays/fuseki/overlay.yml +42 -0
- package/overlays/fuseki/verify.sh +58 -0
- package/overlays/gemini-cli/devcontainer.patch.json +4 -1
- package/overlays/go/overlay.yml +6 -1
- package/overlays/grafana/devcontainer.patch.json +0 -1
- package/overlays/grafana/docker-compose.yml +8 -2
- package/overlays/grafana/overlay.yml +6 -1
- package/overlays/jaeger/.env.example +11 -0
- package/overlays/jaeger/README.md +33 -4
- package/overlays/jaeger/devcontainer.patch.json +9 -1
- package/overlays/jaeger/docker-compose.yml +17 -0
- package/overlays/jaeger/overlay.yml +1 -12
- package/overlays/java/overlay.yml +6 -1
- package/overlays/jupyter/docker-compose.yml +1 -0
- package/overlays/jupyter/overlay.yml +1 -0
- package/overlays/keycloak/devcontainer.patch.json +0 -1
- package/overlays/keycloak/docker-compose.yml +1 -0
- package/overlays/keycloak/overlay.yml +15 -0
- package/overlays/localstack/docker-compose.yml +1 -0
- package/overlays/localstack/overlay.yml +19 -1
- package/overlays/loki/devcontainer.patch.json +0 -1
- package/overlays/loki/docker-compose.yml +8 -0
- package/overlays/loki/overlay.yml +1 -0
- package/overlays/mailpit/docker-compose.yml +1 -0
- package/overlays/mailpit/overlay.yml +1 -0
- package/overlays/minio/devcontainer.patch.json +1 -1
- package/overlays/minio/docker-compose.yml +1 -0
- package/overlays/minio/overlay.yml +23 -2
- package/overlays/mkdocs/devcontainer.patch.json +1 -5
- package/overlays/mkdocs/overlay.yml +3 -1
- package/overlays/mkdocs2/devcontainer.patch.json +1 -5
- package/overlays/mkdocs2/overlay.yml +2 -0
- package/overlays/mongodb/docker-compose.yml +2 -0
- package/overlays/mongodb/overlay.yml +26 -2
- package/overlays/mysql/docker-compose.yml +2 -0
- package/overlays/mysql/overlay.yml +36 -2
- package/overlays/nats/docker-compose.yml +1 -0
- package/overlays/nats/overlay.yml +18 -2
- package/overlays/nodejs/devcontainer.patch.json +1 -12
- package/overlays/nodejs/overlay.yml +8 -1
- package/overlays/ollama/README.md +4 -3
- package/overlays/ollama/docker-compose.yml +1 -0
- package/overlays/ollama/overlay.yml +6 -1
- package/overlays/ollama/verify.sh +5 -28
- package/overlays/ollama-cli/README.md +90 -0
- package/overlays/ollama-cli/devcontainer.patch.json +3 -0
- package/overlays/ollama-cli/overlay.yml +19 -0
- package/overlays/{ollama → ollama-cli}/setup.sh +7 -10
- package/overlays/ollama-cli/verify.sh +49 -0
- package/overlays/open-webui/docker-compose.yml +1 -0
- package/overlays/open-webui/overlay.yml +8 -1
- package/overlays/opencode/devcontainer.patch.json +4 -1
- package/overlays/otel-collector/README.md +4 -0
- package/overlays/otel-collector/devcontainer.patch.json +4 -1
- package/overlays/otel-collector/docker-compose.yml +8 -4
- package/overlays/otel-collector/overlay.yml +1 -0
- package/overlays/otel-demo-nodejs/devcontainer.patch.json +0 -1
- package/overlays/otel-demo-nodejs/docker-compose.yml +1 -0
- package/overlays/otel-demo-nodejs/overlay.yml +9 -1
- package/overlays/otel-demo-python/devcontainer.patch.json +0 -1
- package/overlays/otel-demo-python/docker-compose.yml +1 -0
- package/overlays/otel-demo-python/overlay.yml +6 -1
- package/overlays/pandoc/README.md +10 -0
- package/overlays/pandoc/devcontainer.patch.json +0 -5
- package/overlays/pandoc/overlay.yml +2 -0
- package/overlays/pandoc/setup.sh +10 -0
- package/overlays/pgvector/devcontainer.patch.json +11 -5
- package/overlays/pgvector/docker-compose.yml +1 -0
- package/overlays/pgvector/overlay.yml +3 -0
- package/overlays/playwright/devcontainer.patch.json +0 -5
- package/overlays/playwright/overlay.yml +2 -1
- package/overlays/postgres/docker-compose.yml +1 -0
- package/overlays/postgres/overlay.yml +4 -1
- package/overlays/pre-commit/devcontainer.patch.json +1 -7
- package/overlays/prometheus/devcontainer.patch.json +0 -1
- package/overlays/prometheus/docker-compose.yml +8 -0
- package/overlays/prometheus/overlay.yml +1 -0
- package/overlays/promtail/devcontainer.patch.json +1 -2
- package/overlays/promtail/docker-compose.yml +8 -0
- package/overlays/promtail/overlay.yml +1 -0
- package/overlays/qdrant/docker-compose.yml +1 -0
- package/overlays/qdrant/overlay.yml +5 -1
- package/overlays/rabbitmq/docker-compose.yml +1 -0
- package/overlays/rabbitmq/overlay.yml +25 -2
- package/overlays/redis/docker-compose.yml +7 -0
- package/overlays/redis/overlay.yml +15 -1
- package/overlays/redpanda/docker-compose.yml +1 -0
- package/overlays/redpanda/overlay.yml +15 -3
- package/overlays/rocm/overlay.yml +2 -1
- package/overlays/rust/overlay.yml +3 -1
- package/overlays/sqlserver/docker-compose.yml +1 -0
- package/overlays/sqlserver/overlay.yml +17 -0
- package/overlays/tempo/devcontainer.patch.json +0 -1
- package/overlays/tempo/docker-compose.yml +8 -0
- package/overlays/tempo/overlay.yml +1 -0
- package/overlays/windsurf-cli/devcontainer.patch.json +4 -1
- package/package.json +1 -1
- 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
|
-
|
|
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: '
|
|
858
|
-
status: '
|
|
859
|
-
message: '
|
|
860
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
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
|
|
1881
|
+
let answers;
|
|
894
1882
|
try {
|
|
895
|
-
const
|
|
896
|
-
|
|
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
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
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
|
-
|
|
920
|
-
|
|
921
|
-
|
|
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
|
-
|
|
924
|
-
|
|
1915
|
+
finally {
|
|
1916
|
+
if (silent)
|
|
1917
|
+
console.log = originalLog;
|
|
925
1918
|
}
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
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
|
-
'
|
|
1105
|
-
'
|
|
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));
|