Files
msd-core/src/installer-migration-authoring.cts
Tom Boucher e0f969af6a refactor(#2246): centralize cross-platform path-separator handling (toPosixPath / toNativePath / posixNormalize) (#2247)
Replace every open-coded separator translation across the installer/hooks
source with named, tested seams in shell-command-projection.cts (the platform
seam), removing all hardcoded `/`+`\` from path handling:

- toPosixPath(p)   — this machine's native path → POSIX (running-OS relative;
                     for local filesystem paths).
- toNativePath(p)  — POSIX → native (collapses the win32 `/\//g,'\\'` ternary).
- posixNormalize(p)— unconditional `\`→`/`, OS-independent; for emitting paths
                     to a POSIX/bash TARGET (which may differ from the running
                     OS) and for parsing mixed-separator input.

core-utils.toPosixPath now delegates to the seam, so its 20+ existing consumers
resolve to one implementation; no duplicate helper.

- ~47 sites across runtime-hooks-surface, runtime-artifact-conversion,
  runtime-artifact-install-plan, drift, init, worktree-safety,
  installer-migrations, installer-migration-authoring, install-engine, surface,
  verify, runtime-artifact-layout, schema-detect, check-command-router.
- Closes the latent POSIX-literal-backslash corruption class (the regex form
  corrupts a POSIX path containing a literal backslash; split(path.sep) does not).
- New unit + fast-check property tests for all three helpers.

Closes #2246

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:36:17 -04:00

138 lines
5.7 KiB
TypeScript

/**
* Installer Migration Authoring — validation helpers for installer migration records and actions.
*
* ADR-457 build-at-publish: the hand-written
* bin/lib/installer-migration-authoring.cjs collapsed to a TypeScript source
* of truth. Behaviour is preserved byte-for-behaviour from the prior
* hand-written .cjs; only types are added.
*/
import path from 'node:path';
import { posixNormalize } from './shell-command-projection.cjs';
/** An unvalidated migration record supplied by the caller. */
export type MigrationRecord = Record<string, unknown>;
/** A migration action (open shape). */
export type MigrationAction = Record<string, unknown>;
function getStr(record: MigrationRecord, field: string): string {
const v = record[field];
return typeof v === 'string' ? v : '';
}
function requireNonEmptyString(record: MigrationRecord, field: string, source: string): void {
const v = record[field];
if (typeof v !== 'string' || v.trim() === '') {
throw new Error(`migration record must include a non-empty ${field}: ${source}`);
}
}
function isNonEmptyStringArray(arr: unknown): arr is string[] {
return Array.isArray(arr) && arr.length > 0 && arr.every((v) => typeof v === 'string' && v.trim() !== '');
}
function validateStringArray(record: MigrationRecord, field: string, source: string): void {
if (record[field] === undefined) return;
if (!isNonEmptyStringArray(record[field])) {
throw new Error(`migration record ${field} must be a non-empty string array when provided: ${source}`);
}
}
function requireStringArray(record: MigrationRecord, field: string, source: string): void {
if (!isNonEmptyStringArray(record[field])) {
throw new Error(`migration record ${field} must be a non-empty string array: ${source}`);
}
}
function recordSource(record: MigrationRecord, fallback: string | undefined): string {
const id = getStr(record, 'id');
return fallback ?? (id.trim() ? id : '<unknown>');
}
function actionSource(migration: MigrationRecord, action: MigrationAction): string {
const migrationId = getStr(migration, 'id') || '<unknown>';
const relPath = getStr(action, 'relPath') || '<unknown>';
return `${migrationId} ${relPath}`;
}
function requireActionEvidence(action: MigrationAction, field: string, migration: MigrationRecord): void {
const v = action[field];
if (typeof v !== 'string' || v.trim() === '') {
throw new Error(`migration action ${getStr(action, 'type')} must include ${field}: ${actionSource(migration, action)}`);
}
}
function validateSafeRelPath(relPath: string, migration: MigrationRecord, actionType: string): void {
const source = actionSource(migration, { relPath });
const normalized = posixNormalize(relPath);
if (path.isAbsolute(normalized) || path.win32.isAbsolute(normalized)) {
throw new Error(`migration action ${actionType} relPath must stay inside configDir: ${source}`);
}
const segments = normalized.split('/');
if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) {
throw new Error(`migration action ${actionType} relPath must stay inside configDir: ${source}`);
}
}
export function validateInstallerMigrationRecord(record: unknown, source?: string): MigrationRecord {
const rec = record as MigrationRecord;
const displaySource = recordSource(rec, 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(rec, 'id', displaySource);
requireNonEmptyString(rec, 'title', displaySource);
requireNonEmptyString(rec, 'description', displaySource);
requireNonEmptyString(rec, 'introducedIn', displaySource);
if (typeof rec['destructive'] !== 'boolean') {
throw new Error(`migration record must declare destructive as a boolean: ${displaySource}`);
}
validateStringArray(rec, 'runtimes', displaySource);
requireStringArray(rec, 'scopes', displaySource);
if (typeof rec['plan'] !== 'function') {
throw new Error(`migration record must include a plan function: ${displaySource}`);
}
return rec;
}
export function validateInstallerMigrationActions(actions: unknown, migration: MigrationRecord): MigrationAction[] {
if (!Array.isArray(actions)) {
throw new Error(`migration ${getStr(migration, 'id')} plan must return an array`);
}
for (const action of actions as unknown[]) {
if (!action || typeof action !== 'object') {
throw new Error(`migration action must be an object: ${getStr(migration, 'id')}`);
}
const act = action as MigrationAction;
const actType = getStr(act, 'type');
const actRelPath = getStr(act, 'relPath');
if (!actType || actType.trim() === '') {
throw new Error(`migration action must include a non-empty type: ${getStr(migration, 'id')}`);
}
if (!actRelPath || actRelPath.trim() === '') {
throw new Error(`migration action ${actType} must include a non-empty relPath: ${getStr(migration, 'id')}`);
}
validateSafeRelPath(actRelPath, migration, actType);
// 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 (actType === 'remove-managed' || actType === 'rewrite-json') {
requireActionEvidence(act, 'ownershipEvidence', migration);
}
if (actType === 'rewrite-json') {
const rc = getStr(migration, 'runtimeContract');
if (!rc || rc.trim() === '') {
throw new Error(`migration action rewrite-json requires migration runtimeContract: ${actionSource(migration, act)}`);
}
}
}
return actions as MigrationAction[];
}