@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.
- package/CHANGELOG.md +7 -0
- package/README.md +7 -0
- package/dist/commands/migrate-codebase.d.ts +176 -0
- package/dist/commands/migrate-codebase.js +753 -0
- package/dist/commands/migration-artifact.d.ts +32 -0
- package/dist/commands/migration-artifact.js +48 -0
- package/dist/commands/migration-assurance.d.ts +10 -0
- package/dist/commands/migration-assurance.js +120 -0
- package/dist/commands/migration-database.d.ts +21 -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 +354 -2
- package/dist/lib/migration-artifact.d.ts +13 -0
- package/dist/lib/migration-artifact.js +103 -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,624 @@
|
|
|
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
|
+
const CLI_VERSION = (() => {
|
|
7
|
+
try {
|
|
8
|
+
const packagePath = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../package.json');
|
|
9
|
+
return String(JSON.parse(fs.readFileSync(packagePath, 'utf8')).version);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return 'unknown';
|
|
13
|
+
}
|
|
14
|
+
})();
|
|
15
|
+
function checksum(file) {
|
|
16
|
+
try {
|
|
17
|
+
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function readJson(file) {
|
|
24
|
+
return readMigrationJson(file);
|
|
25
|
+
}
|
|
26
|
+
function parity() {
|
|
27
|
+
return { status: 'pending', evidence: [] };
|
|
28
|
+
}
|
|
29
|
+
export function initializeReviewLedger(analysisPath, ledgerPath) {
|
|
30
|
+
const resolvedAnalysis = path.resolve(analysisPath);
|
|
31
|
+
const analysis = readJson(resolvedAnalysis);
|
|
32
|
+
if (!analysis.source || !Array.isArray(analysis.review_queue)) {
|
|
33
|
+
throw new Error('Invalid migration analysis: source and review_queue are required.');
|
|
34
|
+
}
|
|
35
|
+
const output = path.resolve(ledgerPath ?? path.join(path.dirname(resolvedAnalysis), 'review-ledger.json'));
|
|
36
|
+
if (fs.existsSync(output)) {
|
|
37
|
+
throw new Error(`Review ledger already exists: ${output}`);
|
|
38
|
+
}
|
|
39
|
+
const ledger = {
|
|
40
|
+
version: 1,
|
|
41
|
+
artifact_kind: 'ai_migration_review_ledger',
|
|
42
|
+
source: path.resolve(analysis.source),
|
|
43
|
+
created_at: new Date().toISOString(),
|
|
44
|
+
revision: 0,
|
|
45
|
+
events: [],
|
|
46
|
+
entries: analysis.review_queue.map((item) => ({
|
|
47
|
+
file: item.file,
|
|
48
|
+
status: 'pending',
|
|
49
|
+
safety: item.safety,
|
|
50
|
+
baseline_checksum: checksum(path.join(analysis.source, item.file)),
|
|
51
|
+
reviewed_checksum: null,
|
|
52
|
+
purpose: '',
|
|
53
|
+
dependencies: [],
|
|
54
|
+
follow_up_files: [],
|
|
55
|
+
references: [],
|
|
56
|
+
interfaces: [],
|
|
57
|
+
findings: [],
|
|
58
|
+
interface_parity: parity(),
|
|
59
|
+
functional_parity: parity(),
|
|
60
|
+
operational_parity: parity(),
|
|
61
|
+
})),
|
|
62
|
+
};
|
|
63
|
+
fs.mkdirSync(path.dirname(output), { recursive: true });
|
|
64
|
+
writeMigrationJson(output, ledger, { createOnly: true });
|
|
65
|
+
return { ledger, path: output };
|
|
66
|
+
}
|
|
67
|
+
function validParity(value) {
|
|
68
|
+
if (value.status === 'verified')
|
|
69
|
+
return value.evidence.length > 0;
|
|
70
|
+
if (value.status === 'not_applicable')
|
|
71
|
+
return Boolean(value.reason?.trim());
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
function writeLedger(file, ledger, operation = 'update') {
|
|
75
|
+
const lock = `${file}.lock`;
|
|
76
|
+
let descriptor;
|
|
77
|
+
try {
|
|
78
|
+
try {
|
|
79
|
+
descriptor = fs.openSync(lock, 'wx');
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
if (error.code === 'EEXIST') {
|
|
83
|
+
throw new Error('Review ledger is locked by another reviewer; retry after that update completes.');
|
|
84
|
+
}
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
const current = readJson(file);
|
|
88
|
+
const currentStateHash = current.events?.at(-1)?.state_hash;
|
|
89
|
+
if (currentStateHash &&
|
|
90
|
+
currentStateHash !== crypto.createHash('sha256').update(JSON.stringify(current.entries)).digest('hex')) {
|
|
91
|
+
throw new Error('Review ledger state was modified outside the append-only audit workflow.');
|
|
92
|
+
}
|
|
93
|
+
const expectedRevision = ledger.revision ?? 0;
|
|
94
|
+
if ((current.revision ?? 0) !== expectedRevision) {
|
|
95
|
+
throw new Error(`Review ledger revision conflict: expected ${expectedRevision}, found ${current.revision ?? 0}`);
|
|
96
|
+
}
|
|
97
|
+
const previousHash = ledger.events?.at(-1)?.hash ?? null;
|
|
98
|
+
const eventWithoutHash = {
|
|
99
|
+
revision: expectedRevision + 1,
|
|
100
|
+
timestamp: new Date().toISOString(),
|
|
101
|
+
actor: process.env.USER || 'unknown',
|
|
102
|
+
tool: '@ductape/cli',
|
|
103
|
+
tool_version: CLI_VERSION,
|
|
104
|
+
operation,
|
|
105
|
+
state_hash: crypto.createHash('sha256').update(JSON.stringify(ledger.entries)).digest('hex'),
|
|
106
|
+
previous_hash: previousHash,
|
|
107
|
+
};
|
|
108
|
+
const hash = crypto.createHash('sha256')
|
|
109
|
+
.update(JSON.stringify(eventWithoutHash))
|
|
110
|
+
.digest('hex');
|
|
111
|
+
ledger.revision = eventWithoutHash.revision;
|
|
112
|
+
ledger.events = [...(ledger.events ?? []), { ...eventWithoutHash, hash }];
|
|
113
|
+
const temporary = `${file}.tmp-${process.pid}`;
|
|
114
|
+
fs.writeFileSync(temporary, JSON.stringify(ledger, null, 2) + '\n');
|
|
115
|
+
fs.copyFileSync(file, `${file}.bak`);
|
|
116
|
+
fs.renameSync(temporary, file);
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
if (descriptor !== undefined) {
|
|
120
|
+
fs.closeSync(descriptor);
|
|
121
|
+
if (fs.existsSync(lock))
|
|
122
|
+
fs.unlinkSync(lock);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function loadLedger(ledgerPath) {
|
|
127
|
+
const resolved = path.resolve(ledgerPath);
|
|
128
|
+
const ledger = readJson(resolved);
|
|
129
|
+
if (ledger.artifact_kind !== 'ai_migration_review_ledger' || !Array.isArray(ledger.entries)) {
|
|
130
|
+
throw new Error('Invalid AI migration review ledger.');
|
|
131
|
+
}
|
|
132
|
+
ledger.revision ??= 0;
|
|
133
|
+
ledger.events ??= [];
|
|
134
|
+
return { path: resolved, ledger };
|
|
135
|
+
}
|
|
136
|
+
function repositoryFile(value) {
|
|
137
|
+
const normalized = value.split(path.sep).join('/').replace(/^\.\//, '');
|
|
138
|
+
if (!normalized || path.isAbsolute(value) || normalized === '..' || normalized.startsWith('../')) {
|
|
139
|
+
throw new Error(`File must be repository-relative: ${value}`);
|
|
140
|
+
}
|
|
141
|
+
return normalized;
|
|
142
|
+
}
|
|
143
|
+
const WALK_IGNORE = new Set(['.git', 'node_modules', 'dist', 'build', 'coverage', 'vendor', 'bin', 'obj', 'target']);
|
|
144
|
+
function findFilesByChecksum(root, expected) {
|
|
145
|
+
const matches = [];
|
|
146
|
+
const visit = (directory) => {
|
|
147
|
+
for (const item of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
148
|
+
if (item.isDirectory() && WALK_IGNORE.has(item.name))
|
|
149
|
+
continue;
|
|
150
|
+
const absolute = path.join(directory, item.name);
|
|
151
|
+
if (item.isSymbolicLink())
|
|
152
|
+
continue;
|
|
153
|
+
if (item.isDirectory())
|
|
154
|
+
visit(absolute);
|
|
155
|
+
else if (item.isFile() && checksum(absolute) === expected) {
|
|
156
|
+
matches.push(path.relative(root, absolute).split(path.sep).join('/'));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
visit(root);
|
|
161
|
+
return matches;
|
|
162
|
+
}
|
|
163
|
+
function findEntry(ledger, file) {
|
|
164
|
+
const normalized = repositoryFile(file);
|
|
165
|
+
const entry = ledger.entries.find((candidate) => candidate.file === normalized);
|
|
166
|
+
if (!entry)
|
|
167
|
+
throw new Error(`File is not present in the review ledger: ${normalized}`);
|
|
168
|
+
return entry;
|
|
169
|
+
}
|
|
170
|
+
export function validateReviewLedger(ledgerPath) {
|
|
171
|
+
const { path: resolved, ledger } = loadLedger(ledgerPath);
|
|
172
|
+
const known = new Set(ledger.entries.map((entry) => entry.file));
|
|
173
|
+
const pending = [];
|
|
174
|
+
const stale = [];
|
|
175
|
+
const invalidExclusions = [];
|
|
176
|
+
const missingFollowUps = [];
|
|
177
|
+
const incompleteParity = [];
|
|
178
|
+
const missingContext = [];
|
|
179
|
+
const missingFindingProvenance = [];
|
|
180
|
+
const missingSymbolProvenance = [];
|
|
181
|
+
const probableMoves = [];
|
|
182
|
+
const auditErrors = [];
|
|
183
|
+
let previousHash = null;
|
|
184
|
+
let expectedEventRevision = 1;
|
|
185
|
+
for (const event of ledger.events ?? []) {
|
|
186
|
+
const { hash, ...eventWithoutHash } = event;
|
|
187
|
+
const expectedHash = crypto.createHash('sha256').update(JSON.stringify(eventWithoutHash)).digest('hex');
|
|
188
|
+
if (event.revision !== expectedEventRevision)
|
|
189
|
+
auditErrors.push(`event revision sequence breaks at ${event.revision}`);
|
|
190
|
+
if (event.previous_hash !== previousHash)
|
|
191
|
+
auditErrors.push(`event previous_hash mismatch at ${event.revision}`);
|
|
192
|
+
if (hash !== expectedHash)
|
|
193
|
+
auditErrors.push(`event hash mismatch at ${event.revision}`);
|
|
194
|
+
previousHash = hash;
|
|
195
|
+
expectedEventRevision += 1;
|
|
196
|
+
}
|
|
197
|
+
if ((ledger.revision ?? 0) !== (ledger.events?.length ?? 0)) {
|
|
198
|
+
auditErrors.push('ledger revision does not match append-only event count');
|
|
199
|
+
}
|
|
200
|
+
const lastStateHash = ledger.events?.at(-1)?.state_hash;
|
|
201
|
+
if (lastStateHash &&
|
|
202
|
+
lastStateHash !== crypto.createHash('sha256').update(JSON.stringify(ledger.entries)).digest('hex')) {
|
|
203
|
+
auditErrors.push('ledger entry state does not match the latest audit event');
|
|
204
|
+
}
|
|
205
|
+
const uncoveredReferences = [];
|
|
206
|
+
const invalidGeneratedExclusions = [];
|
|
207
|
+
for (const entry of ledger.entries) {
|
|
208
|
+
const current = checksum(path.join(ledger.source, entry.file));
|
|
209
|
+
if (entry.status === 'pending')
|
|
210
|
+
pending.push(entry.file);
|
|
211
|
+
if (entry.status === 'reviewed') {
|
|
212
|
+
if (!entry.reviewed_checksum || current !== entry.reviewed_checksum) {
|
|
213
|
+
stale.push(entry.file);
|
|
214
|
+
if (current === null && entry.reviewed_checksum) {
|
|
215
|
+
const candidates = findFilesByChecksum(ledger.source, entry.reviewed_checksum)
|
|
216
|
+
.filter((candidate) => candidate !== entry.file);
|
|
217
|
+
if (candidates.length)
|
|
218
|
+
probableMoves.push({ from: entry.file, candidates });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (!entry.purpose.trim())
|
|
222
|
+
missingContext.push(entry.file);
|
|
223
|
+
for (const [concern, evidence] of [
|
|
224
|
+
['interface', entry.interface_parity],
|
|
225
|
+
['functional', entry.functional_parity],
|
|
226
|
+
['operational', entry.operational_parity],
|
|
227
|
+
]) {
|
|
228
|
+
if (!validParity(evidence))
|
|
229
|
+
incompleteParity.push({ file: entry.file, concern });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (entry.status === 'excluded' && !entry.exclusion_reason?.trim()) {
|
|
233
|
+
invalidExclusions.push(entry.file);
|
|
234
|
+
}
|
|
235
|
+
if (entry.status === 'excluded' && entry.exclusion?.classification === 'generated') {
|
|
236
|
+
if (!entry.exclusion.generator?.trim() || !entry.exclusion.regeneration_test?.trim()) {
|
|
237
|
+
invalidGeneratedExclusions.push(entry.file);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
for (const followUp of entry.follow_up_files) {
|
|
241
|
+
if (!known.has(followUp))
|
|
242
|
+
missingFollowUps.push({ file: entry.file, follow_up: followUp });
|
|
243
|
+
}
|
|
244
|
+
for (const finding of entry.findings ?? []) {
|
|
245
|
+
const index = entry.findings.indexOf(finding);
|
|
246
|
+
if (!finding.location ||
|
|
247
|
+
!Number.isInteger(finding.location.start_line) ||
|
|
248
|
+
!Number.isInteger(finding.location.end_line) ||
|
|
249
|
+
finding.location.start_line < 1 ||
|
|
250
|
+
finding.location.end_line < finding.location.start_line) {
|
|
251
|
+
missingFindingProvenance.push({ file: entry.file, finding: index });
|
|
252
|
+
}
|
|
253
|
+
if (!finding.symbol?.kind || !finding.symbol.name?.trim()) {
|
|
254
|
+
missingSymbolProvenance.push({ file: entry.file, finding: index });
|
|
255
|
+
}
|
|
256
|
+
for (const followUp of finding.requires_followup ?? []) {
|
|
257
|
+
if (!known.has(followUp))
|
|
258
|
+
missingFollowUps.push({ file: entry.file, follow_up: followUp });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
for (const reference of entry.references ?? []) {
|
|
262
|
+
if (reference.external)
|
|
263
|
+
continue;
|
|
264
|
+
const target = ledger.entries.find((candidate) => candidate.file === reference.target);
|
|
265
|
+
if (!target || target.status === 'pending') {
|
|
266
|
+
uncoveredReferences.push({ file: entry.file, kind: reference.kind, target: reference.target });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const invalidatedDependents = new Set();
|
|
271
|
+
const changed = new Set(stale);
|
|
272
|
+
let expanded = true;
|
|
273
|
+
while (expanded) {
|
|
274
|
+
expanded = false;
|
|
275
|
+
for (const entry of ledger.entries) {
|
|
276
|
+
if (changed.has(entry.file) || entry.status !== 'reviewed')
|
|
277
|
+
continue;
|
|
278
|
+
const internalTargets = [
|
|
279
|
+
...(entry.follow_up_files ?? []),
|
|
280
|
+
...(entry.references ?? []).filter((reference) => !reference.external).map((reference) => reference.target),
|
|
281
|
+
...(entry.findings ?? []).flatMap((finding) => finding.requires_followup ?? []),
|
|
282
|
+
];
|
|
283
|
+
if (internalTargets.some((target) => changed.has(target))) {
|
|
284
|
+
changed.add(entry.file);
|
|
285
|
+
invalidatedDependents.add(entry.file);
|
|
286
|
+
expanded = true;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const reviewed = ledger.entries.filter((entry) => entry.status === 'reviewed').length;
|
|
291
|
+
const excluded = ledger.entries.filter((entry) => entry.status === 'excluded').length;
|
|
292
|
+
const ready = pending.length === 0 &&
|
|
293
|
+
stale.length === 0 &&
|
|
294
|
+
invalidExclusions.length === 0 &&
|
|
295
|
+
invalidGeneratedExclusions.length === 0 &&
|
|
296
|
+
missingFollowUps.length === 0 &&
|
|
297
|
+
uncoveredReferences.length === 0 &&
|
|
298
|
+
missingFindingProvenance.length === 0 &&
|
|
299
|
+
missingSymbolProvenance.length === 0 &&
|
|
300
|
+
invalidatedDependents.size === 0 &&
|
|
301
|
+
auditErrors.length === 0 &&
|
|
302
|
+
incompleteParity.length === 0 &&
|
|
303
|
+
missingContext.length === 0;
|
|
304
|
+
return {
|
|
305
|
+
ledger: resolved,
|
|
306
|
+
source: ledger.source,
|
|
307
|
+
ready,
|
|
308
|
+
coverage: {
|
|
309
|
+
total: ledger.entries.length,
|
|
310
|
+
reviewed,
|
|
311
|
+
excluded,
|
|
312
|
+
pending: pending.length,
|
|
313
|
+
percent: ledger.entries.length === 0
|
|
314
|
+
? 100
|
|
315
|
+
: Math.round(((reviewed + excluded) / ledger.entries.length) * 10_000) / 100,
|
|
316
|
+
},
|
|
317
|
+
blockers: {
|
|
318
|
+
pending,
|
|
319
|
+
stale,
|
|
320
|
+
invalid_exclusions: invalidExclusions,
|
|
321
|
+
invalid_generated_exclusions: invalidGeneratedExclusions,
|
|
322
|
+
missing_follow_ups: missingFollowUps,
|
|
323
|
+
uncovered_references: uncoveredReferences,
|
|
324
|
+
missing_finding_provenance: missingFindingProvenance,
|
|
325
|
+
missing_symbol_provenance: missingSymbolProvenance,
|
|
326
|
+
probable_moves: probableMoves,
|
|
327
|
+
invalidated_dependents: [...invalidatedDependents],
|
|
328
|
+
audit_errors: auditErrors,
|
|
329
|
+
missing_context: missingContext,
|
|
330
|
+
incomplete_parity: incompleteParity,
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
export function snapshotReviewedFile(ledgerPath, file) {
|
|
335
|
+
const { path: resolved, ledger } = loadLedger(ledgerPath);
|
|
336
|
+
const entry = findEntry(ledger, file);
|
|
337
|
+
if (!entry.purpose.trim()) {
|
|
338
|
+
throw new Error(`Record the file purpose before marking it reviewed: ${file}`);
|
|
339
|
+
}
|
|
340
|
+
const current = checksum(path.join(ledger.source, file));
|
|
341
|
+
if (!current)
|
|
342
|
+
throw new Error(`Cannot read reviewed file: ${file}`);
|
|
343
|
+
entry.status = 'reviewed';
|
|
344
|
+
entry.reviewed_checksum = current;
|
|
345
|
+
delete entry.exclusion_reason;
|
|
346
|
+
writeLedger(resolved, ledger, `snapshot:${entry.file}`);
|
|
347
|
+
return entry;
|
|
348
|
+
}
|
|
349
|
+
export function recordReviewContext(ledgerPath, file, dataPath) {
|
|
350
|
+
const { path: resolved, ledger } = loadLedger(ledgerPath);
|
|
351
|
+
const entry = findEntry(ledger, file);
|
|
352
|
+
const data = readJson(path.resolve(dataPath));
|
|
353
|
+
if (typeof data.purpose === 'string')
|
|
354
|
+
entry.purpose = data.purpose;
|
|
355
|
+
if (Array.isArray(data.dependencies))
|
|
356
|
+
entry.dependencies = data.dependencies;
|
|
357
|
+
if (Array.isArray(data.interfaces))
|
|
358
|
+
entry.interfaces = data.interfaces;
|
|
359
|
+
if (Array.isArray(data.findings))
|
|
360
|
+
entry.findings = data.findings;
|
|
361
|
+
if (Array.isArray(data.references)) {
|
|
362
|
+
entry.references = data.references.map((reference) => ({
|
|
363
|
+
...reference,
|
|
364
|
+
target: reference.external ? reference.target : repositoryFile(reference.target),
|
|
365
|
+
}));
|
|
366
|
+
}
|
|
367
|
+
if (Array.isArray(data.follow_up_files)) {
|
|
368
|
+
entry.follow_up_files = data.follow_up_files.map(repositoryFile);
|
|
369
|
+
}
|
|
370
|
+
writeLedger(resolved, ledger, `record:${entry.file}`);
|
|
371
|
+
return entry;
|
|
372
|
+
}
|
|
373
|
+
export function addFollowUpFile(ledgerPath, fromFile, followUpFile) {
|
|
374
|
+
const { path: resolved, ledger } = loadLedger(ledgerPath);
|
|
375
|
+
const source = findEntry(ledger, fromFile);
|
|
376
|
+
const target = repositoryFile(followUpFile);
|
|
377
|
+
if (!fs.existsSync(path.join(ledger.source, target))) {
|
|
378
|
+
throw new Error(`Follow-up file does not exist in the repository: ${target}`);
|
|
379
|
+
}
|
|
380
|
+
if (!source.follow_up_files.includes(target))
|
|
381
|
+
source.follow_up_files.push(target);
|
|
382
|
+
if (!ledger.entries.some((entry) => entry.file === target)) {
|
|
383
|
+
ledger.entries.push({
|
|
384
|
+
file: target,
|
|
385
|
+
status: 'pending',
|
|
386
|
+
safety: path.basename(target).startsWith('.env') ? 'names_only' : 'normal',
|
|
387
|
+
baseline_checksum: checksum(path.join(ledger.source, target)),
|
|
388
|
+
reviewed_checksum: null,
|
|
389
|
+
purpose: '',
|
|
390
|
+
dependencies: [],
|
|
391
|
+
follow_up_files: [],
|
|
392
|
+
references: [],
|
|
393
|
+
interfaces: [],
|
|
394
|
+
findings: [],
|
|
395
|
+
interface_parity: parity(),
|
|
396
|
+
functional_parity: parity(),
|
|
397
|
+
operational_parity: parity(),
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
writeLedger(resolved, ledger, `follow-up:${source.file}->${target}`);
|
|
401
|
+
return source;
|
|
402
|
+
}
|
|
403
|
+
export function excludeReviewFile(ledgerPath, file, exclusion) {
|
|
404
|
+
if (!['generated', 'vendor', 'build', 'snapshot', 'other'].includes(exclusion.classification)) {
|
|
405
|
+
throw new Error('Invalid exclusion classification.');
|
|
406
|
+
}
|
|
407
|
+
const { path: resolved, ledger } = loadLedger(ledgerPath);
|
|
408
|
+
const entry = findEntry(ledger, file);
|
|
409
|
+
if (!exclusion.reason.trim())
|
|
410
|
+
throw new Error('An exclusion reason is required.');
|
|
411
|
+
if (exclusion.classification === 'generated' &&
|
|
412
|
+
(!exclusion.generator?.trim() || !exclusion.regeneration_test?.trim())) {
|
|
413
|
+
throw new Error('Generated-file exclusions require --generator and --regeneration-test.');
|
|
414
|
+
}
|
|
415
|
+
entry.status = 'excluded';
|
|
416
|
+
entry.exclusion_reason = exclusion.reason;
|
|
417
|
+
entry.exclusion = exclusion;
|
|
418
|
+
entry.reviewed_checksum = null;
|
|
419
|
+
writeLedger(resolved, ledger, `exclude:${entry.file}`);
|
|
420
|
+
return entry;
|
|
421
|
+
}
|
|
422
|
+
export function setParityEvidence(ledgerPath, file, concern, status, evidence, reason) {
|
|
423
|
+
if (!['interface', 'functional', 'operational'].includes(concern)) {
|
|
424
|
+
throw new Error('Invalid parity concern.');
|
|
425
|
+
}
|
|
426
|
+
if (!['verified', 'not_applicable'].includes(status)) {
|
|
427
|
+
throw new Error('Invalid parity status.');
|
|
428
|
+
}
|
|
429
|
+
const { path: resolved, ledger } = loadLedger(ledgerPath);
|
|
430
|
+
const entry = findEntry(ledger, file);
|
|
431
|
+
if (status === 'verified' && !evidence?.trim())
|
|
432
|
+
throw new Error('Verified parity requires evidence.');
|
|
433
|
+
if (status === 'not_applicable' && !reason?.trim())
|
|
434
|
+
throw new Error('Not-applicable parity requires a reason.');
|
|
435
|
+
entry[`${concern}_parity`] = {
|
|
436
|
+
status,
|
|
437
|
+
evidence: evidence?.trim() ? [evidence.trim()] : [],
|
|
438
|
+
...(reason?.trim() ? { reason: reason.trim() } : {}),
|
|
439
|
+
};
|
|
440
|
+
writeLedger(resolved, ledger, `parity:${concern}:${entry.file}`);
|
|
441
|
+
return entry;
|
|
442
|
+
}
|
|
443
|
+
export function reopenReviewFile(ledgerPath, file) {
|
|
444
|
+
const { path: resolved, ledger } = loadLedger(ledgerPath);
|
|
445
|
+
const entry = findEntry(ledger, file);
|
|
446
|
+
entry.status = 'pending';
|
|
447
|
+
entry.reviewed_checksum = null;
|
|
448
|
+
delete entry.exclusion;
|
|
449
|
+
delete entry.exclusion_reason;
|
|
450
|
+
writeLedger(resolved, ledger, `reopen:${entry.file}`);
|
|
451
|
+
return entry;
|
|
452
|
+
}
|
|
453
|
+
export function moveReviewedFile(ledgerPath, from, to) {
|
|
454
|
+
const { path: resolved, ledger } = loadLedger(ledgerPath);
|
|
455
|
+
const entry = findEntry(ledger, from);
|
|
456
|
+
const target = repositoryFile(to);
|
|
457
|
+
if (ledger.entries.some((candidate) => candidate.file === target)) {
|
|
458
|
+
throw new Error(`Move target already exists in the review ledger: ${target}`);
|
|
459
|
+
}
|
|
460
|
+
const targetChecksum = checksum(path.join(ledger.source, target));
|
|
461
|
+
const expected = entry.reviewed_checksum ?? entry.baseline_checksum;
|
|
462
|
+
if (!targetChecksum || !expected || targetChecksum !== expected) {
|
|
463
|
+
throw new Error('Move target content does not match the reviewed source checksum.');
|
|
464
|
+
}
|
|
465
|
+
const previous = entry.file;
|
|
466
|
+
entry.file = target;
|
|
467
|
+
for (const candidate of ledger.entries) {
|
|
468
|
+
candidate.follow_up_files = candidate.follow_up_files.map((value) => value === previous ? target : value);
|
|
469
|
+
for (const reference of candidate.references ?? []) {
|
|
470
|
+
if (!reference.external && reference.target === previous)
|
|
471
|
+
reference.target = target;
|
|
472
|
+
}
|
|
473
|
+
for (const finding of candidate.findings ?? []) {
|
|
474
|
+
finding.requires_followup = finding.requires_followup.map((value) => value === previous ? target : value);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
writeLedger(resolved, ledger, `move:${previous}->${target}`);
|
|
478
|
+
return entry;
|
|
479
|
+
}
|
|
480
|
+
export function signReviewLedger(opts) {
|
|
481
|
+
const ledger = path.resolve(opts.ledger);
|
|
482
|
+
validateReviewLedger(ledger);
|
|
483
|
+
if (!opts.keyId.trim())
|
|
484
|
+
throw new Error('Signing key_id is required.');
|
|
485
|
+
const privateKey = fs.readFileSync(path.resolve(opts.privateKey), 'utf8');
|
|
486
|
+
const ledgerChecksum = checksum(ledger);
|
|
487
|
+
if (!ledgerChecksum)
|
|
488
|
+
throw new Error('Cannot checksum review ledger.');
|
|
489
|
+
const signature = {
|
|
490
|
+
version: 1,
|
|
491
|
+
artifact_kind: 'ductape_review_ledger_signature',
|
|
492
|
+
ledger,
|
|
493
|
+
ledger_checksum: ledgerChecksum,
|
|
494
|
+
key_id: opts.keyId,
|
|
495
|
+
algorithm: 'Ed25519',
|
|
496
|
+
signed_at: new Date().toISOString(),
|
|
497
|
+
signature: crypto.sign(null, Buffer.from(ledgerChecksum, 'utf8'), privateKey).toString('base64'),
|
|
498
|
+
};
|
|
499
|
+
const output = path.resolve(opts.output ?? `${ledger}.sig.json`);
|
|
500
|
+
writeMigrationJson(output, signature, { createOnly: true, mode: 0o600 });
|
|
501
|
+
return { path: output, signature };
|
|
502
|
+
}
|
|
503
|
+
export function verifyReviewLedgerSignature(opts) {
|
|
504
|
+
const ledger = path.resolve(opts.ledger);
|
|
505
|
+
const signature = readJson(path.resolve(opts.signature));
|
|
506
|
+
const current = checksum(ledger);
|
|
507
|
+
const valid = Boolean(signature.artifact_kind === 'ductape_review_ledger_signature' &&
|
|
508
|
+
signature.algorithm === 'Ed25519' &&
|
|
509
|
+
current &&
|
|
510
|
+
current === signature.ledger_checksum &&
|
|
511
|
+
crypto.verify(null, Buffer.from(signature.ledger_checksum, 'utf8'), fs.readFileSync(path.resolve(opts.publicKey), 'utf8'), Buffer.from(signature.signature, 'base64')));
|
|
512
|
+
return { ledger, key_id: signature.key_id, valid };
|
|
513
|
+
}
|
|
514
|
+
export function mergeReviewLedgers(opts) {
|
|
515
|
+
const base = loadLedger(opts.base).ledger;
|
|
516
|
+
const current = loadLedger(opts.current).ledger;
|
|
517
|
+
const incoming = loadLedger(opts.incoming).ledger;
|
|
518
|
+
if (base.source !== current.source || base.source !== incoming.source) {
|
|
519
|
+
throw new Error('Parallel review ledgers must use the same source repository.');
|
|
520
|
+
}
|
|
521
|
+
const baseByFile = new Map(base.entries.map((entry) => [entry.file, entry]));
|
|
522
|
+
const currentByFile = new Map(current.entries.map((entry) => [entry.file, entry]));
|
|
523
|
+
const incomingByFile = new Map(incoming.entries.map((entry) => [entry.file, entry]));
|
|
524
|
+
const files = new Set([...baseByFile.keys(), ...currentByFile.keys(), ...incomingByFile.keys()]);
|
|
525
|
+
const merged = [];
|
|
526
|
+
const conflicts = [];
|
|
527
|
+
for (const file of [...files].sort()) {
|
|
528
|
+
const original = baseByFile.get(file);
|
|
529
|
+
const ours = currentByFile.get(file);
|
|
530
|
+
const theirs = incomingByFile.get(file);
|
|
531
|
+
const originalJson = JSON.stringify(original);
|
|
532
|
+
const oursChanged = JSON.stringify(ours) !== originalJson;
|
|
533
|
+
const theirsChanged = JSON.stringify(theirs) !== originalJson;
|
|
534
|
+
if (oursChanged && theirsChanged && JSON.stringify(ours) !== JSON.stringify(theirs)) {
|
|
535
|
+
conflicts.push(file);
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
const selected = theirsChanged ? theirs : oursChanged ? ours : original;
|
|
539
|
+
if (selected)
|
|
540
|
+
merged.push(selected);
|
|
541
|
+
}
|
|
542
|
+
if (conflicts.length) {
|
|
543
|
+
return { merged: false, conflicts, output: null };
|
|
544
|
+
}
|
|
545
|
+
const result = {
|
|
546
|
+
version: 1,
|
|
547
|
+
artifact_kind: 'ai_migration_review_ledger',
|
|
548
|
+
source: base.source,
|
|
549
|
+
created_at: new Date().toISOString(),
|
|
550
|
+
revision: 0,
|
|
551
|
+
events: [],
|
|
552
|
+
entries: merged,
|
|
553
|
+
};
|
|
554
|
+
const output = path.resolve(opts.output);
|
|
555
|
+
if (fs.existsSync(output))
|
|
556
|
+
throw new Error(`Merge output already exists: ${output}`);
|
|
557
|
+
writeMigrationJson(output, result, { createOnly: true });
|
|
558
|
+
return { merged: true, conflicts: [], output };
|
|
559
|
+
}
|
|
560
|
+
export function runMigrationReviewInit(opts) {
|
|
561
|
+
const result = initializeReviewLedger(opts.analysis, opts.output);
|
|
562
|
+
printJson({ path: result.path, entries: result.ledger.entries.length }, Boolean(opts.json));
|
|
563
|
+
}
|
|
564
|
+
export function runMigrationReviewValidate(opts) {
|
|
565
|
+
const result = validateReviewLedger(opts.ledger);
|
|
566
|
+
printJson(result, Boolean(opts.json));
|
|
567
|
+
if (opts.strict && !result.ready) {
|
|
568
|
+
throw new Error('Migration review is not ready: resolve every reported blocker before cutover.');
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
export function runMigrationReviewSnapshot(opts) {
|
|
572
|
+
const entry = snapshotReviewedFile(opts.ledger, opts.file);
|
|
573
|
+
printJson({
|
|
574
|
+
file: entry.file,
|
|
575
|
+
status: entry.status,
|
|
576
|
+
reviewed_checksum: entry.reviewed_checksum,
|
|
577
|
+
}, Boolean(opts.json));
|
|
578
|
+
}
|
|
579
|
+
export function runMigrationReviewRecord(opts) {
|
|
580
|
+
const entry = recordReviewContext(opts.ledger, opts.file, opts.data);
|
|
581
|
+
printJson({ file: entry.file, purpose: entry.purpose, findings: entry.findings.length }, Boolean(opts.json));
|
|
582
|
+
}
|
|
583
|
+
export function runMigrationReviewFollowUp(opts) {
|
|
584
|
+
const entry = addFollowUpFile(opts.ledger, opts.file, opts.followUp);
|
|
585
|
+
printJson({ file: entry.file, follow_up_files: entry.follow_up_files }, Boolean(opts.json));
|
|
586
|
+
}
|
|
587
|
+
export function runMigrationReviewExclude(opts) {
|
|
588
|
+
const entry = excludeReviewFile(opts.ledger, opts.file, {
|
|
589
|
+
classification: opts.classification,
|
|
590
|
+
reason: opts.reason,
|
|
591
|
+
owner: opts.owner,
|
|
592
|
+
generator: opts.generator,
|
|
593
|
+
regeneration_test: opts.regenerationTest,
|
|
594
|
+
});
|
|
595
|
+
printJson({ file: entry.file, status: entry.status, exclusion: entry.exclusion }, Boolean(opts.json));
|
|
596
|
+
}
|
|
597
|
+
export function runMigrationReviewParity(opts) {
|
|
598
|
+
const entry = setParityEvidence(opts.ledger, opts.file, opts.concern, opts.status, opts.evidence, opts.reason);
|
|
599
|
+
printJson({ file: entry.file, concern: opts.concern, parity: entry[`${opts.concern}_parity`] }, Boolean(opts.json));
|
|
600
|
+
}
|
|
601
|
+
export function runMigrationReviewReopen(opts) {
|
|
602
|
+
const entry = reopenReviewFile(opts.ledger, opts.file);
|
|
603
|
+
printJson({ file: entry.file, status: entry.status }, Boolean(opts.json));
|
|
604
|
+
}
|
|
605
|
+
export function runMigrationReviewMove(opts) {
|
|
606
|
+
const entry = moveReviewedFile(opts.ledger, opts.from, opts.to);
|
|
607
|
+
printJson({ file: entry.file, status: entry.status, reviewed_checksum: entry.reviewed_checksum }, Boolean(opts.json));
|
|
608
|
+
}
|
|
609
|
+
export function runMigrationReviewSign(opts) {
|
|
610
|
+
const result = signReviewLedger(opts);
|
|
611
|
+
printJson({ path: result.path, key_id: result.signature.key_id, algorithm: result.signature.algorithm }, Boolean(opts.json));
|
|
612
|
+
}
|
|
613
|
+
export function runMigrationReviewVerifySignature(opts) {
|
|
614
|
+
const result = verifyReviewLedgerSignature(opts);
|
|
615
|
+
printJson(result, Boolean(opts.json));
|
|
616
|
+
if (!result.valid)
|
|
617
|
+
throw new Error('Review ledger signature verification failed.');
|
|
618
|
+
}
|
|
619
|
+
export function runMigrationReviewMerge(opts) {
|
|
620
|
+
const result = mergeReviewLedgers(opts);
|
|
621
|
+
printJson(result, Boolean(opts.json));
|
|
622
|
+
if (!result.merged)
|
|
623
|
+
throw new Error(`Review merge has conflicts: ${result.conflicts.join(', ')}`);
|
|
624
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export declare function initializeSecretMap(opts: {
|
|
2
|
+
analysis: string;
|
|
3
|
+
definition: string;
|
|
4
|
+
output: string;
|
|
5
|
+
}): {
|
|
6
|
+
path: string;
|
|
7
|
+
};
|
|
8
|
+
export declare function validateSecretMap(file: string): {
|
|
9
|
+
artifact: string;
|
|
10
|
+
ready: boolean;
|
|
11
|
+
references: number;
|
|
12
|
+
blockers: string[];
|
|
13
|
+
};
|
|
14
|
+
export declare function runMigrationSecretsInit(opts: {
|
|
15
|
+
analysis: string;
|
|
16
|
+
definition: string;
|
|
17
|
+
output: string;
|
|
18
|
+
json?: boolean;
|
|
19
|
+
}): void;
|
|
20
|
+
export declare function runMigrationSecretsValidate(opts: {
|
|
21
|
+
file: string;
|
|
22
|
+
strict?: boolean;
|
|
23
|
+
json?: boolean;
|
|
24
|
+
}): void;
|