Feat(installer): add phase 5 migration guardrails
This commit is contained in:
102
get-shit-done/bin/lib/installer-migration-authoring.cjs
Normal file
102
get-shit-done/bin/lib/installer-migration-authoring.cjs
Normal 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,
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user