Skip to content

Commit 3f211f2

Browse files
committed
feat: add analyze function
1 parent 59c2ff2 commit 3f211f2

7 files changed

Lines changed: 167 additions & 39 deletions

File tree

README.md

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@ Perfect for **OTP**, **SMS verification**, **2FA**, **PIN codes**, onboarding st
77

88
## Features
99

10-
- Generates **4-digit** and **6-digit** human-friendly codes
11-
- Pattern-based generation: **AABB**, **ABAB**, **ABBA**, **AAAB**, **ABBB**, **AABC**, **ABBC**,
12-
plus **ABABAB**, **AABBCC**, **AAABBB**, **ABBABB**, **ABCABC**, **ABCCBA**
13-
- Filters out simple sequences (`1234`, `9876`) and repeated digits (`0000`)
14-
- Fully customizable strategies (your own generator logic)
15-
- Optional custom RNG (for cryptographic random or deterministic tests)
10+
- Generates **4-digit** and **6-digit** human-friendly codes
11+
- Pattern-based generation: **AABB**, **ABAB**, **ABBA**, **AAAB**, **ABBB**, **AABC**, **ABBC**,
12+
plus **ABABAB**, **AABBCC**, **AAABBB**, **ABBABB**, **ABCABC**, **ABCCBA**
13+
- Filters out simple sequences (`1234`, `9876`) and repeated digits (`0000`)
14+
- Fully customizable strategies (your own generator logic)
15+
- Optional custom RNG (for cryptographic random or deterministic tests)
1616

1717
---
1818

@@ -33,31 +33,31 @@ pnpm add patcode
3333
### Basic
3434

3535
```ts
36-
import { generateCode } from "patcode";
36+
import { generateCode } from 'patcode'
3737

38-
generateCode(); // "535353" (6-digit, mixed patterns)
38+
generateCode() // "535353" (6-digit, mixed patterns)
3939
```
4040

4141
### 4-digit code
4242

4343
```ts
44-
generateCode({ length: 4 }); // "5566"
44+
generateCode({ length: 4 }) // "5566"
4545
```
4646

4747
## Custom Strategy
4848

4949
```ts
5050
generateCode({
51-
length: 6,
52-
strategy: "custom",
53-
customStrategy(ctx) {
54-
// example: code starting with 9, then ABAB
55-
const [A, B] = ctx.pickTwoDifferentDigits();
56-
return ctx.fromDigits(["9", A, B, A, B, A]);
57-
}
58-
});
51+
length: 6,
52+
strategy: 'custom',
53+
customStrategy(ctx) {
54+
// example: code starting with 9, then ABAB
55+
const [A, B] = ctx.pickTwoDifferentDigits()
56+
return ctx.fromDigits(['9', A, B, A, B, A])
57+
},
58+
})
5959
```
6060

61-
## 📝 License
61+
## License
6262

6363
This project is licensed under the [**MIT License**](./LICENSE).

src/analyze.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { isAllSame, isTooSimpleSequence } from './utils'
2+
import type { CodeLength, CodeAnalysis } from './types'
3+
4+
function isSequentialAsc(code: string): boolean {
5+
for (let i = 1; i < code.length; i++) {
6+
const prev = Number(code[i - 1])
7+
const curr = Number(code[i])
8+
if (curr !== (prev + 1) % 10) return false
9+
}
10+
return true
11+
}
12+
13+
function isSequentialDesc(code: string): boolean {
14+
for (let i = 1; i < code.length; i++) {
15+
const prev = Number(code[i - 1])
16+
const curr = Number(code[i])
17+
if (curr !== (prev + 9) % 10) return false
18+
}
19+
return true
20+
}
21+
22+
function countGroups(code: string): number {
23+
let groups = 1
24+
for (let i = 1; i < code.length; i++) {
25+
if (code[i] !== code[i - 1]) groups++
26+
}
27+
return groups
28+
}
29+
30+
function estimateEntropy(code: string): number {
31+
const freq: Record<string, number> = {}
32+
33+
for (const d of code) freq[d] = (freq[d] || 0) + 1
34+
35+
let entropy = 0
36+
for (const k in freq) {
37+
const p = freq[k] / code.length
38+
entropy -= p * Math.log2(p)
39+
}
40+
41+
return entropy * code.length
42+
}
43+
44+
function collisionRisk(
45+
code: string,
46+
entropy: number
47+
): 'low' | 'medium' | 'high' {
48+
if (code.length === 4) {
49+
// Max entropy ~8 bits
50+
if (entropy > 6.5) return 'low'
51+
if (entropy > 3.5) return 'medium'
52+
return 'high'
53+
}
54+
55+
if (code.length === 6) {
56+
// Max entropy ~12 bits
57+
if (entropy > 10) return 'low'
58+
if (entropy > 6) return 'medium'
59+
return 'high'
60+
}
61+
62+
return 'high'
63+
}
64+
65+
function memorabilityScore(
66+
code: string,
67+
groups: number
68+
): 'low' | 'medium' | 'high' {
69+
if (isAllSame(code)) return 'low'
70+
if (isTooSimpleSequence(code)) return 'low'
71+
72+
if (groups <= 2) return 'high'
73+
74+
if (groups === 3) return 'medium'
75+
76+
return 'low'
77+
}
78+
79+
export function analyze(code: string): CodeAnalysis {
80+
const length = code.length as CodeLength
81+
82+
const allSame = isAllSame(code)
83+
const tooSimple = isTooSimpleSequence(code)
84+
const asc = isSequentialAsc(code)
85+
const desc = isSequentialDesc(code)
86+
const groups = countGroups(code)
87+
88+
const entropy = estimateEntropy(code)
89+
const collision = collisionRisk(code, entropy)
90+
const memory = memorabilityScore(code, groups)
91+
92+
return {
93+
length,
94+
isAllSame: allSame,
95+
isTooSimple: tooSimple,
96+
isSequentialAsc: asc,
97+
isSequentialDesc: desc,
98+
repeatedGroups: groups,
99+
100+
entropy,
101+
collisionRisk: collision,
102+
memorability: memory,
103+
}
104+
}

src/index.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import type {
1+
import {
22
GenerateOptions,
33
CodeLength,
4-
StrategyName,
4+
Strategy,
55
CustomStrategyContext,
6+
CodeAnalysis,
67
} from './types'
78
import { getStrategyImpl } from './strategy'
89
import { isCodeAllowed } from './validator'
@@ -17,11 +18,19 @@ import {
1718
pickThreeDifferentDigits,
1819
} from './utils'
1920

20-
export type { GenerateOptions, CodeLength, StrategyName, CustomStrategyContext }
21+
export { analyze } from './analyze'
22+
23+
export {
24+
GenerateOptions,
25+
CodeLength,
26+
Strategy,
27+
CustomStrategyContext,
28+
CodeAnalysis,
29+
}
2130

2231
export function generateCode(options: GenerateOptions = {}): string {
2332
const length: CodeLength = options.length === 4 ? 4 : 6
24-
const strategy: StrategyName = options.strategy ?? 'mixed'
33+
const strategy: Strategy = options.strategy ?? Strategy.Mixed
2534

2635
const impl = getStrategyImpl(length, strategy, options)
2736

src/strategy.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type {
22
CodeLength,
3-
StrategyName,
3+
Strategy,
44
GenerateOptions,
55
CustomStrategyContext,
66
} from './types'
@@ -10,7 +10,7 @@ import * as s6 from './patterns6'
1010

1111
export function getStrategyImpl(
1212
length: CodeLength,
13-
strategy: StrategyName,
13+
strategy: Strategy,
1414
options: GenerateOptions
1515
): () => string {
1616
const digits = utils.getAllowedDigits(options)

src/types.ts

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
export type CodeLength = 4 | 6
22

3-
export type StrategyName =
4-
| 'mixed'
5-
| 'pairs'
6-
| 'triples'
7-
| 'mirror'
8-
| 'wave'
9-
| 'escalator'
10-
| 'pairWave'
11-
| 'custom'
3+
export enum Strategy {
4+
Mixed = 'mixed',
5+
Pairs = 'pairs',
6+
Triples = 'triples',
7+
Mirror = 'mirror',
8+
Wave = 'wave',
9+
Escalator = 'escalator',
10+
PairWave = 'pairWave',
11+
Custom = 'custom',
12+
}
1213

1314
export interface CustomStrategyContext {
1415
length: CodeLength
@@ -22,7 +23,7 @@ export interface CustomStrategyContext {
2223

2324
export interface GenerateOptions {
2425
length?: CodeLength
25-
strategy?: StrategyName
26+
strategy?: Strategy
2627
avoidSimpleSequences?: boolean
2728
avoidSameDigits?: boolean
2829
blackList?: string[]
@@ -31,3 +32,16 @@ export interface GenerateOptions {
3132
rng?: () => number
3233
customStrategy?: (ctx: CustomStrategyContext) => string
3334
}
35+
36+
export interface CodeAnalysis {
37+
length: CodeLength
38+
isAllSame: boolean
39+
isTooSimple: boolean
40+
isSequentialAsc: boolean
41+
isSequentialDesc: boolean
42+
repeatedGroups: number
43+
pattern?: string
44+
entropy: number
45+
memorability: 'low' | 'medium' | 'high'
46+
collisionRisk: 'low' | 'medium' | 'high'
47+
}

test/random.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from 'vitest'
2-
import { generateCode } from '../src/index'
2+
import { generateCode, Strategy } from '../src/index'
33

44
function fakeRngGenerator(values: number[]) {
55
let i = 0
@@ -12,7 +12,7 @@ describe('deterministic RNG', () => {
1212

1313
const code1 = generateCode({
1414
length: 6,
15-
strategy: 'custom',
15+
strategy: Strategy.Custom,
1616
rng,
1717
customStrategy(ctx) {
1818
const d1 = ctx.pickDigit()
@@ -30,7 +30,7 @@ describe('deterministic RNG', () => {
3030

3131
const code2 = generateCode({
3232
length: 6,
33-
strategy: 'custom',
33+
strategy: Strategy.Custom,
3434
rng: rng2,
3535
customStrategy(ctx) {
3636
const d1 = ctx.pickDigit()

test/strategy.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
import { describe, it, expect } from 'vitest'
22
import { getStrategyImpl } from '../src/strategy'
3+
import { Strategy } from '../src'
34

45
describe('strategy system', () => {
56
it('creates a implementation function', () => {
6-
const impl = getStrategyImpl(6, 'mixed', {})
7+
const impl = getStrategyImpl(6, Strategy.Mixed, {})
78
const code = impl()
89
expect(code.length).toBe(6)
910
})
1011

1112
it('custom strategy works', () => {
12-
const impl = getStrategyImpl(4, 'custom', {
13+
const impl = getStrategyImpl(4, Strategy.Custom, {
1314
customStrategy: () => '1234',
1415
})
1516
expect(impl()).toBe('1234')

0 commit comments

Comments
 (0)