feat: add schema drift detection to prevent false-positive verification
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>
This commit is contained in:
@@ -93,6 +93,7 @@
|
||||
* verify commits <h1> [h2] ... Batch verify commit hashes
|
||||
* verify artifacts <plan-file> Check must_haves.artifacts
|
||||
* verify key-links <plan-file> Check must_haves.key_links
|
||||
* verify schema-drift <phase> [--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;
|
||||
}
|
||||
|
||||
238
get-shit-done/bin/lib/schema-detect.cjs
Normal file
238
get-shit-done/bin/lib/schema-detect.cjs
Normal file
@@ -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,
|
||||
};
|
||||
@@ -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 <phase> [--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,
|
||||
};
|
||||
|
||||
@@ -601,6 +601,72 @@ Options:
|
||||
Use AskUserQuestion to present the options.
|
||||
</step>
|
||||
|
||||
<step name="schema_drift_gate">
|
||||
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.
|
||||
</step>
|
||||
|
||||
<step name="verify_phase_goal">
|
||||
Verify phase achieved its GOAL, not just completed tasks.
|
||||
|
||||
|
||||
@@ -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
|
||||
<schema_push_requirement>
|
||||
**[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.
|
||||
</schema_push_requirement>
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
|
||||
358
tests/schema-drift.test.cjs
Normal file
358
tests/schema-drift.test.cjs
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user