@ductape/cli 0.2.23 → 0.3.1
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/CHANGELOG.md +7 -0
- package/README.md +7 -0
- package/dist/commands/db-migrate.js +47 -8
- package/dist/commands/migrate-codebase.d.ts +190 -0
- package/dist/commands/migrate-codebase.js +776 -0
- package/dist/commands/migration-artifact.d.ts +28 -0
- package/dist/commands/migration-artifact.js +101 -0
- package/dist/commands/migration-assurance.d.ts +10 -0
- package/dist/commands/migration-assurance.js +120 -0
- package/dist/commands/migration-capabilities.d.ts +53 -0
- package/dist/commands/migration-capabilities.js +66 -0
- package/dist/commands/migration-database.d.ts +26 -0
- package/dist/commands/migration-database.js +162 -0
- package/dist/commands/migration-e2e.d.ts +68 -0
- package/dist/commands/migration-e2e.js +189 -0
- package/dist/commands/migration-environments.d.ts +20 -0
- package/dist/commands/migration-environments.js +58 -0
- package/dist/commands/migration-generated.d.ts +68 -0
- package/dist/commands/migration-generated.js +217 -0
- package/dist/commands/migration-portfolio.d.ts +88 -0
- package/dist/commands/migration-portfolio.js +223 -0
- package/dist/commands/migration-products.d.ts +23 -0
- package/dist/commands/migration-products.js +88 -0
- package/dist/commands/migration-review.d.ts +248 -0
- package/dist/commands/migration-review.js +624 -0
- package/dist/commands/migration-secrets.d.ts +24 -0
- package/dist/commands/migration-secrets.js +93 -0
- package/dist/commands/migration-slice.d.ts +132 -0
- package/dist/commands/migration-slice.js +266 -0
- package/dist/commands/migration-verification.d.ts +12 -0
- package/dist/commands/migration-verification.js +194 -0
- package/dist/commands/products.d.ts +1 -0
- package/dist/commands/products.js +33 -1
- package/dist/commands/secrets.d.ts +8 -0
- package/dist/commands/secrets.js +44 -0
- package/dist/index.js +376 -4
- package/dist/lib/migration-artifact.d.ts +19 -0
- package/dist/lib/migration-artifact.js +114 -0
- package/package.json +8 -1
- package/API_PARITY.md +0 -58
- package/DB_MIGRATE_PLAN.md +0 -223
- package/ductape.example.ts +0 -18
- package/src/commands/apply.ts +0 -153
- package/src/commands/apps-import.ts +0 -43
- package/src/commands/apps.ts +0 -92
- package/src/commands/cloud.ts +0 -68
- package/src/commands/completion.ts +0 -119
- package/src/commands/db-migrate.ts +0 -209
- package/src/commands/db-schema.ts +0 -429
- package/src/commands/db.ts +0 -63
- package/src/commands/generate.ts +0 -74
- package/src/commands/graph.ts +0 -51
- package/src/commands/init.ts +0 -39
- package/src/commands/install.ts +0 -76
- package/src/commands/link.ts +0 -84
- package/src/commands/login.ts +0 -145
- package/src/commands/logout.ts +0 -7
- package/src/commands/platform.ts +0 -123
- package/src/commands/products.ts +0 -228
- package/src/commands/profiles.ts +0 -23
- package/src/commands/resources.ts +0 -213
- package/src/commands/secrets.ts +0 -46
- package/src/commands/unlink.ts +0 -15
- package/src/commands/whoami.ts +0 -27
- package/src/commands/workspaces.ts +0 -98
- package/src/index.ts +0 -523
- package/src/lib/api-client.ts +0 -88
- package/src/lib/app-import.ts +0 -82
- package/src/lib/apply-loaders.ts +0 -30
- package/src/lib/config.ts +0 -230
- package/src/lib/context-store.ts +0 -77
- package/src/lib/db-methods.ts +0 -56
- package/src/lib/db-types.ts +0 -104
- package/src/lib/encryption.ts +0 -12
- package/src/lib/forms/enums.ts +0 -52
- package/src/lib/forms/index.ts +0 -8
- package/src/lib/forms/prompt.ts +0 -209
- package/src/lib/forms/schemas.ts +0 -288
- package/src/lib/forms/types.ts +0 -27
- package/src/lib/http.ts +0 -76
- package/src/lib/integrations.ts +0 -110
- package/src/lib/interactive-opts.ts +0 -13
- package/src/lib/migration-files.ts +0 -58
- package/src/lib/output.ts +0 -39
- package/src/lib/platform-api.ts +0 -209
- package/src/lib/proxy/context.ts +0 -94
- package/src/lib/proxy/db-proxy.ts +0 -40
- package/src/lib/proxy/graph-proxy.ts +0 -64
- package/src/lib/proxy/sdk-proxy.ts +0 -58
- package/src/lib/read-body.ts +0 -57
- package/src/lib/resources.ts +0 -160
- package/src/lib/schema-loader.ts +0 -152
- package/src/lib/snippets.ts +0 -165
- package/src/lib/templates.ts +0 -157
- package/src/lib/workspace-context.ts +0 -43
- package/src/lib/workspaces.ts +0 -283
- package/test/apply-loaders.test.ts +0 -40
- package/test/encryption.test.ts +0 -20
- package/test/forms.test.ts +0 -25
- package/test/http.test.ts +0 -15
- package/test/platform-api.test.ts +0 -42
- package/test/resources.test.ts +0 -23
- package/test/workspace-resolve.test.ts +0 -29
- package/tsconfig.json +0 -15
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { printJson } from '../lib/output.js';
|
|
4
|
+
import { readMigrationJson, writeMigrationJson } from '../lib/migration-artifact.js';
|
|
5
|
+
function read(file) {
|
|
6
|
+
return readMigrationJson(file);
|
|
7
|
+
}
|
|
8
|
+
function forbiddenValuePath(value, prefix = '') {
|
|
9
|
+
if (Array.isArray(value)) {
|
|
10
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
11
|
+
const found = forbiddenValuePath(value[index], `${prefix}[${index}]`);
|
|
12
|
+
if (found)
|
|
13
|
+
return found;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
else if (value && typeof value === 'object') {
|
|
17
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
18
|
+
if (/^(?:value|secret_value|private_key|access_token|password|credential)$/i.test(key)) {
|
|
19
|
+
return prefix ? `${prefix}.${key}` : key;
|
|
20
|
+
}
|
|
21
|
+
const found = forbiddenValuePath(nested, prefix ? `${prefix}.${key}` : key);
|
|
22
|
+
if (found)
|
|
23
|
+
return found;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
export function initializeSecretMap(opts) {
|
|
29
|
+
const definition = read(opts.definition);
|
|
30
|
+
const forbidden = forbiddenValuePath(definition);
|
|
31
|
+
if (forbidden)
|
|
32
|
+
throw new Error(`Secret migration evidence must not contain value-bearing field: ${forbidden}`);
|
|
33
|
+
if (!Array.isArray(definition.classifications))
|
|
34
|
+
throw new Error('Definition requires classifications.');
|
|
35
|
+
const output = path.resolve(opts.output);
|
|
36
|
+
if (fs.existsSync(output))
|
|
37
|
+
throw new Error(`Secret migration map already exists: ${output}`);
|
|
38
|
+
const artifact = {
|
|
39
|
+
version: 1,
|
|
40
|
+
artifact_kind: 'ductape_secret_migration_map',
|
|
41
|
+
analysis: path.resolve(opts.analysis),
|
|
42
|
+
classifications: definition.classifications,
|
|
43
|
+
};
|
|
44
|
+
fs.mkdirSync(path.dirname(output), { recursive: true });
|
|
45
|
+
writeMigrationJson(output, artifact, { createOnly: true });
|
|
46
|
+
return { path: output };
|
|
47
|
+
}
|
|
48
|
+
export function validateSecretMap(file) {
|
|
49
|
+
const resolved = path.resolve(file);
|
|
50
|
+
const artifact = read(resolved);
|
|
51
|
+
const forbidden = forbiddenValuePath(artifact);
|
|
52
|
+
if (forbidden)
|
|
53
|
+
throw new Error(`Secret migration artifact contains forbidden value field: ${forbidden}`);
|
|
54
|
+
if (artifact.artifact_kind !== 'ductape_secret_migration_map')
|
|
55
|
+
throw new Error('Invalid secret migration map.');
|
|
56
|
+
const analysis = read(artifact.analysis);
|
|
57
|
+
const expected = [
|
|
58
|
+
...(analysis.secrets ?? []).map((item) => ({ ...item, system: 'env' })),
|
|
59
|
+
...(analysis.secret_references ?? []),
|
|
60
|
+
];
|
|
61
|
+
const blockers = [];
|
|
62
|
+
for (const item of expected) {
|
|
63
|
+
const matches = artifact.classifications.filter((entry) => entry.key === item.key && entry.source === item.source && entry.system === item.system);
|
|
64
|
+
if (matches.length === 0)
|
|
65
|
+
blockers.push(`unclassified secret reference: ${item.system}:${item.source}:${item.key}`);
|
|
66
|
+
if (matches.length > 1)
|
|
67
|
+
blockers.push(`duplicate secret classification: ${item.system}:${item.source}:${item.key}`);
|
|
68
|
+
}
|
|
69
|
+
for (const entry of artifact.classifications) {
|
|
70
|
+
if (!expected.some((item) => item.key === entry.key && item.source === entry.source && item.system === entry.system)) {
|
|
71
|
+
blockers.push(`classification has no discovery evidence: ${entry.system}:${entry.source}:${entry.key}`);
|
|
72
|
+
}
|
|
73
|
+
if (!entry.environment || !entry.service || !entry.provider || !entry.rotation_owner ||
|
|
74
|
+
!entry.ductape_secret_tag || !entry.inventory_evidence) {
|
|
75
|
+
blockers.push(`classification incomplete: ${entry.system}:${entry.source}:${entry.key}`);
|
|
76
|
+
}
|
|
77
|
+
if (entry.inventory_action === 'blocked')
|
|
78
|
+
blockers.push(`secret inventory reconciliation blocked: ${entry.ductape_secret_tag}`);
|
|
79
|
+
if (!entry.rotation?.strategy?.length || !entry.rotation.validation?.length || !entry.rotation.rollback?.length) {
|
|
80
|
+
blockers.push(`rotation plan incomplete: ${entry.ductape_secret_tag}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { artifact: resolved, ready: blockers.length === 0, references: expected.length, blockers };
|
|
84
|
+
}
|
|
85
|
+
export function runMigrationSecretsInit(opts) {
|
|
86
|
+
printJson(initializeSecretMap(opts), Boolean(opts.json));
|
|
87
|
+
}
|
|
88
|
+
export function runMigrationSecretsValidate(opts) {
|
|
89
|
+
const result = validateSecretMap(opts.file);
|
|
90
|
+
printJson(result, Boolean(opts.json));
|
|
91
|
+
if (opts.strict && !result.ready)
|
|
92
|
+
throw new Error('Secret migration map is not ready.');
|
|
93
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
interface SliceManifest {
|
|
2
|
+
version: 1;
|
|
3
|
+
artifact_kind: 'ductape_migration_slice';
|
|
4
|
+
tag: string;
|
|
5
|
+
name: string;
|
|
6
|
+
ledger: string;
|
|
7
|
+
files: string[];
|
|
8
|
+
surfaces: Array<'backend' | 'frontend' | 'worker'>;
|
|
9
|
+
product_boundary: {
|
|
10
|
+
product_tag: string;
|
|
11
|
+
services: string[];
|
|
12
|
+
environments: string[];
|
|
13
|
+
evidence: string;
|
|
14
|
+
};
|
|
15
|
+
sdk_capabilities: Array<{
|
|
16
|
+
language: 'typescript' | 'go' | 'java' | 'dotnet';
|
|
17
|
+
package: string;
|
|
18
|
+
version: string;
|
|
19
|
+
capability: string;
|
|
20
|
+
evidence: string;
|
|
21
|
+
}>;
|
|
22
|
+
interface_contracts: string[];
|
|
23
|
+
functional_requirements: string[];
|
|
24
|
+
operational_requirements: string[];
|
|
25
|
+
parity_evidence: Array<{
|
|
26
|
+
kind: 'characterization' | 'contract' | 'golden' | 'side_effect' | 'database_state' | 'event_order' | 'failure_injection' | 'load_concurrency' | 'shadow_comparison';
|
|
27
|
+
before: string;
|
|
28
|
+
after: string;
|
|
29
|
+
evidence: string;
|
|
30
|
+
}>;
|
|
31
|
+
frontend_parity: {
|
|
32
|
+
routes_navigation: string[];
|
|
33
|
+
rendered_states: string[];
|
|
34
|
+
forms_validation: string[];
|
|
35
|
+
accessibility: string[];
|
|
36
|
+
responsive_behavior: string[];
|
|
37
|
+
browser_storage: string[];
|
|
38
|
+
auth_transitions: string[];
|
|
39
|
+
ssr_hydration: string[];
|
|
40
|
+
realtime_reconnection: string[];
|
|
41
|
+
analytics_privacy: string[];
|
|
42
|
+
performance_budgets: string[];
|
|
43
|
+
visual_regression: string[];
|
|
44
|
+
cross_browser: string[];
|
|
45
|
+
};
|
|
46
|
+
required_assets: Array<{
|
|
47
|
+
kind: string;
|
|
48
|
+
tag: string;
|
|
49
|
+
action: 'reuse' | 'create' | 'update' | 'blocked';
|
|
50
|
+
inventory_evidence: string;
|
|
51
|
+
}>;
|
|
52
|
+
tests: string[];
|
|
53
|
+
failure_tests: string[];
|
|
54
|
+
runtime_evidence: string[];
|
|
55
|
+
smoke_checks: string[];
|
|
56
|
+
cutover_conditions: string[];
|
|
57
|
+
rollback_conditions: string[];
|
|
58
|
+
deployment_cutover: {
|
|
59
|
+
implementation_commit: string;
|
|
60
|
+
deployment_versions: Array<{
|
|
61
|
+
environment: string;
|
|
62
|
+
version: string;
|
|
63
|
+
evidence: string;
|
|
64
|
+
}>;
|
|
65
|
+
traffic: {
|
|
66
|
+
strategy: 'feature_flag' | 'dual_run' | 'percentage' | 'atomic';
|
|
67
|
+
feature_flag: string;
|
|
68
|
+
dual_run: boolean;
|
|
69
|
+
percentage: number;
|
|
70
|
+
evidence: string;
|
|
71
|
+
};
|
|
72
|
+
no_return_points: Array<{
|
|
73
|
+
description: string;
|
|
74
|
+
authorized: boolean;
|
|
75
|
+
mitigation: string;
|
|
76
|
+
}>;
|
|
77
|
+
observation: {
|
|
78
|
+
duration_minutes: number;
|
|
79
|
+
success_thresholds: string[];
|
|
80
|
+
evidence: string;
|
|
81
|
+
};
|
|
82
|
+
monitoring: {
|
|
83
|
+
dashboards: string[];
|
|
84
|
+
alerts: string[];
|
|
85
|
+
evidence: string;
|
|
86
|
+
};
|
|
87
|
+
rollback_rehearsal: {
|
|
88
|
+
environment: string;
|
|
89
|
+
executed_at: string;
|
|
90
|
+
status: 'passed' | 'failed';
|
|
91
|
+
evidence: string[];
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
export declare function initializeMigrationSlice(opts: {
|
|
96
|
+
ledger: string;
|
|
97
|
+
tag: string;
|
|
98
|
+
name: string;
|
|
99
|
+
files: string;
|
|
100
|
+
output?: string;
|
|
101
|
+
}): {
|
|
102
|
+
path: string;
|
|
103
|
+
manifest: SliceManifest;
|
|
104
|
+
};
|
|
105
|
+
export declare function validateMigrationSlice(slicePath: string): {
|
|
106
|
+
slice: string;
|
|
107
|
+
tag: string;
|
|
108
|
+
ready: boolean;
|
|
109
|
+
files: number;
|
|
110
|
+
assets: number;
|
|
111
|
+
blockers: string[];
|
|
112
|
+
};
|
|
113
|
+
export declare function defineMigrationSlice(slicePath: string, dataPath: string): SliceManifest;
|
|
114
|
+
export declare function runMigrationSliceInit(opts: {
|
|
115
|
+
ledger: string;
|
|
116
|
+
tag: string;
|
|
117
|
+
name: string;
|
|
118
|
+
files: string;
|
|
119
|
+
output?: string;
|
|
120
|
+
json?: boolean;
|
|
121
|
+
}): void;
|
|
122
|
+
export declare function runMigrationSliceValidate(opts: {
|
|
123
|
+
slice: string;
|
|
124
|
+
strict?: boolean;
|
|
125
|
+
json?: boolean;
|
|
126
|
+
}): void;
|
|
127
|
+
export declare function runMigrationSliceDefine(opts: {
|
|
128
|
+
slice: string;
|
|
129
|
+
data: string;
|
|
130
|
+
json?: boolean;
|
|
131
|
+
}): void;
|
|
132
|
+
export {};
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { printJson } from '../lib/output.js';
|
|
5
|
+
import { readMigrationJson, writeMigrationJson } from '../lib/migration-artifact.js';
|
|
6
|
+
function readJson(file) {
|
|
7
|
+
return readMigrationJson(file);
|
|
8
|
+
}
|
|
9
|
+
function checksum(file) {
|
|
10
|
+
try {
|
|
11
|
+
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function safeTag(value) {
|
|
18
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/.test(value)) {
|
|
19
|
+
throw new Error('Slice tag must contain lowercase letters, digits, hyphens, or underscores.');
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
export function initializeMigrationSlice(opts) {
|
|
24
|
+
const ledgerPath = path.resolve(opts.ledger);
|
|
25
|
+
const ledger = readJson(ledgerPath);
|
|
26
|
+
const files = readJson(path.resolve(opts.files));
|
|
27
|
+
if (!Array.isArray(files) || !files.every((file) => typeof file === 'string') || files.length === 0) {
|
|
28
|
+
throw new Error('Slice files must be a non-empty JSON array of repository-relative paths.');
|
|
29
|
+
}
|
|
30
|
+
const known = new Set(ledger.entries.map((entry) => entry.file));
|
|
31
|
+
for (const file of files) {
|
|
32
|
+
if (!known.has(file))
|
|
33
|
+
throw new Error(`Slice file is not present in the review ledger: ${file}`);
|
|
34
|
+
}
|
|
35
|
+
const tag = safeTag(opts.tag);
|
|
36
|
+
const output = path.resolve(opts.output ?? path.join(path.dirname(ledgerPath), 'slices', `${tag}.json`));
|
|
37
|
+
if (fs.existsSync(output))
|
|
38
|
+
throw new Error(`Migration slice already exists: ${output}`);
|
|
39
|
+
const manifest = {
|
|
40
|
+
version: 1,
|
|
41
|
+
artifact_kind: 'ductape_migration_slice',
|
|
42
|
+
tag,
|
|
43
|
+
name: opts.name,
|
|
44
|
+
ledger: ledgerPath,
|
|
45
|
+
files,
|
|
46
|
+
surfaces: [],
|
|
47
|
+
product_boundary: { product_tag: '', services: [], environments: [], evidence: '' },
|
|
48
|
+
sdk_capabilities: [],
|
|
49
|
+
interface_contracts: [],
|
|
50
|
+
functional_requirements: [],
|
|
51
|
+
operational_requirements: [],
|
|
52
|
+
parity_evidence: [],
|
|
53
|
+
frontend_parity: {
|
|
54
|
+
routes_navigation: [], rendered_states: [], forms_validation: [], accessibility: [],
|
|
55
|
+
responsive_behavior: [], browser_storage: [], auth_transitions: [], ssr_hydration: [],
|
|
56
|
+
realtime_reconnection: [], analytics_privacy: [], performance_budgets: [],
|
|
57
|
+
visual_regression: [], cross_browser: [],
|
|
58
|
+
},
|
|
59
|
+
required_assets: [],
|
|
60
|
+
tests: [],
|
|
61
|
+
failure_tests: [],
|
|
62
|
+
runtime_evidence: [],
|
|
63
|
+
smoke_checks: [],
|
|
64
|
+
cutover_conditions: [],
|
|
65
|
+
rollback_conditions: [],
|
|
66
|
+
deployment_cutover: {
|
|
67
|
+
implementation_commit: '',
|
|
68
|
+
deployment_versions: [],
|
|
69
|
+
traffic: { strategy: 'atomic', feature_flag: '', dual_run: false, percentage: 0, evidence: '' },
|
|
70
|
+
no_return_points: [],
|
|
71
|
+
observation: { duration_minutes: 0, success_thresholds: [], evidence: '' },
|
|
72
|
+
monitoring: { dashboards: [], alerts: [], evidence: '' },
|
|
73
|
+
rollback_rehearsal: { environment: '', executed_at: '', status: 'failed', evidence: [] },
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
fs.mkdirSync(path.dirname(output), { recursive: true });
|
|
77
|
+
writeMigrationJson(output, manifest, { createOnly: true });
|
|
78
|
+
return { path: output, manifest };
|
|
79
|
+
}
|
|
80
|
+
function parityComplete(value) {
|
|
81
|
+
return (value.status === 'verified' && value.evidence.length > 0) ||
|
|
82
|
+
(value.status === 'not_applicable' && Boolean(value.reason?.trim()));
|
|
83
|
+
}
|
|
84
|
+
export function validateMigrationSlice(slicePath) {
|
|
85
|
+
const resolved = path.resolve(slicePath);
|
|
86
|
+
const slice = readJson(resolved);
|
|
87
|
+
if (slice.artifact_kind !== 'ductape_migration_slice')
|
|
88
|
+
throw new Error('Invalid migration slice manifest.');
|
|
89
|
+
const ledger = readJson(slice.ledger);
|
|
90
|
+
const blockers = [];
|
|
91
|
+
if (!slice.files.length)
|
|
92
|
+
blockers.push('slice has no files');
|
|
93
|
+
for (const file of slice.files) {
|
|
94
|
+
const entry = ledger.entries.find((candidate) => candidate.file === file);
|
|
95
|
+
if (!entry) {
|
|
96
|
+
blockers.push(`${file}: missing from review ledger`);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (entry.status !== 'reviewed')
|
|
100
|
+
blockers.push(`${file}: review status is ${entry.status}`);
|
|
101
|
+
if (!entry.reviewed_checksum || checksum(path.join(ledger.source, file)) !== entry.reviewed_checksum) {
|
|
102
|
+
blockers.push(`${file}: review is stale`);
|
|
103
|
+
}
|
|
104
|
+
if (!parityComplete(entry.interface_parity))
|
|
105
|
+
blockers.push(`${file}: interface parity incomplete`);
|
|
106
|
+
if (!parityComplete(entry.functional_parity))
|
|
107
|
+
blockers.push(`${file}: functional parity incomplete`);
|
|
108
|
+
if (!parityComplete(entry.operational_parity))
|
|
109
|
+
blockers.push(`${file}: operational parity incomplete`);
|
|
110
|
+
}
|
|
111
|
+
for (const [field, values] of [
|
|
112
|
+
['surfaces', slice.surfaces],
|
|
113
|
+
['sdk_capabilities', slice.sdk_capabilities],
|
|
114
|
+
['interface_contracts', slice.interface_contracts],
|
|
115
|
+
['functional_requirements', slice.functional_requirements],
|
|
116
|
+
['operational_requirements', slice.operational_requirements],
|
|
117
|
+
['parity_evidence', slice.parity_evidence],
|
|
118
|
+
['tests', slice.tests],
|
|
119
|
+
['failure_tests', slice.failure_tests],
|
|
120
|
+
['runtime_evidence', slice.runtime_evidence],
|
|
121
|
+
['smoke_checks', slice.smoke_checks],
|
|
122
|
+
['cutover_conditions', slice.cutover_conditions],
|
|
123
|
+
['rollback_conditions', slice.rollback_conditions],
|
|
124
|
+
]) {
|
|
125
|
+
if (!Array.isArray(values) || values.length === 0)
|
|
126
|
+
blockers.push(`${field}: at least one entry is required`);
|
|
127
|
+
}
|
|
128
|
+
if (slice.surfaces?.includes('frontend')) {
|
|
129
|
+
for (const [concern, evidence] of Object.entries(slice.frontend_parity ?? {})) {
|
|
130
|
+
if (!Array.isArray(evidence) || evidence.length === 0) {
|
|
131
|
+
blockers.push(`frontend_parity.${concern}: evidence is required`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const requiredFrontend = [
|
|
135
|
+
'routes_navigation', 'rendered_states', 'forms_validation', 'accessibility',
|
|
136
|
+
'responsive_behavior', 'browser_storage', 'auth_transitions', 'ssr_hydration',
|
|
137
|
+
'realtime_reconnection', 'analytics_privacy', 'performance_budgets',
|
|
138
|
+
'visual_regression', 'cross_browser',
|
|
139
|
+
];
|
|
140
|
+
for (const concern of requiredFrontend) {
|
|
141
|
+
if (!Array.isArray(slice.frontend_parity?.[concern])) {
|
|
142
|
+
blockers.push(`frontend_parity.${concern}: concern is missing`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (!slice.product_boundary?.product_tag ||
|
|
147
|
+
!slice.product_boundary.services?.length ||
|
|
148
|
+
!slice.product_boundary.environments?.length ||
|
|
149
|
+
!slice.product_boundary.evidence) {
|
|
150
|
+
blockers.push('product_boundary: product_tag, services, environments, and evidence are required');
|
|
151
|
+
}
|
|
152
|
+
for (const capability of slice.sdk_capabilities ?? []) {
|
|
153
|
+
if (!capability.language || !capability.package || !capability.version ||
|
|
154
|
+
!capability.capability || !capability.evidence) {
|
|
155
|
+
blockers.push('sdk_capabilities: every entry requires language, package, version, capability, and evidence');
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
for (const asset of slice.required_assets ?? []) {
|
|
159
|
+
if (!asset.kind || !asset.tag || !asset.inventory_evidence) {
|
|
160
|
+
blockers.push(`asset ${asset.tag || '<unknown>'}: kind, tag, and inventory_evidence are required`);
|
|
161
|
+
}
|
|
162
|
+
if (asset.action === 'blocked')
|
|
163
|
+
blockers.push(`asset ${asset.kind}:${asset.tag}: reconciliation is blocked`);
|
|
164
|
+
}
|
|
165
|
+
if (!Array.isArray(slice.required_assets) || slice.required_assets.length === 0) {
|
|
166
|
+
blockers.push('required_assets: at least one reconciled Ductape asset is required');
|
|
167
|
+
}
|
|
168
|
+
const cutover = slice.deployment_cutover;
|
|
169
|
+
if (!cutover?.implementation_commit?.trim() || !cutover.deployment_versions?.length) {
|
|
170
|
+
blockers.push('deployment_cutover: implementation commit and deployment versions are required');
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
for (const deployment of cutover.deployment_versions) {
|
|
174
|
+
if (!deployment.environment?.trim() || !deployment.version?.trim() || !deployment.evidence?.trim()) {
|
|
175
|
+
blockers.push('deployment_cutover.deployment_versions: environment, version, and evidence are required');
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (!cutover?.traffic?.evidence?.trim() || cutover.traffic.percentage < 0 || cutover.traffic.percentage > 100 ||
|
|
180
|
+
(cutover.traffic.strategy === 'feature_flag' && !cutover.traffic.feature_flag?.trim())) {
|
|
181
|
+
blockers.push('deployment_cutover.traffic: valid strategy, percentage, feature flag when applicable, and evidence are required');
|
|
182
|
+
}
|
|
183
|
+
if (!Array.isArray(cutover?.no_return_points) || !cutover.no_return_points.length ||
|
|
184
|
+
cutover.no_return_points.some((point) => !point.description?.trim() || !point.mitigation?.trim())) {
|
|
185
|
+
blockers.push('deployment_cutover.no_return_points: explicit descriptions and mitigations are required');
|
|
186
|
+
}
|
|
187
|
+
if (!cutover?.observation || cutover.observation.duration_minutes <= 0 ||
|
|
188
|
+
!cutover.observation.success_thresholds?.length || !cutover.observation.evidence?.trim()) {
|
|
189
|
+
blockers.push('deployment_cutover.observation: duration, success thresholds, and evidence are required');
|
|
190
|
+
}
|
|
191
|
+
if (!cutover?.monitoring?.dashboards?.length || !cutover.monitoring.alerts?.length ||
|
|
192
|
+
!cutover.monitoring.evidence?.trim()) {
|
|
193
|
+
blockers.push('deployment_cutover.monitoring: dashboard, alerts, and evidence are required');
|
|
194
|
+
}
|
|
195
|
+
if (!cutover?.rollback_rehearsal || cutover.rollback_rehearsal.status !== 'passed' ||
|
|
196
|
+
!cutover.rollback_rehearsal.environment?.trim() || !cutover.rollback_rehearsal.executed_at?.trim() ||
|
|
197
|
+
!cutover.rollback_rehearsal.evidence?.length) {
|
|
198
|
+
blockers.push('deployment_cutover.rollback_rehearsal: passing executed rehearsal evidence is required');
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
slice: resolved,
|
|
202
|
+
tag: slice.tag,
|
|
203
|
+
ready: blockers.length === 0,
|
|
204
|
+
files: slice.files.length,
|
|
205
|
+
assets: slice.required_assets.length,
|
|
206
|
+
blockers,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
export function defineMigrationSlice(slicePath, dataPath) {
|
|
210
|
+
const resolved = path.resolve(slicePath);
|
|
211
|
+
const slice = readJson(resolved);
|
|
212
|
+
if (slice.artifact_kind !== 'ductape_migration_slice')
|
|
213
|
+
throw new Error('Invalid migration slice manifest.');
|
|
214
|
+
const data = readJson(path.resolve(dataPath));
|
|
215
|
+
for (const field of [
|
|
216
|
+
'surfaces',
|
|
217
|
+
'sdk_capabilities',
|
|
218
|
+
'interface_contracts', 'functional_requirements', 'operational_requirements',
|
|
219
|
+
'parity_evidence', 'required_assets', 'tests', 'failure_tests', 'runtime_evidence', 'smoke_checks',
|
|
220
|
+
'cutover_conditions', 'rollback_conditions',
|
|
221
|
+
]) {
|
|
222
|
+
if (data[field] !== undefined) {
|
|
223
|
+
if (!Array.isArray(data[field]))
|
|
224
|
+
throw new Error(`${field} must be an array.`);
|
|
225
|
+
slice[field] = data[field];
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (data.product_boundary !== undefined)
|
|
229
|
+
slice.product_boundary = data.product_boundary;
|
|
230
|
+
if (data.frontend_parity !== undefined)
|
|
231
|
+
slice.frontend_parity = data.frontend_parity;
|
|
232
|
+
if (data.deployment_cutover !== undefined)
|
|
233
|
+
slice.deployment_cutover = data.deployment_cutover;
|
|
234
|
+
for (const asset of slice.required_assets) {
|
|
235
|
+
if (!['reuse', 'create', 'update', 'blocked'].includes(asset.action)) {
|
|
236
|
+
throw new Error(`Invalid asset reconciliation action for ${asset.kind}:${asset.tag}`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (!slice.product_boundary ||
|
|
240
|
+
!Array.isArray(slice.product_boundary.services) ||
|
|
241
|
+
!Array.isArray(slice.product_boundary.environments)) {
|
|
242
|
+
throw new Error('product_boundary must include services and environments arrays.');
|
|
243
|
+
}
|
|
244
|
+
writeMigrationJson(resolved, slice, { backup: true });
|
|
245
|
+
return slice;
|
|
246
|
+
}
|
|
247
|
+
export function runMigrationSliceInit(opts) {
|
|
248
|
+
const result = initializeMigrationSlice(opts);
|
|
249
|
+
printJson({ path: result.path, tag: result.manifest.tag, files: result.manifest.files.length }, Boolean(opts.json));
|
|
250
|
+
}
|
|
251
|
+
export function runMigrationSliceValidate(opts) {
|
|
252
|
+
const result = validateMigrationSlice(opts.slice);
|
|
253
|
+
printJson(result, Boolean(opts.json));
|
|
254
|
+
if (opts.strict && !result.ready) {
|
|
255
|
+
throw new Error('Migration slice is not ready: resolve every reported blocker before cutover.');
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
export function runMigrationSliceDefine(opts) {
|
|
259
|
+
const manifest = defineMigrationSlice(opts.slice, opts.data);
|
|
260
|
+
printJson({
|
|
261
|
+
slice: path.resolve(opts.slice),
|
|
262
|
+
tag: manifest.tag,
|
|
263
|
+
assets: manifest.required_assets.length,
|
|
264
|
+
tests: manifest.tests.length,
|
|
265
|
+
}, Boolean(opts.json));
|
|
266
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare function validateMigrationVerification(file: string): {
|
|
2
|
+
file: string;
|
|
3
|
+
ready: boolean;
|
|
4
|
+
categories: number;
|
|
5
|
+
requirements: number;
|
|
6
|
+
blockers: string[];
|
|
7
|
+
};
|
|
8
|
+
export declare function runMigrationVerificationValidate(opts: {
|
|
9
|
+
file: string;
|
|
10
|
+
strict?: boolean;
|
|
11
|
+
json?: boolean;
|
|
12
|
+
}): void;
|