@ductape/cli 0.2.23 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +7 -0
  3. package/dist/commands/migrate-codebase.d.ts +176 -0
  4. package/dist/commands/migrate-codebase.js +753 -0
  5. package/dist/commands/migration-artifact.d.ts +32 -0
  6. package/dist/commands/migration-artifact.js +48 -0
  7. package/dist/commands/migration-assurance.d.ts +10 -0
  8. package/dist/commands/migration-assurance.js +120 -0
  9. package/dist/commands/migration-database.d.ts +21 -0
  10. package/dist/commands/migration-database.js +162 -0
  11. package/dist/commands/migration-e2e.d.ts +68 -0
  12. package/dist/commands/migration-e2e.js +189 -0
  13. package/dist/commands/migration-environments.d.ts +20 -0
  14. package/dist/commands/migration-environments.js +58 -0
  15. package/dist/commands/migration-generated.d.ts +68 -0
  16. package/dist/commands/migration-generated.js +217 -0
  17. package/dist/commands/migration-portfolio.d.ts +88 -0
  18. package/dist/commands/migration-portfolio.js +223 -0
  19. package/dist/commands/migration-products.d.ts +23 -0
  20. package/dist/commands/migration-products.js +88 -0
  21. package/dist/commands/migration-review.d.ts +248 -0
  22. package/dist/commands/migration-review.js +624 -0
  23. package/dist/commands/migration-secrets.d.ts +24 -0
  24. package/dist/commands/migration-secrets.js +93 -0
  25. package/dist/commands/migration-slice.d.ts +132 -0
  26. package/dist/commands/migration-slice.js +266 -0
  27. package/dist/commands/migration-verification.d.ts +12 -0
  28. package/dist/commands/migration-verification.js +194 -0
  29. package/dist/commands/products.d.ts +1 -0
  30. package/dist/commands/products.js +33 -1
  31. package/dist/commands/secrets.d.ts +8 -0
  32. package/dist/commands/secrets.js +44 -0
  33. package/dist/index.js +354 -2
  34. package/dist/lib/migration-artifact.d.ts +13 -0
  35. package/dist/lib/migration-artifact.js +103 -0
  36. package/package.json +8 -1
  37. package/API_PARITY.md +0 -58
  38. package/DB_MIGRATE_PLAN.md +0 -223
  39. package/ductape.example.ts +0 -18
  40. package/src/commands/apply.ts +0 -153
  41. package/src/commands/apps-import.ts +0 -43
  42. package/src/commands/apps.ts +0 -92
  43. package/src/commands/cloud.ts +0 -68
  44. package/src/commands/completion.ts +0 -119
  45. package/src/commands/db-migrate.ts +0 -209
  46. package/src/commands/db-schema.ts +0 -429
  47. package/src/commands/db.ts +0 -63
  48. package/src/commands/generate.ts +0 -74
  49. package/src/commands/graph.ts +0 -51
  50. package/src/commands/init.ts +0 -39
  51. package/src/commands/install.ts +0 -76
  52. package/src/commands/link.ts +0 -84
  53. package/src/commands/login.ts +0 -145
  54. package/src/commands/logout.ts +0 -7
  55. package/src/commands/platform.ts +0 -123
  56. package/src/commands/products.ts +0 -228
  57. package/src/commands/profiles.ts +0 -23
  58. package/src/commands/resources.ts +0 -213
  59. package/src/commands/secrets.ts +0 -46
  60. package/src/commands/unlink.ts +0 -15
  61. package/src/commands/whoami.ts +0 -27
  62. package/src/commands/workspaces.ts +0 -98
  63. package/src/index.ts +0 -523
  64. package/src/lib/api-client.ts +0 -88
  65. package/src/lib/app-import.ts +0 -82
  66. package/src/lib/apply-loaders.ts +0 -30
  67. package/src/lib/config.ts +0 -230
  68. package/src/lib/context-store.ts +0 -77
  69. package/src/lib/db-methods.ts +0 -56
  70. package/src/lib/db-types.ts +0 -104
  71. package/src/lib/encryption.ts +0 -12
  72. package/src/lib/forms/enums.ts +0 -52
  73. package/src/lib/forms/index.ts +0 -8
  74. package/src/lib/forms/prompt.ts +0 -209
  75. package/src/lib/forms/schemas.ts +0 -288
  76. package/src/lib/forms/types.ts +0 -27
  77. package/src/lib/http.ts +0 -76
  78. package/src/lib/integrations.ts +0 -110
  79. package/src/lib/interactive-opts.ts +0 -13
  80. package/src/lib/migration-files.ts +0 -58
  81. package/src/lib/output.ts +0 -39
  82. package/src/lib/platform-api.ts +0 -209
  83. package/src/lib/proxy/context.ts +0 -94
  84. package/src/lib/proxy/db-proxy.ts +0 -40
  85. package/src/lib/proxy/graph-proxy.ts +0 -64
  86. package/src/lib/proxy/sdk-proxy.ts +0 -58
  87. package/src/lib/read-body.ts +0 -57
  88. package/src/lib/resources.ts +0 -160
  89. package/src/lib/schema-loader.ts +0 -152
  90. package/src/lib/snippets.ts +0 -165
  91. package/src/lib/templates.ts +0 -157
  92. package/src/lib/workspace-context.ts +0 -43
  93. package/src/lib/workspaces.ts +0 -283
  94. package/test/apply-loaders.test.ts +0 -40
  95. package/test/encryption.test.ts +0 -20
  96. package/test/forms.test.ts +0 -25
  97. package/test/http.test.ts +0 -15
  98. package/test/platform-api.test.ts +0 -42
  99. package/test/resources.test.ts +0 -23
  100. package/test/workspace-resolve.test.ts +0 -29
  101. package/tsconfig.json +0 -15
@@ -0,0 +1,20 @@
1
+ export declare function reconcileEnvironments(analysisPath: string, inventoryPath: string): {
2
+ ready: boolean;
3
+ detected: string[];
4
+ live: string[];
5
+ matched: string[];
6
+ missing: string[];
7
+ extra: string[];
8
+ ambiguous: {
9
+ normalized: string;
10
+ candidates: string[];
11
+ }[];
12
+ creation_supported: boolean;
13
+ creation_blocker: string;
14
+ };
15
+ export declare function runMigrationEnvironmentsReconcile(opts: {
16
+ analysis: string;
17
+ inventory: string;
18
+ strict?: boolean;
19
+ json?: boolean;
20
+ }): void;
@@ -0,0 +1,58 @@
1
+ import { printJson } from '../lib/output.js';
2
+ import { readMigrationJson } from '../lib/migration-artifact.js';
3
+ function read(file) {
4
+ return readMigrationJson(file);
5
+ }
6
+ function normalize(value) {
7
+ const v = value.toLowerCase();
8
+ if (['production', 'prod', 'prd'].includes(v))
9
+ return 'prd';
10
+ if (['staging', 'stage', 'stg'].includes(v))
11
+ return 'stg';
12
+ if (['sandbox', 'snd'].includes(v))
13
+ return 'snd';
14
+ if (['development', 'develop', 'dev'].includes(v))
15
+ return 'dev';
16
+ if (['testing', 'test'].includes(v))
17
+ return 'test';
18
+ return v;
19
+ }
20
+ function liveSlugs(inventory) {
21
+ const source = Array.isArray(inventory) ? inventory
22
+ : Array.isArray(inventory?.environments) ? inventory.environments
23
+ : Array.isArray(inventory?.data) ? inventory.data : [];
24
+ return source.map((item) => typeof item === 'string' ? item : item?.slug)
25
+ .filter((value) => typeof value === 'string');
26
+ }
27
+ export function reconcileEnvironments(analysisPath, inventoryPath) {
28
+ const analysis = read(analysisPath);
29
+ const detected = [...new Set((analysis.environments ?? []).map((value) => normalize(value)))];
30
+ const live = liveSlugs(read(inventoryPath));
31
+ const groups = new Map();
32
+ for (const slug of live)
33
+ groups.set(normalize(slug), [...(groups.get(normalize(slug)) ?? []), slug]);
34
+ const ambiguous = [...groups.entries()]
35
+ .filter(([, values]) => values.length > 1)
36
+ .map(([normalized, candidates]) => ({ normalized, candidates }));
37
+ const ambiguousNames = new Set(ambiguous.map((item) => item.normalized));
38
+ const matched = detected.filter((env) => groups.has(env) && !ambiguousNames.has(env));
39
+ const missing = detected.filter((env) => !groups.has(env));
40
+ const extra = [...groups.keys()].filter((env) => !detected.includes(env));
41
+ return {
42
+ ready: missing.length === 0 && ambiguous.length === 0,
43
+ detected,
44
+ live,
45
+ matched,
46
+ missing,
47
+ extra,
48
+ ambiguous,
49
+ creation_supported: false,
50
+ creation_blocker: 'The current administrative CLI exposes environment list/fetch only; create requires a platform route.',
51
+ };
52
+ }
53
+ export function runMigrationEnvironmentsReconcile(opts) {
54
+ const result = reconcileEnvironments(opts.analysis, opts.inventory);
55
+ printJson(result, Boolean(opts.json));
56
+ if (opts.strict && !result.ready)
57
+ throw new Error('Environment reconciliation is not ready.');
58
+ }
@@ -0,0 +1,68 @@
1
+ type ContractKind = 'public_api' | 'generated_client' | 'schema' | 'ui_snapshot' | 'internal';
2
+ interface GeneratedManifest {
3
+ version: 1;
4
+ artifact_kind: 'ductape_migration_generated_vendor_evidence';
5
+ source: string;
6
+ analysis: string;
7
+ generated: Array<{
8
+ file: string;
9
+ checked_in_checksum: string;
10
+ contract_kind: ContractKind;
11
+ generator: {
12
+ name: string;
13
+ version: string;
14
+ command: string;
15
+ inputs: Array<{
16
+ file: string;
17
+ checksum: string;
18
+ }>;
19
+ };
20
+ regeneration: {
21
+ clean_workspace: true;
22
+ status: 'passed';
23
+ evidence: string[];
24
+ regenerated_checksum: string;
25
+ matches_checked_in: boolean;
26
+ };
27
+ compatibility_tests: string[];
28
+ owner: string;
29
+ }>;
30
+ vendor: Array<{
31
+ file: string;
32
+ current_checksum: string;
33
+ upstream_source: string;
34
+ upstream_version: string;
35
+ upstream_checksum: string;
36
+ owner: string;
37
+ locally_modified: boolean;
38
+ diff_evidence: string[];
39
+ compatibility_tests: string[];
40
+ }>;
41
+ }
42
+ export declare function initializeMigrationGenerated(opts: {
43
+ analysis: string;
44
+ definition: string;
45
+ output?: string;
46
+ }): {
47
+ path: string;
48
+ manifest: GeneratedManifest;
49
+ };
50
+ export declare function validateMigrationGenerated(file: string): {
51
+ evidence: string;
52
+ ready: boolean;
53
+ generated: number;
54
+ vendor: number;
55
+ blockers: string[];
56
+ };
57
+ export declare function runMigrationGeneratedInit(opts: {
58
+ analysis: string;
59
+ definition: string;
60
+ output?: string;
61
+ json?: boolean;
62
+ }): void;
63
+ export declare function runMigrationGeneratedValidate(opts: {
64
+ file: string;
65
+ strict?: boolean;
66
+ json?: boolean;
67
+ }): void;
68
+ export {};
@@ -0,0 +1,217 @@
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
+ return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
11
+ }
12
+ function nonEmpty(values) {
13
+ return Array.isArray(values) && values.length > 0 &&
14
+ values.every((value) => typeof value === 'string' && value.trim().length > 0);
15
+ }
16
+ function sourceFile(source, file) {
17
+ if (!file || path.isAbsolute(file))
18
+ throw new Error(`Repository file must be relative: ${file}`);
19
+ const normalized = path.normalize(file).replaceAll('\\', '/');
20
+ if (normalized === '..' || normalized.startsWith('../')) {
21
+ throw new Error(`Repository file escapes the source root: ${file}`);
22
+ }
23
+ const absolute = path.resolve(source, normalized);
24
+ if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile()) {
25
+ throw new Error(`Repository file does not exist: ${normalized}`);
26
+ }
27
+ return { file: normalized, absolute };
28
+ }
29
+ function exactKeys(entries, label) {
30
+ const keys = entries.map((entry) => entry.file);
31
+ if (new Set(keys).size !== keys.length)
32
+ throw new Error(`${label} contains duplicate file classifications.`);
33
+ }
34
+ export function initializeMigrationGenerated(opts) {
35
+ const analysisPath = path.resolve(opts.analysis);
36
+ const analysis = readJson(analysisPath);
37
+ const source = path.resolve(analysis.source);
38
+ const definition = readJson(path.resolve(opts.definition));
39
+ if (!Array.isArray(definition.generated) || !Array.isArray(definition.vendor)) {
40
+ throw new Error('Definition requires generated and vendor arrays.');
41
+ }
42
+ exactKeys(definition.generated, 'generated');
43
+ exactKeys(definition.vendor, 'vendor');
44
+ const hintFiles = new Set((analysis.generated_source_hints ?? []).map((hint) => hint.file));
45
+ const classified = new Set(definition.generated.map((entry) => entry.file));
46
+ const missingHints = [...hintFiles].filter((file) => !classified.has(file));
47
+ if (missingHints.length) {
48
+ throw new Error(`Generated hints require contextual classification: ${missingHints.join(', ')}`);
49
+ }
50
+ const manifest = {
51
+ version: 1,
52
+ artifact_kind: 'ductape_migration_generated_vendor_evidence',
53
+ source,
54
+ analysis: analysisPath,
55
+ generated: definition.generated.map((entry) => {
56
+ const checkedIn = sourceFile(source, entry.file);
57
+ if (!['public_api', 'generated_client', 'schema', 'ui_snapshot', 'internal'].includes(entry.contract_kind)) {
58
+ throw new Error(`${entry.file}: invalid contract_kind.`);
59
+ }
60
+ if (!entry.generator?.name?.trim() || !entry.generator.version?.trim() ||
61
+ !entry.generator.command?.trim() || !nonEmpty(entry.generator.inputs)) {
62
+ throw new Error(`${entry.file}: generator name, version, command, and inputs are required.`);
63
+ }
64
+ if (!entry.owner?.trim())
65
+ throw new Error(`${entry.file}: owner is required.`);
66
+ if (entry.regeneration?.clean_workspace !== true || entry.regeneration.status !== 'passed' ||
67
+ !nonEmpty(entry.regeneration.evidence) || !entry.regeneration.regenerated_output?.trim()) {
68
+ throw new Error(`${entry.file}: passing clean-workspace regeneration evidence is required.`);
69
+ }
70
+ const regenerated = path.resolve(entry.regeneration.regenerated_output);
71
+ if (!fs.existsSync(regenerated) || !fs.statSync(regenerated).isFile()) {
72
+ throw new Error(`${entry.file}: regenerated_output does not exist.`);
73
+ }
74
+ const checkedInChecksum = checksum(checkedIn.absolute);
75
+ const regeneratedChecksum = checksum(regenerated);
76
+ const requiresContract = entry.contract_kind !== 'internal';
77
+ if (requiresContract && !nonEmpty(entry.compatibility_tests)) {
78
+ throw new Error(`${entry.file}: ${entry.contract_kind} requires compatibility tests.`);
79
+ }
80
+ return {
81
+ file: checkedIn.file,
82
+ checked_in_checksum: checkedInChecksum,
83
+ contract_kind: entry.contract_kind,
84
+ generator: {
85
+ name: entry.generator.name,
86
+ version: entry.generator.version,
87
+ command: entry.generator.command,
88
+ inputs: entry.generator.inputs.map((input) => {
89
+ const resolved = sourceFile(source, input);
90
+ return { file: resolved.file, checksum: checksum(resolved.absolute) };
91
+ }),
92
+ },
93
+ regeneration: {
94
+ clean_workspace: true,
95
+ status: 'passed',
96
+ evidence: entry.regeneration.evidence,
97
+ regenerated_checksum: regeneratedChecksum,
98
+ matches_checked_in: regeneratedChecksum === checkedInChecksum,
99
+ },
100
+ compatibility_tests: entry.compatibility_tests ?? [],
101
+ owner: entry.owner,
102
+ };
103
+ }),
104
+ vendor: definition.vendor.map((entry) => {
105
+ const current = sourceFile(source, entry.file);
106
+ if (!entry.upstream_source?.trim() || !entry.upstream_version?.trim() ||
107
+ !/^[a-f0-9]{64}$/.test(entry.upstream_checksum) || !entry.owner?.trim()) {
108
+ throw new Error(`${entry.file}: upstream source, version, SHA-256 checksum, and owner are required.`);
109
+ }
110
+ const currentChecksum = checksum(current.absolute);
111
+ const actuallyModified = currentChecksum !== entry.upstream_checksum;
112
+ if (entry.locally_modified !== actuallyModified) {
113
+ throw new Error(`${entry.file}: locally_modified does not match the upstream checksum comparison.`);
114
+ }
115
+ if (actuallyModified && !nonEmpty(entry.diff_evidence)) {
116
+ throw new Error(`${entry.file}: locally modified vendor source requires diff evidence.`);
117
+ }
118
+ return {
119
+ ...entry,
120
+ file: current.file,
121
+ current_checksum: currentChecksum,
122
+ diff_evidence: entry.diff_evidence ?? [],
123
+ compatibility_tests: entry.compatibility_tests ?? [],
124
+ };
125
+ }),
126
+ };
127
+ const output = path.resolve(opts.output ??
128
+ path.join(path.dirname(analysisPath), 'generated-vendor-evidence.json'));
129
+ if (fs.existsSync(output))
130
+ throw new Error(`Generated/vendor evidence already exists: ${output}`);
131
+ fs.mkdirSync(path.dirname(output), { recursive: true });
132
+ writeMigrationJson(output, manifest, { createOnly: true });
133
+ return { path: output, manifest };
134
+ }
135
+ export function validateMigrationGenerated(file) {
136
+ const resolved = path.resolve(file);
137
+ const manifest = readJson(resolved);
138
+ if (manifest.artifact_kind !== 'ductape_migration_generated_vendor_evidence') {
139
+ throw new Error('Invalid generated/vendor migration evidence.');
140
+ }
141
+ const blockers = [];
142
+ for (const entry of manifest.generated ?? []) {
143
+ try {
144
+ const current = sourceFile(manifest.source, entry.file);
145
+ if (checksum(current.absolute) !== entry.checked_in_checksum) {
146
+ blockers.push(`${entry.file}: checked-in generated output changed`);
147
+ }
148
+ }
149
+ catch {
150
+ blockers.push(`${entry.file}: checked-in generated output is missing`);
151
+ }
152
+ for (const input of entry.generator?.inputs ?? []) {
153
+ try {
154
+ if (checksum(sourceFile(manifest.source, input.file).absolute) !== input.checksum) {
155
+ blockers.push(`${entry.file}: generator input changed: ${input.file}`);
156
+ }
157
+ }
158
+ catch {
159
+ blockers.push(`${entry.file}: generator input is missing: ${input.file}`);
160
+ }
161
+ }
162
+ if (!entry.generator?.version?.trim())
163
+ blockers.push(`${entry.file}: generator version is missing`);
164
+ if (!entry.regeneration?.clean_workspace || entry.regeneration.status !== 'passed' ||
165
+ !entry.regeneration.evidence?.length) {
166
+ blockers.push(`${entry.file}: clean regeneration evidence is incomplete`);
167
+ }
168
+ if (!entry.regeneration?.matches_checked_in ||
169
+ entry.regeneration.regenerated_checksum !== entry.checked_in_checksum) {
170
+ blockers.push(`${entry.file}: regenerated output differs from checked-in output`);
171
+ }
172
+ if (entry.contract_kind !== 'internal' && !entry.compatibility_tests?.length) {
173
+ blockers.push(`${entry.file}: contract compatibility tests are missing`);
174
+ }
175
+ }
176
+ for (const entry of manifest.vendor ?? []) {
177
+ let currentChecksum;
178
+ try {
179
+ const current = sourceFile(manifest.source, entry.file);
180
+ currentChecksum = checksum(current.absolute);
181
+ }
182
+ catch {
183
+ blockers.push(`${entry.file}: vendored source is missing`);
184
+ continue;
185
+ }
186
+ if (currentChecksum !== entry.current_checksum)
187
+ blockers.push(`${entry.file}: vendored source changed after review`);
188
+ const actuallyModified = currentChecksum !== entry.upstream_checksum;
189
+ if (actuallyModified !== entry.locally_modified)
190
+ blockers.push(`${entry.file}: vendor modification classification is stale`);
191
+ if (actuallyModified && (!entry.owner?.trim() || !entry.diff_evidence?.length)) {
192
+ blockers.push(`${entry.file}: local vendor modification lacks owner or diff evidence`);
193
+ }
194
+ }
195
+ return {
196
+ evidence: resolved,
197
+ ready: blockers.length === 0,
198
+ generated: manifest.generated?.length ?? 0,
199
+ vendor: manifest.vendor?.length ?? 0,
200
+ blockers,
201
+ };
202
+ }
203
+ export function runMigrationGeneratedInit(opts) {
204
+ const result = initializeMigrationGenerated(opts);
205
+ printJson({
206
+ path: result.path,
207
+ generated: result.manifest.generated.length,
208
+ vendor: result.manifest.vendor.length,
209
+ }, Boolean(opts.json));
210
+ }
211
+ export function runMigrationGeneratedValidate(opts) {
212
+ const result = validateMigrationGenerated(opts.file);
213
+ printJson(result, Boolean(opts.json));
214
+ if (opts.strict && !result.ready) {
215
+ throw new Error('Generated/vendor evidence is not ready: resolve every reported blocker.');
216
+ }
217
+ }
@@ -0,0 +1,88 @@
1
+ interface PartitionProposal {
2
+ version: 1;
3
+ artifact_kind: 'ductape_migration_partition_proposal';
4
+ authority: 'advisory_only';
5
+ ledger: string;
6
+ limits: {
7
+ max_bytes: number;
8
+ max_tokens: number;
9
+ token_estimate: 'ceil(bytes/4)';
10
+ };
11
+ partitions: Array<{
12
+ tag: string;
13
+ files: string[];
14
+ dependencies: string[];
15
+ byte_estimate: number;
16
+ token_estimate: number;
17
+ oversized_scc: boolean;
18
+ }>;
19
+ }
20
+ interface Portfolio {
21
+ version: 1;
22
+ artifact_kind: 'ductape_migration_portfolio';
23
+ ledger: string;
24
+ partitions: Array<{
25
+ tag: string;
26
+ files: string[];
27
+ context_budget: number;
28
+ summary: string;
29
+ provenance: Array<{
30
+ file: string;
31
+ checksum: string;
32
+ }>;
33
+ }>;
34
+ slices: string[];
35
+ cross_slice_contracts: Array<{
36
+ provider: string;
37
+ consumer: string;
38
+ contract: string;
39
+ evidence: string;
40
+ }>;
41
+ }
42
+ export declare function proposeMigrationPartitions(opts: {
43
+ ledger: string;
44
+ maxBytes?: number;
45
+ maxTokens?: number;
46
+ output?: string;
47
+ }): {
48
+ path: string;
49
+ proposal: PartitionProposal;
50
+ };
51
+ export declare function initializeMigrationPortfolio(opts: {
52
+ ledger: string;
53
+ definition: string;
54
+ output?: string;
55
+ }): {
56
+ path: string;
57
+ portfolio: Portfolio;
58
+ };
59
+ export declare function validateMigrationPortfolio(portfolioPath: string): {
60
+ portfolio: string;
61
+ ready: boolean;
62
+ partitions: number;
63
+ slices: number;
64
+ coverage: {
65
+ reviewed_files: number;
66
+ partitioned_files: number;
67
+ };
68
+ blockers: string[];
69
+ };
70
+ export declare function runMigrationPortfolioInit(opts: {
71
+ ledger: string;
72
+ definition: string;
73
+ output?: string;
74
+ json?: boolean;
75
+ }): void;
76
+ export declare function runMigrationPortfolioValidate(opts: {
77
+ portfolio: string;
78
+ strict?: boolean;
79
+ json?: boolean;
80
+ }): void;
81
+ export declare function runMigrationPortfolioPropose(opts: {
82
+ ledger: string;
83
+ maxBytes?: number;
84
+ maxTokens?: number;
85
+ output?: string;
86
+ json?: boolean;
87
+ }): void;
88
+ export {};
@@ -0,0 +1,223 @@
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 { validateMigrationSlice } from './migration-slice.js';
6
+ import { readMigrationJson, writeMigrationJson } from '../lib/migration-artifact.js';
7
+ function read(file) {
8
+ return readMigrationJson(file);
9
+ }
10
+ function digest(file) {
11
+ try {
12
+ return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ }
18
+ export function proposeMigrationPartitions(opts) {
19
+ const ledgerPath = path.resolve(opts.ledger);
20
+ const ledger = read(ledgerPath);
21
+ const maxBytes = opts.maxBytes ?? 128_000;
22
+ const maxTokens = opts.maxTokens ?? 32_000;
23
+ if (!Number.isInteger(maxBytes) || maxBytes <= 0 || !Number.isInteger(maxTokens) || maxTokens <= 0) {
24
+ throw new Error('Partition byte and token limits must be positive integers.');
25
+ }
26
+ const known = new Set(ledger.entries.map((entry) => entry.file));
27
+ const graph = new Map(ledger.entries.map((entry) => [
28
+ entry.file,
29
+ [
30
+ ...(entry.references ?? []).filter((reference) => !reference.external).map((reference) => reference.target),
31
+ ...(entry.follow_up_files ?? []),
32
+ ].filter((target) => known.has(target)),
33
+ ]));
34
+ let nextIndex = 0;
35
+ const stack = [];
36
+ const onStack = new Set();
37
+ const indices = new Map();
38
+ const low = new Map();
39
+ const components = [];
40
+ const visit = (node) => {
41
+ indices.set(node, nextIndex);
42
+ low.set(node, nextIndex++);
43
+ stack.push(node);
44
+ onStack.add(node);
45
+ for (const dependency of graph.get(node) ?? []) {
46
+ if (!indices.has(dependency)) {
47
+ visit(dependency);
48
+ low.set(node, Math.min(low.get(node), low.get(dependency)));
49
+ }
50
+ else if (onStack.has(dependency)) {
51
+ low.set(node, Math.min(low.get(node), indices.get(dependency)));
52
+ }
53
+ }
54
+ if (low.get(node) === indices.get(node)) {
55
+ const component = [];
56
+ let current;
57
+ do {
58
+ current = stack.pop();
59
+ onStack.delete(current);
60
+ component.push(current);
61
+ } while (current !== node);
62
+ components.push(component.sort());
63
+ }
64
+ };
65
+ for (const entry of ledger.entries)
66
+ if (!indices.has(entry.file))
67
+ visit(entry.file);
68
+ const componentFor = new Map();
69
+ components.forEach((files, component) => files.forEach((file) => componentFor.set(file, component)));
70
+ const componentDependencies = components.map(() => new Set());
71
+ for (const [file, dependencies] of graph) {
72
+ const owner = componentFor.get(file);
73
+ for (const dependencyFile of dependencies) {
74
+ const dependency = componentFor.get(dependencyFile);
75
+ if (owner !== dependency)
76
+ componentDependencies[owner].add(dependency);
77
+ }
78
+ }
79
+ const ordered = [];
80
+ const seen = new Set();
81
+ const add = (component) => {
82
+ if (seen.has(component))
83
+ return;
84
+ seen.add(component);
85
+ componentDependencies[component].forEach(add);
86
+ ordered.push(component);
87
+ };
88
+ components.forEach((_, component) => add(component));
89
+ const tags = new Map(ordered.map((component, position) => [component, `partition-${position + 1}`]));
90
+ const partitions = ordered.map((component) => {
91
+ const files = components[component];
92
+ const byteEstimate = files.reduce((sum, file) => {
93
+ try {
94
+ return sum + fs.statSync(path.join(ledger.source, file)).size;
95
+ }
96
+ catch {
97
+ return sum;
98
+ }
99
+ }, 0);
100
+ const tokenEstimate = Math.ceil(byteEstimate / 4);
101
+ return {
102
+ tag: tags.get(component),
103
+ files,
104
+ dependencies: [...componentDependencies[component]].map((dependency) => tags.get(dependency)),
105
+ byte_estimate: byteEstimate,
106
+ token_estimate: tokenEstimate,
107
+ oversized_scc: byteEstimate > maxBytes || tokenEstimate > maxTokens,
108
+ };
109
+ });
110
+ const proposal = {
111
+ version: 1,
112
+ artifact_kind: 'ductape_migration_partition_proposal',
113
+ authority: 'advisory_only',
114
+ ledger: ledgerPath,
115
+ limits: { max_bytes: maxBytes, max_tokens: maxTokens, token_estimate: 'ceil(bytes/4)' },
116
+ partitions,
117
+ };
118
+ const output = path.resolve(opts.output ?? path.join(path.dirname(ledgerPath), 'partition-proposal.json'));
119
+ writeMigrationJson(output, proposal);
120
+ return { path: output, proposal };
121
+ }
122
+ export function initializeMigrationPortfolio(opts) {
123
+ const ledgerPath = path.resolve(opts.ledger);
124
+ const definition = read(path.resolve(opts.definition));
125
+ if (!Array.isArray(definition.partitions) || !Array.isArray(definition.slices) ||
126
+ !Array.isArray(definition.cross_slice_contracts)) {
127
+ throw new Error('Portfolio definition requires partitions, slices, and cross_slice_contracts arrays.');
128
+ }
129
+ const output = path.resolve(opts.output ?? path.join(path.dirname(ledgerPath), 'portfolio.json'));
130
+ if (fs.existsSync(output))
131
+ throw new Error(`Migration portfolio already exists: ${output}`);
132
+ const portfolio = {
133
+ version: 1,
134
+ artifact_kind: 'ductape_migration_portfolio',
135
+ ledger: ledgerPath,
136
+ ...definition,
137
+ partitions: definition.partitions.map((partition) => ({
138
+ ...partition,
139
+ provenance: partition.files.map((file) => ({
140
+ file,
141
+ checksum: digest(path.join(read(ledgerPath).source, file)) ?? '',
142
+ })),
143
+ })),
144
+ slices: definition.slices.map((slice) => path.resolve(slice)),
145
+ };
146
+ writeMigrationJson(output, portfolio, { createOnly: true });
147
+ return { path: output, portfolio };
148
+ }
149
+ export function validateMigrationPortfolio(portfolioPath) {
150
+ const resolved = path.resolve(portfolioPath);
151
+ const portfolio = read(resolved);
152
+ if (portfolio.artifact_kind !== 'ductape_migration_portfolio')
153
+ throw new Error('Invalid migration portfolio.');
154
+ const ledger = read(portfolio.ledger);
155
+ const blockers = [];
156
+ const coverage = new Map();
157
+ for (const partition of portfolio.partitions) {
158
+ if (!partition.tag || partition.context_budget <= 0 || !partition.summary.trim()) {
159
+ blockers.push(`partition ${partition.tag || '<unknown>'}: tag, positive context_budget, and summary are required`);
160
+ }
161
+ const provenance = new Map(partition.provenance.map((item) => [item.file, item.checksum]));
162
+ for (const file of partition.files) {
163
+ coverage.set(file, (coverage.get(file) ?? 0) + 1);
164
+ const recorded = provenance.get(file);
165
+ if (!recorded || digest(path.join(ledger.source, file)) !== recorded) {
166
+ blockers.push(`partition ${partition.tag}: stale or missing provenance for ${file}`);
167
+ }
168
+ }
169
+ }
170
+ for (const entry of ledger.entries) {
171
+ if (!coverage.has(entry.file))
172
+ blockers.push(`portfolio coverage missing: ${entry.file}`);
173
+ }
174
+ for (const [file, count] of coverage) {
175
+ if (!ledger.entries.some((entry) => entry.file === file))
176
+ blockers.push(`partition references unknown file: ${file}`);
177
+ if (count > 1)
178
+ blockers.push(`file appears in multiple partitions: ${file}`);
179
+ }
180
+ const sliceTags = new Set();
181
+ if (portfolio.slices.length === 0)
182
+ blockers.push('portfolio must contain at least one migration slice');
183
+ for (const slicePath of portfolio.slices) {
184
+ const result = validateMigrationSlice(slicePath);
185
+ sliceTags.add(result.tag);
186
+ if (!result.ready)
187
+ blockers.push(`slice ${result.tag}: ${result.blockers.join('; ')}`);
188
+ }
189
+ for (const contract of portfolio.cross_slice_contracts) {
190
+ if (!sliceTags.has(contract.provider) || !sliceTags.has(contract.consumer) ||
191
+ !contract.contract || !contract.evidence) {
192
+ blockers.push(`cross-slice contract ${contract.provider} -> ${contract.consumer} is incomplete`);
193
+ }
194
+ }
195
+ return {
196
+ portfolio: resolved,
197
+ ready: blockers.length === 0,
198
+ partitions: portfolio.partitions.length,
199
+ slices: portfolio.slices.length,
200
+ coverage: { reviewed_files: ledger.entries.length, partitioned_files: coverage.size },
201
+ blockers,
202
+ };
203
+ }
204
+ export function runMigrationPortfolioInit(opts) {
205
+ const result = initializeMigrationPortfolio(opts);
206
+ printJson({ path: result.path, partitions: result.portfolio.partitions.length }, Boolean(opts.json));
207
+ }
208
+ export function runMigrationPortfolioValidate(opts) {
209
+ const result = validateMigrationPortfolio(opts.portfolio);
210
+ printJson(result, Boolean(opts.json));
211
+ if (opts.strict && !result.ready) {
212
+ throw new Error('Migration portfolio is not ready: resolve every partition, slice, and contract blocker.');
213
+ }
214
+ }
215
+ export function runMigrationPortfolioPropose(opts) {
216
+ const result = proposeMigrationPartitions(opts);
217
+ printJson({
218
+ path: result.path,
219
+ authority: result.proposal.authority,
220
+ partitions: result.proposal.partitions.length,
221
+ oversized_sccs: result.proposal.partitions.filter((partition) => partition.oversized_scc).length,
222
+ }, Boolean(opts.json));
223
+ }