diff --git a/.repolicy.json b/.repolicy.json index 8e7fa81899..09adbd728c 100644 --- a/.repolicy.json +++ b/.repolicy.json @@ -3,6 +3,7 @@ "ts/packages/typechat/", "ts/pnpm-lock.yaml", "docs/pnpm-lock.yaml", - "ts/pnpm-workspace.yaml" + "ts/pnpm-workspace.yaml", + "ts/examples/websiteAliases/cache/" ] } diff --git a/ts/package.json b/ts/package.json index 184a5fc7bc..93bf678be8 100644 --- a/ts/package.json +++ b/ts/package.json @@ -61,7 +61,7 @@ "prettier": "^3.5.3", "shx": "^0.4.0" }, - "packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017", + "packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be", "engines": { "node": ">=20", "pnpm": ">=10" diff --git a/ts/packages/defaultAgentProvider/data/config.test.json b/ts/packages/defaultAgentProvider/data/config.test.json new file mode 100644 index 0000000000..effd29ad14 --- /dev/null +++ b/ts/packages/defaultAgentProvider/data/config.test.json @@ -0,0 +1,33 @@ +{ + "description": "Test configuration - only includes agents needed for shell integration tests", + "agents": { + "calendar": { + "name": "calendar", + "execMode": "dispatcher" + }, + "list": { + "name": "list-agent", + "execMode": "dispatcher" + }, + "chat": { + "name": "chat-agent", + "execMode": "dispatcher" + }, + "greeting": { + "name": "greeting-agent", + "execMode": "dispatcher" + } + }, + "explainers": { + "v5": { + "constructions": { + "data": ["./data/explainer/v5/data/player/basic.json"], + "file": "./data/explainer/v5/constructions.json" + } + } + }, + "tests": [ + "./test/data/explanations/**/**/*.json", + "./test/repo/explanations/**/**/*.json" + ] +} diff --git a/ts/packages/dispatcher/dispatcher/src/utils/fsUtils.ts b/ts/packages/dispatcher/dispatcher/src/utils/fsUtils.ts index 1f877e4048..16779b8afb 100644 --- a/ts/packages/dispatcher/dispatcher/src/utils/fsUtils.ts +++ b/ts/packages/dispatcher/dispatcher/src/utils/fsUtils.ts @@ -49,6 +49,9 @@ export async function lockInstanceDir(instanceDir: string) { isExiting = true; }); return await lockfile.lock(instanceDir, { + // Retry for up to ~15 seconds to handle the case where a previous + // process was forcibly killed and its lock file is not yet stale. + retries: { retries: 15, minTimeout: 1000, maxTimeout: 1000 }, onCompromised: (err) => { if (isExiting) { // We are exiting, just ignore the error diff --git a/ts/packages/shell/package.json b/ts/packages/shell/package.json index b68a5c05d1..012692286a 100644 --- a/ts/packages/shell/package.json +++ b/ts/packages/shell/package.json @@ -41,7 +41,7 @@ "prettier": "prettier --check . --ignore-path ../../.prettierignore", "prettier:fix": "prettier --write . --ignore-path ../../.prettierignore", "shell:smoke": "npx playwright test simple.spec.ts", - "shell:test": "npx playwright test", + "shell:test": "pnpm run jest-esm && npx playwright test", "start": "npm run prepare-vite && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-vite preview -- --env ../../.env", "start:connect": "npm run prepare-vite && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-vite preview -- --env ../../.env --connect", "start:nosandbox": "npm run prepare-vite && electron-vite preview --noSandbox -- --env ../../.env", diff --git a/ts/packages/shell/playwright.config.ts b/ts/packages/shell/playwright.config.ts index 4102afa60a..491c33126e 100644 --- a/ts/packages/shell/playwright.config.ts +++ b/ts/packages/shell/playwright.config.ts @@ -16,6 +16,8 @@ import { defineConfig, devices } from "@playwright/test"; */ export default defineConfig({ testDir: "./test", + /* Exclude jest unit tests in partialCompletion – they are run separately via jest-esm */ + testIgnore: ["**/partialCompletion/**"], /* Run tests sequentially otherwise the client will complain about locked session file */ fullyParallel: false, /* Fail the build on CI if you accidentally left test.only in the source code. */ @@ -37,7 +39,7 @@ export default defineConfig({ }, maxFailures: 0, - timeout: 300_000, // Set global timeout to 120 seconds + timeout: 600_000, // Set global timeout to 10 minutes (for LLM-heavy tests) /* Configure projects for major browsers */ projects: [ diff --git a/ts/packages/shell/src/main/instance.ts b/ts/packages/shell/src/main/instance.ts index 0963f7710f..9770c7a6bc 100644 --- a/ts/packages/shell/src/main/instance.ts +++ b/ts/packages/shell/src/main/instance.ts @@ -32,7 +32,7 @@ import { import { getStatusSummary } from "agent-dispatcher/helpers/status"; import { setPendingUpdateCallback } from "./commands/update.js"; import { createClientIORpcClient } from "@typeagent/dispatcher-rpc/clientio/client"; -import { isProd } from "./index.js"; +import { isProd, isTest } from "./index.js"; import { getFsStorageProvider } from "dispatcher-node-providers"; import { ensureAndConnectDispatcher } from "@typeagent/agent-server-client"; @@ -151,13 +151,16 @@ async function initializeDispatcher( "instanceDir is required when not in connect mode", ); } - const indexingServiceRegistry = - await getIndexingServiceRegistry(instanceDir); + const configName = isTest ? "test" : undefined; + const indexingServiceRegistry = await getIndexingServiceRegistry( + instanceDir, + configName, + ); newDispatcher = await createDispatcher("shell", { appAgentProviders: [ createShellAgentProvider(shellWindow), - ...getDefaultAppAgentProviders(instanceDir), + ...getDefaultAppAgentProviders(instanceDir, configName), ], agentInitOptions: { browser: browserControl.control, @@ -250,7 +253,12 @@ async function initializeDispatcher( return dispatcher; } catch (e: any) { - dialog.showErrorBox("Exception initializing dispatcher", e.stack); + if (isTest) { + // In test mode, avoid blocking dialogs so the process can exit cleanly + console.error("Exception initializing dispatcher:", e.stack); + } else { + dialog.showErrorBox("Exception initializing dispatcher", e.stack); + } return undefined; } } diff --git a/ts/packages/shell/src/preload/shellSettingsType.ts b/ts/packages/shell/src/preload/shellSettingsType.ts index f3bf4feebd..5266bfc937 100644 --- a/ts/packages/shell/src/preload/shellSettingsType.ts +++ b/ts/packages/shell/src/preload/shellSettingsType.ts @@ -47,7 +47,7 @@ export const defaultUserSettings: ShellUserSettings = { provider: undefined, voice: undefined, }, - agentGreeting: false, + agentGreeting: true, multiModalContent: true, ui: { verticalLayout: true, diff --git a/ts/packages/shell/src/renderer/src/chat/chatView.ts b/ts/packages/shell/src/renderer/src/chat/chatView.ts index 49b7c7592b..10fdf15f09 100644 --- a/ts/packages/shell/src/renderer/src/chat/chatView.ts +++ b/ts/packages/shell/src/renderer/src/chat/chatView.ts @@ -904,7 +904,7 @@ export class ChatView { ) { const messages: NodeListOf = this.messageDiv.querySelectorAll( - ":not(.history) > .chat-message-container-user:not(.chat-message-hidden) .chat-message-content", + ".chat-message-container-user:not(.history):not(.chat-message-hidden) .chat-message-content", ); this.commandBackStack = Array.from(messages).map( (m: Element) => diff --git a/ts/packages/shell/src/renderer/src/main.ts b/ts/packages/shell/src/renderer/src/main.ts index 14cd4f4bb5..ee2e2fdf12 100644 --- a/ts/packages/shell/src/renderer/src/main.ts +++ b/ts/packages/shell/src/renderer/src/main.ts @@ -413,9 +413,30 @@ function registerClient( } maxSeqSeen = Math.max(maxSeqSeen, entry.seq); } + + // Mark every message currently in the scroll container as historical. + // This covers both the HTML chat history loaded at startup (already + // marked by initializeChatHistory) and any display-log entries that + // were just replayed above (from previous sessions). Marking them + // here prevents them from appearing in the command back-stack. + for (const child of Array.from( + chatView.getScrollContainer().children, + )) { + (child as HTMLElement).classList.add("history"); + } + replayPending = false; for (const fn of replayQueue) fn(); replayQueue.length = 0; + + // Signal that the dispatcher is fully initialised, all historical + // messages have been replayed and marked, and the replay queue has + // been drained. Tests wait for this attribute before sending the + // first request to avoid racing with the replay mechanism (which + // would queue IPC callbacks and delay metrics updates). + chatView + .getScrollContainer() + .setAttribute("data-dispatcher-ready", "true"); }, updateRegisterAgents(updatedAgents: [string, string][]): void { agents.clear(); diff --git a/ts/packages/shell/test/testHelper.ts b/ts/packages/shell/test/testHelper.ts index 3dac9985b9..6df03421b3 100644 --- a/ts/packages/shell/test/testHelper.ts +++ b/ts/packages/shell/test/testHelper.ts @@ -114,9 +114,23 @@ async function startShell(testGreetings: boolean = false): Promise { await expect(inputLocator).toHaveAttribute( "contenteditable", "true", - { timeout: 30000 }, + { timeout: 120000 }, ); + // Wait for the display-log replay to complete and all historical + // messages to be marked. dispatcherInitialized() in the renderer + // sets data-dispatcher-ready on the scroll container only after it + // has finished replaying the display log and marking those messages + // as .history, so waiting for this attribute guarantees a stable DOM + // before tests start sending requests. + const scrollContainer = mainWindow.locator( + ".chat[data-dispatcher-ready='true']", + ); + await scrollContainer.waitFor({ + timeout: 120000, + state: "attached", + }); + return mainWindow; } catch (e) { console.warn( @@ -217,6 +231,11 @@ export async function sendUserRequest(prompt: string, page: Page) { await locator.waitFor({ timeout: 30000, state: "visible" }); await locator.focus({ timeout: 30000 }); await locator.fill(prompt, { timeout: 30000 }); + + // robgruen - dismiss completion suggestion since it doesn't auto-dismiss on input and would cause the Enter key press to not submit the request but instead accept the suggestion + // TODO: fix completion to not need this workaround + await locator.press("ArrowLeft", { timeout: 30000 }); + await locator.press("Enter", { timeout: 30000 }); } @@ -270,6 +289,7 @@ export async function sendUserRequestAndWaitForResponse( export async function sendUserRequestAndWaitForCompletion( prompt: string, page: Page, + timeout: number = 90000, ): Promise { const locators = await getAgentMessageLocators(page); @@ -277,7 +297,7 @@ export async function sendUserRequestAndWaitForCompletion( await sendUserRequest(prompt, page); // wait for agent response - return waitForAgentMessage(page, 30000, locators.length + 1, true); + return waitForAgentMessage(page, timeout, locators.length + 1, true); } /** diff --git a/ts/tools/scripts/policyChecks/invisibleUnicode.mjs b/ts/tools/scripts/policyChecks/invisibleUnicode.mjs new file mode 100644 index 0000000000..bd83562cde --- /dev/null +++ b/ts/tools/scripts/policyChecks/invisibleUnicode.mjs @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Invisible Unicode character scanner. + * + * Detects Unicode characters that are invisible to human reviewers but can + * be used to hide malicious code in source files. This covers two known + * attack classes: + * + * - Trojan Source (CVE-2021-42574): Bidirectional control characters that + * cause editors/diff tools to display code in a different order than it + * is actually parsed and executed by the compiler/interpreter. + * + * - GlassWorm / steganographic payloads: Zero-width and invisible Unicode + * characters used to encode hidden instructions inside ordinary-looking + * source files. The hidden payload is extracted at runtime by a small + * decoder that is itself concealed using the same technique. + * + * References: + * https://trojansource.codes/ (CVE-2021-42574) + * https://www.scientificamerican.com/article/glassworm-malware-hides-in-invisible-open-source-code/ + */ + +// Bidirectional control characters — can reorder what editors display vs +// what compilers see, allowing an attacker to make malicious code look like +// an innocent comment or string literal. +const BIDI_CHARS = [ + { code: 0x200e, name: "LEFT-TO-RIGHT MARK (LRM)" }, + { code: 0x200f, name: "RIGHT-TO-LEFT MARK (RLM)" }, + { code: 0x202a, name: "LEFT-TO-RIGHT EMBEDDING (LRE)" }, + { code: 0x202b, name: "RIGHT-TO-LEFT EMBEDDING (RLE)" }, + { code: 0x202c, name: "POP DIRECTIONAL FORMATTING (PDF)" }, + { code: 0x202d, name: "LEFT-TO-RIGHT OVERRIDE (LRO)" }, + { code: 0x202e, name: "RIGHT-TO-LEFT OVERRIDE (RLO)" }, + { code: 0x2066, name: "LEFT-TO-RIGHT ISOLATE (LRI)" }, + { code: 0x2067, name: "RIGHT-TO-LEFT ISOLATE (RLI)" }, + { code: 0x2068, name: "FIRST STRONG ISOLATE (FSI)" }, + { code: 0x2069, name: "POP DIRECTIONAL ISOLATE (PDI)" }, +]; + +// Zero-width and invisible characters — can encode steganographic payloads +// that are completely invisible in editors, code review tools, and diffs. +const ZERO_WIDTH_CHARS = [ + { code: 0x200b, name: "ZERO WIDTH SPACE (ZWSP)" }, + { code: 0x200c, name: "ZERO WIDTH NON-JOINER (ZWNJ)" }, + { code: 0x200d, name: "ZERO WIDTH JOINER (ZWJ)" }, + { code: 0x2060, name: "WORD JOINER" }, + { code: 0x2061, name: "FUNCTION APPLICATION" }, + { code: 0x2062, name: "INVISIBLE TIMES" }, + { code: 0x2063, name: "INVISIBLE SEPARATOR" }, + { code: 0x2064, name: "INVISIBLE PLUS" }, + // U+FEFF is the UTF-8 BOM when it appears at byte offset 0 of a file, but + // is otherwise a zero-width no-break space that has no visible form. + { code: 0xfeff, name: "ZERO WIDTH NO-BREAK SPACE / BOM (ZWNBSP)" }, +]; + +const ALL_SUSPICIOUS = [...BIDI_CHARS, ...ZERO_WIDTH_CHARS]; + +// Build a single regex that matches any of the suspicious characters. +const SUSPICIOUS_PATTERN = ALL_SUSPICIOUS.map((c) => + String.fromCodePoint(c.code), +).join(""); +const SUSPICIOUS_REGEX = new RegExp(`[${SUSPICIOUS_PATTERN}]`, "g"); + +// Map from character → descriptor for fast lookup when reporting. +const CHAR_INFO = new Map( + ALL_SUSPICIOUS.map((c) => [String.fromCodePoint(c.code), c]), +); + +/** + * Scan a Repofile for invisible Unicode characters. + * + * Returns true if clean, or an array of human-readable error strings if any + * suspicious characters are found. + */ +function checkInvisibleUnicode(file) { + const content = file.content; + const lines = content.split("\n"); + const errors = []; + + for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { + const line = lines[lineIdx]; + // Reset lastIndex for each line by creating a fresh exec loop. + const re = new RegExp(SUSPICIOUS_REGEX.source, "g"); + let match; + while ((match = re.exec(line)) !== null) { + const info = CHAR_INFO.get(match[0]); + // U+FEFF is the UTF-8 BOM marker: tolerate it only at the very + // first character of the file (line 0, column 0). + if (info.code === 0xfeff && lineIdx === 0 && match.index === 0) { + continue; + } + // U+200D ZERO WIDTH JOINER is legitimately used inside emoji + // sequences (e.g., 👨‍💻 = 👨 + ZWJ + 💻). Non-BMP emoji are + // encoded as surrogate pairs in JavaScript strings, so if the + // character immediately before the ZWJ is a low surrogate + // (U+DC00–U+DFFF) we can be confident this is an emoji sequence + // rather than an attempt to hide content. + if (info.code === 0x200d && match.index > 0) { + const prevCode = line.charCodeAt(match.index - 1); + if (prevCode >= 0xdc00 && prevCode <= 0xdfff) { + continue; + } + } + const hex = info.code.toString(16).toUpperCase().padStart(4, "0"); + errors.push( + `Line ${lineIdx + 1}, col ${match.index + 1}: ` + + `Invisible Unicode U+${hex} ${info.name}`, + ); + } + } + + return errors.length === 0 ? true : errors; +} + +// Apply the check to all common source-code file types. +const SOURCE_FILE_PATTERN = + /.*\.[cm]?[jt]sx?$|.*\.py$|.*\.cs$|.*\.ya?ml$|.*\.json$|.*\.html?$|.*\.(sh|cmd|bat|ps1)$/i; + +export const rules = [ + { + name: "invisible-unicode", + match: SOURCE_FILE_PATTERN, + check: (file) => checkInvisibleUnicode(file), + applyToDependencies: true, + }, +]; diff --git a/ts/tools/scripts/repo-policy-check.mjs b/ts/tools/scripts/repo-policy-check.mjs index 1e4574395d..ef645cbe14 100644 --- a/ts/tools/scripts/repo-policy-check.mjs +++ b/ts/tools/scripts/repo-policy-check.mjs @@ -6,13 +6,18 @@ import fs from "node:fs"; import path from "node:path"; import { rules as copyrightHeadersRules } from "./policyChecks/copyrightHeaders.mjs"; import { rules as npmPackageRules } from "./policyChecks/npmPackage.mjs"; +import { rules as invisibleUnicodeRules } from "./policyChecks/invisibleUnicode.mjs"; import chalk from "chalk"; /******************************************************** * Rules ********************************************************/ -const rules = [...copyrightHeadersRules, ...npmPackageRules]; +const rules = [ + ...copyrightHeadersRules, + ...npmPackageRules, + ...invisibleUnicodeRules, +]; /******************************************************** * Main @@ -25,6 +30,73 @@ function getCheckFiles() { return files.trim().split("\n"); } +// Walk a directory tree without following symlinks, collecting files whose +// names match `filter`. Errors on unreadable entries are silently skipped. +function walkDir(dir, filter, results) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (entry.isSymbolicLink()) { + continue; + } + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walkDir(full, filter, results); + } else if (entry.isFile() && filter.test(entry.name)) { + results.push(full); + } + } +} + +// Return absolute paths to source files inside every node_modules/.pnpm +// directory found within the repo tree. Scanning only .pnpm avoids reading +// the same file twice through pnpm's per-package symlinks. +function getDependencyFiles(repo, filter) { + const pnpmDirs = []; + + // Recursively search for node_modules directories. When found, check for + // a .pnpm subdirectory and record it — but do not recurse further into + // node_modules itself (avoids exploding depth through nested installs). + function findPnpmDirs(dir, depth) { + if (depth > 8) { + return; + } + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (!entry.isDirectory() || entry.isSymbolicLink()) { + continue; + } + const full = path.join(dir, entry.name); + if (entry.name === "node_modules") { + const pnpmDir = path.join(full, ".pnpm"); + if (fs.existsSync(pnpmDir)) { + pnpmDirs.push(pnpmDir); + } + // Do not recurse inside node_modules + } else { + findPnpmDirs(full, depth + 1); + } + } + } + + findPnpmDirs(repo, 0); + + const results = []; + for (const pnpmDir of pnpmDirs) { + walkDir(pnpmDir, filter, results); + } + return results; +} + class Repofile { constructor(repo, name) { this.name = name; @@ -106,6 +178,13 @@ function main() { let fixed = 0; let failed = 0; let failedFiles = 0; + let excludedFiles = 0; + let skippedFiles = 0; + + // Per-rule counters: { checked: number, failed: number } + const ruleStats = new Map( + rules.map((r) => [r.name, { checked: 0, failed: 0 }]), + ); const files = getCheckFiles(); @@ -115,8 +194,10 @@ function main() { const config = loadConfig(repo); const fix = process.argv.includes("--fix"); const verbose = process.argv.includes("--verbose"); + const checkDependencies = process.argv.includes("--check-dependencies"); for (const file of files) { if (!checkFile(file, config)) { + excludedFiles++; continue; } const rulesChecked = []; @@ -129,6 +210,7 @@ function main() { continue; } check++; + ruleStats.get(rule.name).checked++; const ruleColor = colors[ruleIndex++ % colors.length]; const coloredRule = ruleColor(` ${rule.name}: `); rulesChecked.push(coloredRule); @@ -154,10 +236,14 @@ function main() { } failed++; failedFile = true; + ruleStats.get(rule.name).failed++; } if (failedFile) { failedFiles++; } + if (rulesChecked.length === 0) { + skippedFiles++; + } if (verbose) { if (rulesChecked.length === 0) { if (coloredFile !== undefined) { @@ -188,10 +274,101 @@ function main() { console.log(); } } - if (failed > 0) { + + // Dependency scan (--check-dependencies) + let depCheck = 0; + let depFailed = 0; + let depFailedFiles = 0; + const depRules = rules.filter((r) => r.applyToDependencies); + const depRuleStats = new Map( + depRules.map((r) => [r.name, { checked: 0, failed: 0 }]), + ); + + if (checkDependencies && depRules.length > 0) { + // Build a combined extension filter from all dependency rules so the + // filesystem walk can skip non-matching files before reading them. + const depFilter = new RegExp( + depRules.map((r) => r.match.source).join("|"), + "i", + ); + + console.log(chalk.bold("\nScanning dependencies...")); + const depFiles = getDependencyFiles(repo, depFilter); + console.log(` Found ${depFiles.length} dependency source files.\n`); + + let ruleIndex = 0; + for (const absPath of depFiles) { + const relPath = path.relative(repo, absPath).replace(/\\/g, "/"); + const repoFile = new Repofile(repo, relPath); + let failedFile = false; + let coloredFile = chalk.red(`${relPath}:`); + + for (const rule of depRules) { + if (!rule.match.test(relPath)) { + continue; + } + depCheck++; + depRuleStats.get(rule.name).checked++; + const ruleColor = colors[ruleIndex++ % colors.length]; + const coloredRule = ruleColor(` ${rule.name}: `); + const result = rule.check(repoFile); + if (result === true) { + continue; + } + if (coloredFile !== undefined) { + console.log(coloredFile); + coloredFile = undefined; + } + if (Array.isArray(result)) { + for (const message of result) { + console.error(`${coloredRule}${message}`); + } + } else { + console.error(`${coloredRule}${result}`); + } + depFailed++; + failedFile = true; + depRuleStats.get(rule.name).failed++; + } + if (failedFile) { + depFailedFiles++; + console.log(); + } + } + } + + // Rule summary + console.log(chalk.bold("\nRule summary:")); + for (const [name, stats] of ruleStats) { + const failNote = + stats.failed > 0 ? chalk.red(` (${stats.failed} failed)`) : ""; + console.log(` ${name}: ${stats.checked} checked${failNote}`); + } + if (checkDependencies) { + console.log(chalk.bold("\nDependency rule summary:")); + for (const [name, stats] of depRuleStats) { + const failNote = + stats.failed > 0 ? chalk.red(` (${stats.failed} failed)`) : ""; + console.log(` ${name}: ${stats.checked} checked${failNote}`); + } + } + + // File summary + console.log(chalk.bold("\nFile summary:")); + console.log(` Total: ${files.length}`); + console.log(` Excluded: ${excludedFiles}`); + console.log(` Skipped: ${skippedFiles} (no rules matched)`); + console.log(` Checked: ${files.length - excludedFiles - skippedFiles}`); + + const totalFailed = failed + depFailed; + if (totalFailed > 0) { + const depNote = + depFailed > 0 + ? ` Dependency failures: ${depFailed} checks, ${depFailedFiles} files.` + : ""; console.error( chalk.red( - `Failed ${failed}/${check} checks. ${failedFiles}/${files.length} files. ${fixed !== 0 ? `Fixed ${fixed}` : ""}`, + `\nFailed ${failed}/${check} checks. ${failedFiles}/${files.length} files.${depNote} ${fixed !== 0 ? `Fixed ${fixed}` : ""}`, ), ); process.exit(1); @@ -199,7 +376,7 @@ function main() { console.log( chalk.green( - `Passed ${check} checks. ${files.length} files. ${fixed !== 0 ? `Fixed ${fixed}` : ""}`, + `\nPassed ${check} checks. ${fixed !== 0 ? `Fixed ${fixed}` : ""}`, ), ); }