Files
2026-05-27 23:29:53 +01:00

34 KiB
Raw Permalink Blame History

Capability Spec Delta: password-strength (enterprise-password-strength)

Package: @suaveplan/password-strength Status: PROPOSED — first delta; becomes canonical spec on archive.

Every requirement below carries a **Verified by:** line at authoring time. (See openspec/AGENTS.md Section 13.)

ADDED Requirements

Requirement: Dictionary pattern matcher with l33t and reverse-string lookup

The package SHALL export dictionaryMatch(password, dictionaries) returning every substring of password that exists in any supplied dictionary, with one match record per (substring, dictionary, l33t-substitution-set, reversed-flag) tuple. The matcher SHALL enumerate every l33t substitution combination from the configured l33tTable (default per Wheeler §3.1) and SHALL also test the reversed string. Every match SHALL carry pattern: "dictionary", the matched substring, the matched dictionary entry, the rank, and the dictionary name. The matcher SHALL NOT mutate the supplied dictionaries argument.

Scenario: matches a plain dictionary token

  • WHEN dictionaryMatch("password", { passwords: new Map([["password", 1]]) }) runs
  • THEN the result contains exactly one match with i=0, j=7, token="password", matchedWord="password", rank=1, l33t=false, reversed=false

Scenario: matches a l33t-substituted token

  • WHEN dictionaryMatch("p4ssw0rd", { passwords: new Map([["password", 1]]) }) runs with the default l33t table
  • THEN the result contains a match with l33t=true, sub: { "4": "a", "0": "o" }, matchedWord="password"

Scenario: matches a reversed token

  • WHEN dictionaryMatch("drowssap", { passwords: new Map([["password", 1]]) }) runs
  • THEN the result contains a match with reversed=true, matchedWord="password", i=0, j=7

Scenario: emits one match per (l33t, reversed) tuple

  • WHEN the same substring matches under both l33t-true and reversed-true
  • THEN two distinct match records are emitted (the cover algorithm picks the lower-guesses one)

Verified by: packages/crypto/password-strength/src/matchers/dictionary/dictionary.test.ts::"matches a plain dictionary token" AND packages/crypto/password-strength/src/matchers/dictionary/dictionary.test.ts::"matches a l33t-substituted token" AND packages/crypto/password-strength/src/matchers/dictionary/dictionary.test.ts::"matches a reversed token" AND packages/crypto/password-strength/src/matchers/dictionary/dictionary.test.ts::"emits one match per l33t-reversed tuple"


Requirement: Sequence pattern matcher

The package SHALL export sequenceMatch(password) returning every substring of length ≥ 2 that is an alphabetical (abcd, zyx) or numeric (1234, 87) sequence in either direction. Every match SHALL carry pattern: "sequence", sequenceName: "lower" | "upper" | "digits", sequenceSpace: 26 | 26 | 10, and ascending: boolean.

Scenario: matches an ascending alphabetical run

  • WHEN sequenceMatch("abcd") runs
  • THEN the result contains one match with i=0, j=3, ascending=true, sequenceName="lower"

Scenario: matches a descending numeric run

  • WHEN sequenceMatch("9876") runs
  • THEN the result contains one match with ascending=false, sequenceName="digits", sequenceSpace=10

Scenario: rejects length-1 substrings

  • WHEN sequenceMatch("a") runs
  • THEN the result is an empty array

Verified by: packages/crypto/password-strength/src/matchers/sequence/sequence.test.ts::"matches ascending alphabetical run" AND packages/crypto/password-strength/src/matchers/sequence/sequence.test.ts::"matches descending numeric run" AND packages/crypto/password-strength/src/matchers/sequence/sequence.test.ts::"rejects length-1 substrings"


Requirement: Repeat pattern matcher

The package SHALL export repeatMatch(password, dictionaries) returning every substring composed of a repeating chunk of length ≥ 1 (e.g. aaaa, abcabc, babababa). Every match SHALL carry pattern: "repeat", baseToken (the repeating chunk), repeatCount, and baseGuesses computed by recursively running mostGuessableMatchSequence on the baseToken with the supplied dictionaries.

Scenario: matches a single-character repeat

  • WHEN repeatMatch("aaaa", emptyDictionaries) runs
  • THEN the result contains a match with baseToken="a", repeatCount=4

Scenario: matches a multi-character repeat

  • WHEN repeatMatch("abcabc", emptyDictionaries) runs
  • THEN the result contains a match with baseToken="abc", repeatCount=2

Scenario: recursively scores baseToken via mostGuessableMatchSequence

  • WHEN repeatMatch("passwordpassword", { passwords: new Map([["password", 1]]) }) runs
  • THEN the resulting match's baseGuesses equals mostGuessableMatchSequence("password", […]).guesses

Verified by: packages/crypto/password-strength/src/matchers/repeat/repeat.test.ts::"matches single-character repeat" AND packages/crypto/password-strength/src/matchers/repeat/repeat.test.ts::"matches multi-character repeat" AND packages/crypto/password-strength/src/matchers/repeat/repeat.test.ts::"recursively scores baseToken"


Requirement: Date pattern matcher

The package SHALL export dateMatch(password, opts?: { referenceYear?: number }) returning every substring that parses as a date with 1900 ≤ year ≤ referenceYear + 50 (default referenceYear is current year). Both separator (/, -, ., ) and no-separator (mmddyyyy, yyyymmdd, mmyy, yyyy) forms SHALL be recognised. Every match SHALL carry pattern: "date", year, month, day, separator. When a substring admits multiple date parses, the matcher SHALL emit the parse whose year is closest to referenceYear.

Scenario: matches a separator date

  • WHEN dateMatch("11/22/1985") runs with referenceYear=2026
  • THEN the result contains a match with year=1985, month=11, day=22, separator="/"

Scenario: matches a no-separator date

  • WHEN dateMatch("11221985") runs with referenceYear=2026
  • THEN the result contains a match with year=1985, month=11, day=22, separator=""

Scenario: rejects out-of-range year

  • WHEN dateMatch("11/22/1850") runs with referenceYear=2026
  • THEN the result does NOT contain a date match

Scenario: prefers year closest to reference

  • WHEN dateMatch("010199") runs with referenceYear=2026 (parseable as 1999 or 2099)
  • THEN the resulting match has year=2099 (closest to referenceYear + 50 window)

Verified by: packages/crypto/password-strength/src/matchers/date/date.test.ts::"matches separator date" AND packages/crypto/password-strength/src/matchers/date/date.test.ts::"matches no-separator date" AND packages/crypto/password-strength/src/matchers/date/date.test.ts::"rejects out-of-range year" AND packages/crypto/password-strength/src/matchers/date/date.test.ts::"prefers year closest to reference"


Requirement: Keyboard adjacency pattern matcher

The package SHALL export keyboardMatch(password, opts?: { graphs?: KeyboardGraphMap }) returning every substring that traces a walk on a keyboard adjacency graph. QWERTY graph SHALL be the default. DVORAK and AZERTY graphs SHALL be exported under subpaths ./graphs/dvorak and ./graphs/azerty respectively and SHALL be runtime-pluggable via the graphs option. Every match SHALL carry pattern: "spatial", graph (graph name), turns (count of direction changes), and shiftedCount (count of shifted-key presses on the walk). The matcher SHALL throw PWSTRENGTH.GRAPH_INVALID when invoked with a graph containing self-adjacency or non-symmetric edges.

Scenario: matches a QWERTY straight-line walk

  • WHEN keyboardMatch("qwerty") runs with the default QWERTY graph
  • THEN the result contains a match with graph="qwerty", turns=0, shiftedCount=0, i=0, j=5

Scenario: counts turns on direction change

  • WHEN keyboardMatch("qazwsx") runs
  • THEN the resulting match's turns is ≥ 1

Scenario: counts shifted keys

  • WHEN keyboardMatch("QWERTY") runs
  • THEN the resulting match's shiftedCount equals 6

Scenario: rejects malformed graph

  • WHEN keyboardMatch("qwerty", { graphs: { broken: { q: ["q"] } } }) runs
  • THEN the call throws PasswordStrengthError with code "PWSTRENGTH.GRAPH_INVALID"

Verified by: packages/crypto/password-strength/src/matchers/keyboard/keyboard.test.ts::"matches QWERTY straight-line walk" AND packages/crypto/password-strength/src/matchers/keyboard/keyboard.test.ts::"counts turns on direction change" AND packages/crypto/password-strength/src/matchers/keyboard/keyboard.test.ts::"counts shifted keys" AND packages/crypto/password-strength/src/matchers/keyboard/keyboard.test.ts::"rejects malformed graph"


Requirement: Regex pattern matcher (recent-year + registry-pluggable)

The package SHALL export regexMatch(password, opts?: { regexes?: Record<string, RegExp> }) returning every substring matching a registered regex. The default registry SHALL include recent_year matching 19xx / 20xx. Callers MAY register additional regexes via opts.regexes. Every match SHALL carry pattern: "regex", regexName, and regexMatch: RegExpMatchArray.

Scenario: matches a recent year

  • WHEN regexMatch("login1985") runs
  • THEN the result contains a match with regexName="recent_year", token="1985"

Scenario: matches a custom registered regex

  • WHEN regexMatch("ABCDEF", { regexes: { hex: /[0-9A-F]+/g } }) runs
  • THEN the result contains a match with regexName="hex"

Verified by: packages/crypto/password-strength/src/matchers/regex/regex.test.ts::"matches recent year" AND packages/crypto/password-strength/src/matchers/regex/regex.test.ts::"matches custom registered regex"


Requirement: mostGuessableMatchSequence dynamic-programming cover

The package SHALL export mostGuessableMatchSequence(password, matches): MatchSequence returning { password, guesses, guessesLog10, sequence } where sequence is the lowest-Π guesses(match) × k! cover of password (factorial penalty for k matches per Wheeler §4). When two covers tie on product-times-factorial, the cover with fewer matches SHALL win. The DP SHALL be O(n^2) over n = password.length. Empty input SHALL return { guesses: 1, guessesLog10: 0, sequence: [] }.

Scenario: empty input

  • WHEN mostGuessableMatchSequence("", []) runs
  • THEN the result equals { password: "", guesses: 1, guessesLog10: 0, sequence: [] }

Scenario: single-match cover

  • WHEN the input has exactly one match covering the entire password
  • THEN the resulting sequence contains exactly that match and guesses equals match.guesses × 1!

Scenario: prefers fewer-match cover on tie

  • WHEN two covers have identical Π guesses × k!
  • THEN the cover with fewer matches is returned

Scenario: factorial penalty

  • WHEN a 2-match cover has guess product 1000
  • THEN the resulting guesses equals 1000 × 2! = 2000

Verified by: packages/crypto/password-strength/src/scoring/most-guessable-match-sequence.test.ts::"empty input returns 1 guess" AND packages/crypto/password-strength/src/scoring/most-guessable-match-sequence.test.ts::"single-match cover" AND packages/crypto/password-strength/src/scoring/most-guessable-match-sequence.test.ts::"prefers fewer-match cover on tie" AND packages/crypto/password-strength/src/scoring/most-guessable-match-sequence.test.ts::"applies factorial penalty"


Requirement: estimateGuesses per pattern type

The package SHALL export estimateGuesses(match, password): number implementing the Wheeler §4 closed-form formulas: dictionary (rank × uppercaseVariations × l33tVariations × (reversed ? 2 : 1)), sequence (base × length × (ascending ? 1 : 2) where base ∈ {4, 26, 26, 10} for one-char / lower / upper / digits), repeat (baseGuesses × repeatCount), date (365 × |year - referenceYear| × (separator ? 4 : 1)), keyboard (Σ_{i,j,t,s} (i_choose_j) × possible_starts × paths per Wheeler §4.5), regex (year-distance heuristic for recent_year).

Scenario: dictionary closed form

  • GIVEN a match { pattern: "dictionary", rank: 100, l33t: false, reversed: false, token: "password" }
  • WHEN estimateGuesses(match, "password") runs
  • THEN the result equals 100 (no uppercase, no l33t, no reverse multipliers)

Scenario: dictionary with reverse and l33t multipliers

  • GIVEN a match { pattern: "dictionary", rank: 100, l33t: true, sub: { "4": "a" }, reversed: true, token: "p4ssword" }
  • WHEN estimateGuesses(match, "p4ssword") runs
  • THEN the result is ≥ 100 × 2 × l33tVariations(sub) (reverse and l33t multipliers applied)

Scenario: agrees with Wheeler corpus to ≤1 bit log10

  • WHEN every vector in the vendored Wheeler test corpus is fed through estimateGuesses
  • THEN every result agrees to |Δ log10(guesses)| ≤ 1

Verified by: packages/crypto/password-strength/src/scoring/estimate-guesses.test.ts::"dictionary closed form" AND packages/crypto/password-strength/src/scoring/estimate-guesses.test.ts::"dictionary reverse and l33t multipliers" AND packages/crypto/password-strength/src/conformance.test.ts::"Wheeler corpus agrees to <=1 bit log10"


Requirement: crackTimes four-scenario calculator

The package SHALL export crackTimes(guesses, opts?: CrackTimeOptions): CrackTimes returning a record { online_throttling_100_per_hour, online_no_throttling_10_per_second, offline_slow_hashing_1e4_per_second, offline_fast_hashing_1e10_per_second } where each value is guesses / rate × (opts.adjustments?.<scenario> ?? 1). All four scenarios SHALL always be present in the output. Adjustments map values SHALL be positive numbers; Zod boundary SHALL reject zero or negative.

Scenario: four scenarios at boundary guesses

  • WHEN crackTimes(1e10) runs
  • THEN the result has online_throttling_100_per_hour ≈ 1e10 / (100/3600) AND offline_fast_hashing_1e10_per_second = 1

Scenario: applies adjustments multiplier

  • WHEN crackTimes(1e10, { adjustments: { online_throttled: 0.5 } }) runs
  • THEN the resulting online_throttling_100_per_hour is half the unadjusted value

Scenario: rejects non-positive adjustment

  • WHEN crackTimes(1e10, { adjustments: { online_throttled: 0 } }) runs
  • THEN the call throws PasswordStrengthError with code "PWSTRENGTH.INVALID_OPTIONS"

Verified by: packages/crypto/password-strength/src/scoring/crack-times.test.ts::"four scenarios at boundary guesses" AND packages/crypto/password-strength/src/scoring/crack-times.test.ts::"applies adjustments multiplier" AND packages/crypto/password-strength/src/scoring/crack-times.test.ts::"rejects non-positive adjustment"


Requirement: Score buckets 0 through 4

The package SHALL export score(guesses): 0 | 1 | 2 | 3 | 4 per Wheeler §5: 0 when guesses < 1e3, 1 when < 1e6, 2 when < 1e8, 3 when < 1e10, 4 when ≥ 1e10. The output type SHALL be the literal union 0 | 1 | 2 | 3 | 4 (verified at type level).

Scenario: bucket boundary 1e3

  • WHEN score(999) and score(1000) are called
  • THEN the results are 0 and 1

Scenario: bucket boundary 1e6

  • WHEN score(999999) and score(1000000) are called
  • THEN the results are 1 and 2

Scenario: bucket boundary 1e8

  • WHEN score(99999999) and score(100000000) are called
  • THEN the results are 2 and 3

Scenario: bucket boundary 1e10

  • WHEN score(9999999999) and score(10000000000) are called
  • THEN the results are 3 and 4

Scenario: type-level literal union

  • WHEN expectTypeOf(score(0)).toEqualTypeOf<0 | 1 | 2 | 3 | 4>() runs
  • THEN the type-level check passes

Verified by: packages/crypto/password-strength/src/scoring/score.test.ts::"bucket boundary 1e3" AND packages/crypto/password-strength/src/scoring/score.test.ts::"bucket boundary 1e6" AND packages/crypto/password-strength/src/scoring/score.test.ts::"bucket boundary 1e8" AND packages/crypto/password-strength/src/scoring/score.test.ts::"bucket boundary 1e10" AND packages/crypto/password-strength/src/scoring/score.test.ts::"score literal union type"


Requirement: assess full-pipeline public API

The package SHALL export assess(password: string, opts?: AssessOptions): StrengthResult returning { password, guesses, guessesLog10, score, crackTimes, sequence, feedback, calcTimeMs }. The pipeline SHALL: (1) Zod-validate opts; (2) reject input with length > 200 via PWSTRENGTH.INPUT_TOO_LONG; (3) prepend opts.userInputs (if any) to the dictionary set as a synthetic user_inputs ranked dictionary; (4) run all six pattern matchers; (5) call mostGuessableMatchSequence; (6) call crackTimes; (7) call score; (8) call feedback; (9) record OTel histogram and counter; (10) return result. The DoS-guard boundary SHALL be exclusive of 200 (199 accepted, 200 rejected).

Scenario: full pipeline returns canonical shape

  • WHEN assess("Password1!") runs
  • THEN the result is shaped per StrengthResultSchema and score is ≤ 1

Scenario: 199-character input is accepted

  • WHEN assess("a".repeat(199)) runs
  • THEN the call returns a StrengthResult (no throw)

Scenario: 200-character input is rejected

  • WHEN assess("a".repeat(200)) runs
  • THEN the call throws PasswordStrengthError with code "PWSTRENGTH.INPUT_TOO_LONG"

Scenario: userInputs surface as ranked matches

  • WHEN assess("hello-acme-corp", { userInputs: ["acme corp"] }) runs
  • THEN the resulting sequence contains a match with dictionaryName="user_inputs"

Scenario: custom dictionaries take precedence

  • WHEN assess("trustno1", { dictionaries: loadDictionaries({ passwords: new Map([["trustno1", 1]]) }) }) runs
  • THEN the resulting score is 0

Verified by: packages/crypto/password-strength/src/assess/assess.test.ts::"full pipeline returns canonical shape" AND packages/crypto/password-strength/src/assess/assess.test.ts::"accepts 199-character input" AND packages/crypto/password-strength/src/assess/assess.test.ts::"rejects 200-character input" AND packages/crypto/password-strength/src/assess/assess.test.ts::"userInputs surface as ranked matches" AND packages/crypto/password-strength/src/assess/assess.test.ts::"custom dictionaries take precedence"


Requirement: feedback keyed-string generator with i18n resolver

The package SHALL export feedback(result: StrengthResult, opts?: FeedbackOptions): { warning: string; suggestions: string[] } returning a warning + 0..N suggestions. Strings SHALL be obtained by calling opts.resolver(key, values) when supplied; the package SHALL fall back to a built-in English message map when opts.resolver is undefined or returns undefined for a key. The warning SHALL be keyed off the longest-cover pattern in result.sequence (e.g. "feedback.warning.dictionary.top10" for result.sequence[0] being a top-10 password). When the resolver throws, the package SHALL throw PWSTRENGTH.RESOLVER_FAILED with cause preserved.

Scenario: dispatches to resolver

  • GIVEN a result whose longest match is a top-10 password
  • AND opts.resolver returns "RESOLVED" for the corresponding key
  • WHEN feedback(result, opts) runs
  • THEN the resulting warning equals "RESOLVED"

Scenario: falls back to English when resolver is undefined

  • GIVEN a result whose longest match is a top-10 password
  • WHEN feedback(result) runs (no resolver)
  • THEN the resulting warning is the built-in English string for that key

Scenario: resolver throw surfaces RESOLVER_FAILED

  • WHEN opts.resolver throws an Error("boom")
  • THEN feedback(result, opts) throws PasswordStrengthError with code "PWSTRENGTH.RESOLVER_FAILED" and cause.message === "boom"

Verified by: packages/crypto/password-strength/src/feedback/feedback.test.ts::"dispatches to resolver" AND packages/crypto/password-strength/src/feedback/feedback.test.ts::"falls back to English when resolver undefined" AND packages/crypto/password-strength/src/feedback/feedback.test.ts::"resolver throw surfaces RESOLVER_FAILED"


Requirement: loadDictionaries runtime swap (no global mutation)

The package SHALL export loadDictionaries(dicts: PartialDictionaryMap): DictionarySet returning a fresh DictionarySet value with the supplied dictionaries merged over the bundled defaults. The bundled default dictionaries SHALL NOT be mutated. The function SHALL throw PWSTRENGTH.DICTIONARY_INVALID when any entry has a non-positive rank or non-string token.

Scenario: returns a fresh DictionarySet

  • WHEN loadDictionaries({ passwords: new Map([["alpha", 1]]) }) is called twice
  • THEN the two returned values are distinct object references

Scenario: does not mutate bundled defaults

  • GIVEN the default English dictionary set is captured before the call
  • WHEN loadDictionaries({ passwords: new Map([["alpha", 1]]) }) runs
  • THEN the captured default dictionary set is byte-equal to its post-call value

Scenario: rejects non-positive rank

  • WHEN loadDictionaries({ passwords: new Map([["alpha", 0]]) }) runs
  • THEN the call throws PasswordStrengthError with code "PWSTRENGTH.DICTIONARY_INVALID"

Verified by: packages/crypto/password-strength/src/dicts/load-dictionaries.test.ts::"returns a fresh DictionarySet" AND packages/crypto/password-strength/src/dicts/load-dictionaries.test.ts::"does not mutate bundled defaults" AND packages/crypto/password-strength/src/dicts/load-dictionaries.test.ts::"rejects non-positive rank"


Requirement: userInputs synthetic dictionary

The package SHALL accept opts.userInputs: string[] on assess. Every supplied input SHALL contribute to a synthetic user_inputs ranked dictionary (rank assigned by input order). Matches against user_inputs SHALL surface in feedback with the personal-info warning key (feedback.warning.personalInfo).

Scenario: userInputs become a ranked dictionary

  • WHEN assess("alice@example.com-pwd", { userInputs: ["alice@example.com"] }) runs
  • THEN result.sequence contains a match with dictionaryName="user_inputs" and matchedWord="alice@example.com"

Scenario: feedback surfaces personalInfo key

  • WHEN the longest-cover match in result.sequence has dictionaryName="user_inputs"
  • THEN feedback(result) returns a warning equal to the resolver's value for "feedback.warning.personalInfo" (or the English fallback)

Verified by: packages/crypto/password-strength/src/assess/assess.test.ts::"userInputs become a ranked dictionary" AND packages/crypto/password-strength/src/feedback/feedback.test.ts::"surfaces personalInfo warning key"


Requirement: Multilingual dictionary subpaths

The package SHALL ship Wikipedia top-30 000 dictionaries for en (default), de, fr, es, zh-Hans, ru under subpath exports ./dicts/<locale>. Each subpath SHALL export a DictionarySet-shaped value usable directly with assess(pwd, { dictionaries: <imported> }).

Scenario: every locale subpath resolves

  • WHEN every subpath in ./dicts/{en,de,fr,es,zh-Hans,ru} is imported
  • THEN every import resolves with no Module not found errors AND every imported value satisfies DictionarySetSchema

Scenario: locale dictionary plugs into assess

  • WHEN assess("passwort", { dictionaries: deDictionaries }) runs (where passwort is rank 1 in the de bundle)
  • THEN result.score is 0

Verified by: packages/crypto/password-strength/src/dicts/dicts.test.ts::"every locale subpath resolves" AND packages/crypto/password-strength/src/dicts/dicts.test.ts::"locale dictionary plugs into assess"


Requirement: Online-attack adjustment

The package SHALL accept opts.adjustments on crackTimes allowing site operators to multiply each scenario's effective rate by a positive number (e.g. { online_throttled: 0.5 } to model a stricter rate-limit policy halving the attacker's online throughput).

Scenario: halves online_throttled time

  • WHEN crackTimes(1e6, { adjustments: { online_throttled: 0.5 } }) runs
  • THEN the resulting online_throttling_100_per_hour equals half the unadjusted value

Scenario: every scenario is independently adjustable

  • WHEN crackTimes(1e6, { adjustments: { offline_fast: 2 } }) runs
  • THEN only offline_fast_hashing_1e10_per_second is affected; the other three scenarios match the unadjusted values

Verified by: packages/crypto/password-strength/src/scoring/crack-times.test.ts::"halves online_throttled time" AND packages/crypto/password-strength/src/scoring/crack-times.test.ts::"adjusts each scenario independently"


Requirement: assessReactive streaming wrapper

The package SHALL export assessReactive(input$: AsyncIterable<string>, opts?: AssessReactiveOptions): AsyncIterable<StrengthResult> debouncing input emissions by opts.debounceMs (default 150) and cancelling any in-flight assess when a newer input arrives. Cancelled assessments SHALL NOT yield a result. The wrapper SHALL honour opts.signal: AbortSignal; aborted iteration SHALL complete cleanly with no throw and no further yields.

Scenario: debounces rapid inputs

  • WHEN an input stream emits ["p", "pa", "pas", "pass"] within < debounceMs
  • THEN the output stream yields exactly one result for "pass"

Scenario: cancels in-flight on newer input

  • WHEN an input emits "first", assess for "first" is in flight, and "second" is emitted
  • THEN the output stream yields a result only for "second" (no result for "first")

Scenario: AbortSignal completes cleanly

  • GIVEN opts.signal is aborted mid-stream
  • WHEN the consumer awaits the next iteration
  • THEN the iterable completes (done: true) with no thrown error

Verified by: packages/crypto/password-strength/src/assess/assess-reactive.test.ts::"debounces rapid inputs" AND packages/crypto/password-strength/src/assess/assess-reactive.test.ts::"cancels in-flight on newer input" AND packages/crypto/password-strength/src/assess/assess-reactive.test.ts::"AbortSignal completes cleanly"


Requirement: INPUT_TOO_LONG DoS guard

The package SHALL reject any input string with length > 200 from assess and assessReactive by throwing PWSTRENGTH.INPUT_TOO_LONG. The boundary is exclusive: 199 accepted, 200 rejected.

Scenario: 199-character input accepted

  • WHEN assess("a".repeat(199)) runs
  • THEN the call returns a StrengthResult

Scenario: 200-character input rejected

  • WHEN assess("a".repeat(200)) runs
  • THEN the call throws PasswordStrengthError with code "PWSTRENGTH.INPUT_TOO_LONG"

Scenario: assessReactive enforces the same bound

  • WHEN an input stream emits "a".repeat(200)
  • THEN the consumer receives a thrown PasswordStrengthError with code "PWSTRENGTH.INPUT_TOO_LONG"

Verified by: packages/crypto/password-strength/src/assess/assess.test.ts::"accepts 199-character input" AND packages/crypto/password-strength/src/assess/assess.test.ts::"rejects 200-character input" AND packages/crypto/password-strength/src/assess/assess-reactive.test.ts::"rejects 200-character input"


Requirement: Error code registry

The package SHALL declare an error registry via defineCodes("PWSTRENGTH", […]) containing exactly: INPUT_TOO_LONG, INVALID_OPTIONS, DICTIONARY_INVALID, GRAPH_INVALID, RESOLVER_FAILED, ABORTED. Every code SHALL be thrown by at least one production path; ABORTED MAY be surfaced as the iteration-completion reason on assessReactive rather than as a thrown error, but SHALL be referenced from production code (per openspec/AGENTS.md §2c "wired in, not deleted").

Scenario: every code thrown by ≥1 production path

  • WHEN the test suite drives each of the five throw-paths and the one signal-cancellation path
  • THEN every code in the registry is observed at least once

Verified by: packages/crypto/password-strength/src/errors/error-codes.test.ts::"every code is thrown by a production path" AND packages/crypto/password-strength/src/errors/error-codes.test.ts::"ABORTED is surfaced from assessReactive cancellation"


Requirement: Zod boundary schemas

The package SHALL export Zod schemas at every public boundary: AssessOptionsSchema, FeedbackOptionsSchema, AssessReactiveOptionsSchema, CrackTimeOptionsSchema, MatchSchema (discriminated union), StrengthResultSchema, DictionarySetSchema. Every schema SHALL throw PWSTRENGTH.INVALID_OPTIONS (wrapping ZodError) when validation fails. Public functions SHALL Zod-parse their options before any other work.

Scenario: rejects malformed AssessOptions

  • WHEN assess("pwd", { userInputs: 42 } as never) runs
  • THEN the call throws PasswordStrengthError with code "PWSTRENGTH.INVALID_OPTIONS"

Scenario: MatchSchema discriminates over pattern types

  • WHEN MatchSchema.parse({ pattern: "dictionary", i: 0, j: 7, token: "password", matchedWord: "password", rank: 1, dictionaryName: "passwords", l33t: false, reversed: false }) runs
  • THEN the parse succeeds

Scenario: rejects unknown pattern type

  • WHEN MatchSchema.parse({ pattern: "unknown" } as never) runs
  • THEN the parse throws ZodError

Verified by: packages/crypto/password-strength/src/schemas/schemas.test.ts::"rejects malformed AssessOptions" AND packages/crypto/password-strength/src/schemas/schemas.test.ts::"MatchSchema discriminates over pattern types" AND packages/crypto/password-strength/src/schemas/schemas.test.ts::"MatchSchema rejects unknown pattern type"


Requirement: OTel observability

The package SHALL emit the following instruments via @opentelemetry/api: pwstrength.assess_duration_ms{score} histogram (recorded once per assess call), pwstrength.score_distribution_total{score} counter (incremented once per assess call), pwstrength.errors_total{code} counter (incremented once per thrown PWSTRENGTH.* code), pwstrength.matchers_run_total{matcher} counter (incremented once per match found, by matcher name).

Scenario: assess emits duration histogram and score counter

  • GIVEN an in-memory MeterProvider registered as the global meter provider
  • WHEN assess("Password1!") runs
  • THEN the recorded data contains exactly one pwstrength.assess_duration_ms data point with score matching the result AND one pwstrength.score_distribution_total increment with the same score

Scenario: throws emit errors_total counter

  • WHEN assess("a".repeat(200)) throws
  • THEN the recorded data contains a pwstrength.errors_total{code="PWSTRENGTH.INPUT_TOO_LONG"} increment

Scenario: matcher runs emit matchers_run_total counter

  • WHEN assess("abcd1234") runs
  • THEN the recorded data contains pwstrength.matchers_run_total increments with matcher="sequence" and matcher="sequence" (one per matched substring)

Verified by: packages/crypto/password-strength/src/observability/metrics.test.ts::"assess emits duration histogram and score counter" AND packages/crypto/password-strength/src/observability/metrics.test.ts::"throws emit errors_total counter" AND packages/crypto/password-strength/src/observability/metrics.test.ts::"matcher runs emit matchers_run_total counter"


Requirement: Property tests — matcher commutativity and score-bucket monotonicity

The package SHALL include fast-check property suites pinning: (1) matcher commutativity — running matchers in any permutation produces identical mostGuessableMatchSequence output; (2) score monotonicity — g1 ≤ g2 → score(g1) ≤ score(g2); (3) crackTimes monotonicity — every scenario value is monotonic in guesses; (4) idempotence — assess(p) called twice on the same input returns deeply-equal results; (5) DoS-guard boundary — length ≤ 199 accepts, length ≥ 200 rejects, with no exception leaks.

Scenario: matcher commutativity

  • WHEN fast-check generates a password and runs the matcher set in randomised order
  • THEN the resulting MatchSequence (sorted) is byte-equal across permutations

Scenario: score-bucket monotonicity

  • WHEN fast-check generates (g1, g2) with g1 ≤ g2
  • THEN score(g1) ≤ score(g2) holds

Scenario: idempotence

  • WHEN fast-check generates a password ≤199 chars
  • THEN assess(p) called twice returns deeply-equal results (excluding calcTimeMs)

Scenario: DoS-guard boundary holds across all inputs

  • WHEN fast-check generates strings of every length in [0, 250]
  • THEN length < 200 always returns; length ≥ 200 always throws PWSTRENGTH.INPUT_TOO_LONG

Verified by: packages/crypto/password-strength/src/properties.test.ts::"matcher commutativity" AND packages/crypto/password-strength/src/properties.test.ts::"score-bucket monotonicity" AND packages/crypto/password-strength/src/properties.test.ts::"crackTimes monotonicity" AND packages/crypto/password-strength/src/properties.test.ts::"assess idempotence" AND packages/crypto/password-strength/src/properties.test.ts::"DoS-guard boundary"


Requirement: Subpath exports

The package SHALL declare subpath exports so consumers can tree-shake the surface they need:

  • @suaveplan/password-strength — root barrel
  • @suaveplan/password-strength/assess — assess, assessReactive, types
  • @suaveplan/password-strength/feedback — feedback, types
  • @suaveplan/password-strength/scoring — mostGuessableMatchSequence, estimateGuesses, crackTimes, score
  • @suaveplan/password-strength/matchers/{dictionary,sequence,repeat,date,keyboard,regex}
  • @suaveplan/password-strength/dicts/{en,de,fr,es,zh-Hans,ru}
  • @suaveplan/password-strength/graphs/{qwerty,dvorak,azerty}
  • @suaveplan/password-strength/errors
  • @suaveplan/password-strength/schemas

Scenario: every subpath resolves

  • WHEN every documented subpath is imported in a consumer test fixture
  • THEN every import resolves with no Module not found errors

Verified by: packages/crypto/password-strength/src/index.test.ts::"every documented subpath export resolves"