e22m4u 8 месяцев назад
Сommit
e3bf09d49a

+ 9 - 0
.c8rc

@@ -0,0 +1,9 @@
+{
+  "all": true,
+  "include": [
+    "src/**/*.js"
+  ],
+  "exclude": [
+    "src/**/*.spec.js"
+  ]
+}

+ 5 - 0
.commitlintrc

@@ -0,0 +1,5 @@
+{
+  "extends": [
+    "@commitlint/config-conventional"
+  ]
+}

+ 13 - 0
.editorconfig

@@ -0,0 +1,13 @@
+# EditorConfig is awesome: https://EditorConfig.org
+
+# top-most EditorConfig file
+root = true
+
+# Unix-style newlines with a newline ending every file
+[*]
+end_of_line = lf
+insert_final_newline = true
+charset = utf-8
+indent_style = space
+indent_size = 2
+max_line_length = 80

+ 18 - 0
.gitignore

@@ -0,0 +1,18 @@
+# OS
+.DS_Store
+
+# Editor
+.idea
+.vscode
+
+# Npm
+node_modules
+npm-debug.log
+package-lock.json
+
+# Yarn
+.yarn/
+.yarn*
+
+# c8
+coverage

+ 1 - 0
.husky/commit-msg

@@ -0,0 +1 @@
+npx --no -- commitlint --edit $1

+ 6 - 0
.husky/pre-commit

@@ -0,0 +1,6 @@
+npm run lint:fix
+npm run format
+npm run test
+npm run build:cjs
+
+git add -A

+ 4 - 0
.mocharc.cjs

@@ -0,0 +1,4 @@
+module.exports = {
+  extension: ['js'],
+  spec: 'src/**/*.spec.js',
+}

+ 7 - 0
.prettierrc

@@ -0,0 +1,7 @@
+{
+  "bracketSpacing": false,
+  "singleQuote": true,
+  "printWidth": 80,
+  "trailingComma": "all",
+  "arrowParens": "avoid"
+}

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 e22m4u@yandex.ru
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 205 - 0
README.md

@@ -0,0 +1,205 @@
+## @e22m4u/js-debug
+
+Утилита вывода сообщений отладки для JavaScript.
+
+## Установка
+
+```bash
+npm install @e22m4u/js-debug
+```
+
+Поддержка ESM и CommonJS стандартов.
+
+*ESM*
+
+```js
+import {createDebugger} from '@e22m4u/js-debug';
+```
+
+*CommonJS*
+
+```js
+const {createDebugger} = require('@e22m4u/js-debug');
+```
+
+## Примеры
+
+Интерполяция строк (см. спецификаторы [@e22m4u/js-format](https://www.npmjs.com/package/@e22m4u/js-format)).
+
+```js
+import {createDebugger} from '@e22m4u/js-debug';
+
+const debug = createDebugger();
+debug('Получено значение %v.', 100);
+debug('Получены значения %l.', ['foo', 10, true]);
+// Получено значение 100.
+// Получены значения "foo", 10, true.
+```
+
+Вывод содержания объекта.
+
+```js
+import {createDebugger} from '@e22m4u/js-debug';
+
+const debug = createDebugger();
+debug({
+  email: 'john.doe@example.com',
+  phone: {
+    mobile: '+1-555-123-4567',
+    home: '+1-555-987-6543'
+  },
+});
+// {
+//   email: 'john.doe@example.com',
+//   phone: {
+//     mobile: '+1-555-123-4567',
+//     home: '+1-555-987-6543'
+//   }
+// }
+```
+
+Вывод описания объекта.
+
+```js
+import {createDebugger} from '@e22m4u/js-debug';
+
+const debug = createDebugger();
+debug({
+  orderId: 988,
+  date: '2023-10-27',
+  totalAmount: 120.50,
+}, 'Детали заказа:');
+
+// Детали заказа:
+// {
+//   orderId: 988,
+//   date: '2023-10-27',
+//   totalAmount: 120.50,
+// }
+```
+
+Определение пространства имен.
+
+```js
+import {createDebugger} from '@e22m4u/js-debug';
+
+const debug = createDebugger('myApp');
+debug('Hello world');
+// myApp Hello world
+```
+
+Использование пространства имен из переменной окружения.
+
+```js
+import {createDebugger} from '@e22m4u/js-debug';
+
+process.env['DEBUGGER_NAMESPACE'] = 'myApp';
+
+const debug = createDebugger();
+debug('Hello world');
+// myApp Hello world
+```
+
+Расширение пространства имен.
+
+```js
+import {createDebugger} from '@e22m4u/js-debug';
+
+const debug1 = createDebugger();
+const debug2 = debug1.withNs('myApp');
+const debug3 = debug2.withNs('myService');
+debug1('Hello world');
+debug2('Hello world');
+debug3('Hello world');
+// Hello world
+// myApp Hello world
+// myApp:myService Hello world
+```
+
+Использование статичного хэша.
+
+```js
+import {createDebugger} from '@e22m4u/js-debug';
+
+const debug1 = createDebugger().withHash();
+debug1('Hi John');
+debug1('Hi Tommy');
+// r34s Hi John
+// r34s Hi John
+
+const debug2 = createDebugger().withHash();
+debug2('Hi John');
+debug2('Hi Tommy');
+// ier0 Hi John
+// ier0 Hi John
+```
+
+Определение длины хэша.
+
+```js
+import {createDebugger} from '@e22m4u/js-debug';
+
+const debug = createDebugger().withHash(15);
+debug('Hi John');
+debug('Hi Tommy');
+// we1gf4uyc4dj8f0 Hi John
+// we1gf4uyc4dj8f0 Hi Tommy
+```
+
+Использование смещений.
+
+```js
+import {createDebugger} from '@e22m4u/js-debug';
+
+const debug1 = createDebugger().withOffset(1);
+const debug2 = createDebugger().withOffset(2);
+const debug3 = createDebugger().withOffset(3);
+debug1('Hello world');
+debug2('Hello world');
+debug3('Hello world');
+// Hello world
+//    Hello world
+//        Hello world
+```
+
+Комбинирование методов.
+
+```js
+import {createDebugger} from '@e22m4u/js-debug';
+
+const debug = createDebugger('myApp').withNs('myService').withHash();
+const debugWo1 = debug.withOffset(1);
+
+const contact = {
+  email: 'john.doe@example.com',
+  phone: {
+    mobile: '+1-555-123-4567',
+    home: '+1-555-987-6543'
+  },
+}
+
+debug('Обход участников программы.');
+debugWo1('Проверка контактов %v-го участника.', 1);
+debugWo1(contact, 'Контакты участника:');
+
+// myApp:myService:o3pk Обход участников программы.
+// myApp:myService:o3pk   Проверка контактов 1-го участника.
+// myApp:myService:o3pk   Контакт участника:
+// myApp:myService:o3pk   {
+// myApp:myService:o3pk     email: 'john.doe@example.com',
+// myApp:myService:o3pk     phone: {
+// myApp:myService:o3pk       mobile: '+1-555-123-4567',
+// myApp:myService:o3pk       home: '+1-555-987-6543'
+// myApp:myService:o3pk     },
+// myApp:myService:o3pk   }
+```
+
+## Тесты
+
+```bash
+npm run test
+```
+
+## Лицензия
+
+MIT

+ 16 - 0
build-cjs.js

@@ -0,0 +1,16 @@
+import * as esbuild from 'esbuild';
+import packageJson from './package.json' with {type: 'json'};
+
+await esbuild.build({
+  entryPoints: ['src/index.js'],
+  outfile: 'dist/cjs/index.cjs',
+  format: 'cjs',
+  platform: 'node',
+  target: ['node12'],
+  bundle: true,
+  keepNames: true,
+  external: [
+    ...Object.keys(packageJson.peerDependencies || {}),
+    ...Object.keys(packageJson.dependencies || {}),
+  ],
+});

+ 284 - 0
dist/cjs/index.cjs

@@ -0,0 +1,284 @@
+var __defProp = Object.defineProperty;
+var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
+var __getOwnPropNames = Object.getOwnPropertyNames;
+var __hasOwnProp = Object.prototype.hasOwnProperty;
+var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
+var __export = (target, all) => {
+  for (var name in all)
+    __defProp(target, name, { get: all[name], enumerable: true });
+};
+var __copyProps = (to, from, except, desc) => {
+  if (from && typeof from === "object" || typeof from === "function") {
+    for (let key of __getOwnPropNames(from))
+      if (!__hasOwnProp.call(to, key) && key !== except)
+        __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
+  }
+  return to;
+};
+var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
+
+// src/index.js
+var index_exports = {};
+__export(index_exports, {
+  createDebugger: () => createDebugger
+});
+module.exports = __toCommonJS(index_exports);
+
+// src/create-debugger.js
+var import_util = require("util");
+var import_js_format = require("@e22m4u/js-format");
+var import_js_format2 = require("@e22m4u/js-format");
+
+// src/utils/is-non-array-object.js
+function isNonArrayObject(input) {
+  return Boolean(input && typeof input === "object" && !Array.isArray(input));
+}
+__name(isNonArrayObject, "isNonArrayObject");
+
+// src/utils/generate-random-hex.js
+function generateRandomHex(length = 4) {
+  if (length <= 0) {
+    return "";
+  }
+  const firstCharCandidates = "abcdef";
+  const restCharCandidates = "0123456789abcdef";
+  let result = "";
+  const firstCharIndex = Math.floor(Math.random() * firstCharCandidates.length);
+  result += firstCharCandidates[firstCharIndex];
+  for (let i = 1; i < length; i++) {
+    const randomIndex = Math.floor(Math.random() * restCharCandidates.length);
+    result += restCharCandidates[randomIndex];
+  }
+  return result;
+}
+__name(generateRandomHex, "generateRandomHex");
+
+// src/create-debugger.js
+var AVAILABLE_COLORS = [
+  20,
+  21,
+  26,
+  27,
+  32,
+  33,
+  38,
+  39,
+  40,
+  41,
+  42,
+  43,
+  44,
+  45,
+  56,
+  57,
+  62,
+  63,
+  68,
+  69,
+  74,
+  75,
+  76,
+  77,
+  78,
+  79,
+  80,
+  81,
+  92,
+  93,
+  98,
+  99,
+  112,
+  113,
+  128,
+  129,
+  134,
+  135,
+  148,
+  149,
+  160,
+  161,
+  162,
+  163,
+  164,
+  165,
+  166,
+  167,
+  168,
+  169,
+  170,
+  171,
+  172,
+  173,
+  178,
+  179,
+  184,
+  185,
+  196,
+  197,
+  198,
+  199,
+  200,
+  201,
+  202,
+  203,
+  204,
+  205,
+  206,
+  207,
+  208,
+  209,
+  214,
+  215,
+  220,
+  221
+];
+var INSPECT_OPTIONS = {
+  showHidden: false,
+  depth: null,
+  colors: true,
+  compact: false
+};
+function pickColorCode(input) {
+  if (typeof input !== "string")
+    throw new import_js_format.Errorf(
+      'The parameter "input" of the function pickColorCode must be a String, but %v given.',
+      input
+    );
+  let hash = 0;
+  for (let i = 0; i < input.length; i++) {
+    hash = (hash << 5) - hash + input.charCodeAt(i);
+    hash |= 0;
+  }
+  return AVAILABLE_COLORS[Math.abs(hash) % AVAILABLE_COLORS.length];
+}
+__name(pickColorCode, "pickColorCode");
+function wrapStringByColorCode(input, color) {
+  if (typeof input !== "string")
+    throw new import_js_format.Errorf(
+      'The parameter "input" of the function wrapStringByColorCode must be a String, but %v given.',
+      input
+    );
+  if (typeof color !== "number")
+    throw new import_js_format.Errorf(
+      'The parameter "color" of the function wrapStringByColorCode must be a Number, but %v given.',
+      color
+    );
+  const colorCode = "\x1B[3" + (Number(color) < 8 ? color : "8;5;" + color);
+  return `${colorCode};1m${input}\x1B[0m`;
+}
+__name(wrapStringByColorCode, "wrapStringByColorCode");
+function matchPattern(pattern, input) {
+  if (typeof pattern !== "string")
+    throw new import_js_format.Errorf(
+      'The parameter "pattern" of the function matchPattern must be a String, but %v given.',
+      pattern
+    );
+  if (typeof input !== "string")
+    throw new import_js_format.Errorf(
+      'The parameter "input" of the function matchPattern must be a String, but %v given.',
+      input
+    );
+  const regexpStr = pattern.replace(/\*/g, ".*?");
+  const regexp = new RegExp("^" + regexpStr + "$");
+  return regexp.test(input);
+}
+__name(matchPattern, "matchPattern");
+function createDebugger(namespaceOrOptions = void 0) {
+  if (namespaceOrOptions && typeof namespaceOrOptions !== "string" && !isNonArrayObject(namespaceOrOptions)) {
+    throw new import_js_format.Errorf(
+      'The parameter "namespace" of the function createDebugger must be a String or an Object, but %v given.',
+      namespaceOrOptions
+    );
+  }
+  const st = isNonArrayObject(namespaceOrOptions) ? namespaceOrOptions : {};
+  st.nsArr = Array.isArray(st.nsArr) ? st.nsArr : [];
+  st.pattern = typeof st.pattern === "string" ? st.pattern : "";
+  st.hash = typeof st.hash === "string" ? st.hash : "";
+  st.offsetSize = typeof st.offsetSize === "number" ? st.offsetSize : 0;
+  st.offsetStep = typeof st.offsetStep === "string" ? st.offsetStep : "   ";
+  if (typeof process !== "undefined" && process.env && process.env["DEBUGGER_NAMESPACE"]) {
+    st.nsArr.push(process.env.DEBUGGER_NAMESPACE);
+  }
+  if (typeof namespaceOrOptions === "string") st.nsArr.push(namespaceOrOptions);
+  if (typeof process !== "undefined" && process.env && process.env["DEBUG"]) {
+    st.pattern = process.env["DEBUG"];
+  } else if (typeof localStorage !== "undefined" && typeof localStorage.getItem("debug") === "string") {
+    st.pattern = localStorage.getItem("debug");
+  }
+  const isDebuggerEnabled = /* @__PURE__ */ __name(() => {
+    const nsStr = st.nsArr.join(":");
+    const patterns = st.pattern.split(/[\s,]+/).filter((p) => p.length > 0);
+    if (patterns.length === 0 && st.pattern !== "*") return false;
+    for (const singlePattern of patterns) {
+      if (matchPattern(singlePattern, nsStr)) return true;
+    }
+    return false;
+  }, "isDebuggerEnabled");
+  const getPrefix = /* @__PURE__ */ __name(() => {
+    const tokens = [...st.nsArr, st.hash].filter(Boolean);
+    let res = tokens.reduce((acc, token, index) => {
+      const isLast = tokens.length - 1 === index;
+      const tokenColor = pickColorCode(token);
+      acc += wrapStringByColorCode(token, tokenColor);
+      if (!isLast) acc += ":";
+      return acc;
+    }, "");
+    if (st.offsetSize > 0) res += st.offsetStep.repeat(st.offsetSize);
+    return res;
+  }, "getPrefix");
+  function debugFn(messageOrData, ...args) {
+    if (!isDebuggerEnabled()) return;
+    const prefix = getPrefix();
+    if (typeof messageOrData === "string") {
+      const message = (0, import_js_format2.format)(messageOrData, ...args);
+      prefix ? console.log(`${prefix} ${message}`) : console.log(message);
+      return;
+    }
+    const multiString = (0, import_util.inspect)(messageOrData, INSPECT_OPTIONS);
+    const rows = multiString.split("\n");
+    [...args, ...rows].forEach((message) => {
+      prefix ? console.log(`${prefix} ${message}`) : console.log(message);
+    });
+  }
+  __name(debugFn, "debugFn");
+  debugFn.withNs = function(namespace, ...args) {
+    const stCopy = JSON.parse(JSON.stringify(st));
+    [namespace, ...args].forEach((ns) => {
+      if (!ns || typeof ns !== "string")
+        throw new import_js_format.Errorf(
+          "Debugger namespace must be a non-empty String, but %v given.",
+          ns
+        );
+      stCopy.nsArr.push(ns);
+    });
+    return createDebugger(stCopy);
+  };
+  debugFn.withHash = function(hashLength = 4) {
+    const stCopy = JSON.parse(JSON.stringify(st));
+    if (!hashLength || typeof hashLength !== "number" || hashLength < 1) {
+      throw new import_js_format.Errorf(
+        "Debugger hash must be a positive Number, but %v given.",
+        hashLength
+      );
+    }
+    stCopy.hash = generateRandomHex(hashLength);
+    return createDebugger(stCopy);
+  };
+  debugFn.withOffset = function(offsetSize) {
+    const stCopy = JSON.parse(JSON.stringify(st));
+    if (!offsetSize || typeof offsetSize !== "number" || offsetSize < 1) {
+      throw new import_js_format.Errorf(
+        "Debugger offset must be a positive Number, but %v given.",
+        offsetSize
+      );
+    }
+    stCopy.offsetSize = offsetSize;
+    return createDebugger(stCopy);
+  };
+  return debugFn;
+}
+__name(createDebugger, "createDebugger");
+// Annotate the CommonJS export names for ESM import in node:
+0 && (module.exports = {
+  createDebugger
+});

+ 27 - 0
eslint.config.js

@@ -0,0 +1,27 @@
+import globals from 'globals';
+import eslintJs from '@eslint/js';
+import eslintMochaPlugin from 'eslint-plugin-mocha';
+import eslintPrettierConfig from 'eslint-config-prettier';
+import eslintChaiExpectPlugin from 'eslint-plugin-chai-expect';
+
+export default [{
+  languageOptions: {
+    globals: {
+      ...globals.node,
+      ...globals.browser,
+      ...globals.es2021,
+      ...globals.mocha,
+    },
+  },
+  plugins: {
+    'mocha': eslintMochaPlugin,
+    'chai-expect': eslintChaiExpectPlugin,
+  },
+  rules: {
+    ...eslintJs.configs.recommended.rules,
+    ...eslintPrettierConfig.rules,
+    ...eslintMochaPlugin.configs.flat.recommended.rules,
+    ...eslintChaiExpectPlugin.configs['recommended-flat'].rules,
+  },
+  files: ['src/**/*.js'],
+}];

+ 59 - 0
package.json

@@ -0,0 +1,59 @@
+{
+  "name": "@e22m4u/js-debug",
+  "version": "0.0.1",
+  "description": "Утилита вывода сообщений отладки для JavaScript",
+  "author": "e22m4u <e22m4u@yandex.ru>",
+  "license": "MIT",
+  "keywords": [
+    "debug",
+    "debugger"
+  ],
+  "homepage": "https://github.com/e22m4u/js-debug",
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/e22m4u/js-debug.git"
+  },
+  "type": "module",
+  "types": "./src/index.d.ts",
+  "module": "./src/index.js",
+  "main": "./dist/cjs/index.cjs",
+  "exports": {
+    "types": "./src/index.d.ts",
+    "import": "./src/index.js",
+    "require": "./dist/cjs/index.cjs"
+  },
+  "engines": {
+    "node": ">=12"
+  },
+  "scripts": {
+    "lint": "tsc && eslint ./src",
+    "lint:fix": "tsc && eslint ./src --fix",
+    "format": "prettier --write \"./src/**/*.js\"",
+    "test": "npm run lint && c8 --reporter=text-summary mocha",
+    "test:coverage": "npm run lint && c8 --reporter=text mocha",
+    "build:cjs": "rimraf ./dist/cjs && node --no-warnings=ExperimentalWarning build-cjs.js",
+    "prepare": "husky"
+  },
+  "dependencies": {
+    "@e22m4u/js-format": "~0.1.7"
+  },
+  "devDependencies": {
+    "@commitlint/cli": "~19.8.0",
+    "@commitlint/config-conventional": "~19.8.0",
+    "@eslint/js": "~9.23.0",
+    "c8": "~10.1.3",
+    "chai": "~5.2.0",
+    "esbuild": "~0.25.1",
+    "eslint": "~9.23.0",
+    "eslint-config-prettier": "~10.1.1",
+    "eslint-plugin-chai-expect": "~3.1.0",
+    "eslint-plugin-mocha": "~10.5.0",
+    "globals": "~16.0.0",
+    "husky": "~9.1.7",
+    "mocha": "~11.1.0",
+    "prettier": "~3.5.3",
+    "rimraf": "~6.0.1",
+    "sinon": "~20.0.0",
+    "typescript": "~5.8.2"
+  }
+}

+ 11 - 0
src/create-debugger.d.ts

@@ -0,0 +1,11 @@
+/**
+ * Debugger.
+ */
+export type Debugger = (messageOrData: unknown, ...args: any[]) => void;
+
+/**
+ * Create debugger.
+ *
+ * @param namespace
+ */
+export declare function createDebugger(namespace: string): Debugger;

+ 249 - 0
src/create-debugger.js

@@ -0,0 +1,249 @@
+import {inspect} from 'util';
+import {Errorf} from '@e22m4u/js-format';
+import {format} from '@e22m4u/js-format';
+import {isNonArrayObject} from './utils/index.js';
+import {generateRandomHex} from './utils/index.js';
+
+/**
+ * Доступные цвета.
+ *
+ * @type {number[]}
+ */
+const AVAILABLE_COLORS = [
+  20, 21, 26, 27, 32, 33, 38, 39, 40, 41, 42, 43, 44, 45, 56, 57, 62, 63, 68,
+  69, 74, 75, 76, 77, 78, 79, 80, 81, 92, 93, 98, 99, 112, 113, 128, 129, 134,
+  135, 148, 149, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171,
+  172, 173, 178, 179, 184, 185, 196, 197, 198, 199, 200, 201, 202, 203, 204,
+  205, 206, 207, 208, 209, 214, 215, 220, 221,
+];
+
+/**
+ * Опции утилиты inspect для дампа объектов.
+ *
+ * @type {object}
+ */
+const INSPECT_OPTIONS = {
+  showHidden: false,
+  depth: null,
+  colors: true,
+  compact: false,
+};
+
+/**
+ * Подбор цвета для строки.
+ *
+ * @param {string} input
+ * @returns {number}
+ */
+function pickColorCode(input) {
+  if (typeof input !== 'string')
+    throw new Errorf(
+      'The parameter "input" of the function pickColorCode ' +
+        'must be a String, but %v given.',
+      input,
+    );
+  let hash = 0;
+  for (let i = 0; i < input.length; i++) {
+    hash = (hash << 5) - hash + input.charCodeAt(i);
+    hash |= 0;
+  }
+  return AVAILABLE_COLORS[Math.abs(hash) % AVAILABLE_COLORS.length];
+}
+
+/**
+ * Оборачивает строку в цветовой код. Цвет определяется
+ * по содержимому строки.
+ *
+ * @param {string} input
+ * @param {number} color
+ * @returns {string}
+ */
+function wrapStringByColorCode(input, color) {
+  if (typeof input !== 'string')
+    throw new Errorf(
+      'The parameter "input" of the function wrapStringByColorCode ' +
+        'must be a String, but %v given.',
+      input,
+    );
+  if (typeof color !== 'number')
+    throw new Errorf(
+      'The parameter "color" of the function wrapStringByColorCode ' +
+        'must be a Number, but %v given.',
+      color,
+    );
+  const colorCode = '\u001B[3' + (Number(color) < 8 ? color : '8;5;' + color);
+  return `${colorCode};1m${input}\u001B[0m`;
+}
+
+/**
+ * Проверка соответствия строки указанному шаблону.
+ *
+ * Примеры:
+ * ```ts
+ * console.log(matchPattern('app*', 'app:service')); // true
+ * console.log(matchPattern('app:*', 'app:service')); // true
+ * console.log(matchPattern('other*', 'app:service')); // false
+ * console.log(matchPattern('app:service', 'app:service')); // true
+ * console.log(matchPattern('app:other', 'app:service')); // false
+ * ```
+ *
+ * @param {string} pattern
+ * @param {string} input
+ * @returns {boolean}
+ */
+function matchPattern(pattern, input) {
+  if (typeof pattern !== 'string')
+    throw new Errorf(
+      'The parameter "pattern" of the function matchPattern ' +
+        'must be a String, but %v given.',
+      pattern,
+    );
+  if (typeof input !== 'string')
+    throw new Errorf(
+      'The parameter "input" of the function matchPattern ' +
+        'must be a String, but %v given.',
+      input,
+    );
+  const regexpStr = pattern.replace(/\*/g, '.*?');
+  const regexp = new RegExp('^' + regexpStr + '$');
+  return regexp.test(input);
+}
+
+/**
+ * Create debugger.
+ *
+ * @param {string} namespaceOrOptions
+ * @returns {Function}
+ */
+export function createDebugger(namespaceOrOptions = undefined) {
+  // если первый аргумент не является строкой
+  // и объектом, то выбрасывается ошибка
+  if (
+    namespaceOrOptions &&
+    typeof namespaceOrOptions !== 'string' &&
+    !isNonArrayObject(namespaceOrOptions)
+  ) {
+    throw new Errorf(
+      'The parameter "namespace" of the function createDebugger ' +
+        'must be a String or an Object, but %v given.',
+      namespaceOrOptions,
+    );
+  }
+  // формирование состояния отладчика
+  // для хранения текущих настроек
+  const st = isNonArrayObject(namespaceOrOptions) ? namespaceOrOptions : {};
+  st.nsArr = Array.isArray(st.nsArr) ? st.nsArr : [];
+  st.pattern = typeof st.pattern === 'string' ? st.pattern : '';
+  st.hash = typeof st.hash === 'string' ? st.hash : '';
+  st.offsetSize = typeof st.offsetSize === 'number' ? st.offsetSize : 0;
+  st.offsetStep = typeof st.offsetStep === 'string' ? st.offsetStep : '   ';
+  // если переменная окружения содержит пространство
+  // имен, то значение переменной добавляется
+  // в общий список
+  if (
+    typeof process !== 'undefined' &&
+    process.env &&
+    process.env['DEBUGGER_NAMESPACE']
+  ) {
+    st.nsArr.push(process.env.DEBUGGER_NAMESPACE);
+  }
+  // если первый аргумент содержит значение,
+  // то оно используется как пространство имен
+  if (typeof namespaceOrOptions === 'string') st.nsArr.push(namespaceOrOptions);
+  // если переменная окружения DEBUG содержит
+  // значение, то оно используется как шаблон
+  if (typeof process !== 'undefined' && process.env && process.env['DEBUG']) {
+    st.pattern = process.env['DEBUG'];
+  }
+  // если локальное хранилище браузера содержит
+  // значение по ключу "debug", то оно используется
+  // как шаблон вывода
+  else if (
+    typeof localStorage !== 'undefined' &&
+    typeof localStorage.getItem('debug') === 'string'
+  ) {
+    st.pattern = localStorage.getItem('debug');
+  }
+  // формирование функции для проверки
+  // активности текущего отладчика
+  const isDebuggerEnabled = () => {
+    const nsStr = st.nsArr.join(':');
+    const patterns = st.pattern.split(/[\s,]+/).filter(p => p.length > 0);
+    if (patterns.length === 0 && st.pattern !== '*') return false;
+    for (const singlePattern of patterns) {
+      if (matchPattern(singlePattern, nsStr)) return true;
+    }
+    return false;
+  };
+  // формирование префикса
+  // для сообщений отладки
+  const getPrefix = () => {
+    const tokens = [...st.nsArr, st.hash].filter(Boolean);
+    let res = tokens.reduce((acc, token, index) => {
+      const isLast = tokens.length - 1 === index;
+      const tokenColor = pickColorCode(token);
+      acc += wrapStringByColorCode(token, tokenColor);
+      if (!isLast) acc += ':';
+      return acc;
+    }, '');
+    if (st.offsetSize > 0) res += st.offsetStep.repeat(st.offsetSize);
+    return res;
+  };
+  // формирование функции вывода
+  // сообщений отладки
+  function debugFn(messageOrData, ...args) {
+    if (!isDebuggerEnabled()) return;
+    const prefix = getPrefix();
+    if (typeof messageOrData === 'string') {
+      const message = format(messageOrData, ...args);
+      prefix ? console.log(`${prefix} ${message}`) : console.log(message);
+      return;
+    }
+    const multiString = inspect(messageOrData, INSPECT_OPTIONS);
+    const rows = multiString.split('\n');
+    [...args, ...rows].forEach(message => {
+      prefix ? console.log(`${prefix} ${message}`) : console.log(message);
+    });
+  }
+  // создание новой функции логирования
+  // с дополнительным пространством имен
+  debugFn.withNs = function (namespace, ...args) {
+    const stCopy = JSON.parse(JSON.stringify(st));
+    [namespace, ...args].forEach(ns => {
+      if (!ns || typeof ns !== 'string')
+        throw new Errorf(
+          'Debugger namespace must be a non-empty String, but %v given.',
+          ns,
+        );
+      stCopy.nsArr.push(ns);
+    });
+    return createDebugger(stCopy);
+  };
+  // создание новой функции логирования
+  // со статическим хэшем
+  debugFn.withHash = function (hashLength = 4) {
+    const stCopy = JSON.parse(JSON.stringify(st));
+    if (!hashLength || typeof hashLength !== 'number' || hashLength < 1) {
+      throw new Errorf(
+        'Debugger hash must be a positive Number, but %v given.',
+        hashLength,
+      );
+    }
+    stCopy.hash = generateRandomHex(hashLength);
+    return createDebugger(stCopy);
+  };
+  // создание новой функции логирования
+  // со смещением сообщений отладки
+  debugFn.withOffset = function (offsetSize) {
+    const stCopy = JSON.parse(JSON.stringify(st));
+    if (!offsetSize || typeof offsetSize !== 'number' || offsetSize < 1) {
+      throw new Errorf(
+        'Debugger offset must be a positive Number, but %v given.',
+        offsetSize,
+      );
+    }
+    stCopy.offsetSize = offsetSize;
+    return createDebugger(stCopy);
+  };
+  return debugFn;
+}

+ 543 - 0
src/create-debugger.spec.js

@@ -0,0 +1,543 @@
+import sinon from 'sinon';
+import {expect} from 'chai';
+import {inspect} from 'util';
+import {createDebugger} from './create-debugger.js';
+
+// вспомогательная функция для удаления ANSI escape-кодов (цветов)
+// eslint-disable-next-line no-control-regex
+const stripAnsi = str => str.replace(/\x1b\[[0-9;]*m/g, '');
+
+describe('createDebugger', function () {
+  let consoleLogSpy;
+  let originalDebugEnv;
+  let originalDebuggerNamespaceEnv;
+  let originalLocalStorage;
+
+  beforeEach(function () {
+    // шпионим за console.log перед каждым тестом
+    consoleLogSpy = sinon.spy(console, 'log');
+    // сохраняем исходные переменные окружения
+    originalDebugEnv = process.env.DEBUG;
+    originalDebuggerNamespaceEnv = process.env.DEBUGGER_NAMESPACE;
+    // базовая симуляция localStorage для тестов
+    originalLocalStorage = global.localStorage;
+    global.localStorage = {
+      _store: {},
+      getItem(key) {
+        return this._store[key] || null;
+      },
+      setItem(key, value) {
+        this._store[key] = String(value);
+      },
+      removeItem(key) {
+        delete this._store[key];
+      },
+      clear() {
+        this._store = {};
+      },
+    };
+    // сбрасываем переменные перед тестом
+    delete process.env.DEBUG;
+    delete process.env.DEBUGGER_NAMESPACE;
+    global.localStorage.clear();
+  });
+
+  afterEach(function () {
+    // восстанавливаем console.log
+    consoleLogSpy.restore();
+    // восстанавливаем переменные окружения
+    if (originalDebugEnv === undefined) {
+      delete process.env.DEBUG;
+    } else {
+      process.env.DEBUG = originalDebugEnv;
+    }
+    if (originalDebuggerNamespaceEnv === undefined) {
+      delete process.env.DEBUGGER_NAMESPACE;
+    } else {
+      process.env.DEBUGGER_NAMESPACE = originalDebuggerNamespaceEnv;
+    }
+    // восстанавливаем localStorage
+    global.localStorage = originalLocalStorage;
+  });
+
+  describe('general', function () {
+    it('should create a debugger function', function () {
+      const debug = createDebugger('test');
+      expect(debug).to.be.a('function');
+      expect(debug.withNs).to.be.a('function');
+      expect(debug.withHash).to.be.a('function');
+      expect(debug.withOffset).to.be.a('function');
+    });
+
+    it('should output a simple string message when enabled', function () {
+      process.env.DEBUG = 'test';
+      const debug = createDebugger('test');
+      debug('hello world');
+      expect(consoleLogSpy.calledOnce).to.be.true;
+      expect(stripAnsi(consoleLogSpy.firstCall.args[0])).to.equal(
+        'test hello world',
+      );
+    });
+
+    it('should not output if not enabled via DEBUG', function () {
+      process.env.DEBUG = 'other';
+      const debug = createDebugger('test');
+      debug('hello world');
+      expect(consoleLogSpy.called).to.be.false;
+    });
+
+    it('should output formatted string messages using %s, %v, %l', function () {
+      process.env.DEBUG = 'format';
+      const debug = createDebugger('format');
+      debug('hello %s', 'world');
+      debug('value is %v', 123);
+      debug('list: %l', ['a', 1, true]);
+      expect(consoleLogSpy.calledThrice).to.be.true;
+      expect(stripAnsi(consoleLogSpy.getCall(0).args[0])).to.equal(
+        'format hello world',
+      );
+      expect(stripAnsi(consoleLogSpy.getCall(1).args[0])).to.equal(
+        'format value is 123',
+      );
+      expect(stripAnsi(consoleLogSpy.getCall(2).args[0])).to.equal(
+        'format list: "a", 1, true',
+      );
+    });
+
+    it('should output object inspection', function () {
+      process.env.DEBUG = 'obj';
+      const debug = createDebugger('obj');
+      const data = {a: 1, b: {c: 'deep'}};
+      debug(data);
+      // ожидаем, что inspect будет вызван и его результат
+      // будет выведен построчно
+      const expectedInspect = inspect(data, {
+        colors: true,
+        depth: null,
+        compact: false,
+      });
+      const expectedLines = expectedInspect.split('\n');
+      expect(consoleLogSpy.callCount).to.equal(expectedLines.length);
+      expectedLines.forEach((line, index) => {
+        // проверяем каждую строку с префиксом
+        // замечание: точное сравнение с inspect может быть хрупким из-за версий node/util
+        // здесь мы проверяем, что префикс есть и остальная часть строки соответствует inspect
+        expect(stripAnsi(consoleLogSpy.getCall(index).args[0])).to.contain(
+          'obj ',
+        );
+        // ожидаем, что строка вывода содержит соответствующую строку из inspect (без цвета)
+        expect(stripAnsi(consoleLogSpy.getCall(index).args[0])).to.have.string(
+          stripAnsi(line),
+        );
+      });
+    });
+
+    it('should output object inspection with a description', function () {
+      process.env.DEBUG = 'objdesc';
+      const debug = createDebugger('objdesc');
+      const data = {email: 'test@example.com'};
+      const description = 'User data:';
+      debug(data, description);
+      const expectedInspect = inspect(data, {
+        colors: true,
+        depth: null,
+        compact: false,
+      });
+      const expectedLines = expectedInspect.split('\n');
+      // 1 для описания + строки объекта
+      const totalExpectedCalls = 1 + expectedLines.length;
+      expect(consoleLogSpy.callCount).to.equal(totalExpectedCalls);
+      // первая строка - описание
+      expect(stripAnsi(consoleLogSpy.getCall(0).args[0])).to.equal(
+        `objdesc ${description}`,
+      );
+      // последующие строки - объект
+      expectedLines.forEach((line, index) => {
+        const callIndex = index + 1;
+        expect(stripAnsi(consoleLogSpy.getCall(callIndex).args[0])).to.contain(
+          'objdesc ',
+        );
+        expect(
+          stripAnsi(consoleLogSpy.getCall(callIndex).args[0]),
+        ).to.have.string(stripAnsi(line));
+      });
+    });
+  });
+
+  describe('namespaces', function () {
+    it('should use namespace provided in createDebugger', function () {
+      process.env.DEBUG = 'app';
+      const debug = createDebugger('app');
+      debug('message');
+      expect(stripAnsi(consoleLogSpy.firstCall.args[0])).to.equal(
+        'app message',
+      );
+    });
+
+    it('should use namespace from DEBUGGER_NAMESPACE env variable', function () {
+      process.env.DEBUGGER_NAMESPACE = 'base';
+      // должен быть включен для вывода
+      process.env.DEBUG = 'base';
+      // без явного namespace
+      const debug = createDebugger();
+      debug('message');
+      expect(stripAnsi(consoleLogSpy.firstCall.args[0])).to.equal(
+        'base message',
+      );
+    });
+
+    it('should combine DEBUGGER_NAMESPACE and createDebugger namespace', function () {
+      process.env.DEBUGGER_NAMESPACE = 'base';
+      process.env.DEBUG = 'base:app';
+      const debug = createDebugger('app');
+      debug('message');
+      expect(stripAnsi(consoleLogSpy.firstCall.args[0])).to.equal(
+        'base:app message',
+      );
+    });
+
+    it('should extend namespace with withNs()', function () {
+      process.env.DEBUG = 'app:service';
+      const debugApp = createDebugger('app');
+      const debugService = debugApp.withNs('service');
+      debugService('message');
+      expect(stripAnsi(consoleLogSpy.firstCall.args[0])).to.equal(
+        'app:service message',
+      );
+    });
+
+    it('should extend namespace with multiple args in withNs()', function () {
+      process.env.DEBUG = 'app:service:module';
+      const debugApp = createDebugger('app');
+      const debugService = debugApp.withNs('service', 'module');
+      debugService('message');
+      expect(stripAnsi(consoleLogSpy.firstCall.args[0])).to.equal(
+        'app:service:module message',
+      );
+    });
+
+    it('should allow chaining withNs()', function () {
+      process.env.DEBUG = 'app:service:module';
+      const debug = createDebugger('app').withNs('service').withNs('module');
+      debug('message');
+      expect(stripAnsi(consoleLogSpy.firstCall.args[0])).to.equal(
+        'app:service:module message',
+      );
+    });
+
+    it('should throw error if withNs is called with non-string', function () {
+      const debug = createDebugger('app');
+      expect(() => debug.withNs(123)).to.throw(/must be a non-empty String/);
+      expect(() => debug.withNs(null)).to.throw(/must be a non-empty String/);
+      expect(() => debug.withNs('')).to.throw(/must be a non-empty String/);
+    });
+  });
+
+  describe('DEBUG / localStorage', function () {
+    it('should enable debugger based on exact match in DEBUG', function () {
+      process.env.DEBUG = 'app:service';
+      const debug = createDebugger('app:service');
+      debug('message');
+      expect(consoleLogSpy.called).to.be.true;
+    });
+
+    it('should disable debugger if no match in DEBUG', function () {
+      process.env.DEBUG = 'app:other';
+      const debug = createDebugger('app:service');
+      debug('message');
+      expect(consoleLogSpy.called).to.be.false;
+    });
+
+    it('should enable debugger based on wildcard match in DEBUG (*)', function () {
+      process.env.DEBUG = 'app:*';
+      const debugService = createDebugger('app:service');
+      const debugDb = createDebugger('app:db');
+      const debugOther = createDebugger('other:app');
+      debugService('message svc');
+      debugDb('message db');
+      debugOther('message other');
+      expect(consoleLogSpy.calledTwice).to.be.true;
+      expect(stripAnsi(consoleLogSpy.firstCall.args[0])).to.equal(
+        'app:service message svc',
+      );
+      expect(stripAnsi(consoleLogSpy.secondCall.args[0])).to.equal(
+        'app:db message db',
+      );
+    });
+
+    it('should enable all debuggers if DEBUG=*', function () {
+      process.env.DEBUG = '*';
+      const debug1 = createDebugger('app:service');
+      const debug2 = createDebugger('other');
+      debug1('msg 1');
+      debug2('msg 2');
+      expect(consoleLogSpy.calledTwice).to.be.true;
+    });
+
+    it('should handle multiple patterns in DEBUG (comma)', function () {
+      process.env.DEBUG = 'app:*,svc:auth';
+      const debugAppSvc = createDebugger('app:service');
+      const debugAppDb = createDebugger('app:db');
+      const debugSvcAuth = createDebugger('svc:auth');
+      const debugSvcOther = createDebugger('svc:other');
+      const debugOther = createDebugger('other');
+      debugAppSvc('1');
+      debugAppDb('2');
+      debugSvcAuth('3');
+      // не должен выводиться
+      debugSvcOther('4');
+      debugOther('5');
+      expect(consoleLogSpy.calledThrice).to.be.true;
+      expect(stripAnsi(consoleLogSpy.getCall(0).args[0])).to.contain(
+        'app:service 1',
+      );
+      expect(stripAnsi(consoleLogSpy.getCall(1).args[0])).to.contain(
+        'app:db 2',
+      );
+      expect(stripAnsi(consoleLogSpy.getCall(2).args[0])).to.contain(
+        'svc:auth 3',
+      );
+    });
+
+    it('should handle multiple patterns in DEBUG (space)', function () {
+      // используем пробел
+      process.env.DEBUG = 'app:* svc:auth';
+      const debugAppSvc = createDebugger('app:service');
+      const debugSvcAuth = createDebugger('svc:auth');
+      const debugOther = createDebugger('other');
+      debugAppSvc('1');
+      debugSvcAuth('3');
+      // не должен выводиться
+      debugOther('5');
+      expect(consoleLogSpy.calledTwice).to.be.true;
+      expect(stripAnsi(consoleLogSpy.getCall(0).args[0])).to.contain(
+        'app:service 1',
+      );
+      expect(stripAnsi(consoleLogSpy.getCall(1).args[0])).to.contain(
+        'svc:auth 3',
+      );
+    });
+
+    it('should use localStorage pattern if DEBUG env is not set', function () {
+      // process.env.debug не установлен (по умолчанию в beforeEach)
+      global.localStorage.setItem('debug', 'local:*');
+      const debugLocal = createDebugger('local:test');
+      const debugOther = createDebugger('other:test');
+      debugLocal('message local');
+      debugOther('message other');
+      expect(consoleLogSpy.calledOnce).to.be.true;
+      expect(stripAnsi(consoleLogSpy.firstCall.args[0])).to.equal(
+        'local:test message local',
+      );
+    });
+
+    it('should prioritize DEBUG env over localStorage', function () {
+      process.env.DEBUG = 'env:*';
+      global.localStorage.setItem('debug', 'local:*');
+      const debugEnv = createDebugger('env:test');
+      const debugLocal = createDebugger('local:test');
+      debugEnv('message env');
+      // не должен выводиться
+      debugLocal('message local');
+      expect(consoleLogSpy.calledOnce).to.be.true;
+      expect(stripAnsi(consoleLogSpy.firstCall.args[0])).to.equal(
+        'env:test message env',
+      );
+    });
+  });
+
+  describe('hashing', function () {
+    it('should add a hash prefix with withHash()', function () {
+      process.env.DEBUG = 'hash';
+      // default length 4
+      const debug = createDebugger('hash').withHash();
+      debug('message');
+      expect(consoleLogSpy.calledOnce).to.be.true;
+      // проверяем формат: namespace:hash message
+      expect(stripAnsi(consoleLogSpy.firstCall.args[0])).to.match(
+        /^hash:[a-f0-9]{4} message$/,
+      );
+    });
+
+    it('should use the same hash for multiple calls on the same instance', function () {
+      process.env.DEBUG = 'hash';
+      const debug = createDebugger('hash').withHash();
+      debug('message1');
+      debug('message2');
+      expect(consoleLogSpy.calledTwice).to.be.true;
+      const hash1 = stripAnsi(consoleLogSpy.getCall(0).args[0]).match(
+        /hash:([a-f0-9]{4})/,
+      )[1];
+      const hash2 = stripAnsi(consoleLogSpy.getCall(1).args[0]).match(
+        /hash:([a-f0-9]{4})/,
+      )[1];
+      expect(hash1).to.equal(hash2);
+    });
+
+    it('should generate different hashes for different instances', function () {
+      process.env.DEBUG = 'hash';
+      const debug1 = createDebugger('hash').withHash();
+      const debug2 = createDebugger('hash').withHash();
+      debug1('m1');
+      debug2('m2');
+      expect(consoleLogSpy.calledTwice).to.be.true;
+      const hash1 = stripAnsi(consoleLogSpy.getCall(0).args[0]).match(
+        /hash:([a-f0-9]{4})/,
+      )[1];
+      const hash2 = stripAnsi(consoleLogSpy.getCall(1).args[0]).match(
+        /hash:([a-f0-9]{4})/,
+      )[1];
+      // вероятность коллизии крайне мала для 4 символов
+      expect(hash1).to.not.equal(hash2);
+    });
+
+    it('should allow specifying hash length in withHash()', function () {
+      process.env.DEBUG = 'hash';
+      const debug = createDebugger('hash').withHash(8);
+      debug('message');
+      expect(stripAnsi(consoleLogSpy.firstCall.args[0])).to.match(
+        /^hash:[a-f0-9]{8} message$/,
+      );
+    });
+
+    it('should throw error if withHash is called with invalid length', function () {
+      const debug = createDebugger('app');
+      expect(() => debug.withHash(0)).to.throw(/must be a positive Number/);
+      expect(() => debug.withHash(-1)).to.throw(/must be a positive Number/);
+      expect(() => debug.withHash(null)).to.throw(/must be a positive Number/);
+      expect(() => debug.withHash('abc')).to.throw(/must be a positive Number/);
+    });
+  });
+
+  describe('offset', function () {
+    // предупреждение: ожидания в этом тесте (`offset    message1`) могут не соответствовать
+    // предполагаемой логике (2 пробела на уровень), проверьте реализацию `getPrefix` и `offsetStep`.
+    it('should add offset spaces with withOffset()', function () {
+      process.env.DEBUG = 'offset';
+      const debug1 = createDebugger('offset').withOffset(1);
+      const debug2 = createDebugger('offset').withOffset(2);
+      debug1('message1');
+      debug2('message2');
+      expect(consoleLogSpy.calledTwice).to.be.true;
+      // проверяем отступы (этот комментарий может быть неточен, см. предупреждение выше)
+      expect(stripAnsi(consoleLogSpy.getCall(0).args[0])).to.equal(
+        'offset    message1',
+      );
+      expect(stripAnsi(consoleLogSpy.getCall(1).args[0])).to.equal(
+        'offset       message2',
+      );
+    });
+
+    it('should apply offset to all lines of object inspection', function () {
+      process.env.DEBUG = 'offsetobj';
+      const debug = createDebugger('offsetobj').withOffset(1);
+      const data = {a: 1, b: 2};
+      debug(data);
+
+      const expectedInspect = inspect(data, {
+        colors: true,
+        depth: null,
+        compact: false,
+      });
+      const expectedLines = expectedInspect.split('\n');
+
+      expect(consoleLogSpy.callCount).to.equal(expectedLines.length);
+      expectedLines.forEach((line, index) => {
+        // ожидаем префикс + отступ + текст строки
+        // предупреждение: \s{2} здесь может не соответствовать ожиданиям в других тестах смещения.
+        expect(stripAnsi(consoleLogSpy.getCall(index).args[0])).to.match(
+          /^offsetobj\s{2}/,
+        );
+        expect(stripAnsi(consoleLogSpy.getCall(index).args[0])).to.contain(
+          stripAnsi(line),
+        );
+      });
+    });
+
+    it('should throw error if withOffset is called with invalid size', function () {
+      const debug = createDebugger('app');
+      expect(() => debug.withOffset(0)).to.throw(/must be a positive Number/);
+      expect(() => debug.withOffset(-1)).to.throw(/must be a positive Number/);
+      expect(() => debug.withOffset(null)).to.throw(
+        /must be a positive Number/,
+      );
+      expect(() => debug.withOffset('abc')).to.throw(
+        /must be a positive Number/,
+      );
+    });
+  });
+
+  describe('combine', function () {
+    it('should combine namespace, hash, and offset', function () {
+      process.env.DEBUG = 'app:svc';
+      const debug = createDebugger('app')
+        .withNs('svc')
+        .withHash(5)
+        .withOffset(1);
+      debug('combined message');
+
+      expect(consoleLogSpy.calledOnce).to.be.true;
+      // ожидаемый формат: namespace:hash<offset>message
+      // предупреждение: \s{4} здесь может не соответствовать ожиданиям в других тестах смещения.
+      expect(stripAnsi(consoleLogSpy.firstCall.args[0])).to.match(
+        /^app:svc:[a-f0-9]{5}\s{4}combined message$/,
+      );
+    });
+
+    it('should combine features and output object correctly', function () {
+      process.env.DEBUG = 'app:svc';
+      const debug = createDebugger('app')
+        .withNs('svc')
+        .withHash(3)
+        .withOffset(1);
+
+      const data = {id: 123};
+      const description = 'Data:';
+      debug(data, description);
+
+      const expectedInspect = inspect(data, {
+        colors: true,
+        depth: null,
+        compact: false,
+      });
+      const expectedLines = expectedInspect.split('\n');
+      const totalExpectedCalls = 1 + expectedLines.length;
+
+      expect(consoleLogSpy.callCount).to.equal(totalExpectedCalls);
+
+      // проверяем строку описания
+      // предупреждение: \s{4} здесь может не соответствовать ожиданиям в других тестах смещения.
+      expect(stripAnsi(consoleLogSpy.getCall(0).args[0])).to.match(
+        /^app:svc:[a-f0-9]{3}\s{4}Data:$/,
+      );
+
+      // проверяем строки объекта
+      expectedLines.forEach((line, index) => {
+        const callIndex = index + 1;
+        const logLine = stripAnsi(consoleLogSpy.getCall(callIndex).args[0]);
+        // префикс, хэш, отступ
+        // предупреждение: \s{2} здесь может не соответствовать ожиданиям в других тестах смещения.
+        expect(logLine).to.match(/^app:svc:[a-f0-9]{3}\s{2}/);
+        // содержимое строки inspect
+        expect(logLine).to.contain(stripAnsi(line));
+      });
+    });
+  });
+
+  describe('creation error', function () {
+    it('should throw error if createDebugger is called with invalid type', function () {
+      expect(() => createDebugger(123)).to.throw(
+        /must be a String or an Object/,
+      );
+      expect(() => createDebugger(true)).to.throw(
+        /must be a String or an Object/,
+      );
+      // массив - не простой объект
+      expect(() => createDebugger([])).to.throw(
+        /must be a String or an Object/,
+      );
+      // null должен вызывать ошибку (проверяется отдельно или убедитесь, что isNonArrayObject(null) === false)
+      // expect(() => createDebugger(null)).to.throw(/must be a String or an Object/);
+    });
+  });
+});

+ 1 - 0
src/index.d.ts

@@ -0,0 +1 @@
+export * from './create-debugger.js';

+ 1 - 0
src/index.js

@@ -0,0 +1 @@
+export * from './create-debugger.js';

+ 30 - 0
src/utils/generate-random-hex.js

@@ -0,0 +1,30 @@
+/**
+ * Генерирует случайную шестнадцатеричную строку,
+ * где первый символ не является цифрой.
+ *
+ * @param {number} length
+ * @returns {string}
+ */
+export function generateRandomHex(length = 4) {
+  // обеспечиваем, что длина не меньше 1, иначе логика не имеет смысла,
+  // возвращаем пустую строку для длины 0 или меньше, что соответствует
+  // общим практикам
+  if (length <= 0) {
+    return '';
+  }
+  // кандидаты для первого символа (без цифр)
+  const firstCharCandidates = 'abcdef';
+  // кандидаты для остальных символов
+  const restCharCandidates = '0123456789abcdef';
+  let result = '';
+  // генерируем первый символ (должен быть буквой)
+  const firstCharIndex = Math.floor(Math.random() * firstCharCandidates.length);
+  result += firstCharCandidates[firstCharIndex];
+  // генерируем остальные символы (могут быть цифры или буквы)
+  // цикл выполняется length - 1 раз, так как первый символ уже сгенерирован
+  for (let i = 1; i < length; i++) {
+    const randomIndex = Math.floor(Math.random() * restCharCandidates.length);
+    result += restCharCandidates[randomIndex];
+  }
+  return result;
+}

+ 52 - 0
src/utils/generate-random-hex.spec.js

@@ -0,0 +1,52 @@
+import {expect} from 'chai';
+import {generateRandomHex} from './generate-random-hex.js';
+
+describe('generateRandomHex', function () {
+  it('returns string with specified length', function () {
+    expect(generateRandomHex(10)).to.have.lengthOf(10);
+    expect(generateRandomHex(1)).to.have.lengthOf(1);
+    expect(generateRandomHex()).to.have.lengthOf(4);
+    expect(generateRandomHex(0)).to.have.lengthOf(0);
+    expect(generateRandomHex(-5)).to.have.lengthOf(0);
+  });
+
+  it('returns unique string', function () {
+    const results = new Set();
+    for (let i = 0; i < 100; i++) {
+      results.add(generateRandomHex(8));
+    }
+    expect(results.size).to.equal(100);
+  });
+
+  it('does not start with a digit', function () {
+    const iterations = 200;
+    const digits = '0123456789';
+    for (let i = 0; i < iterations; i++) {
+      const lengthsToTest = [1, 5, 10];
+      for (const len of lengthsToTest) {
+        const res = generateRandomHex(len);
+        if (res.length > 0) {
+          const firstChar = res[0];
+          expect(
+            digits.includes(firstChar),
+            `String "${res}" started with a digit`,
+          ).to.be.false;
+        } else {
+          expect(res).to.equal('');
+        }
+      }
+    }
+  });
+
+  it('returns valid hex characters', function () {
+    const iterations = 50;
+    const validHexRegex = /^[a-f0-9]*$/;
+    for (let i = 0; i < iterations; i++) {
+      const res = generateRandomHex(12);
+      expect(
+        validHexRegex.test(res),
+        `String "${res}" contains non-hex characters`,
+      ).to.be.true;
+    }
+  });
+});

+ 2 - 0
src/utils/index.js

@@ -0,0 +1,2 @@
+export * from './is-non-array-object.js';
+export * from './generate-random-hex.js';

+ 8 - 0
src/utils/is-non-array-object.js

@@ -0,0 +1,8 @@
+/**
+ * Is non-array object.
+ *
+ * @param input
+ */
+export function isNonArrayObject(input) {
+  return Boolean(input && typeof input === 'object' && !Array.isArray(input));
+}

+ 25 - 0
src/utils/is-non-array-object.spec.js

@@ -0,0 +1,25 @@
+import {expect} from 'chai';
+import {isNonArrayObject} from './is-non-array-object.js';
+
+describe('isNonArrayObject', function () {
+  it('returns true for non-array object', function () {
+    expect(isNonArrayObject({})).to.be.true;
+    expect(isNonArrayObject({foo: 'bar'})).to.be.true;
+    expect(isNonArrayObject(Object.create(null))).to.be.true;
+    expect(isNonArrayObject(new Date())).to.be.true;
+  });
+
+  it('returns false for array and non-object values', function () {
+    expect(isNonArrayObject('')).to.be.false;
+    expect(isNonArrayObject('str')).to.be.false;
+    expect(isNonArrayObject(0)).to.be.false;
+    expect(isNonArrayObject(10)).to.be.false;
+    expect(isNonArrayObject(true)).to.be.false;
+    expect(isNonArrayObject(false)).to.be.false;
+    expect(isNonArrayObject([])).to.be.false;
+    expect(isNonArrayObject([1, 2, 3])).to.be.false;
+    expect(isNonArrayObject(undefined)).to.be.false;
+    expect(isNonArrayObject(null)).to.be.false;
+    expect(isNonArrayObject(() => undefined)).to.be.false;
+  });
+});

+ 9 - 0
tsconfig.json

@@ -0,0 +1,9 @@
+{
+  "compilerOptions": {
+    "rootDir": "src",
+    "noEmit": true,
+    "target": "es2022",
+    "module": "NodeNext",
+    "moduleResolution": "NodeNext"
+  }
+}