Feat(installer): add phase 5 migration guardrails

This commit is contained in:
Tom Boucher
2026-05-11 10:44:57 -04:00
parent c046dbaacc
commit a1f00a8a0c
11 changed files with 167 additions and 38 deletions

View File

@@ -0,0 +1,102 @@
'use strict';
function requireNonEmptyString(record, field, source) {
if (typeof record[field] !== 'string' || record[field].trim() === '') {
throw new Error(`migration record must include a non-empty ${field}: ${source}`);
}
}
function validateStringArray(record, field, source) {
if (record[field] === undefined) return;
if (
!Array.isArray(record[field]) ||
record[field].length === 0 ||
record[field].some((value) => typeof value !== 'string' || value.trim() === '')
) {
throw new Error(`migration record ${field} must be a non-empty string array when provided: ${source}`);
}
}
function requireStringArray(record, field, source) {
if (
!Array.isArray(record[field]) ||
record[field].length === 0 ||
record[field].some((value) => typeof value !== 'string' || value.trim() === '')
) {
throw new Error(`migration record ${field} must be a non-empty string array: ${source}`);
}
}
function recordSource(record, fallback) {
return fallback || (record && typeof record.id === 'string' && record.id.trim() ? record.id : '<unknown>');
}
function validateInstallerMigrationRecord(record, source) {
const displaySource = recordSource(record, source);
if (!record || typeof record !== 'object') {
throw new Error(`migration record must export an object: ${displaySource}`);
}
// Authoring contract follows docs/installer-migrations.md#authoring-workflow
// and docs/adr/0008-installer-migration-module.md#decision.
requireNonEmptyString(record, 'id', displaySource);
requireNonEmptyString(record, 'title', displaySource);
requireNonEmptyString(record, 'description', displaySource);
requireNonEmptyString(record, 'introducedIn', displaySource);
if (typeof record.destructive !== 'boolean') {
throw new Error(`migration record must declare destructive as a boolean: ${displaySource}`);
}
validateStringArray(record, 'runtimes', displaySource);
requireStringArray(record, 'scopes', displaySource);
if (typeof record.plan !== 'function') {
throw new Error(`migration record must include a plan function: ${displaySource}`);
}
return record;
}
function actionSource(migration, action) {
const migrationId = migration && typeof migration.id === 'string' ? migration.id : '<unknown>';
const relPath = action && typeof action.relPath === 'string' ? action.relPath : '<unknown>';
return `${migrationId} ${relPath}`;
}
function requireActionEvidence(action, field, migration) {
if (typeof action[field] !== 'string' || action[field].trim() === '') {
throw new Error(`migration action ${action.type} must include ${field}: ${actionSource(migration, action)}`);
}
}
function validateInstallerMigrationActions(actions, migration) {
if (!Array.isArray(actions)) {
throw new Error(`migration ${migration.id} plan must return an array`);
}
for (const action of actions) {
if (!action || typeof action !== 'object') {
throw new Error(`migration action must be an object: ${migration.id}`);
}
if (typeof action.type !== 'string' || action.type.trim() === '') {
throw new Error(`migration action must include a non-empty type: ${migration.id}`);
}
if (typeof action.relPath !== 'string' || action.relPath.trim() === '') {
throw new Error(`migration action ${action.type} must include a non-empty relPath: ${migration.id}`);
}
// Ownership and runtime-contract evidence are required by
// docs/installer-migrations.md#action-types and
// docs/adr/0008-installer-migration-module.md#runtime-contract-decision.
if (action.type === 'remove-managed' || action.type === 'rewrite-json') {
requireActionEvidence(action, 'ownershipEvidence', migration);
}
if (action.type === 'rewrite-json' && (typeof migration.runtimeContract !== 'string' || migration.runtimeContract.trim() === '')) {
throw new Error(`migration action rewrite-json requires migration runtimeContract: ${actionSource(migration, action)}`);
}
}
return actions;
}
module.exports = {
validateInstallerMigrationActions,
validateInstallerMigrationRecord,
};

View File

@@ -3,6 +3,10 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const {
validateInstallerMigrationActions,
validateInstallerMigrationRecord,
} = require('./installer-migration-authoring.cjs');
const MANIFEST_NAME = 'gsd-file-manifest.json';
const INSTALL_STATE_NAME = 'gsd-install-state.json';
@@ -171,19 +175,6 @@ function migrationMatchesContext(migration, { runtime, scope }) {
return true;
}
function validateMigrationRecord(record, source) {
if (!record || typeof record !== 'object') {
throw new Error(`migration record must export an object: ${source}`);
}
if (typeof record.id !== 'string' || record.id.trim() === '') {
throw new Error(`migration record must include a non-empty id: ${source}`);
}
if (typeof record.plan !== 'function') {
throw new Error(`migration record must include a plan function: ${source}`);
}
return record;
}
function discoverInstallerMigrations({ migrationsDir }) {
if (!migrationsDir || !fs.existsSync(migrationsDir)) return [];
return fs.readdirSync(migrationsDir, { withFileTypes: true })
@@ -196,7 +187,9 @@ function discoverInstallerMigrations({ migrationsDir }) {
delete require.cache[require.resolve(source)];
const exported = require(source);
const records = Array.isArray(exported) ? exported : [exported];
return records.map((record) => validateMigrationRecord({ ...record, checksum: record.checksum || checksum }, source));
return records.map((record) =>
validateInstallerMigrationRecord({ ...record, checksum: record.checksum || checksum }, source)
);
});
}
@@ -302,8 +295,11 @@ function planInstallerMigrations({
const manifest = readInstallManifest(configDir);
const state = readInstallState(configDir);
const scopedMigrations = migrations.filter((migration) =>
migration && migrationMatchesContext(migration, { runtime, scope })
const validatedMigrations = migrations.map((migration) =>
validateInstallerMigrationRecord(migration)
);
const scopedMigrations = validatedMigrations.filter((migration) =>
migrationMatchesContext(migration, { runtime, scope })
);
const applied = appliedMigrationEntries(state);
assertAppliedMigrationChecksums(applied, scopedMigrations);
@@ -320,12 +316,6 @@ function planInstallerMigrations({
};
for (const migration of pending) {
if (typeof migration.id !== 'string' || migration.id.trim() === '') {
throw new Error('migration id must be a non-empty string');
}
if (typeof migration.plan !== 'function') {
throw new Error(`migration ${migration.id} must provide a plan function`);
}
const plannedActions = migration.plan({
configDir,
runtime,
@@ -337,9 +327,7 @@ function planInstallerMigrations({
classifyArtifact: classify,
readJson: (relPath) => readJson(configDir, relPath),
});
if (!Array.isArray(plannedActions)) {
throw new Error(`migration ${migration.id} plan must return an array`);
}
validateInstallerMigrationActions(plannedActions, migration);
const checksum = migrationChecksum(migration);
for (const rawAction of plannedActions) {
const relPath = normalizeRelPath(rawAction.relPath);

View File

@@ -151,6 +151,21 @@ module.exports = {
const actions = [];
for (const relPath of scanBaselineFiles(configDir, runtime)) {
// docs/installer-migrations.md#baseline-preserve-user keeps user-owned
// artifacts out of destructive migration flow; classify later only when
// ownership is not already known.
if (isUserOwnedBaselinePath(relPath)) {
actions.push({
type: 'baseline-preserve-user',
relPath,
reason: 'known user-owned artifact preserved by first-time migration baseline',
classification: 'user-owned',
originalHash: null,
currentHash: null,
});
continue;
}
const artifact = classifyArtifact(relPath);
if (artifact.classification === 'managed-pristine' || artifact.classification === 'managed-modified') {
actions.push({
@@ -174,18 +189,6 @@ module.exports = {
continue;
}
if (isUserOwnedBaselinePath(relPath)) {
actions.push({
type: 'baseline-preserve-user',
relPath,
reason: 'known user-owned artifact preserved by first-time migration baseline',
classification: 'user-owned',
originalHash: null,
currentHash,
});
continue;
}
if (isStaleGsdLookingPath(relPath)) {
actions.push({
type: 'prompt-user',

View File

@@ -25,6 +25,7 @@ module.exports = {
type: 'remove-managed',
relPath,
reason: 'legacy orphan hook file retired by installer migration',
ownershipEvidence: 'legacy hook path is manifest-managed in gsd-file-manifest.json',
});
}
}

View File

@@ -74,6 +74,7 @@ module.exports = {
value: pruned.value,
deleteIfEmpty: true,
reason: 'legacy Codex hooks.json GSD registration retired by installer migration',
ownershipEvidence: 'pruned command matches generated GSD hook command under the install hooks directory',
},
];
},