diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index e583bd5bf..8cdf43617 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -93,6 +93,7 @@ * verify commits

[h2] ... Batch verify commit hashes * verify artifacts Check must_haves.artifacts * verify key-links Check must_haves.key_links + * verify schema-drift [--skip] Detect schema file changes without push * * Template Fill: * template fill summary --phase N Create pre-filled SUMMARY.md @@ -502,8 +503,11 @@ async function runCommand(command, args, cwd, raw) { verify.cmdVerifyArtifacts(cwd, args[2], raw); } else if (subcommand === 'key-links') { verify.cmdVerifyKeyLinks(cwd, args[2], raw); + } else if (subcommand === 'schema-drift') { + const skipFlag = args.includes('--skip'); + verify.cmdVerifySchemaDrift(cwd, args[2], skipFlag, raw); } else { - error('Unknown verify subcommand. Available: plan-structure, phase-completeness, references, commits, artifacts, key-links'); + error('Unknown verify subcommand. Available: plan-structure, phase-completeness, references, commits, artifacts, key-links, schema-drift'); } break; } diff --git a/get-shit-done/bin/lib/schema-detect.cjs b/get-shit-done/bin/lib/schema-detect.cjs new file mode 100644 index 000000000..40d800eb6 --- /dev/null +++ b/get-shit-done/bin/lib/schema-detect.cjs @@ -0,0 +1,238 @@ +/** + * 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, +}; diff --git a/get-shit-done/bin/lib/verify.cjs b/get-shit-done/bin/lib/verify.cjs index b11a5c3a1..8975f9f39 100644 --- a/get-shit-done/bin/lib/verify.cjs +++ b/get-shit-done/bin/lib/verify.cjs @@ -874,6 +874,84 @@ function cmdValidateAgents(cwd, raw) { }, raw); } +// ─── Schema Drift Detection ────────────────────────────────────────────────── + +function cmdVerifySchemaDrift(cwd, phaseArg, skipFlag, raw) { + const { detectSchemaFiles, checkSchemaDrift } = require('./schema-detect.cjs'); + + if (!phaseArg) { + error('Usage: verify schema-drift [--skip]'); + return; + } + + // Find phase directory + const pDir = planningDir(cwd); + const phasesDir = path.join(pDir, 'phases'); + if (!fs.existsSync(phasesDir)) { + output({ drift_detected: false, blocking: false, message: 'No phases directory' }, raw); + return; + } + + // Find matching phase directory + let phaseDir = null; + const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory() && entry.name.includes(phaseArg)) { + phaseDir = path.join(phasesDir, entry.name); + break; + } + } + + // Also try exact match + if (!phaseDir) { + const exact = path.join(phasesDir, phaseArg); + if (fs.existsSync(exact)) phaseDir = exact; + } + + if (!phaseDir) { + output({ drift_detected: false, blocking: false, message: `Phase directory not found: ${phaseArg}` }, raw); + return; + } + + // Collect files_modified from all PLAN.md files in the phase + const allFiles = []; + const planFiles = fs.readdirSync(phaseDir).filter(f => f.endsWith('-PLAN.md')); + for (const pf of planFiles) { + const content = fs.readFileSync(path.join(phaseDir, pf), 'utf-8'); + // Extract files_modified from frontmatter + const fmMatch = content.match(/files_modified:\s*\[([^\]]*)\]/); + if (fmMatch) { + const files = fmMatch[1].split(',').map(f => f.trim()).filter(Boolean); + allFiles.push(...files); + } + } + + // Collect execution log from SUMMARY.md files + let executionLog = ''; + const summaryFiles = fs.readdirSync(phaseDir).filter(f => f.endsWith('-SUMMARY.md')); + for (const sf of summaryFiles) { + executionLog += fs.readFileSync(path.join(phaseDir, sf), 'utf-8') + '\n'; + } + + // Also check git commit messages for push evidence + const gitLog = execGit(cwd, ['log', '--oneline', '--all', '-50']); + if (gitLog.exitCode === 0) { + executionLog += '\n' + gitLog.stdout; + } + + const result = checkSchemaDrift(allFiles, executionLog, { skipCheck: !!skipFlag }); + + output({ + drift_detected: result.driftDetected, + blocking: result.blocking, + schema_files: result.schemaFiles, + orms: result.orms, + unpushed_orms: result.unpushedOrms, + message: result.message, + skipped: result.skipped || false, + }, raw); +} + module.exports = { cmdVerifySummary, cmdVerifyPlanStructure, @@ -885,4 +963,5 @@ module.exports = { cmdValidateConsistency, cmdValidateHealth, cmdValidateAgents, + cmdVerifySchemaDrift, }; diff --git a/get-shit-done/workflows/execute-phase.md b/get-shit-done/workflows/execute-phase.md index 838179190..d770f6c80 100644 --- a/get-shit-done/workflows/execute-phase.md +++ b/get-shit-done/workflows/execute-phase.md @@ -601,6 +601,72 @@ Options: Use AskUserQuestion to present the options. + +Post-execution schema drift detection. Catches false-positive verification where +build/types pass because TypeScript types come from config, not the live database. + +**Run after execution completes but BEFORE verification marks success.** + +```bash +SCHEMA_DRIFT=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" verify schema-drift "${PHASE_NUMBER}" 2>/dev/null) +``` + +Parse JSON result for: `drift_detected`, `blocking`, `schema_files`, `orms`, `unpushed_orms`, `message`. + +**If `drift_detected` is false:** Skip to verify_phase_goal. + +**If `drift_detected` is true AND `blocking` is true:** + +Check for override: +```bash +SKIP_SCHEMA=$(echo "${GSD_SKIP_SCHEMA_CHECK:-false}") +``` + +**If `SKIP_SCHEMA` is `true`:** + +Display: +``` +⚠ Schema drift detected but GSD_SKIP_SCHEMA_CHECK=true — bypassing gate. + +Schema files changed: {schema_files} +ORMs requiring push: {unpushed_orms} + +Proceeding to verification (database may be out of sync). +``` +→ Continue to verify_phase_goal. + +**If `SKIP_SCHEMA` is not `true`:** + +BLOCK verification. Display: + +``` +## BLOCKED: Schema Drift Detected + +Schema-relevant files changed during this phase but no database push command +was executed. Build and type checks pass because TypeScript types come from +config, not the live database — verification would produce a false positive. + +Schema files changed: {schema_files} +ORMs requiring push: {unpushed_orms} + +Required push commands: +{For each unpushed ORM, show the push command from the message} + +Options: +1. Run push command now (recommended) — execute the push, then re-verify +2. Skip schema check (GSD_SKIP_SCHEMA_CHECK=true) — bypass this gate +3. Abort — stop execution and investigate +``` + +If `TEXT_MODE` is true, present as a plain-text numbered list. Otherwise use AskUserQuestion. + +**If user selects option 1:** Present the specific push command(s) to run. After user confirms execution, re-run the schema drift check. If it passes, continue to verify_phase_goal. + +**If user selects option 2:** Set override and continue to verify_phase_goal. + +**If user selects option 3:** Stop execution. Report partial completion. + + Verify phase achieved its GOAL, not just completed tasks. diff --git a/get-shit-done/workflows/plan-phase.md b/get-shit-done/workflows/plan-phase.md index f72dd581b..daf337519 100644 --- a/get-shit-done/workflows/plan-phase.md +++ b/get-shit-done/workflows/plan-phase.md @@ -435,7 +435,69 @@ Otherwise use AskUserQuestion: - "Continue without UI-SPEC" → Continue to step 6. - "Not a frontend phase" → Continue to step 6. -**If `HAS_UI` is 1 (no frontend indicators):** Skip silently to step 6. +**If `HAS_UI` is 1 (no frontend indicators):** Skip silently to step 5.7. + +## 5.7. Schema Push Detection Gate + +> Detects schema-relevant files in the phase scope and injects a mandatory `[BLOCKING]` schema push task into the plan. Prevents false-positive verification where build/types pass because TypeScript types come from config, not the live database. + +Check if any files in the phase scope match schema patterns: + +```bash +PHASE_SECTION=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" roadmap get-phase "${PHASE}" --pick section 2>/dev/null) +``` + +Scan `PHASE_SECTION`, `CONTEXT.md` (if loaded), and `RESEARCH.md` (if exists) for file paths matching these ORM patterns: + +| ORM | File Patterns | +|-----|--------------| +| Payload CMS | `src/collections/**/*.ts`, `src/globals/**/*.ts` | +| Prisma | `prisma/schema.prisma`, `prisma/schema/*.prisma` | +| Drizzle | `drizzle/schema.ts`, `src/db/schema.ts`, `drizzle/*.ts` | +| Supabase | `supabase/migrations/*.sql` | +| TypeORM | `src/entities/**/*.ts`, `src/migrations/**/*.ts` | + +Also check if any existing PLAN.md files for this phase already reference these file patterns in `files_modified`. + +**If schema-relevant files detected:** + +Set `SCHEMA_PUSH_REQUIRED=true` and `SCHEMA_ORM={detected_orm}`. + +Determine the push command for the detected ORM: + +| ORM | Push Command | Non-TTY Workaround | +|-----|-------------|-------------------| +| Payload CMS | `npx payload migrate` | `CI=true PAYLOAD_MIGRATING=true npx payload migrate` | +| Prisma | `npx prisma db push` | `npx prisma db push --accept-data-loss` (if destructive) | +| Drizzle | `npx drizzle-kit push` | `npx drizzle-kit push` | +| Supabase | `supabase db push` | Set `SUPABASE_ACCESS_TOKEN` env var | +| TypeORM | `npx typeorm migration:run` | `npx typeorm migration:run -d src/data-source.ts` | + +Inject the following into the planner prompt (step 8) as an additional constraint: + +```markdown + +**[BLOCKING] Schema Push Required** + +This phase modifies schema-relevant files ({detected_files}). The planner MUST include +a `[BLOCKING]` task that runs the database schema push command AFTER all schema file +modifications are complete but BEFORE verification. + +- ORM detected: {SCHEMA_ORM} +- Push command: {push_command} +- Non-TTY workaround: {env_hint} +- If push requires interactive prompts that cannot be suppressed, flag the task for + manual intervention with `autonomous: false` + +This task is mandatory — the phase CANNOT pass verification without it. Build and +type checks will pass without the push (types come from config, not the live database), +creating a false-positive verification state. + +``` + +Display: `Schema files detected ({SCHEMA_ORM}) — [BLOCKING] push task will be injected into plans` + +**If no schema-relevant files detected:** Skip silently to step 6. ## 6. Check Existing Plans diff --git a/tests/schema-drift.test.cjs b/tests/schema-drift.test.cjs new file mode 100644 index 000000000..beb15a200 --- /dev/null +++ b/tests/schema-drift.test.cjs @@ -0,0 +1,358 @@ +/** + * GSD Tools Tests - Schema Drift Detection + * + * Tests for schema-relevant file detection (plan-phase injection) + * and post-execution schema drift gate (execute-phase verification). + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { createTempProject, createTempGitProject, cleanup, runGsdTools } = require('./helpers.cjs'); + +// ─── Unit: detectSchemaFiles ───────────────────────────────────────────────── + +const { detectSchemaFiles, detectSchemaOrm, checkSchemaDrift } = require( + path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'schema-detect.cjs') +); + +describe('detectSchemaFiles', () => { + test('detects Payload CMS collection files', () => { + const files = ['src/collections/Posts.ts', 'src/collections/Users.ts', 'src/lib/utils.ts']; + const result = detectSchemaFiles(files); + assert.ok(result.detected, 'should detect schema files'); + assert.deepStrictEqual(result.matches, [ + 'src/collections/Posts.ts', + 'src/collections/Users.ts', + ]); + assert.ok(result.orms.includes('payload'), 'should identify Payload CMS'); + }); + + test('detects Payload CMS globals files', () => { + const files = ['src/globals/Settings.ts']; + const result = detectSchemaFiles(files); + assert.ok(result.detected); + assert.ok(result.orms.includes('payload')); + }); + + test('detects Prisma schema file', () => { + const files = ['prisma/schema.prisma', 'src/index.ts']; + const result = detectSchemaFiles(files); + assert.ok(result.detected); + assert.deepStrictEqual(result.matches, ['prisma/schema.prisma']); + assert.ok(result.orms.includes('prisma')); + }); + + test('detects Prisma multi-file schema', () => { + const files = ['prisma/schema/user.prisma', 'prisma/schema/post.prisma']; + const result = detectSchemaFiles(files); + assert.ok(result.detected); + assert.strictEqual(result.matches.length, 2); + assert.ok(result.orms.includes('prisma')); + }); + + test('detects Drizzle schema files', () => { + const files = ['drizzle/schema.ts', 'src/routes/api.ts']; + const result = detectSchemaFiles(files); + assert.ok(result.detected); + assert.ok(result.orms.includes('drizzle')); + }); + + test('detects Drizzle schema in src/db/', () => { + const files = ['src/db/schema.ts']; + const result = detectSchemaFiles(files); + assert.ok(result.detected); + assert.ok(result.orms.includes('drizzle')); + }); + + test('detects Drizzle multi-file schemas', () => { + const files = ['drizzle/users.ts', 'drizzle/posts.ts']; + const result = detectSchemaFiles(files); + assert.ok(result.detected); + assert.ok(result.orms.includes('drizzle')); + }); + + test('detects Supabase migration files', () => { + const files = ['supabase/migrations/20240101_add_users.sql']; + const result = detectSchemaFiles(files); + assert.ok(result.detected); + assert.ok(result.orms.includes('supabase')); + }); + + test('detects TypeORM entity files', () => { + const files = ['src/entities/User.ts', 'src/entities/Post.ts']; + const result = detectSchemaFiles(files); + assert.ok(result.detected); + assert.ok(result.orms.includes('typeorm')); + }); + + test('detects TypeORM migration files', () => { + const files = ['src/migrations/1234567890-CreateUsers.ts']; + const result = detectSchemaFiles(files); + assert.ok(result.detected); + assert.ok(result.orms.includes('typeorm')); + }); + + test('returns not detected for non-schema files', () => { + const files = ['src/index.ts', 'src/utils/helpers.ts', 'package.json', 'README.md']; + const result = detectSchemaFiles(files); + assert.strictEqual(result.detected, false); + assert.strictEqual(result.matches.length, 0); + assert.strictEqual(result.orms.length, 0); + }); + + test('returns empty for empty file list', () => { + const result = detectSchemaFiles([]); + assert.strictEqual(result.detected, false); + assert.strictEqual(result.matches.length, 0); + }); + + test('detects multiple ORMs in same file list', () => { + const files = ['prisma/schema.prisma', 'src/collections/Posts.ts']; + const result = detectSchemaFiles(files); + assert.ok(result.detected); + assert.ok(result.orms.includes('prisma')); + assert.ok(result.orms.includes('payload')); + }); + + test('handles Windows-style paths', () => { + const files = ['src\\collections\\Posts.ts', 'src\\globals\\Settings.ts']; + const result = detectSchemaFiles(files); + assert.ok(result.detected, 'should detect schema files with backslash paths'); + }); +}); + +// ─── Unit: detectSchemaOrm ─────────────────────────────────────────────────── + +describe('detectSchemaOrm', () => { + test('returns push command for Payload CMS', () => { + const info = detectSchemaOrm('payload'); + assert.ok(info.pushCommand); + assert.ok(info.pushCommand.includes('payload')); + assert.ok(info.envHint, 'should include env hint for non-TTY'); + }); + + test('returns push command for Prisma', () => { + const info = detectSchemaOrm('prisma'); + assert.ok(info.pushCommand.includes('prisma')); + }); + + test('returns push command for Drizzle', () => { + const info = detectSchemaOrm('drizzle'); + assert.ok(info.pushCommand.includes('drizzle')); + }); + + test('returns push command for Supabase', () => { + const info = detectSchemaOrm('supabase'); + assert.ok(info.pushCommand.includes('supabase')); + }); + + test('returns push command for TypeORM', () => { + const info = detectSchemaOrm('typeorm'); + assert.ok(info.pushCommand.includes('typeorm')); + }); + + test('returns null for unknown ORM', () => { + const info = detectSchemaOrm('unknown-orm'); + assert.strictEqual(info, null); + }); +}); + +// ─── Unit: checkSchemaDrift ────────────────────────────────────────────────── + +describe('checkSchemaDrift', () => { + test('returns no drift when no schema files changed', () => { + const changedFiles = ['src/index.ts', 'package.json']; + const executionLog = ''; + const result = checkSchemaDrift(changedFiles, executionLog); + assert.strictEqual(result.driftDetected, false); + assert.strictEqual(result.blocking, false); + }); + + test('detects drift when schema files changed but no push executed', () => { + const changedFiles = ['src/collections/Posts.ts', 'src/index.ts']; + const executionLog = 'npm run build\nnpm run test'; + const result = checkSchemaDrift(changedFiles, executionLog); + assert.strictEqual(result.driftDetected, true); + assert.strictEqual(result.blocking, true); + assert.ok(result.schemaFiles.length > 0); + assert.ok(result.orms.includes('payload')); + assert.ok(result.message.length > 0); + }); + + test('no drift when schema files changed AND push was executed (payload)', () => { + const changedFiles = ['src/collections/Posts.ts']; + const executionLog = 'npx payload migrate\nnpm run build'; + const result = checkSchemaDrift(changedFiles, executionLog); + assert.strictEqual(result.driftDetected, false); + assert.strictEqual(result.blocking, false); + }); + + test('no drift when schema files changed AND push was executed (prisma)', () => { + const changedFiles = ['prisma/schema.prisma']; + const executionLog = 'npx prisma db push\nnpm run build'; + const result = checkSchemaDrift(changedFiles, executionLog); + assert.strictEqual(result.driftDetected, false); + assert.strictEqual(result.blocking, false); + }); + + test('no drift when schema files changed AND push was executed (drizzle)', () => { + const changedFiles = ['drizzle/schema.ts']; + const executionLog = 'npx drizzle-kit push\nnpm run test'; + const result = checkSchemaDrift(changedFiles, executionLog); + assert.strictEqual(result.driftDetected, false); + assert.strictEqual(result.blocking, false); + }); + + test('no drift when schema files changed AND push was executed (supabase)', () => { + const changedFiles = ['supabase/migrations/001_init.sql']; + const executionLog = 'supabase db push\nnpm run test'; + const result = checkSchemaDrift(changedFiles, executionLog); + assert.strictEqual(result.driftDetected, false); + assert.strictEqual(result.blocking, false); + }); + + test('no drift when schema files changed AND push was executed (typeorm)', () => { + const changedFiles = ['src/entities/User.ts']; + const executionLog = 'npx typeorm migration:run\nnpm run test'; + const result = checkSchemaDrift(changedFiles, executionLog); + assert.strictEqual(result.driftDetected, false); + assert.strictEqual(result.blocking, false); + }); + + test('respects GSD_SKIP_SCHEMA_CHECK override', () => { + const changedFiles = ['src/collections/Posts.ts']; + const executionLog = 'npm run build'; + const result = checkSchemaDrift(changedFiles, executionLog, { skipCheck: true }); + assert.strictEqual(result.driftDetected, true); + assert.strictEqual(result.blocking, false, 'should not block when skip override is set'); + assert.ok(result.skipped, 'should indicate the check was skipped'); + }); + + test('detects drift with multiple ORMs and partial push', () => { + const changedFiles = ['prisma/schema.prisma', 'src/collections/Posts.ts']; + const executionLog = 'npx prisma db push'; + const result = checkSchemaDrift(changedFiles, executionLog); + // Prisma was pushed but Payload was not + assert.strictEqual(result.driftDetected, true); + assert.strictEqual(result.blocking, true); + assert.ok(result.unpushedOrms.includes('payload')); + assert.ok(!result.unpushedOrms.includes('prisma')); + }); + + test('includes actionable message with push commands', () => { + const changedFiles = ['prisma/schema.prisma']; + const executionLog = ''; + const result = checkSchemaDrift(changedFiles, executionLog); + assert.ok(result.message.includes('prisma')); + }); +}); + +// ─── CLI: verify schema-drift ──────────────────────────────────────────────── + +describe('verify schema-drift CLI command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempGitProject('gsd-schema-drift-'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('passes when no schema files in phase diff', () => { + // Create a phase dir with a plan that modifies non-schema files + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), [ + '---', + 'files_modified: [src/index.ts, src/utils.ts]', + '---', + '', + 'Plan content', + ].join('\n')); + + const result = runGsdTools(['verify', 'schema-drift', '01-setup'], tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.drift_detected, false); + assert.strictEqual(output.blocking, false); + }); + + test('detects drift when schema files in plan but no push evidence', () => { + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), [ + '---', + 'files_modified: [src/collections/Posts.ts, src/index.ts]', + '---', + '', + 'Plan content', + ].join('\n')); + // No SUMMARY.md with push evidence + fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), [ + '# Summary', + '', + '## Accomplishments', + '- Added Post collection', + '', + '## Commands Run', + '- npm run build', + '- npm run test', + ].join('\n')); + + const result = runGsdTools(['verify', 'schema-drift', '01-setup'], tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.drift_detected, true); + assert.strictEqual(output.blocking, true); + }); + + test('passes when schema files in plan AND push evidence in summary', () => { + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), [ + '---', + 'files_modified: [src/collections/Posts.ts]', + '---', + '', + 'Plan content', + ].join('\n')); + fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), [ + '# Summary', + '', + '## Accomplishments', + '- Added Post collection', + '', + '## Commands Run', + '- npx payload migrate', + '- npm run build', + ].join('\n')); + + const result = runGsdTools(['verify', 'schema-drift', '01-setup'], tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.drift_detected, false); + assert.strictEqual(output.blocking, false); + }); + + test('respects skip flag', () => { + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), [ + '---', + 'files_modified: [src/collections/Posts.ts]', + '---', + '', + 'Plan content', + ].join('\n')); + fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary\n'); + + const result = runGsdTools(['verify', 'schema-drift', '01-setup', '--skip'], tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.blocking, false); + }); +});