diff --git a/data/users.json b/data/users.json index 3434a38..64a0503 100644 --- a/data/users.json +++ b/data/users.json @@ -13,7 +13,7 @@ "active": true, "created_at": "2026-03-23T19:38:00.742597+00:00", "last_login": null, - "failed_attempts": 0, + "failed_attempts": 2, "locked_until": null } } diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..0ace009 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,20 @@ +services: + obsigate: + build: + context: . + image: obsigate:latest + container_name: obsigate + user: "1000:1000" + restart: unless-stopped + ports: + - "2020:8080" + volumes: + - C:\dev\git\python\obsigate\test-vault:/vaults/TestVault + - C:\dev\git\python\obsigate\data:/app/data + environment: + - VAULT_1_NAME=TestVault + - VAULT_1_PATH=/vaults/TestVault + - OBSIGATE_AUTH_ENABLED=false + - OBSIGATE_ADMIN_USER=admin + - OBSIGATE_ADMIN_PASSWORD=test123 + - OBSIGATE_WATCHER_ENABLED=false diff --git a/test-vault/Accueil.md b/test-vault/Accueil.md new file mode 100644 index 0000000..066cde6 --- /dev/null +++ b/test-vault/Accueil.md @@ -0,0 +1,22 @@ +--- +titre: Accueil +statut: actif +tags: [accueil, important] +catégorie: général +publish: true +date: 2025-01-15 +--- + +# Bienvenue dans le vault de test + +Ceci est un document de test pour [[ObsiGate]]. + +## Sections + +- [[Projets/Projet Alpha]] - Un projet en cours +- [[Notes/Configuration serveur]] - Documentation technique +- [[Recettes/Pâtes carbonara]] - Une recette + +## Tags + +#accueil #important #test diff --git a/test-vault/Recettes/Pâtes carbonara.md b/test-vault/Recettes/Pâtes carbonara.md new file mode 100644 index 0000000..fb80866 --- /dev/null +++ b/test-vault/Recettes/Pâtes carbonara.md @@ -0,0 +1,29 @@ +--- +titre: Pâtes carbonara +tags: [recette, cuisine, italien, pâtes] +catégorie: cuisine +date: 2025-04-10 +favoris: true +--- + +# Pâtes carbonara authentiques + +## Ingrédients + +- 400g de spaghetti +- 200g de guanciale +- 4 jaunes d'œufs +- 100g de pecorino romano +- Poivre noir + +## Préparation + +1. Cuire les pâtes al dente +2. Faire revenir le guanciale +3. Mélanger les jaunes avec le fromage +4. Assembler hors du feu + +## Notes + +Ne jamais utiliser de crème ! +La vraie carbonara n'a pas de crème. diff --git a/test-vault/Recettes/Tiramisu.md b/test-vault/Recettes/Tiramisu.md new file mode 100644 index 0000000..b2c93cf --- /dev/null +++ b/test-vault/Recettes/Tiramisu.md @@ -0,0 +1,28 @@ +--- +titre: Tiramisu classique +tags: [recette, cuisine, italien, dessert] +catégorie: cuisine +date: 2025-04-15 +publish: true +--- + +# Tiramisu + +## Ingrédients + +- 500g de mascarpone +- 4 œufs +- 100g de sucre +- Biscuits à la cuillère +- Café fort +- Cacao en poudre + +## Préparation + +1. Séparer les blancs des jaunes +2. Mélanger jaunes + sucre + mascarpone +3. Monter les blancs en neige +4. Incorporer délicatement +5. Tremper les biscuits dans le café +6. Alterner couches +7. Réfrigérer 4h minimum diff --git a/test-vault/notes/Configuration serveur.md b/test-vault/notes/Configuration serveur.md new file mode 100644 index 0000000..85b51f4 --- /dev/null +++ b/test-vault/notes/Configuration serveur.md @@ -0,0 +1,32 @@ +--- +titre: Configuration serveur +tags: [serveur, configuration, technique, linux] +catégorie: technique +date: 2025-02-20 +--- + +# Configuration du serveur + +Documentation pour configurer un serveur Linux. + +## Prérequis + +- Ubuntu 22.04 LTS +- Docker installé +- 4 Go RAM minimum + +## Étapes + +1. Installation des dépendances +2. Configuration du firewall +3. Déploiement Docker + +```bash +sudo apt update +sudo apt install docker.io +``` + +## Notes + +Voir [[Notes/Sécurité]] pour la sécurisation. +Voir [[Notes/Performance]] pour l'optimisation. diff --git a/test-vault/notes/Docker tips.md b/test-vault/notes/Docker tips.md new file mode 100644 index 0000000..be1184e --- /dev/null +++ b/test-vault/notes/Docker tips.md @@ -0,0 +1,22 @@ +--- +titre: Astuces Docker +tags: [docker, conteneur, déploiement, technique] +catégorie: technique +date: 2025-05-01 +--- + +# Astuces Docker + +## Nettoyage + +```bash +docker system prune -a +``` + +## Docker Compose + +Toujours utiliser des fichiers `.env` pour les secrets. + +## Volumes + +Préférer les volumes nommés aux bind mounts. diff --git a/test-vault/notes/Performance.md b/test-vault/notes/Performance.md new file mode 100644 index 0000000..dda12a4 --- /dev/null +++ b/test-vault/notes/Performance.md @@ -0,0 +1,20 @@ +--- +titre: Optimisation des performances +tags: [performance, optimisation, serveur] +catégorie: technique +date: 2025-03-01 +--- + +# Optimisation des performances + +## Cache + +Utiliser Redis pour le cache. + +## Indexation + +Configurer les index de base de données. + +## Monitoring + +Mettre en place Prometheus et Grafana. diff --git a/test-vault/notes/Sécurité.md b/test-vault/notes/Sécurité.md new file mode 100644 index 0000000..a73971c --- /dev/null +++ b/test-vault/notes/Sécurité.md @@ -0,0 +1,31 @@ +--- +titre: Sécurité +tags: [sécurité, serveur, firewall] +catégorie: technique +date: 2025-01-30 +publish: true +--- + +# Sécurité du serveur + +## Firewall + +Utiliser ufw pour le firewall : + +```bash +sudo ufw enable +sudo ufw allow 22/tcp +sudo ufw allow 80/tcp +sudo ufw allow 443/tcp +``` + +## Certificats SSL + +Utiliser Let's Encrypt pour les certificats. + +## Mots de passe + +Toujours utiliser des mots de passe forts. +Minimum 16 caractères. + +Voir aussi [[Notes/Configuration serveur]]. diff --git a/test-vault/projets/Projet Alpha.md b/test-vault/projets/Projet Alpha.md new file mode 100644 index 0000000..ef6b157 --- /dev/null +++ b/test-vault/projets/Projet Alpha.md @@ -0,0 +1,31 @@ +--- +titre: Projet Alpha +statut: en cours +tags: [projet, alpha, serveur] +catégorie: développement +publish: true +date: 2025-03-10 +favoris: true +--- + +# Projet Alpha + +Ce projet consiste à déployer un [[Notes/Configuration serveur|serveur]] pour l'application. + +## Objectifs + +- Mise en place du serveur +- Configuration de [[Notes/Sécurité]] +- Tests de performance + +## Équipe + +- Bruno (lead) +- Équipe DevOps + +## Avancement + +Le projet est **en cours**. La première phase est terminée. +La deuxième phase débute la semaine prochaine. + +Voir aussi [[Projet Beta]] pour le projet connexe. diff --git a/test-vault/projets/Projet Beta.md b/test-vault/projets/Projet Beta.md new file mode 100644 index 0000000..b63b06f --- /dev/null +++ b/test-vault/projets/Projet Beta.md @@ -0,0 +1,21 @@ +--- +titre: Projet Beta +statut: planifié +tags: [projet, beta, frontend] +catégorie: développement +date: 2025-05-20 +--- + +# Projet Beta + +Ce projet est le successeur du [[Projets/Projet Alpha]]. + +## Technos + +- React +- TypeScript +- Tailwind CSS + +## Planning + +Début prévu : juin 2025 diff --git a/test-vault/subfolder/Document caché.md b/test-vault/subfolder/Document caché.md new file mode 100644 index 0000000..552a8d9 --- /dev/null +++ b/test-vault/subfolder/Document caché.md @@ -0,0 +1,7 @@ +--- +tags: [caché, test] +--- + +# Document dans un sous-dossier + +Ce document teste l'arborescence. diff --git a/tests/frontend/audit.mjs b/tests/frontend/audit.mjs new file mode 100644 index 0000000..dc5b107 --- /dev/null +++ b/tests/frontend/audit.mjs @@ -0,0 +1,418 @@ +#!/usr/bin/env node +/** + * ObsiGate — Comprehensive frontend validator. + * Scans ALL modules and catches: + * 1. Function calls without import or local definition + * 2. State variables used without state. prefix + * 3. Imported functions that don't exist in source module + * 4. Const reassignments + * Usage: node tests/frontend/audit.mjs + */ + +import { readFileSync, readdirSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const JS_DIR = join(__dirname, '../../frontend/js'); + +let errors = []; +let warnings = []; + +// ═══════════════════════════════════════════════════════════════════════════ +// Browser globals allowed without import +// ═══════════════════════════════════════════════════════════════════════════ +const BROWSER_GLOBALS = new Set([ + 'console','document','window','fetch','localStorage','sessionStorage','navigator','location','history', + 'setTimeout','clearTimeout','setInterval','clearInterval','requestAnimationFrame','cancelAnimationFrame', + 'addEventListener','removeEventListener','getElementById','querySelector','querySelectorAll', + 'getComputedStyle','getBoundingClientRect','createElement','createElementNS','createTextNode', + 'createDocumentFragment','appendChild','removeChild','insertBefore','replaceChild','cloneNode', + 'setAttribute','getAttribute','removeAttribute','hasAttribute','classList','parentElement','parentNode', + 'children','childNodes','firstChild','lastChild','nextSibling','previousSibling','textContent','innerHTML', + 'style','className','value','checked','focus','blur','click','preventDefault','stopPropagation', + 'JSON','Math','Date','Object','Array','Map','Set','WeakMap','Promise','Symbol','RegExp','Error', + 'parseInt','parseFloat','isNaN','isFinite','encodeURIComponent','decodeURIComponent','atob','btoa', + 'String','Number','Boolean','Intl','devicePixelRatio','URLSearchParams','FormData','Blob','FileReader', + 'Image','EventSource','WebSocket','Worker','ServiceWorker','Notification','Headers','Request','Response', + 'MutationObserver','IntersectionObserver','ResizeObserver','DOMParser','CustomEvent','Event', + 'KeyboardEvent','MouseEvent','WheelEvent','FocusEvent','InputEvent','ClipboardEvent','TouchEvent', + 'requestFullscreen','exitFullscreen','fullscreenElement','crypto','performance','alert','confirm', + 'scrollTo','scrollBy','scrollIntoView','getContext','toDataURL','normalize','dispatchEvent', + 'execCommand','closest','contains','toggle','replaceChildren','remove','before','after','TextDecoder', + 'TextEncoder','Uint8Array','ArrayBuffer','File','FileList','DataTransfer','matchMedia', + 'structuredClone','queueMicrotask','reportError', + // lucide icons + 'lucide','createIcons', + // markdown rendering + 'marked','hljs','frontmatter','mistune', + // Canvas drawing + 'beginPath','closePath','moveTo','lineTo','arc','fill','stroke','fillStyle','strokeStyle', + 'lineWidth','font','textAlign','fillText','clearRect','measureText','setLineDash','shadowColor', + 'shadowBlur','setTransform','save','restore','translate','scale','rotate','roundRect', + 'getPropertyValue', +]); + +// ═══════════════════════════════════════════════════════════════════════════ +// Phase 1: Parse all modules — collect exports, imports, local definitions +// ═══════════════════════════════════════════════════════════════════════════ + +const modules = {}; +const allExports = {}; + +function parseModule(fileName) { + const content = readFileSync(join(JS_DIR, fileName), 'utf-8'); + const lines = content.split('\n'); + + const info = { + imports: [], // [{ name, from }] + exports: new Set(), + localDefs: new Set(), // function names, const names defined in this file + localVars: new Set(), // let/var names + content, + lines, + }; + + let inExportBlock = false; + let exportBlockText = ''; + let inMultiLineImport = false; + let importBlockText = ''; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + // ─ Multi-line export { ... } ─ + if (/^export\s*\{/.test(trimmed) && !/\}/.test(trimmed)) { + inExportBlock = true; + exportBlockText = trimmed; + continue; + } + if (inExportBlock) { + exportBlockText += ' ' + trimmed; + if (/\}/.test(trimmed)) { + inExportBlock = false; + const m = exportBlockText.match(/\{([^}]+)\}/); + if (m) { + for (const name of m[1].split(',')) { + const n = name.trim().replace(/\s+as\s+.*/, '').trim(); + if (n) info.exports.add(n); + } + } + } + continue; + } + + // ─ Multi-line import { ... } ─ + if (/^import\s*\{/.test(trimmed) && !/from/.test(trimmed)) { + inMultiLineImport = true; + importBlockText = trimmed; + continue; + } + if (inMultiLineImport) { + importBlockText += ' ' + trimmed; + if (/from/.test(trimmed)) { + inMultiLineImport = false; + processImportLine(importBlockText, info, fileName); + } + continue; + } + + // ─ export const/let/function/async function X ─ + let m = trimmed.match(/^export\s+(?:const|let|function|async\s+function|class)\s+(\w+)/); + if (m) { + info.exports.add(m[1]); + info.localDefs.add(m[1]); + continue; + } + + // ─ Single-line export { a, b } ─ + m = trimmed.match(/^export\s*\{([^}]+)\}/); + if (m) { + for (const name of m[1].split(',')) { + const n = name.trim().replace(/\s+as\s+.*/, '').trim(); + if (n) info.exports.add(n); + } + continue; + } + + // ─ export { a } from './x.js' ─ + m = trimmed.match(/^export\s*\{([^}]+)\}\s*from\s*['"]\.\/(\w+\.js)['"]/); + if (m) { + for (const name of m[1].split(',')) { + const n = name.trim().replace(/\s+as\s+.*/, '').trim(); + if (n) info.exports.add(n); + } + continue; + } + + // ─ import { a, b } from './x.js' ─ + m = trimmed.match(/^import\s*\{([^}]+)\}\s*from\s*['"]\.\/(\w+\.js)['"]/); + if (m) processImportLine(trimmed, info, fileName); + + // ─ import * as NS from './x.js' ─ + m = trimmed.match(/^import\s+\*\s+as\s+(\w+)\s+from\s*['"]\.\/(\w+\.js)['"]/); + if (m) { + info.imports.push({ ns: m[1], from: m[2] }); + } + + // ─ function / async function X ─ + m = trimmed.match(/^(?:async\s+)?function\s+(\w+)/); + if (m && !trimmed.startsWith('//') && !trimmed.startsWith('*')) { + info.localDefs.add(m[1]); + } + + // ─ Object method definitions: methodName() { or methodName: function ─ + m = trimmed.match(/^\s+(\w+)\s*\([^)]*\)\s*\{/); + if (m && !trimmed.startsWith('//') && !trimmed.startsWith('*')) { + info.localDefs.add(m[1]); + } + m = trimmed.match(/^\s+(\w+)\s*:\s*function/); + if (m && !trimmed.startsWith('//') && !trimmed.startsWith('*')) { + info.localDefs.add(m[1]); + } + m = trimmed.match(/^\s+(\w+)\s*:\s*async\s+function/); + if (m && !trimmed.startsWith('//') && !trimmed.startsWith('*')) { + info.localDefs.add(m[1]); + } + m = trimmed.match(/^\s+(\w+)\s*:\s*\([^)]*\)\s*=>/); + if (m && !trimmed.startsWith('//') && !trimmed.startsWith('*')) { + info.localDefs.add(m[1]); + } + + // ─ const X = ─ + m = trimmed.match(/^const\s+(\w+)\s*=/); + if (m) { + info.localDefs.add(m[1]); + info.localVars.add(m[1]); + } + + // ─ let X = ─ + m = trimmed.match(/^let\s+(\w+)\s*=/); + if (m) { + info.localVars.add(m[1]); + } + } + + allExports[fileName] = info.exports; + modules[fileName] = info; +} + +function processImportLine(line, info, fileName) { + const m = line.match(/^import\s*\{([^}]+)\}\s*from\s*['"]\.\/(\w+\.js)['"]/); + if (!m) return; + const source = m[2]; + for (const name of m[1].split(',')) { + const parts = name.trim().split(/\s+as\s+/); + const importName = parts[0].trim(); + const localName = parts.length > 1 ? parts[1].trim() : importName; + info.imports.push({ name: localName, from: source }); + info.localDefs.add(localName); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Phase 2: Validate imports exist in source +// ═══════════════════════════════════════════════════════════════════════════ + +function validateImports() { + for (const [modName, info] of Object.entries(modules)) { + for (const imp of info.imports) { + if (imp.ns) { + // Namespace import — check accessed properties + const nsRegex = new RegExp(`\\b${imp.ns}\\.(\\w+)`, 'g'); + let m; + while ((m = nsRegex.exec(info.content)) !== null) { + const accessed = m[1]; + if (!allExports[imp.from]?.has(accessed)) { + if (!BROWSER_GLOBALS.has(accessed) && !info.localDefs.has(accessed)) { + errors.push(`${modName}: uses ${imp.ns}.${accessed} but ${imp.from} doesn't export '${accessed}'`); + } + } + } + } else if (imp.name && imp.from) { + if (!allExports[imp.from]?.has(imp.name)) { + // Check if it's a re-export from another file + let found = false; + for (const [otherMod, otherExports] of Object.entries(allExports)) { + if (otherExports.has(imp.name)) found = true; + } + if (!found) { + errors.push(`${modName}: imports '${imp.name}' from ${imp.from} but it's NOT exported there`); + } + } + } + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// JavaScript keywords that look like function calls but aren't +// ═══════════════════════════════════════════════════════════════════════════ +const JS_KEYWORDS = new Set([ + 'if','else','for','while','do','switch','case','default','break','continue', + 'return','throw','try','catch','finally','async','await','function','var', + 'let','const','class','extends','super','new','delete','typeof','instanceof', + 'void','yield','import','export','in','of','this','debugger','with', + 'true','false','null','undefined','NaN','Infinity', + // French/English words that appear in comments/strings + 'lien','entrant','fichier','passe','vue','function','var','widgets','unsupported', + 'management','directory','directories','dot','container','storage','options', + 'connect','interface','header','js','forces','bg','border','accent','muted','text', + 'all','add','get','set','has','keys','values','entries','some','apply','update', + 'load','show','hide','render','open','close','init','destroy','toggle', + 'then','catch','finally','parse','stringify','log','warn','error','debug', + 'now','random','max','min','abs','round','floor','ceil','sqrt','pow', + 'cos','sin','log','exp','toString','toFixed','toISOString','toLocaleString', + 'toLocaleDateString','toLocaleTimeString','toUpperCase','toLowerCase', + 'search','test','exec','match','replace','split','join','trim','startsWith', + 'endsWith','includes','indexOf','charAt','substring','slice','sort','reverse', + 'map','filter','reduce','forEach','find','findIndex','push','pop','shift', + 'unshift','splice','concat','every','some','flat','flatMap', + 'getItem','setItem','removeItem','addEventListener','removeEventListener', + 'dispatchEvent','querySelector','querySelectorAll','getElementById', + 'getElementsByClassName','getElementsByTagName','createElement', + 'createTextNode','appendChild','removeChild','insertBefore','replaceChild', + 'setAttribute','getAttribute','removeAttribute','classList', + 'getBoundingClientRect','getComputedStyle','requestAnimationFrame', + 'parseInt','parseFloat','isNaN','isFinite', + 'disconnect','observe','unobserve','compareDocumentPosition', + 'preventDefault','stopPropagation','stopImmediatePropagation', + 'writeText','execCommand','createIcons','getContext','toDataURL', + 'setTransform','beginPath','closePath','moveTo','lineTo','arc','fill','stroke', + 'save','restore','translate','scale','rotate','clearRect','fillText', + 'setLineDash','measureText','getPropertyValue','focus','blur','click', + 'setTimeout','clearTimeout','setInterval','clearInterval', + 'encodeURIComponent','decodeURIComponent', + 'getRecentEvents','getState','getConfig','getMenuData','getVaultIcon', + 'patternToRegex','isTagFiltered','fmtSize','localCompare', + 'fromCharCode','codePointAt','normalize','padStart','padEnd','repeat', + 'createRange','surroundContents','getClientRects', + 'switch','declare','module','require', +]); + +// ═══════════════════════════════════════════════════════════════════════════ +// Phase 3: Find function calls and variable refs without import/definition +// ═══════════════════════════════════════════════════════════════════════════ + +// State variable names that MUST use state. prefix +const STATE_VARS = new Set([ + 'APP_VERSION','currentVault','currentPath','allVaults','selectedContextVault', + 'searchTimeout','searchAbortController','advancedSearchOffset','advancedSearchTotal', + 'advancedSearchSort','advancedSearchLastQuery','suggestAbortController', + 'dropdownActiveIndex','dropdownItems','currentSearchId','selectedTags', + 'searchCaseSensitive','searchWholeWord','searchRegex','searchFilterVisible', + 'SEARCH_HISTORY_KEY','MAX_HISTORY_ENTRIES','SUGGEST_DEBOUNCE_MS', + 'ADVANCED_SEARCH_LIMIT','MIN_SEARCH_LENGTH','SEARCH_TIMEOUT_MS', + 'showingSource','cachedRawSource','editorView','editorVault','editorPath', + 'fallbackEditorEl','_iconDebounceTimer','outlineObserver','activeHeadingId', + 'headingsCache','rightSidebarVisible','rightSidebarWidth', + 'sidebarFilterCaseSensitive','activeSidebarTab','filterDebounce','vaultSettings', +]); + +function findUndefinedRefs(modName) { + const info = modules[modName]; + if (!info) return; + + // Build "available" set: imported names + locally defined + browser globals + const available = new Set([...info.localDefs, ...BROWSER_GLOBALS]); + + // Also add imported namespace prefixes + for (const imp of info.imports) { + if (imp.ns) available.add(imp.ns); + } + + // Scan for standalone identifiers used as function calls or variable refs + const content = info.content; + + // Find all identifiers that look like function calls or property access + // Pattern: word( + const callRegex = /\b([a-zA-Z_$][\w$]*)\s*\(/g; + let m; + const checkedLines = new Set(); + + while ((m = callRegex.exec(content)) !== null) { + const name = m[1]; + if (available.has(name)) continue; + if (JS_KEYWORDS.has(name)) continue; + if (name[0] === name[0].toUpperCase() && name[0] !== '_') continue; // skip classes/types + + // Get the line for context + const lineNum = content.substring(0, m.index).split('\n').length; + if (checkedLines.has(`${name}:${lineNum}`)) continue; + checkedLines.add(`${name}:${lineNum}`); + + // Skip if it's a method call like .name( + const before = content.substring(Math.max(0, m.index - 1), m.index); + if (before === '.') continue; + + // Skip if it's inside a string or comment + const lineStart = content.lastIndexOf('\n', m.index) + 1; + const lineEnd = content.indexOf('\n', m.index); + const line = content.substring(lineStart, lineEnd > 0 ? lineEnd : content.length); + if (line.trim().startsWith('//') || line.trim().startsWith('*')) continue; + + errors.push(`${modName}:${lineNum} — '${name}()' called but not imported or defined`); + } + + // Also check for bare state variable references (without state. prefix) + for (const varName of STATE_VARS) { + const bareRegex = new RegExp(`(? f.endsWith('.js') && f !== 'sidebar_raw.js'); + +for (const file of files) { + parseModule(file); +} + +validateImports(); + +for (const file of files) { + findUndefinedRefs(file); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Report +// ═══════════════════════════════════════════════════════════════════════════ + +if (errors.length > 0) { + console.error(`\n❌ ${errors.length} frontend error(s) found:\n`); + for (const err of errors) { + console.error(` • ${err}`); + } + console.error(`\n💡 Fix: add missing imports, exports, or state. prefixes`); + process.exit(1); +} else { + const totalExports = Object.values(allExports).reduce((s, e) => s + e.size, 0); + console.log(`\n✅ ${files.length} modules, ${totalExports} exports — 0 issues`); + process.exit(0); +}