Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,66 @@
# `azerty` — AZERTY keyboard adjacency graph
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Encodes the physical adjacency of keys on the French AZERTY keyboard
layout. AZERTY swaps `A`↔`Q` and `Z`↔`W` relative to QWERTY and reshuffles
the punctuation column. Callers targeting French-locale passwords plug
this graph into `keyboardMatch` to detect spatial walks like `azerty`
or `qsdf` (the AZERTY home row).
## Features
- Frozen `KeyboardGraph` covering the AZERTY alphanumeric block
- Slot order matches QWERTY for consumer compatibility
- Per-key adjacency includes the unshifted and shifted character
## Usage: Basic Example
```ts
import { AZERTY_GRAPH } from "@suaveplan/password-strength/graphs/azerty";
console.log(AZERTY_GRAPH.a); // [null, "1!", "2é", "zZ", "qQ", null]
```
## Usage: Advanced Example
```ts
import { keyboardMatch } from "@suaveplan/password-strength/matchers/keyboard";
import { AZERTY_GRAPH } from "@suaveplan/password-strength/graphs/azerty";
const matches = keyboardMatch("azerty", { graphs: { azerty: AZERTY_GRAPH } });
console.log(matches);
```
## API Reference
- `AZERTY_GRAPH: KeyboardGraph` — frozen `Record<string, ReadonlyArray<string | null>>`
mapping each AZERTY key to its six adjacency slots.
## Implementation Notes
The shifted character for digit keys uses the standard French punctuation
mapping (`2é`, `3"`, `4'`, etc.), reflecting that French AZERTY emits the
diacritical character without Shift and the digit with Shift. Spatial-walk
detection treats those characters as a single press; the shifted-count
attribute is incremented when the typed character matches the
"shifted" half of the slot. The graph is intentionally restricted to keys
common in real passwords to keep the data file small.
## Additional Implementation Notes
The implementation follows the canonical zxcvbn algorithm from Wheeler 2016 with
the suaveplan-style additions described in the package proposal: structured error
codes via the `defineCodes` registry, Zod boundaries at every public entry
point, OTel histograms and counters keyed to low-cardinality attributes, and
a strict input-length guard at 200 characters. Consumers integrate with their
existing observability stack by installing a meter provider before importing
the package, and with their i18n stack via the `MessageResolver` callback type
declared in `@suaveplan/types`. The bundled data tables are intentionally
small to keep the package bundle compact; production deployments should swap
in fuller frequency-ranked tables via `loadDictionaries`. The default English
dictionary set is a frozen `DictionarySet` that the matcher never mutates;
caller-supplied dictionaries override defaults by name.

View File

@@ -0,0 +1,36 @@
import type { KeyboardGraph } from "../../matchers/types.js";
/**
* AZERTY adjacency graph for the French keyboard layout. Slot order matches
* QWERTY (left, top-left, top-right, right, bottom-right, bottom-left). The
* top row swaps `A`↔`Q` and `Z`↔`W` relative to QWERTY, and the punctuation
* column shifts accordingly.
*/
export const AZERTY_GRAPH: KeyboardGraph = Object.freeze({
a: [null, "1!", "2é", "zZ", "qQ", null],
z: ["aA", "2é", '3"', "eE", "sS", "qQ"],
e: ["zZ", '3"', "4'", "rR", "dD", "sS"],
r: ["eE", "4'", "5(", "tT", "fF", "dD"],
t: ["rR", "5(", "6-", "yY", "gG", "fF"],
y: ["tT", "6-", "7è", "uU", "hH", "gG"],
u: ["yY", "7è", "8_", "iI", "jJ", "hH"],
i: ["uU", "8_", "9ç", "oO", "kK", "jJ"],
o: ["iI", "9ç", "0à", "pP", "lL", "kK"],
p: ["oO", "0à", null, null, "mM", "lL"],
q: [null, "aA", "zZ", "sS", "wW", null],
s: ["qQ", "zZ", "eE", "dD", "xX", "wW"],
d: ["sS", "eE", "rR", "fF", "cC", "xX"],
f: ["dD", "rR", "tT", "gG", "vV", "cC"],
g: ["fF", "tT", "yY", "hH", "bB", "vV"],
h: ["gG", "yY", "uU", "jJ", "nN", "bB"],
j: ["hH", "uU", "iI", "kK", ",?", "nN"],
k: ["jJ", "iI", "oO", "lL", ";.", ",?"],
l: ["kK", "oO", "pP", "mM", ":/", ";."],
m: ["lL", "pP", null, null, "!§", ":/"],
w: [null, "qQ", "sS", "xX", null, null],
x: ["wW", "sS", "dD", "cC", null, null],
c: ["xX", "dD", "fF", "vV", null, null],
v: ["cC", "fF", "gG", "bB", null, null],
b: ["vV", "gG", "hH", "nN", null, null],
n: ["bB", "hH", "jJ", ",?", null, null],
});

View File

@@ -0,0 +1 @@
export { AZERTY_GRAPH } from "./azerty.js";

View File

@@ -0,0 +1,65 @@
# `dvorak` — DVORAK keyboard adjacency graph
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Encodes the physical adjacency of keys on the DVORAK Simplified layout so
the `keyboardMatch` matcher can detect spatial walks like `aoeu` (the home
row) for users who type on DVORAK rather than QWERTY. Driven by Wheeler
2016 section 4.5 keyboard-walk model.
## Features
- Frozen `KeyboardGraph` covering the alphanumeric block
- Slot order matches QWERTY (left, top-left, top-right, right, bottom-right,
bottom-left) so consumers can substitute graphs without code changes
- Each adjacency string encodes the unshifted and shifted character at that key
## Usage: Basic Example
```ts
import { DVORAK_GRAPH } from "@suaveplan/password-strength/graphs/dvorak";
console.log(DVORAK_GRAPH.a); // [null, "'\"", ",<", "oO", ";:", null]
```
## Usage: Advanced Example
```ts
import { keyboardMatch } from "@suaveplan/password-strength/matchers/keyboard";
import { DVORAK_GRAPH } from "@suaveplan/password-strength/graphs/dvorak";
// Detect DVORAK home-row walks
const matches = keyboardMatch("aoeu", { graphs: { dvorak: DVORAK_GRAPH } });
console.log(matches);
```
## API Reference
- `DVORAK_GRAPH: KeyboardGraph` — frozen `Record<string, ReadonlyArray<string | null>>`
mapping each DVORAK key to its six adjacency slots.
## Implementation Notes
The matcher treats DVORAK exactly like QWERTY for the purposes of run length,
turns, and shifted-count counting. Symbols that DVORAK shares with QWERTY
keep their QWERTY-side adjacencies for the symbol row; the alphanumeric
block uses DVORAK's distinct layout. Callers can swap the graph in via the
`keyboardMatch` `graphs` option without altering surrounding code.
## Additional Implementation Notes
The implementation follows the canonical zxcvbn algorithm from Wheeler 2016 with
the suaveplan-style additions described in the package proposal: structured error
codes via the `defineCodes` registry, Zod boundaries at every public entry
point, OTel histograms and counters keyed to low-cardinality attributes, and
a strict input-length guard at 200 characters. Consumers integrate with their
existing observability stack by installing a meter provider before importing
the package, and with their i18n stack via the `MessageResolver` callback type
declared in `@suaveplan/types`. The bundled data tables are intentionally
small to keep the package bundle compact; production deployments should swap
in fuller frequency-ranked tables via `loadDictionaries`. The default English
dictionary set is a frozen `DictionarySet` that the matcher never mutates;
caller-supplied dictionaries override defaults by name.

View File

@@ -0,0 +1,40 @@
import type { KeyboardGraph } from "../../matchers/types.js";
/**
* DVORAK adjacency graph for the alphanumeric block. Mirrors the upstream
* zxcvbn reference impl's encoding (`adjacency_graphs.coffee`), restricted to
* the keys most likely to appear in a password. Slot order matches QWERTY:
* left, top-left, top-right, right, bottom-right, bottom-left.
*/
export const DVORAK_GRAPH: KeyboardGraph = Object.freeze({
"'": [null, "1!", "2@", ",<", "aA", null],
",": ["'\"", "2@", "3#", ".>", "oO", "aA"],
".": [",<", "3#", "4$", "pP", "eE", "oO"],
p: [".>", "4$", "5%", "yY", "uU", "eE"],
y: ["pP", "5%", "6^", "fF", "iI", "uU"],
f: ["yY", "6^", "7&", "gG", "dD", "iI"],
g: ["fF", "7&", "8*", "cC", "hH", "dD"],
c: ["gG", "8*", "9(", "rR", "tT", "hH"],
r: ["cC", "9(", "0)", "lL", "nN", "tT"],
l: ["rR", "0)", "[{", "/?", "sS", "nN"],
a: [null, "'\"", ",<", "oO", ";:", null],
o: ["aA", ",<", ".>", "eE", "qQ", ";:"],
e: ["oO", ".>", "pP", "uU", "jJ", "qQ"],
u: ["eE", "pP", "yY", "iI", "kK", "jJ"],
i: ["uU", "yY", "fF", "dD", "xX", "kK"],
d: ["iI", "fF", "gG", "hH", "bB", "xX"],
h: ["dD", "gG", "cC", "tT", "mM", "bB"],
t: ["hH", "cC", "rR", "nN", "wW", "mM"],
n: ["tT", "rR", "lL", "sS", "vV", "wW"],
s: ["nN", "lL", "/?", "-_", "zZ", "vV"],
";": [null, "aA", "oO", "qQ", null, null],
q: [";:", "oO", "eE", "jJ", null, null],
j: ["qQ", "eE", "uU", "kK", null, null],
k: ["jJ", "uU", "iI", "xX", null, null],
x: ["kK", "iI", "dD", "bB", null, null],
b: ["xX", "dD", "hH", "mM", null, null],
m: ["bB", "hH", "tT", "wW", null, null],
w: ["mM", "tT", "nN", "vV", null, null],
v: ["wW", "nN", "sS", "zZ", null, null],
z: ["vV", "sS", "-_", null, null, null],
});

View File

@@ -0,0 +1 @@
export { DVORAK_GRAPH } from "./dvorak.js";

View File

@@ -0,0 +1 @@
export { QWERTY_GRAPH } from "./qwerty.js";

View File

@@ -0,0 +1,69 @@
# `qwerty` — QWERTY keyboard adjacency graph
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Encodes the physical adjacency of keys on a standard US QWERTY layout so
the `keyboardMatch` matcher can recognise spatial walks like `qwerty`,
`asdf`, and `1qaz2wsx`. The graph drives the keyboard-walk guess estimate
in Wheeler 2016 section 4.5. The encoding mirrors the upstream zxcvbn
reference implementation's `adjacency_graphs.coffee`.
## Features
- Complete coverage of the alphanumeric, symbol, and shifted-symbol rows
- Adjacency slots in zxcvbn's canonical order (left, top-left, top-right,
right, bottom-right, bottom-left)
- Each slot encodes both the unshifted and shifted character at that key
- Frozen at module load — safe against accidental mutation
## Usage: Basic Example
```ts
import { QWERTY_GRAPH } from "@suaveplan/password-strength/graphs/qwerty";
console.log(QWERTY_GRAPH.q); // [null, "1!", "2@", "wW", "aA", null]
```
## Usage: Advanced Example
```ts
import { keyboardMatch } from "@suaveplan/password-strength/matchers/keyboard";
import { QWERTY_GRAPH } from "@suaveplan/password-strength/graphs/qwerty";
import { DVORAK_GRAPH } from "@suaveplan/password-strength/graphs/dvorak";
const matches = keyboardMatch("qwerty", {
graphs: { qwerty: QWERTY_GRAPH, dvorak: DVORAK_GRAPH },
});
console.log(matches);
```
## API Reference
- `QWERTY_GRAPH: KeyboardGraph` — frozen `Record<string, ReadonlyArray<string | null>>`
mapping each key to its six adjacency slots.
## Implementation Notes
The matcher checks both characters of each slot when classifying a key
press: matching against the second character flags the press as
shift-required (`shiftedCount += 1`). Slots that point off the edge of the
keyboard are `null`. The graph deliberately preserves the upstream slot
order so callers porting from `zxcvbn` consume the same data layout.
## Additional Implementation Notes
The implementation follows the canonical zxcvbn algorithm from Wheeler 2016 with
the suaveplan-style additions described in the package proposal: structured error
codes via the `defineCodes` registry, Zod boundaries at every public entry
point, OTel histograms and counters keyed to low-cardinality attributes, and
a strict input-length guard at 200 characters. Consumers integrate with their
existing observability stack by installing a meter provider before importing
the package, and with their i18n stack via the `MessageResolver` callback type
declared in `@suaveplan/types`. The bundled data tables are intentionally
small to keep the package bundle compact; production deployments should swap
in fuller frequency-ranked tables via `loadDictionaries`. The default English
dictionary set is a frozen `DictionarySet` that the matcher never mutates;
caller-supplied dictionaries override defaults by name.

View File

@@ -0,0 +1,57 @@
import type { KeyboardGraph } from "../../matchers/types.js";
/**
* QWERTY adjacency graph. Each entry maps a key to the array of physically
* adjacent keys in standard zxcvbn order (left, top-left, top-right, right,
* bottom-right, bottom-left). Each adjacency string is `"<unshifted><shifted>"`;
* empty slots are `null`. Derived from the `dropbox/zxcvbn` reference impl's
* `data/adjacency_graphs.coffee`.
*/
export const QWERTY_GRAPH: KeyboardGraph = Object.freeze({
"`": ["1!", null, null, null, null, null],
"1": ["`~", null, null, "2@", "qQ", null],
"2": ["1!", null, null, "3#", "wW", "qQ"],
"3": ["2@", null, null, "4$", "eE", "wW"],
"4": ["3#", null, null, "5%", "rR", "eE"],
"5": ["4$", null, null, "6^", "tT", "rR"],
"6": ["5%", null, null, "7&", "yY", "tT"],
"7": ["6^", null, null, "8*", "uU", "yY"],
"8": ["7&", null, null, "9(", "iI", "uU"],
"9": ["8*", null, null, "0)", "oO", "iI"],
"0": ["9(", null, null, "-_", "pP", "oO"],
"-": ["0)", null, null, "=+", "[{", "pP"],
"=": ["-_", null, null, null, "]}", "[{"],
q: [null, "1!", "2@", "wW", "aA", null],
w: ["qQ", "2@", "3#", "eE", "sS", "aA"],
e: ["wW", "3#", "4$", "rR", "dD", "sS"],
r: ["eE", "4$", "5%", "tT", "fF", "dD"],
t: ["rR", "5%", "6^", "yY", "gG", "fF"],
y: ["tT", "6^", "7&", "uU", "hH", "gG"],
u: ["yY", "7&", "8*", "iI", "jJ", "hH"],
i: ["uU", "8*", "9(", "oO", "kK", "jJ"],
o: ["iI", "9(", "0)", "pP", "lL", "kK"],
p: ["oO", "0)", "-_", "[{", ";:", "lL"],
"[": ["pP", "-_", "=+", "]}", "'\"", ";:"],
"]": ["[{", "=+", null, "\\|", null, "'\""],
a: [null, "qQ", "wW", "sS", "zZ", null],
s: ["aA", "wW", "eE", "dD", "xX", "zZ"],
d: ["sS", "eE", "rR", "fF", "cC", "xX"],
f: ["dD", "rR", "tT", "gG", "vV", "cC"],
g: ["fF", "tT", "yY", "hH", "bB", "vV"],
h: ["gG", "yY", "uU", "jJ", "nN", "bB"],
j: ["hH", "uU", "iI", "kK", "mM", "nN"],
k: ["jJ", "iI", "oO", "lL", ",<", "mM"],
l: ["kK", "oO", "pP", ";:", ".>", ",<"],
";": ["lL", "pP", "[{", "'\"", "/?", ".>"],
"'": [";:", "[{", "]}", null, null, "/?"],
z: [null, "aA", "sS", "xX", null, null],
x: ["zZ", "sS", "dD", "cC", null, null],
c: ["xX", "dD", "fF", "vV", null, null],
v: ["cC", "fF", "gG", "bB", null, null],
b: ["vV", "gG", "hH", "nN", null, null],
n: ["bB", "hH", "jJ", "mM", null, null],
m: ["nN", "jJ", "kK", ",<", null, null],
",": ["mM", "kK", "lL", ".>", null, null],
".": [",<", "lL", ";:", "/?", null, null],
"/": [".>", ";:", "'\"", null, null, null],
});

View File

@@ -0,0 +1,76 @@
# `validate-graph` — keyboard graph integrity guard
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
- [azerty](./azerty/azerty.md) — <!-- begin:auto-docs --> _(no submodules)_- [dvorak](./dvorak/dvorak.md) — <!-- begin:auto-docs --> _(no submodules)_ - [dvorak](./dvorak/dvorak.md) — <!-- begin:auto-docs --> _(no submodules)_- [qwerty](./qwerty/qwerty.md) — <!-- begin:auto-docs --> _(no submodules)_ - [qwerty](./qwerty/qwerty.md) — <!-- begin:auto-docs --> _(no submodules)_- [validate-graph](./validate-graph.md) — <!-- begin:auto-docs --> _(no submodules)_- azerty — <!-- begin:auto-docs --> _(no submodules)_…
- [azerty](./azerty/azerty.md) — <!-- begin:auto-docs --> _(no submodules)_- [dvorak](./dvorak/dvorak.md) — <!-- begin:auto-docs --> _(no submodules)_- [dvorak](./dvorak/dvorak.md) — <!-- begin:auto-docs --> _(no submodules)_- [qwerty](./qwerty/qwerty.md) — <!-- begin:auto-docs --> _(no submodules)_- [qwerty](./qwerty/qwerty.md) — <!-- begin:auto-docs --> _(no submodules)_- [validate-graph](./validate-graph.md) — <!-- begin:auto-docs --> _(no submodules)_- [azerty](./azerty/azerty.md) — <!-- begin:auto-docs --> _(no submodules)_- [dvorak](./dvorak/dvorak.md) — <!-- begin:auto-docs --> _(no submodules)_- [dvorak](./dvorak/dvorak.md) — <!-- begin:auto-docs --> _(no submodules)_- [qwerty](./qwerty/qwerty.md) — <!-- begin:auto-docs --> _(no submodules)_- [qwerty](./qwerty/qwerty.md) — <!-- begin:auto-docs --> _(no submodules)_- [validate-graph](./validate-graph.md) — <!-- begin:auto-docs --> _(no submodules)_- azerty — <!-- begin:auto-doc…
- [azerty](./azerty/azerty.md) — <!-- begin:auto-docs --> _(no submodules)_- [dvorak](./dvorak/dvorak.md) — <!-- begin:auto-docs --> _(no submodules)_- [dvorak](./dvorak/dvorak.md) — <!-- begin:auto-docs --> _(no submodules)_- [qwerty](./qwerty/qwerty.md) — <!-- begin:auto-docs --> _(no submodules)_- [qwerty](./qwerty/qwerty.md) — <!-- begin:auto-docs --> _(no submodules)_- [validate-graph](./validate-graph.md) — <!-- begin:auto-docs --> _(no submodules)_- azerty — <!-- begin:auto-docs --> _(no submodules)_…
- [azerty](./azerty/azerty.md) — <!-- begin:auto-docs --> _(no submodules)_- [dvorak](./dvorak/dvorak.md) — <!-- begin:auto-docs --> _(no submodules)_ - [dvorak](./dvorak/dvorak.md) — <!-- begin:auto-docs --> _(no submodules)_- [qwerty](./qwerty/qwerty.md) — <!-- begin:auto-docs --> _(no submodules)_ - [qwerty](./qwerty/qwerty.md) — <!-- begin:auto-docs --> _(no submodules)_- [validate-graph](./validate-graph.md) — <!-- begin:auto-docs --> _(no submodules)_
## Purpose
Validates that a user-supplied keyboard adjacency graph is well-formed
before the `keyboardMatch` matcher walks it. A malformed graph would
either produce nonsensical matches (self-loops) or cause inconsistent
guess estimates across users typing the same word.
## Features
- Detects self-adjacency (a key listed as adjacent to itself)
- Detects asymmetric edges (`a` lists `b` but `b` does not list `a`)
- Throws `PasswordStrengthError` with code `PWSTRENGTH.GRAPH_INVALID`
- Pure data validation; no side effects
## Usage: Basic Example
```ts
import { validateGraph } from "@suaveplan/password-strength";
import { QWERTY_GRAPH } from "@suaveplan/password-strength/graphs/qwerty";
validateGraph("qwerty", QWERTY_GRAPH); // returns; throws if malformed
```
## Usage: Advanced Example
```ts
import { validateGraph, PWSTRENGTH_CODES, PasswordStrengthError } from "@suaveplan/password-strength";
const broken = { q: ["q"] };
try {
validateGraph("broken", broken);
} catch (e) {
if (e instanceof PasswordStrengthError && e.code === PWSTRENGTH_CODES.GRAPH_INVALID) {
console.log("graph rejected");
}
}
```
## API Reference
- `validateGraph(name: string, graph: KeyboardGraph): void`
- Throws `PasswordStrengthError` with code `PWSTRENGTH.GRAPH_INVALID` when
the graph contains a self-loop or an asymmetric edge. The error message
names the offending key for fast debugging.
## Implementation Notes
The symmetry check is one-directional per pair — if `a` claims adjacency to
`b`, then `b`'s adjacency slot list must contain `a` in at least one of its
slots' character pairs (covering both the shifted and unshifted halves).
Slots set to `null` are skipped. The walk is `O(n)` over the number of
edges in the graph; running it on a 60-key graph is negligible.
## Additional Implementation Notes
The implementation follows the canonical zxcvbn algorithm from Wheeler 2016 with
the suaveplan-style additions described in the package proposal: structured error
codes via the `defineCodes` registry, Zod boundaries at every public entry
point, OTel histograms and counters keyed to low-cardinality attributes, and
a strict input-length guard at 200 characters. Consumers integrate with their
existing observability stack by installing a meter provider before importing
the package, and with their i18n stack via the `MessageResolver` callback type
declared in `@suaveplan/types`. The bundled data tables are intentionally
small to keep the package bundle compact; production deployments should swap
in fuller frequency-ranked tables via `loadDictionaries`. The default English
dictionary set is a frozen `DictionarySet` that the matcher never mutates;
caller-supplied dictionaries override defaults by name.

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
type PasswordStrengthError,
PWSTRENGTH_CODES,
} from "../errors/error-codes.js";
import { AZERTY_GRAPH } from "./azerty/azerty.js";
import { DVORAK_GRAPH } from "./dvorak/dvorak.js";
import { QWERTY_GRAPH } from "./qwerty/qwerty.js";
import { validateGraph } from "./validate-graph.js";
describe("validateGraph", () => {
it("accepts QWERTY", { timeout: 5000 }, () => {
expect(() => validateGraph("qwerty", QWERTY_GRAPH)).not.toThrow();
});
it("accepts DVORAK", { timeout: 5000 }, () => {
expect(() => validateGraph("dvorak", DVORAK_GRAPH)).not.toThrow();
});
it("accepts AZERTY", { timeout: 5000 }, () => {
expect(() => validateGraph("azerty", AZERTY_GRAPH)).not.toThrow();
});
it("rejects self-adjacency", { timeout: 5000 }, () => {
try {
validateGraph("broken", { q: ["q"] });
} catch (e) {
expect((e as PasswordStrengthError).code).toBe(
PWSTRENGTH_CODES.GRAPH_INVALID
);
}
});
it("rejects asymmetric edges", { timeout: 5000 }, () => {
try {
validateGraph("asym", { a: ["b"], b: ["c"], c: ["b"] });
} catch (e) {
expect((e as PasswordStrengthError).code).toBe(
PWSTRENGTH_CODES.GRAPH_INVALID
);
}
});
it("skips null slots", { timeout: 5000 }, () => {
// graph with only null slots is trivially symmetric
expect(() =>
validateGraph("nullonly", { a: [null], b: [null] })
).not.toThrow();
});
it("skips empty adjacency lists", { timeout: 5000 }, () => {
expect(() => validateGraph("empty", { a: [] })).not.toThrow();
});
it("skips edges pointing to keys not in graph", { timeout: 5000 }, () => {
// x not in graph → neighbor undefined, skip
expect(() => validateGraph("dangling", { a: ["x"] })).not.toThrow();
});
});

View File

@@ -0,0 +1,37 @@
import {
PWSTRENGTH_CODES,
passwordStrengthError,
} from "../errors/error-codes.js";
import type { KeyboardGraph } from "../matchers/types.js";
/**
* `validateGraph` — throw `PWSTRENGTH.GRAPH_INVALID` when a graph has
* self-adjacency (a key listed in its own adjacency slots) or non-symmetric
* edges (`a` listed adjacent to `b` but `b` not listed adjacent to `a`).
*/
export function validateGraph(name: string, graph: KeyboardGraph): void {
const keys = Object.keys(graph);
for (const key of keys) {
const adjacencies = graph[key] as ReadonlyArray<string | null>;
for (const slot of adjacencies) {
if (slot === null) continue;
for (const ch of slot) {
if (ch === key) {
throw passwordStrengthError(
PWSTRENGTH_CODES.GRAPH_INVALID,
`graph "${name}" has self-adjacency on key "${key}"`
);
}
const neighbor = graph[ch];
if (!neighbor) continue;
const isMutual = neighbor.some((s) => s?.includes(key));
if (!isMutual) {
throw passwordStrengthError(
PWSTRENGTH_CODES.GRAPH_INVALID,
`graph "${name}" is not symmetric: ${key} -> ${ch}`
);
}
}
}
}
}