GSD agents silently skip database schema push — verification passes but production breaks because TypeScript types come from config, not the live database. This adds two layers of protection: 1. Plan-phase template detection (step 5.7): When the planner detects schema-relevant file patterns in the phase scope, it injects a mandatory [BLOCKING] schema push task into the plan with the appropriate push command for the detected ORM. 2. Post-execution drift detection gate: After execution completes but before verification marks success, scans for schema-relevant file changes and checks if a push command was executed. Blocks verification with actionable guidance if drift is detected. Supports Payload CMS, Prisma, Drizzle, Supabase, and TypeORM. Override with GSD_SKIP_SCHEMA_CHECK=true. Fixes #1381 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
239 lines
7.2 KiB
JavaScript
239 lines
7.2 KiB
JavaScript
/**
|
|
* Schema Drift Detection — Detects schema-relevant file changes and verifies
|
|
* that the appropriate database push command was executed during a phase.
|
|
*
|
|
* Prevents false-positive verification when schema files change but no push
|
|
* occurs — TypeScript types come from config, not the live database, so
|
|
* build/types pass on a broken state.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
// ─── ORM Patterns ────────────────────────────────────────────────────────────
|
|
//
|
|
// Each entry maps a glob-like pattern to an ORM name. Patterns use forward
|
|
// slashes internally — Windows backslash paths are normalized before matching.
|
|
|
|
const SCHEMA_PATTERNS = [
|
|
// Payload CMS
|
|
{ pattern: /^src\/collections\/.*\.ts$/, orm: 'payload' },
|
|
{ pattern: /^src\/globals\/.*\.ts$/, orm: 'payload' },
|
|
|
|
// Prisma
|
|
{ pattern: /^prisma\/schema\.prisma$/, orm: 'prisma' },
|
|
{ pattern: /^prisma\/schema\/.*\.prisma$/, orm: 'prisma' },
|
|
|
|
// Drizzle
|
|
{ pattern: /^drizzle\/schema\.ts$/, orm: 'drizzle' },
|
|
{ pattern: /^src\/db\/schema\.ts$/, orm: 'drizzle' },
|
|
{ pattern: /^drizzle\/.*\.ts$/, orm: 'drizzle' },
|
|
|
|
// Supabase
|
|
{ pattern: /^supabase\/migrations\/.*\.sql$/, orm: 'supabase' },
|
|
|
|
// TypeORM
|
|
{ pattern: /^src\/entities\/.*\.ts$/, orm: 'typeorm' },
|
|
{ pattern: /^src\/migrations\/.*\.ts$/, orm: 'typeorm' },
|
|
];
|
|
|
|
// ─── Push Commands & Evidence Patterns ───────────────────────────────────────
|
|
//
|
|
// For each ORM, the push command that agents should run, plus regex patterns
|
|
// that indicate the push was actually executed (matched against execution logs,
|
|
// SUMMARY.md content, and git commit messages).
|
|
|
|
const ORM_INFO = {
|
|
payload: {
|
|
pushCommand: 'npx payload migrate',
|
|
envHint: 'CI=true PAYLOAD_MIGRATING=true npx payload migrate',
|
|
interactiveWarning: 'Payload migrate may require interactive prompts — use CI=true PAYLOAD_MIGRATING=true to suppress',
|
|
evidencePatterns: [
|
|
/payload\s+migrate/i,
|
|
/PAYLOAD_MIGRATING/,
|
|
],
|
|
},
|
|
prisma: {
|
|
pushCommand: 'npx prisma db push',
|
|
envHint: 'npx prisma db push --accept-data-loss (if destructive changes are intended)',
|
|
interactiveWarning: 'Prisma db push may prompt for confirmation on destructive changes — use --accept-data-loss to bypass',
|
|
evidencePatterns: [
|
|
/prisma\s+db\s+push/i,
|
|
/prisma\s+migrate\s+deploy/i,
|
|
/prisma\s+migrate\s+dev/i,
|
|
],
|
|
},
|
|
drizzle: {
|
|
pushCommand: 'npx drizzle-kit push',
|
|
envHint: 'npx drizzle-kit push',
|
|
interactiveWarning: null,
|
|
evidencePatterns: [
|
|
/drizzle-kit\s+push/i,
|
|
/drizzle-kit\s+migrate/i,
|
|
],
|
|
},
|
|
supabase: {
|
|
pushCommand: 'supabase db push',
|
|
envHint: 'supabase db push',
|
|
interactiveWarning: 'Supabase db push may require authentication — ensure SUPABASE_ACCESS_TOKEN is set',
|
|
evidencePatterns: [
|
|
/supabase\s+db\s+push/i,
|
|
/supabase\s+migration\s+up/i,
|
|
],
|
|
},
|
|
typeorm: {
|
|
pushCommand: 'npx typeorm migration:run',
|
|
envHint: 'npx typeorm migration:run -d src/data-source.ts',
|
|
interactiveWarning: null,
|
|
evidencePatterns: [
|
|
/typeorm\s+migration:run/i,
|
|
/typeorm\s+schema:sync/i,
|
|
],
|
|
},
|
|
};
|
|
|
|
// ─── Public API ──────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Detect schema-relevant files in a list of file paths.
|
|
*
|
|
* @param {string[]} files - List of file paths (relative to project root)
|
|
* @returns {{ detected: boolean, matches: string[], orms: string[] }}
|
|
*/
|
|
function detectSchemaFiles(files) {
|
|
const matches = [];
|
|
const orms = new Set();
|
|
|
|
for (const rawFile of files) {
|
|
// Normalize Windows backslash paths
|
|
const file = rawFile.replace(/\\/g, '/');
|
|
|
|
for (const { pattern, orm } of SCHEMA_PATTERNS) {
|
|
if (pattern.test(file)) {
|
|
matches.push(rawFile);
|
|
orms.add(orm);
|
|
break; // One match per file is enough
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
detected: matches.length > 0,
|
|
matches,
|
|
orms: Array.from(orms),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get ORM-specific push command info.
|
|
*
|
|
* @param {string} ormName - ORM identifier (payload, prisma, drizzle, supabase, typeorm)
|
|
* @returns {{ pushCommand: string, envHint: string, interactiveWarning: string|null, evidencePatterns: RegExp[] } | null}
|
|
*/
|
|
function detectSchemaOrm(ormName) {
|
|
return ORM_INFO[ormName] || null;
|
|
}
|
|
|
|
/**
|
|
* Check for schema drift: schema files changed but no push evidence found.
|
|
*
|
|
* @param {string[]} changedFiles - Files changed during the phase
|
|
* @param {string} executionLog - Combined text from SUMMARY.md, commit messages, and execution logs
|
|
* @param {{ skipCheck?: boolean }} [options] - Options
|
|
* @returns {{ driftDetected: boolean, blocking: boolean, schemaFiles: string[], orms: string[], unpushedOrms: string[], message: string, skipped?: boolean }}
|
|
*/
|
|
function checkSchemaDrift(changedFiles, executionLog, options = {}) {
|
|
const { skipCheck = false } = options;
|
|
|
|
const detection = detectSchemaFiles(changedFiles);
|
|
|
|
if (!detection.detected) {
|
|
return {
|
|
driftDetected: false,
|
|
blocking: false,
|
|
schemaFiles: [],
|
|
orms: [],
|
|
unpushedOrms: [],
|
|
message: '',
|
|
};
|
|
}
|
|
|
|
// Check which ORMs have push evidence in the execution log
|
|
const pushedOrms = new Set();
|
|
const unpushedOrms = [];
|
|
|
|
for (const orm of detection.orms) {
|
|
const info = ORM_INFO[orm];
|
|
if (!info) continue;
|
|
|
|
const hasPushEvidence = info.evidencePatterns.some(p => p.test(executionLog));
|
|
if (hasPushEvidence) {
|
|
pushedOrms.add(orm);
|
|
} else {
|
|
unpushedOrms.push(orm);
|
|
}
|
|
}
|
|
|
|
const driftDetected = unpushedOrms.length > 0;
|
|
|
|
if (!driftDetected) {
|
|
return {
|
|
driftDetected: false,
|
|
blocking: false,
|
|
schemaFiles: detection.matches,
|
|
orms: detection.orms,
|
|
unpushedOrms: [],
|
|
message: '',
|
|
};
|
|
}
|
|
|
|
// Build actionable message
|
|
const pushCommands = unpushedOrms
|
|
.map(orm => {
|
|
const info = ORM_INFO[orm];
|
|
return info ? ` ${orm}: ${info.envHint || info.pushCommand}` : null;
|
|
})
|
|
.filter(Boolean)
|
|
.join('\n');
|
|
|
|
const message = [
|
|
'Schema drift detected: schema-relevant files changed but no database push was executed.',
|
|
'',
|
|
`Schema files changed: ${detection.matches.join(', ')}`,
|
|
`ORMs requiring push: ${unpushedOrms.join(', ')}`,
|
|
'',
|
|
'Required push commands:',
|
|
pushCommands,
|
|
'',
|
|
'Run the appropriate push command, or set GSD_SKIP_SCHEMA_CHECK=true to bypass this gate.',
|
|
].join('\n');
|
|
|
|
if (skipCheck) {
|
|
return {
|
|
driftDetected: true,
|
|
blocking: false,
|
|
skipped: true,
|
|
schemaFiles: detection.matches,
|
|
orms: detection.orms,
|
|
unpushedOrms,
|
|
message: 'Schema drift detected but check was skipped (GSD_SKIP_SCHEMA_CHECK=true).',
|
|
};
|
|
}
|
|
|
|
return {
|
|
driftDetected: true,
|
|
blocking: true,
|
|
schemaFiles: detection.matches,
|
|
orms: detection.orms,
|
|
unpushedOrms,
|
|
message,
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
SCHEMA_PATTERNS,
|
|
ORM_INFO,
|
|
detectSchemaFiles,
|
|
detectSchemaOrm,
|
|
checkSchemaDrift,
|
|
};
|