The CLI wrapper of Ambiglyph (Homoglyph Detection Tool)
  • TypeScript 92.2%
  • JavaScript 7.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Azer 32ba029979 Report a word spelled wholly in Cyrillic or Greek inside a link
The check only looked closely at words that mixed scripts, so a word
whose letters were all Cyrillic was taken for Russian even when every
one of them imitated a Latin letter: "https://аррӏе.com/login",
the classic homograph of apple.com, and "user@раураӏ.example"
passed without a word.

Such a word is now reported as mixed-script, with the Latin spelling
as its fix, when it has at least two letters, every letter has a Latin
twin, and a dot, slash, underscore or @ joins it to a Latin letter, as
in domain names, URLs, addresses and dotted identifiers. On its own,
or next to text of its own script, it is an ordinary word and is left
alone. Hyphens do not count, since Serbian and Russian hyphenate case
endings to Latin acronyms ("API-је"), and single letters do
not, which spares a Greek letter in a name such as "ρ_water".

Findings over two trees of third-party packages, 3,110 and 14,458
files, the second with 37 Russian, Ukrainian, Bulgarian, Serbian and
Greek locale files, are unchanged. detect() on 8 MB of Russian takes
134 ms against 127 ms, within the noise of the machine measured on.
2026-10-13 20:24:09 +04:00
.forgejo/workflows Make check work offline, on files, directories and globs 2026-10-11 01:09:04 +04:00
.github/workflows Run the CI gate on the GitHub mirror too 2026-10-11 11:11:59 +04:00
scripts Add an offline homoglyph engine built from Unicode's confusables 2026-10-11 01:01:25 +04:00
src Report a word spelled wholly in Cyrillic or Greek inside a link 2026-10-13 20:24:09 +04:00
test Report a word spelled wholly in Cyrillic or Greek inside a link 2026-10-13 20:24:09 +04:00
.gitignore Make the tool build and run, and stop leaking the token 2026-10-07 01:04:56 +04:00
.sample.env Load each command's dependencies only when it runs 2026-10-11 00:44:06 +04:00
eslint.config.js Make the tool build and run, and stop leaking the token 2026-10-07 01:04:56 +04:00
index.ts Load each command's dependencies only when it runs 2026-10-11 00:44:06 +04:00
package-lock.json Add an offline homoglyph engine built from Unicode's confusables 2026-10-11 01:01:25 +04:00
package.json Add an offline homoglyph engine built from Unicode's confusables 2026-10-11 01:01:25 +04:00
README.md Report a word spelled wholly in Cyrillic or Greek inside a link 2026-10-13 20:24:09 +04:00
tsconfig.json Add an offline homoglyph engine built from Unicode's confusables 2026-10-11 01:01:25 +04:00

🔥 Ambiglyph CLI

Finds homoglyphs - look-alike characters such as a Cyrillic "а" posing as a Latin "a" - along with mixed-script words and invisible characters, in your files and offline. It started as the CLI wrapper of Ambiglyph (Homoglyph Detection Tool), whose server it can still talk to.

❗️Important Point

The code has NOT been polished and is provided "as is". There's a lot of code that is redundant and there are tons of improvements that can be made.

Install and build

It is not published to the npm registry, so it is built from a clone:

git clone https://got.az/azer/ambiglyph-cli.git
cd ambiglyph-cli
npm install
npm run build
node dist/index.js --help

npm link puts it on PATH as ambiglyph. It needs Node 22 or newer.

Checking files

ambiglyph check                    # everything under the current directory
ambiglyph check README.md src      # files and directories
ambiglyph check 'docs/**/*.md'     # globs, expanded by ambiglyph itself
git show | ambiglyph check -       # standard input
ambiglyph check --fix docs         # apply every suggested fix in place
ambiglyph check --staged           # what git is about to commit
ambiglyph check --sarif > out.sarif # SARIF for code scanning
docs/login.md:12:9  mixed-script  "pаypal" mixes Latin with Cyrillic: а U+0430 (Cyrillic) -> paypal
src/auth.ts:40:23  invisible  U+202E RIGHT-TO-LEFT OVERRIDE can reorder how the text around it is displayed (remove it)
2 problems in 2 of 31 files checked (2 fixable with --fix)

Each finding is file:line:column, the rule, what is wrong and, when there is one, the fix. Columns count UTF-16 code units, as ESLint and tsc do. Findings go to standard output and the summary to standard error.

Rule Reported when
mixed-script A word mixes scripts and a letter of the minority script passes for one of the majority: the Cyrillic "а" in "pаypal", or a Latin "p" in a Russian word. A mix with nothing to mistake, such as "Δt", is fine. Also a word all in another script whose every letter passes for Latin, where a dot, slash, underscore or @ joins it to Latin text: the Cyrillic "аррӏе" of "https://аррӏе.com".
confusable A word holds a compatibility, mathematical or ligature form that Unicode restricts in identifiers: "pay", "𝐩𝐚𝐲", "file". Fullwidth letters and digits in a word with Chinese, Japanese or Korean characters, such as "第1回", or set off from them only by punctuation, as in "(1)概要", are how those languages write them and are left alone.
invisible Zero-width characters, the Hangul fillers, bidi controls that can reorder a line ("Trojan Source"), tag characters, whose hidden text is decoded and shown, and variation selectors after the first in a run, unless they only repeat it: a character takes one, and the rest show nothing but can each carry a byte of hidden data. Zero-width joiners and non-joiners only next to a Latin, Greek or Cyrillic letter or digit, since Arabic, Indic scripts and emoji need them, and bidi marks, the grapheme joiner, a lone variation selector and other marks and controls that display nothing only between two of them, as in "pay\u200Fpal".

Ordinary text in any one script, Azerbaijani "ə" and "ı" included, is left alone.

Directories are walked including dotfiles such as .github, but skipping node_modules and .git. A file with a NUL byte in its first 8000 bytes is taken as binary and skipped without reading the rest, unless it starts with a UTF-16 byte order mark: UTF-16 files, which Windows tools write and C# and PowerShell accept as source, are checked, and --fix writes them back in UTF-16.

Ignoring

--ignore <glob> skips the files a glob matches, and can be repeated. Globs are read against paths relative to the working directory; * and ? stay inside one name and ** crosses directories. As in .gitignore, a pattern without a slash (a trailing one aside) matches a name at any depth, a pattern that matches a directory skips everything in it, and a trailing slash matches only directories. A directory skipped this way is not walked at all, so it can be as large as it likes, or unreadable.

ambiglyph check --ignore vendor --ignore '*.min.js'

For a single line, write ambiglyph-ignore in a comment on it, or ambiglyph-ignore-next-line on the line above. The findings on that line are not reported and --fix leaves them alone.

Dear pаrtner <!-- ambiglyph-ignore -->

Fixing

--fix rewrites each file with every suggestion applied. Only the flagged characters change; line endings, a byte order mark and the rest of the file stay exactly as they were. A finding with no suggestion, such as a Greek "ε" in a Latin word, is reported and left for you. A file with bytes that are not valid UTF-8, a stray Latin-1 "é" for instance, is not rewritten at all, since those bytes could not be written back as they were; it is reported and the exit status is 2. Given - alone, --fix is a filter: the corrected text goes to standard output and the report, in --json or --sarif too, to standard error.

ambiglyph check --fix - < draft.md > clean.md

In CI and git hooks

The exit status is 0 when nothing is found, 1 when something is, and 2 when the check could not run: a missing file, a directory that cannot be read, a glob that matches nothing, a file --fix could not write, or a bad option. With --fix, only what is left unfixed counts.

--json prints all findings as one array. Each has file, line, column, rule, message, text (the word), suggestion (absent when there is none, empty when the fix is to delete), index (UTF-16 offset in the file) and suspects, one per character, with its char, codePoint, script, index and replacement. With --fix, the findings that were applied carry "fixed": true.

--staged checks what is about to be committed. It reads the content from git's index, so a file edited after git add is judged as it will be committed, not as it is on disk, and paths are shown relative to the current directory. With nothing staged it exits 0. It cannot be combined with paths or --fix.

--sarif prints the same findings as a SARIF 2.1.0 log, which GitHub code scanning and most CI dashboards read. Paths are relative to the working directory and the exit status is unchanged, so a workflow step that should still upload the results has to tolerate the 1. The workflow builds the tool from its repository; npx ambiglyph would instead run whatever package the registry has under that name.

- run: |
    git clone --depth 1 https://got.az/azer/ambiglyph-cli.git "$RUNNER_TEMP/ambiglyph"
    npm ci --prefix "$RUNNER_TEMP/ambiglyph"
    npm run build --prefix "$RUNNER_TEMP/ambiglyph"
- run: node "$RUNNER_TEMP/ambiglyph/dist/index.js" check --sarif > ambiglyph.sarif || [ $? -eq 1 ]
- uses: github/codeql-action/upload-sarif@v3
  with:
    sarif_file: ambiglyph.sarif

A pre-commit hook (.git/hooks/pre-commit, made executable):

#!/bin/sh
exec ambiglyph check --staged

It takes about as long as piping the file list through xargs (130 ms for 300 files, almost all of it git and Node starting up), without the file-name plumbing.

Where the verdicts come from

The lookalike table is generated from the Unicode 18.0.0 security data (UTS #39) by scripts/generate-confusables.mjs. It keeps 1800 of the 6712 entries in confusables.txt: the letters and digits outside ASCII that pass for a Latin letter or an ASCII digit, split by IdentifierStatus.txt into ordinary letters of other scripts and restricted forms. It is 17.5 KB of ASCII escapes in src/engine/confusables.ts; npm run confusables regenerates it, and npm run confusables -- 19.0.0 moves it to another version.

The suggested fix is the letter the character stands for, not the raw UTS #39 prototype, which maps "I" and "1" to "l" and "m" to "rn". So a Cyrillic "І" becomes "I", and a Cyrillic "ә" in Azerbaijani text becomes the Latin "ə".

On Node 24 a single file is checked in about 55 ms, most of which is Node starting up, and all 3,100 files of this project's node_modules (41 MB) in about 0.2 s. A file that is pure ASCII is settled on its raw bytes; text runs at roughly 200 MB/s in Russian or Azerbaijani, where every word has to be looked at, and about 1 GB/s in English.

Server commands

The original Ambiglyph backend is a Java and MySQL service that checks words against its dictionary. The instance this CLI was written against no longer answers, so these commands need a server of your own, named by url.

Command What it does
ambiglyph login Asks for credentials and stores the bearer token in ./.ambi
ambiglyph logout Removes ./.ambi
ambiglyph add <path> Uploads every word of the file to your word list
ambiglyph check --server <path> Checks one file, asks which replacement to use for each detection, and writes <path>.remove

check --server is what check <path> did before the offline check existed.

./.ambi holds a credential. It is written with mode 0600 and is listed in .gitignore; it is also resolved against the current directory, so login and check --server have to be run from the same place.

Configuration is read from the environment or a .env file in the current directory - see .sample.env. Only the server commands read it.

Variable Meaning
url Base URL of the Ambiglyph server
serverWordPerRequest Words sent per request (default 4)
errorText Message shown when a request fails

Standalone binaries

The build targets Node ESM, because got has been ESM-only since v12. That rules out pkg, which cannot bundle ES modules and has been archived upstream, so the compile-linux / compile-macos-arm64 scripts are gone. Standalone binaries would now need Node's own single-executable applications, or a CJS bundle produced by a bundler first.