diff --git a/.code-health-baseline.json b/.code-health-baseline.json index 62dabbf4cb7..0ea3900b19e 100644 --- a/.code-health-baseline.json +++ b/.code-health-baseline.json @@ -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" + } ] } } diff --git a/packages/testing/code-health/src/index.ts b/packages/testing/code-health/src/index.ts index 89fea1bd148..56401bc9fc8 100644 --- a/packages/testing/code-health/src/index.ts +++ b/packages/testing/code-health/src/index.ts @@ -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(); runner.registerRule(new CatalogViolationsRule()); runner.registerRule(new WorkflowPrTargetSafetyRule()); + runner.registerRule(new MigrationTimestampRule()); runner.applySettings(mergeSettings(defaultRuleSettings, settings)); return runner; } diff --git a/packages/testing/code-health/src/rules/migration-timestamp.rule.test.ts b/packages/testing/code-health/src/rules/migration-timestamp.rule.test.ts new file mode 100644 index 00000000000..e18ae74eaa2 --- /dev/null +++ b/packages/testing/code-health/src/rules/migration-timestamp.rule.test.ts @@ -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); + }); +}); diff --git a/packages/testing/code-health/src/rules/migration-timestamp.rule.ts b/packages/testing/code-health/src/rules/migration-timestamp.rule.ts new file mode 100644 index 00000000000..c902b09de50 --- /dev/null +++ b/packages/testing/code-health/src/rules/migration-timestamp.rule.ts @@ -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 { + 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 { + 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`; +}