feat(core): Add migration-timestamp code-health rule (no-changelog) (#30388)

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Declan Carroll 2026-05-13 11:44:56 +01:00 committed by GitHub
parent 5d872d1375
commit 2e8f08df32
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 244 additions and 37 deletions

View File

@ -1,7 +1,7 @@
{
"version": 1,
"generated": "2026-05-12T09:37:31.489Z",
"totalViolations": 82,
"generated": "2026-05-13T10:23:52.870Z",
"totalViolations": 87,
"violations": {
"packages/@n8n/ai-workflow-builder.ee/package.json": [
{
@ -52,13 +52,25 @@
"packages/@n8n/nodes-langchain/package.json": [
{
"rule": "catalog-violations",
"line": 292,
"line": 266,
"message": "@n8n/typeorm@0.3.20-16 should use \"catalog:\" (exists in pnpm-workspace.yaml)",
"hash": "a7c1bca1c439"
},
{
"rule": "catalog-violations",
"line": 267,
"message": "mysql2@3.17.0 should use \"catalog:\" (exists in pnpm-workspace.yaml)",
"hash": "2d7d1b715d0c"
},
{
"rule": "catalog-violations",
"line": 293,
"message": "openai@^6.34.0 should use \"catalog:\" (exists in pnpm-workspace.yaml)",
"hash": "3c1f53f0afe3"
},
{
"rule": "catalog-violations",
"line": 299,
"line": 300,
"message": "tmp-promise appears in 4 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
"hash": "88d67e2ef747"
},
@ -70,25 +82,25 @@
},
{
"rule": "catalog-violations",
"line": 274,
"line": 275,
"message": "cheerio appears in 2 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
"hash": "8cd029bb871e"
},
{
"rule": "catalog-violations",
"line": 284,
"line": 285,
"message": "jsdom appears in 4 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
"hash": "26f20ebea4b1"
},
{
"rule": "catalog-violations",
"line": 289,
"line": 290,
"message": "mongodb appears in 2 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
"hash": "46cb48884e22"
},
{
"rule": "catalog-violations",
"line": 293,
"line": 294,
"message": "pdf-parse appears in 2 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
"hash": "0c7d44a9c2e4"
}
@ -220,49 +232,49 @@
"packages/@n8n/instance-ai/package.json": [
{
"rule": "catalog-violations",
"line": 80,
"line": 81,
"message": "@ai-sdk/anthropic appears in 3 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
"hash": "5b2153508e47"
},
{
"rule": "catalog-violations",
"line": 86,
"line": 87,
"message": "@types/psl appears in 2 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
"hash": "56dabb51b433"
},
{
"rule": "catalog-violations",
"line": 56,
"line": 57,
"message": "@mozilla/readability appears in 5 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
"hash": "8fa6b9a8fc91"
},
{
"rule": "catalog-violations",
"line": 64,
"line": 65,
"message": "csv-parse appears in 3 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
"hash": "8f082fc2e8b6"
},
{
"rule": "catalog-violations",
"line": 71,
"line": 72,
"message": "turndown appears in 3 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
"hash": "9a9d97065952"
},
{
"rule": "catalog-violations",
"line": 87,
"line": 88,
"message": "@types/turndown appears in 3 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
"hash": "12e346c47b39"
},
{
"rule": "catalog-violations",
"line": 50,
"line": 51,
"message": "@joplin/turndown-plugin-gfm appears in 2 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
"hash": "a3cf1504b5c2"
},
{
"rule": "catalog-violations",
"line": 68,
"line": 69,
"message": "pdf-parse appears in 2 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
"hash": "283fa9114c03"
}
@ -290,61 +302,61 @@
"packages/nodes-base/package.json": [
{
"rule": "catalog-violations",
"line": 911,
"line": 913,
"message": "change-case appears in 5 packages with 3 different versions — add to pnpm-workspace.yaml catalog",
"hash": "2d1fab7a5b05"
},
{
"rule": "catalog-violations",
"line": 961,
"line": 963,
"message": "semver appears in 4 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
"hash": "2daf37aa14e4"
},
{
"rule": "catalog-violations",
"line": 966,
"line": 968,
"message": "tmp-promise appears in 4 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
"hash": "3f93c404ae9c"
},
{
"rule": "catalog-violations",
"line": 900,
"line": 902,
"message": "@mozilla/readability appears in 5 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
"hash": "ca4ac788adc6"
},
{
"rule": "catalog-violations",
"line": 912,
"line": 914,
"message": "cheerio appears in 2 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
"hash": "1a1b5bbc50c9"
},
{
"rule": "catalog-violations",
"line": 915,
"line": 917,
"message": "csv-parse appears in 3 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
"hash": "781db4a1e068"
},
{
"rule": "catalog-violations",
"line": 917,
"line": 919,
"message": "eventsource appears in 2 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
"hash": "9795e6c6d9e9"
},
{
"rule": "catalog-violations",
"line": 930,
"line": 932,
"message": "jsdom appears in 4 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
"hash": "02341f2b5e3e"
},
{
"rule": "catalog-violations",
"line": 941,
"line": 943,
"message": "mongodb appears in 2 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
"hash": "f688907d087a"
},
{
"rule": "catalog-violations",
"line": 892,
"line": 894,
"message": "eslint-plugin-n8n-nodes-base appears in 2 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
"hash": "ac254baa61f9"
}
@ -384,19 +396,19 @@
},
{
"rule": "catalog-violations",
"line": 90,
"line": 91,
"message": "prettier appears in 3 packages with 3 different versions — add to pnpm-workspace.yaml catalog",
"hash": "9e9c7ec09a0b"
},
{
"rule": "catalog-violations",
"line": 92,
"line": 93,
"message": "semver appears in 4 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
"hash": "d8c606e42c92"
},
{
"rule": "catalog-violations",
"line": 77,
"line": 78,
"message": "esprima-next appears in 3 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
"hash": "62156c2613b2"
}
@ -491,6 +503,14 @@
"hash": "733c3960022e"
}
],
"packages/@n8n/computer-use/package.json": [
{
"rule": "catalog-violations",
"line": 47,
"message": "eventsource appears in 2 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
"hash": "f50c1eee2ed6"
}
],
"packages/workflow/package.json": [
{
"rule": "catalog-violations",
@ -511,14 +531,6 @@
"hash": "b660317b5f6f"
}
],
"packages/@n8n/computer-use/package.json": [
{
"rule": "catalog-violations",
"line": 47,
"message": "eventsource appears in 2 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
"hash": "f50c1eee2ed6"
}
],
"packages/@n8n/eslint-plugin-community-nodes/package.json": [
{
"rule": "catalog-violations",
@ -540,6 +552,30 @@
"message": "stylelint appears in 2 packages with 2 different versions — add to pnpm-workspace.yaml catalog",
"hash": "955f3fe044c7"
}
],
"packages/@n8n/db/src/migrations/common/1783000000000-CreateAgentTables.ts": [
{
"rule": "migration-timestamp",
"line": 1,
"message": "1783000000000-CreateAgentTables.ts is prefixed with a future timestamp (1783000000000, ~50d ahead of now). Migration prefixes must be the exact Date.now() value at the time of creation — AI agents commonly fabricate future timestamps.",
"hash": "d81428d567aa"
}
],
"packages/@n8n/db/src/migrations/common/1783000000001-CreateAgentExecutionTables.ts": [
{
"rule": "migration-timestamp",
"line": 1,
"message": "1783000000001-CreateAgentExecutionTables.ts is prefixed with a future timestamp (1783000000001, ~50d ahead of now). Migration prefixes must be the exact Date.now() value at the time of creation — AI agents commonly fabricate future timestamps.",
"hash": "e33a8a794126"
}
],
"packages/@n8n/db/src/migrations/common/1784000000000-CreateAgentObservationTables.ts": [
{
"rule": "migration-timestamp",
"line": 1,
"message": "1784000000000-CreateAgentObservationTables.ts is prefixed with a future timestamp (1784000000000, ~61d ahead of now). Migration prefixes must be the exact Date.now() value at the time of creation — AI agents commonly fabricate future timestamps.",
"hash": "4bae5ef065ad"
}
]
}
}

View File

@ -3,10 +3,12 @@ import type { RuleSettingsMap } from '@n8n/rules-engine';
import type { CodeHealthContext } from './context.js';
import { CatalogViolationsRule } from './rules/catalog-violations.rule.js';
import { MigrationTimestampRule } from './rules/migration-timestamp.rule.js';
import { WorkflowPrTargetSafetyRule } from './rules/workflow-pr-target-safety.rule.js';
export type { CodeHealthContext } from './context.js';
export { CatalogViolationsRule } from './rules/catalog-violations.rule.js';
export { MigrationTimestampRule } from './rules/migration-timestamp.rule.js';
export { WorkflowPrTargetSafetyRule } from './rules/workflow-pr-target-safety.rule.js';
const defaultRuleSettings: RuleSettingsMap = {
@ -20,6 +22,11 @@ const defaultRuleSettings: RuleSettingsMap = {
severity: 'error',
options: { allowedWorkflows: ['ci-cla-check.yml'] },
},
'migration-timestamp': {
enabled: true,
severity: 'error',
options: {},
},
};
function mergeSettings(defaults: RuleSettingsMap, overrides?: RuleSettingsMap): RuleSettingsMap {
@ -39,6 +46,7 @@ export function createDefaultRunner(settings?: RuleSettingsMap): RuleRunner<Code
const runner = new RuleRunner<CodeHealthContext>();
runner.registerRule(new CatalogViolationsRule());
runner.registerRule(new WorkflowPrTargetSafetyRule());
runner.registerRule(new MigrationTimestampRule());
runner.applySettings(mergeSettings(defaultRuleSettings, settings));
return runner;
}

View File

@ -0,0 +1,96 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { CodeHealthContext } from '../context.js';
import { MigrationTimestampRule } from './migration-timestamp.rule.js';
const NOW = 1_777_000_000_000; // fixed reference time used in assertions
const MIGRATIONS_DIR = path.join('packages', '@n8n', 'db', 'src', 'migrations', 'postgresdb');
function createTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'code-health-migration-test-'));
}
function writeMigration(rootDir: string, fileName: string, content = 'export {};\n'): void {
const fullPath = path.join(rootDir, MIGRATIONS_DIR, fileName);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content);
}
describe('MigrationTimestampRule', () => {
let tmpDir: string;
let rule: MigrationTimestampRule;
beforeEach(() => {
tmpDir = createTempDir();
rule = new MigrationTimestampRule();
rule.configure({ options: { now: NOW } });
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function context(): CodeHealthContext {
return { rootDir: tmpDir };
}
it('accepts a precise past timestamp', async () => {
writeMigration(tmpDir, '1761047826451-AddWorkflowVersionColumn.ts');
const violations = await rule.analyze(context());
expect(violations).toHaveLength(0);
});
it('accepts a rounded past timestamp', async () => {
writeMigration(tmpDir, '1766500000000-ExpandInsightsWorkflowIdLength.ts');
const violations = await rule.analyze(context());
expect(violations).toHaveLength(0);
});
it('flags a future timestamp', async () => {
writeMigration(tmpDir, '1784000000000-AiHallucinatedMigration.ts');
const violations = await rule.analyze(context());
expect(violations).toHaveLength(1);
expect(violations[0].rule).toBe('migration-timestamp');
expect(violations[0].message).toContain('future timestamp');
expect(violations[0].message).toContain('1784000000000');
});
it('ignores index.ts and other non-migration files', async () => {
writeMigration(tmpDir, 'index.ts');
writeMigration(tmpDir, 'README.md');
const violations = await rule.analyze(context());
expect(violations).toHaveLength(0);
});
it('scans every configured migration directory', async () => {
writeMigration(tmpDir, '1784000000000-Postgres.ts');
const sqlitePath = path.join(
tmpDir,
'packages',
'@n8n',
'db',
'src',
'migrations',
'sqlite',
'1784000000001-Sqlite.ts',
);
fs.mkdirSync(path.dirname(sqlitePath), { recursive: true });
fs.writeFileSync(sqlitePath, 'export {};\n');
const violations = await rule.analyze(context());
expect(violations).toHaveLength(2);
});
});

View File

@ -0,0 +1,67 @@
import { BaseRule } from '@n8n/rules-engine';
import type { Violation } from '@n8n/rules-engine';
import fg from 'fast-glob';
import * as path from 'node:path';
import type { CodeHealthContext } from '../context.js';
const DEFAULT_MIGRATION_GLOBS = [
'packages/@n8n/db/src/migrations/common/*.ts',
'packages/@n8n/db/src/migrations/postgresdb/*.ts',
'packages/@n8n/db/src/migrations/sqlite/*.ts',
];
const MIGRATION_FILENAME = /^(\d{10,16})-.+\.ts$/;
export class MigrationTimestampRule extends BaseRule<CodeHealthContext> {
readonly id = 'migration-timestamp';
readonly name = 'Migration Timestamp Hygiene';
readonly description =
'Migration filenames must be prefixed with a Date.now() value from the past — not a future timestamp.';
readonly severity = 'error' as const;
async analyze(context: CodeHealthContext): Promise<Violation[]> {
const { rootDir } = context;
const options = this.getOptions();
const globs = Array.isArray(options.migrationGlobs)
? (options.migrationGlobs as string[])
: DEFAULT_MIGRATION_GLOBS;
const now = typeof options.now === 'number' ? options.now : Date.now();
const files = await fg(globs, { cwd: rootDir, absolute: true });
const violations: Violation[] = [];
for (const filePath of files) {
const fileName = path.basename(filePath);
const match = MIGRATION_FILENAME.exec(fileName);
if (!match) continue;
const timestamp = Number(match[1]);
if (!Number.isFinite(timestamp)) continue;
if (timestamp <= now) continue;
violations.push(
this.createViolation(
filePath,
1,
1,
`${fileName} is prefixed with a future timestamp (${timestamp}, ${formatDelta(timestamp - now)} ahead of now). Migration prefixes must be the exact Date.now() value at the time of creation — AI agents commonly fabricate future timestamps.`,
"Rename the file using the current millisecond timestamp from Date.now() (or 'date +%s%3N').",
),
);
}
return violations;
}
}
function formatDelta(ms: number): string {
const abs = Math.abs(ms);
const days = Math.floor(abs / 86_400_000);
if (days >= 1) return `~${days}d`;
const hours = Math.floor(abs / 3_600_000);
if (hours >= 1) return `~${hours}h`;
const minutes = Math.floor(abs / 60_000);
return `~${minutes}m`;
}