diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2ef41ec0..5b99b7079 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,11 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Check architecture boundaries + run: | + pnpm check:contracts + pnpm check:architecture + - name: Lint run: pnpm lint diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 3fb4fc6f0..f2bcdc98e 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -13,7 +13,7 @@ "scripts": { "format": "prettier --write .", "lint": "eslint --cache .", - "pretypecheck": "pnpm --dir ../.. check:contract-boundaries && pnpm ipc:check", + "pretypecheck": "pnpm --dir ../.. check:contracts && pnpm --dir ../.. check:architecture && pnpm ipc:check", "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", "typecheck": "pnpm typecheck:node && pnpm typecheck:web", diff --git a/apps/desktop/src/main/database/client.ts b/apps/desktop/src/main/database/client.ts index ef0dfa6ad..ac42194a6 100644 --- a/apps/desktop/src/main/database/client.ts +++ b/apps/desktop/src/main/database/client.ts @@ -1,18 +1,20 @@ -import { drizzle, BetterSQLite3Database } from 'drizzle-orm/better-sqlite3' import Database from 'better-sqlite3' +import { drizzle } from 'drizzle-orm/better-sqlite3' import { existsSync } from 'fs' -import * as schema from '@memry/db-schema/schema' +import * as dataSchema from '@memry/db-schema/data-schema' +import * as indexSchema from '@memry/db-schema/index-schema' import * as sqliteVec from 'sqlite-vec' import { EMBEDDING_DIMENSION } from '../lib/embeddings-constants' +import type { DataDb, IndexDb, RawIndexDb, DrizzleDb } from './types' -export type DrizzleDb = BetterSQLite3Database +export type { DataDb, IndexDb, RawIndexDb, DrizzleDb } from './types' -let dataDb: DrizzleDb | null = null -let indexDb: DrizzleDb | null = null +let dataDb: DataDb | null = null +let indexDb: IndexDb | null = null let sqliteDataDb: Database.Database | null = null let sqliteIndexDb: Database.Database | null = null -export function initDatabase(dbPath: string): DrizzleDb { +export function initDatabase(dbPath: string): DataDb { sqliteDataDb = new Database(dbPath) // WAL mode for better concurrency and crash recovery @@ -33,11 +35,11 @@ export function initDatabase(dbPath: string): DrizzleDb { // Store temp tables in memory sqliteDataDb.pragma('temp_store = MEMORY') - dataDb = drizzle(sqliteDataDb, { schema }) + dataDb = drizzle(sqliteDataDb, { schema: dataSchema }) return dataDb } -export function initIndexDatabase(dbPath: string): DrizzleDb { +export function initIndexDatabase(dbPath: string): IndexDb { sqliteIndexDb = new Database(dbPath) // WAL mode for better concurrency @@ -69,13 +71,13 @@ export function initIndexDatabase(dbPath: string): DrizzleDb { ) `) - indexDb = drizzle(sqliteIndexDb, { schema }) + indexDb = drizzle(sqliteIndexDb, { schema: indexSchema }) return indexDb } export function getDatabase(): DrizzleDb { if (!dataDb) throw new Error('Database not initialized') - return dataDb + return dataDb as DrizzleDb } export function isDatabaseInitialized(): boolean { @@ -84,14 +86,14 @@ export function isDatabaseInitialized(): boolean { export function getIndexDatabase(): DrizzleDb { if (!indexDb) throw new Error('Index database not initialized') - return indexDb + return indexDb as DrizzleDb } /** * Get the raw better-sqlite3 connection for the index database. * Used for direct sqlite-vec queries on vec_notes virtual table. */ -export function getRawIndexDatabase(): Database.Database { +export function getRawIndexDatabase(): RawIndexDb { if (!sqliteIndexDb) throw new Error('Index database not initialized') return sqliteIndexDb } diff --git a/apps/desktop/src/main/database/index.ts b/apps/desktop/src/main/database/index.ts index 544b25be2..0c16174ee 100644 --- a/apps/desktop/src/main/database/index.ts +++ b/apps/desktop/src/main/database/index.ts @@ -9,10 +9,11 @@ export { closeAllDatabases, checkIndexHealth, withTimeout, - type DrizzleDb, type IndexHealth } from './client' +export type { DataDb, IndexDb, RawIndexDb, DrizzleDb } from './types' + export { runMigrations, runIndexMigrations } from './migrate' export { diff --git a/apps/desktop/src/main/database/queries/notes/index.ts b/apps/desktop/src/main/database/queries/notes/index.ts index 7476cbf75..d9904b060 100644 --- a/apps/desktop/src/main/database/queries/notes/index.ts +++ b/apps/desktop/src/main/database/queries/notes/index.ts @@ -29,14 +29,17 @@ export { renameTag, deleteTag, removeTagFromNote, + type NoteWithTagInfo +} from './tag-queries' + +export { getOrCreateTag, getAllTagDefinitions, updateTagColor, renameTagDefinition, deleteTagDefinition, - ensureTagDefinitions, - type NoteWithTagInfo -} from './tag-queries' + ensureTagDefinitions +} from '../tag-definitions' export { setNoteLinks, diff --git a/apps/desktop/src/main/database/queries/notes/tag-queries.ts b/apps/desktop/src/main/database/queries/notes/tag-queries.ts index efd6acdfc..eb6212064 100644 --- a/apps/desktop/src/main/database/queries/notes/tag-queries.ts +++ b/apps/desktop/src/main/database/queries/notes/tag-queries.ts @@ -1,15 +1,11 @@ import { eq, and, or, inArray, like, count, desc, sql } from 'drizzle-orm' -import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3' import { noteCache, noteTags, type NoteCache, type NewNoteTag } from '@memry/db-schema/schema/notes-cache' -import { tagDefinitions } from '@memry/db-schema/schema/tag-definitions' -import * as schema from '@memry/db-schema/schema' - -type DrizzleDb = BetterSQLite3Database +import type { DrizzleDb } from '../../types' // ============================================================================ // Tag Operations @@ -261,142 +257,3 @@ export function removeTagFromNote(db: DrizzleDb, noteId: string, tag: string): v .where(and(eq(noteTags.noteId, noteId), eq(noteTags.tag, normalizedTag))) .run() } - -// ============================================================================ -// Tag Definition Operations (vault-wide tag registry with colors) -// ============================================================================ - -const TAG_COLOR_PALETTE = [ - 'rose', - 'pink', - 'fuchsia', - 'purple', - 'violet', - 'indigo', - 'blue', - 'sky', - 'cyan', - 'teal', - 'emerald', - 'green', - 'lime', - 'yellow', - 'amber', - 'orange', - 'stone', - 'slate', - 'gray', - 'zinc', - 'neutral', - 'warm', - 'red', - 'coral' -] - -export function getOrCreateTag(db: DrizzleDb, name: string): { name: string; color: string } { - const normalizedName = name.toLowerCase().trim() - - const existing = db - .select() - .from(tagDefinitions) - .where(eq(tagDefinitions.name, normalizedName)) - .get() - - if (existing) { - return { name: existing.name, color: existing.color } - } - - const tagCount = db.select({ count: count() }).from(tagDefinitions).get()?.count ?? 0 - - const color = TAG_COLOR_PALETTE[tagCount % TAG_COLOR_PALETTE.length] - - db.insert(tagDefinitions).values({ name: normalizedName, color }).run() - - return { name: normalizedName, color } -} - -export function getAllTagDefinitions(db: DrizzleDb): { name: string; color: string }[] { - return db - .select({ - name: tagDefinitions.name, - color: tagDefinitions.color - }) - .from(tagDefinitions) - .all() -} - -export function updateTagColor(db: DrizzleDb, name: string, color: string): void { - const normalizedName = name.toLowerCase().trim() - db.update(tagDefinitions).set({ color }).where(eq(tagDefinitions.name, normalizedName)).run() -} - -export function renameTagDefinition(db: DrizzleDb, oldName: string, newName: string): void { - const normalizedOld = oldName.toLowerCase().trim() - const normalizedNew = newName.toLowerCase().trim() - - if (normalizedOld === normalizedNew) return - - const existingNew = db - .select() - .from(tagDefinitions) - .where(eq(tagDefinitions.name, normalizedNew)) - .get() - - if (existingNew) { - db.delete(tagDefinitions).where(eq(tagDefinitions.name, normalizedOld)).run() - } else { - db.update(tagDefinitions) - .set({ name: normalizedNew }) - .where(eq(tagDefinitions.name, normalizedOld)) - .run() - } - - const children = db - .select({ name: tagDefinitions.name }) - .from(tagDefinitions) - .where(like(tagDefinitions.name, `${normalizedOld}/%`)) - .all() - - for (const child of children) { - const newChildName = normalizedNew + child.name.slice(normalizedOld.length) - const existingChild = db - .select() - .from(tagDefinitions) - .where(eq(tagDefinitions.name, newChildName)) - .get() - - if (existingChild) { - db.delete(tagDefinitions).where(eq(tagDefinitions.name, child.name)).run() - } else { - db.update(tagDefinitions) - .set({ name: newChildName }) - .where(eq(tagDefinitions.name, child.name)) - .run() - } - } -} - -export function deleteTagDefinition( - db: DrizzleDb, - name: string, - options: { cascade?: boolean } = {} -): void { - const normalizedName = name.toLowerCase().trim() - db.delete(tagDefinitions).where(eq(tagDefinitions.name, normalizedName)).run() - - if (options.cascade) { - db.delete(tagDefinitions) - .where(like(tagDefinitions.name, `${normalizedName}/%`)) - .run() - } -} - -export function ensureTagDefinitions( - db: DrizzleDb, - tags: string[] -): { name: string; color: string }[] { - const normalized = Array.from( - new Set(tags.map((tag) => tag.toLowerCase().trim()).filter(Boolean)) - ) - return normalized.map((tag) => getOrCreateTag(db, tag)) -} diff --git a/apps/desktop/src/main/database/queries/tag-definitions.ts b/apps/desktop/src/main/database/queries/tag-definitions.ts new file mode 100644 index 000000000..4cd598e1e --- /dev/null +++ b/apps/desktop/src/main/database/queries/tag-definitions.ts @@ -0,0 +1,130 @@ +import { count, eq, like } from 'drizzle-orm' +import { tagDefinitions } from '@memry/db-schema/schema/tag-definitions' +import type { DrizzleDb } from '../types' + +const TAG_COLOR_PALETTE = [ + 'rose', + 'pink', + 'fuchsia', + 'purple', + 'violet', + 'indigo', + 'blue', + 'sky', + 'cyan', + 'teal', + 'emerald', + 'green', + 'lime', + 'yellow', + 'amber', + 'orange', + 'stone', + 'slate', + 'gray', + 'zinc', + 'neutral', + 'warm', + 'red', + 'coral' +] + +export function getOrCreateTag(db: DrizzleDb, name: string): { name: string; color: string } { + const normalizedName = name.toLowerCase().trim() + + const existing = db + .select() + .from(tagDefinitions) + .where(eq(tagDefinitions.name, normalizedName)) + .get() + + if (existing) { + return { name: existing.name, color: existing.color } + } + + const tagCount = db.select({ count: count() }).from(tagDefinitions).get()?.count ?? 0 + const color = TAG_COLOR_PALETTE[tagCount % TAG_COLOR_PALETTE.length] + + db.insert(tagDefinitions).values({ name: normalizedName, color }).run() + + return { name: normalizedName, color } +} + +export function getAllTagDefinitions(db: DrizzleDb): { name: string; color: string }[] { + return db + .select({ + name: tagDefinitions.name, + color: tagDefinitions.color + }) + .from(tagDefinitions) + .all() +} + +export function updateTagColor(db: DrizzleDb, name: string, color: string): void { + const normalizedName = name.toLowerCase().trim() + db.update(tagDefinitions).set({ color }).where(eq(tagDefinitions.name, normalizedName)).run() +} + +export function renameTagDefinition(db: DrizzleDb, oldName: string, newName: string): void { + const normalizedOld = oldName.toLowerCase().trim() + const normalizedNew = newName.toLowerCase().trim() + + if (normalizedOld === normalizedNew) return + + const existingNew = db + .select() + .from(tagDefinitions) + .where(eq(tagDefinitions.name, normalizedNew)) + .get() + + if (existingNew) { + db.delete(tagDefinitions).where(eq(tagDefinitions.name, normalizedOld)).run() + } else { + db.update(tagDefinitions).set({ name: normalizedNew }).where(eq(tagDefinitions.name, normalizedOld)).run() + } + + const children = db + .select({ name: tagDefinitions.name }) + .from(tagDefinitions) + .where(like(tagDefinitions.name, `${normalizedOld}/%`)) + .all() + + for (const child of children) { + const newChildName = normalizedNew + child.name.slice(normalizedOld.length) + const existingChild = db + .select() + .from(tagDefinitions) + .where(eq(tagDefinitions.name, newChildName)) + .get() + + if (existingChild) { + db.delete(tagDefinitions).where(eq(tagDefinitions.name, child.name)).run() + } else { + db.update(tagDefinitions).set({ name: newChildName }).where(eq(tagDefinitions.name, child.name)).run() + } + } +} + +export function deleteTagDefinition( + db: DrizzleDb, + name: string, + options: { cascade?: boolean } = {} +): void { + const normalizedName = name.toLowerCase().trim() + db.delete(tagDefinitions).where(eq(tagDefinitions.name, normalizedName)).run() + + if (options.cascade) { + db.delete(tagDefinitions).where(like(tagDefinitions.name, `${normalizedName}/%`)).run() + } +} + +export function ensureTagDefinitions( + db: DrizzleDb, + tags: string[] +): { name: string; color: string }[] { + const normalized = Array.from( + new Set(tags.map((tag) => tag.toLowerCase().trim()).filter(Boolean)) + ) + + return normalized.map((tag) => getOrCreateTag(db, tag)) +} diff --git a/apps/desktop/src/main/database/types.ts b/apps/desktop/src/main/database/types.ts new file mode 100644 index 000000000..e9b68663c --- /dev/null +++ b/apps/desktop/src/main/database/types.ts @@ -0,0 +1,12 @@ +import Database from 'better-sqlite3' +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3' +import type * as dataSchema from '@memry/db-schema/data-schema' +import type * as indexSchema from '@memry/db-schema/index-schema' +import type * as legacySchema from '@memry/db-schema/schema' + +export type DataDb = BetterSQLite3Database +export type IndexDb = BetterSQLite3Database +export type RawIndexDb = Database.Database + +// Transitional compatibility alias for untouched legacy call sites. +export type DrizzleDb = BetterSQLite3Database diff --git a/package.json b/package.json index 35cffa7bd..2423244a8 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,9 @@ "lint": "pnpm lint:desktop", "lint:desktop": "pnpm --filter @memry/desktop lint", "format": "pnpm --filter @memry/desktop format", + "check:contracts": "node scripts/check-contract-boundaries.js", "check:contract-boundaries": "node scripts/check-contract-boundaries.js", + "check:architecture": "node scripts/check-architecture-boundaries.js", "ipc:generate": "pnpm --filter @memry/desktop ipc:generate", "ipc:check": "pnpm --filter @memry/desktop ipc:check", "sync:clean": "rm -rf apps/sync-server/.wrangler", diff --git a/scripts/check-architecture-boundaries.js b/scripts/check-architecture-boundaries.js new file mode 100644 index 000000000..7d21b1391 --- /dev/null +++ b/scripts/check-architecture-boundaries.js @@ -0,0 +1,301 @@ +const fs = require('fs/promises') +const path = require('path') + +const repoRoot = process.cwd() +const desktopRoot = path.resolve(repoRoot, 'apps/desktop') +const mainRoot = path.resolve(desktopRoot, 'src/main') +const rendererRoot = path.resolve(desktopRoot, 'src/renderer/src') +const ipcRoot = path.resolve(mainRoot, 'ipc') +const generatedIpcInvokeMapPath = path.resolve(ipcRoot, 'generated-ipc-invoke-map.ts') +const databaseRoot = path.resolve(mainRoot, 'database') +const queriesRoot = path.resolve(databaseRoot, 'queries') +const notesQueriesRoot = path.resolve(queriesRoot, 'notes') +const mainSyncRoot = path.resolve(mainRoot, 'sync') +const ipcQueryAllowlist = new Set([ + 'apps/desktop/src/main/ipc/ai-inline-handlers.ts', + 'apps/desktop/src/main/ipc/bookmarks-handlers.ts', + 'apps/desktop/src/main/ipc/graph-handlers.ts', + 'apps/desktop/src/main/ipc/journal-handlers.ts', + 'apps/desktop/src/main/ipc/notes-handlers.ts', + 'apps/desktop/src/main/ipc/properties-handlers.ts', + 'apps/desktop/src/main/ipc/reminder-handlers.ts', + 'apps/desktop/src/main/ipc/saved-filters-handlers.ts', + 'apps/desktop/src/main/ipc/search-handlers.ts', + 'apps/desktop/src/main/ipc/settings-handlers.ts', + 'apps/desktop/src/main/ipc/sync-handlers.ts', + 'apps/desktop/src/main/ipc/tags-handlers.ts', + 'apps/desktop/src/main/ipc/tasks-handlers.ts' +]) +const dataOnlySchemaSpecifiers = new Map([ + ['@memry/db-schema/schema/tag-definitions', 'data-db schema import'], + ['@memry/db-schema/schema/note-positions', 'data-db schema import'] +]) +const dataOnlyQueryTargets = [ + path.resolve(queriesRoot, 'tasks'), + path.resolve(queriesRoot, 'projects'), + path.resolve(queriesRoot, 'note-positions') +] +const databaseModuleTargets = [ + databaseRoot, + path.resolve(databaseRoot, 'index'), + path.resolve(databaseRoot, 'client') +] + +async function walk(dir) { + const entries = await fs.readdir(dir, { withFileTypes: true }) + const files = await Promise.all( + entries.map(async (entry) => { + const entryPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + return walk(entryPath) + } + return [entryPath] + }) + ) + + return files.flat() +} + +function isSourceFile(filePath) { + if (filePath.endsWith('.d.ts')) { + return false + } + + return /\.(ts|tsx|js|jsx|mjs|cjs)$/.test(filePath) +} + +function isTestFile(filePath) { + return /\.(test|spec)\.(ts|tsx|js|jsx|mjs|cjs)$/.test(filePath) +} + +function stripSourceExtension(filePath) { + return filePath.replace(/\.(ts|tsx|js|jsx|mjs|cjs)$/, '') +} + +function isInside(targetPath, rootPath) { + const normalizedTarget = stripSourceExtension(targetPath) + const normalizedRoot = stripSourceExtension(rootPath) + return ( + normalizedTarget === normalizedRoot || + normalizedTarget.startsWith(`${normalizedRoot}${path.sep}`) + ) +} + +function matchesTarget(targetPath, candidatePath) { + return stripSourceExtension(targetPath) === stripSourceExtension(candidatePath) +} + +function resolveImport(fromFile, specifier) { + if (specifier.startsWith('.')) { + return path.resolve(path.dirname(fromFile), specifier) + } + + if (specifier.startsWith('@main/')) { + return path.resolve(mainRoot, specifier.slice('@main/'.length)) + } + + return null +} + +function scanImports(source) { + return Array.from( + source.matchAll(/\b(?:import|export)\b[\s\S]*?\bfrom\s+['"]([^'"]+)['"]/g), + (match) => ({ + statement: match[0], + specifier: match[1] + }) + ) +} + +function getFilesForRoot(rootPath) { + return walk(rootPath).then((files) => files.filter(isSourceFile).filter((filePath) => !isTestFile(filePath))) +} + +function formatViolation(filePath, specifier, reason) { + return `${path.relative(repoRoot, filePath)} -> ${specifier} (${reason})` +} + +function isBlockedDataSchemaImport(specifier) { + for (const [blockedSpecifier, reason] of dataOnlySchemaSpecifiers) { + if (specifier === blockedSpecifier || specifier.startsWith(`${blockedSpecifier}/`)) { + return reason + } + } + + return null +} + +function isDataOnlyQueryImport(specifier, resolvedPath) { + if (specifier === '@main/database/queries/tasks' || specifier.startsWith('@main/database/queries/tasks/')) { + return 'data-db task query import' + } + + if ( + specifier === '@main/database/queries/projects' || + specifier.startsWith('@main/database/queries/projects/') + ) { + return 'data-db project query import' + } + + if ( + specifier === '@main/database/queries/note-positions' || + specifier.startsWith('@main/database/queries/note-positions/') + ) { + return 'data-db note-position query import' + } + + if (!resolvedPath) { + return null + } + + if (matchesTarget(resolvedPath, dataOnlyQueryTargets[0])) { + return 'data-db task query import' + } + + if (matchesTarget(resolvedPath, dataOnlyQueryTargets[1])) { + return 'data-db project query import' + } + + if (matchesTarget(resolvedPath, dataOnlyQueryTargets[2])) { + return 'data-db note-position query import' + } + + return null +} + +function isGetDatabaseImport(statement, specifier, resolvedPath) { + if (!/\bgetDatabase\b/.test(statement)) { + return false + } + + if (specifier === '@main/database') { + return true + } + + if (!resolvedPath) { + return false + } + + return databaseModuleTargets.some((targetPath) => matchesTarget(resolvedPath, targetPath)) +} + +async function main() { + const blockingViolations = new Set() + const allowlistedIpcViolations = new Set() + + const rendererFiles = await getFilesForRoot(rendererRoot) + for (const filePath of rendererFiles) { + const source = await fs.readFile(filePath, 'utf8') + + for (const { specifier } of scanImports(source)) { + if (specifier.startsWith('@main/')) { + blockingViolations.add( + formatViolation(filePath, specifier, 'renderer import of @main/* is not allowed') + ) + continue + } + + const resolvedPath = resolveImport(filePath, specifier) + if (!resolvedPath) { + continue + } + + if (isInside(resolvedPath, mainSyncRoot)) { + blockingViolations.add( + formatViolation(filePath, specifier, 'renderer import of main sync code is not allowed') + ) + continue + } + + if (isInside(resolvedPath, mainRoot)) { + blockingViolations.add( + formatViolation(filePath, specifier, 'renderer import of main code is not allowed') + ) + } + } + } + + const ipcFiles = (await getFilesForRoot(ipcRoot)).filter( + (filePath) => !matchesTarget(filePath, generatedIpcInvokeMapPath) + ) + for (const filePath of ipcFiles) { + const source = await fs.readFile(filePath, 'utf8') + + for (const { specifier } of scanImports(source)) { + const resolvedPath = resolveImport(filePath, specifier) + const isDirectQueryImport = + specifier === '@main/database/queries' || + specifier.startsWith('@main/database/queries/') || + (resolvedPath ? isInside(resolvedPath, queriesRoot) : false) + + if (!isDirectQueryImport) { + continue + } + + const relativeFilePath = path.relative(repoRoot, filePath) + const violation = formatViolation(filePath, specifier, 'direct IPC query import') + + if (ipcQueryAllowlist.has(relativeFilePath)) { + allowlistedIpcViolations.add(violation) + } else { + blockingViolations.add(violation) + } + } + } + + const noteQueryFiles = await getFilesForRoot(notesQueriesRoot) + for (const filePath of noteQueryFiles) { + const source = await fs.readFile(filePath, 'utf8') + + for (const { statement, specifier } of scanImports(source)) { + const resolvedPath = resolveImport(filePath, specifier) + const blockedSchemaReason = isBlockedDataSchemaImport(specifier) + if (blockedSchemaReason) { + blockingViolations.add(formatViolation(filePath, specifier, blockedSchemaReason)) + continue + } + + const blockedQueryReason = isDataOnlyQueryImport(specifier, resolvedPath) + if (blockedQueryReason) { + blockingViolations.add(formatViolation(filePath, specifier, blockedQueryReason)) + continue + } + + if (isGetDatabaseImport(statement, specifier, resolvedPath)) { + blockingViolations.add(formatViolation(filePath, specifier, 'getDatabase import')) + } + } + } + + if (blockingViolations.size === 0) { + if (allowlistedIpcViolations.size === 0) { + console.log('architecture boundary check passed') + return + } + + console.log('architecture boundary check passed with allowlisted IPC query imports:') + for (const violation of [...allowlistedIpcViolations].sort()) { + console.log(`- ${violation}`) + } + return + } + + console.error('architecture boundary check failed:') + for (const violation of [...blockingViolations].sort()) { + console.error(`- ${violation}`) + } + + if (allowlistedIpcViolations.size > 0) { + console.error('allowlisted IPC query imports:') + for (const violation of [...allowlistedIpcViolations].sort()) { + console.error(`- ${violation}`) + } + } + + process.exit(1) +} + +main().catch((error) => { + console.error(error) + process.exit(1) +})