@sungen/driver-data-factory 3.2.12-beta.1 → 3.2.12-beta.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/common/experience/auth-account-states.yaml +19 -0
- package/common/field-types/currency.yaml +1 -1
- package/common/field-types/email.yaml +4 -1
- package/common/field-types/number-float.yaml +1 -1
- package/common/field-types/number-integer.yaml +2 -2
- package/common/field-types/password.yaml +1 -1
- package/common/field-types/phone.yaml +1 -1
- package/common/field-types/postcode.yaml +1 -1
- package/common/field-types/string.yaml +1 -1
- package/package.json +3 -3
- package/src/applicability.ts +80 -0
- package/src/boundary.ts +319 -0
- package/src/catalog-loader.ts +102 -16
- package/src/cli.ts +184 -11
- package/src/contract.ts +93 -0
- package/src/coverage.ts +165 -0
- package/src/crosscheck.ts +149 -0
- package/src/detect.ts +122 -0
- package/src/fixture.ts +42 -10
- package/src/generator.ts +161 -89
- package/src/index.ts +18 -3
- package/src/lint.ts +8 -3
- package/src/sensor.ts +32 -4
- package/src/state.ts +225 -0
- package/src/types.ts +81 -0
- package/src/validate.ts +77 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Experience checklist: authentication credential / account states — from real login-testing experience.
|
|
2
|
+
# These are LOGIN/AUTH *scenarios*, NOT field-format data: each case is a credential pair whose ACCOUNT
|
|
3
|
+
# is in a particular state, with an expected auth outcome. Most need a domain-state PRECONDITION (a
|
|
4
|
+
# seeded account in that state) — declare it with a state-recipe (`sungen data state`) so it renders a
|
|
5
|
+
# @manual:data-setup Background. Data Factory carries this knowledge; create-test's login viewpoint
|
|
6
|
+
# generates the scenarios. Confirm the exact outcome/message code with the spec/BA — never assume.
|
|
7
|
+
experience: auth-account-states
|
|
8
|
+
description: Credential/account states a login (email+password or credential form) screen must cover.
|
|
9
|
+
applies_when: "a login / sign-in / authentication screen (credential form)"
|
|
10
|
+
cases:
|
|
11
|
+
- { id: CHK-AUTH-01, state: active, expect: "login succeeds → authenticated destination", note: "happy path — a valid ACTIVE account" }
|
|
12
|
+
- { id: CHK-AUTH-02, state: wrong-password, expect: "auth fails with a generic error (no user-enumeration)" }
|
|
13
|
+
- { id: CHK-AUTH-03, state: non-existent-account, expect: "auth fails with the SAME generic error as wrong-password (don't reveal which is wrong)" }
|
|
14
|
+
- { id: CHK-AUTH-04, state: soft-deleted, expect: "login REJECTED — a soft-deleted account must NOT authenticate", note: "real-world bug precedent: soft-deleted rows still passing auth because the query didn't filter deleted_at" }
|
|
15
|
+
- { id: CHK-AUTH-05, state: locked-or-suspended, expect: "login blocked with a locked/suspended message (not the generic auth error)" }
|
|
16
|
+
- { id: CHK-AUTH-06, state: unverified-email, expect: "login blocked or routed to verification, per spec" }
|
|
17
|
+
- { id: CHK-AUTH-07, state: password-expired, expect: "routed to password reset / forced change, per spec" }
|
|
18
|
+
- { id: CHK-AUTH-08, state: valid-with-surrounding-whitespace, expect: "email trimmed then login succeeds (pair a valid account email with leading/trailing spaces)", note: "trim behaviour — see the email field-type CHK-EMAIL-WS" }
|
|
19
|
+
oracle: "Each non-happy state needs a seeded account IN that state (a domain-state precondition / fixture). The 'wrong-password' and 'non-existent' outcomes should be indistinguishable (anti-enumeration). soft-deleted / locked / unverified / expired must each be blocked with the correct, spec-defined outcome."
|
|
@@ -14,7 +14,7 @@ boundary:
|
|
|
14
14
|
invalid:
|
|
15
15
|
- { id: CHK-CUR-I1, gen: "alphabetic / stray characters", reason: "not a number", errorRef: M13 }
|
|
16
16
|
- { id: CHK-CUR-I2, gen: "min-1 / max+1", reason: "out of bounds", errorRef: M13 }
|
|
17
|
-
- { id: CHK-CUR-I3, value: "", reason: "empty (per spec)", errorRef: M02 }
|
|
17
|
+
- { id: CHK-CUR-I3, value: "", reason: "empty (per spec)", errorRef: M02, when: { required: true } }
|
|
18
18
|
notes:
|
|
19
19
|
- "Check separately: leading zero (0100) not stripped; thousands separator display format."
|
|
20
20
|
uses_security: [sqli]
|
|
@@ -16,6 +16,9 @@ boundary:
|
|
|
16
16
|
- { id: CHK-EMAIL-B1, gen: "local part = maxLocal", note: "local part = max (64)" }
|
|
17
17
|
- { id: CHK-EMAIL-B2, gen: "local part = maxLocal-1", note: "local part = max-1" }
|
|
18
18
|
- { id: CHK-EMAIL-B3, gen: "total = maxTotal", note: "total length = max (255)" }
|
|
19
|
+
# min-length side (total = minLength / minLength±1) is auto-completed by the BVA pass from the
|
|
20
|
+
# field-map's `minLength` — see ensureBvaComplete; no gen entry needed here.
|
|
21
|
+
- { id: CHK-EMAIL-WS, value: " user@example.com ", validity: valid, note: "leading/trailing whitespace — server MUST trim then accept (pair with a valid password); if the spec rejects instead, override validity to invalid" }
|
|
19
22
|
invalid:
|
|
20
23
|
- { id: CHK-EMAIL-I1, value: "user@", reason: "missing domain", errorRef: M13 }
|
|
21
24
|
- { id: CHK-EMAIL-I2, value: "user@.com", reason: "dot in wrong position", errorRef: M13 }
|
|
@@ -24,7 +27,7 @@ invalid:
|
|
|
24
27
|
- { id: CHK-EMAIL-I5, value: "user@example.com", reason: "full-width characters", errorRef: M39 }
|
|
25
28
|
- { id: CHK-EMAIL-I6, gen: "local > maxLocal", reason: "exceeds maxLocal", errorRef: M13 }
|
|
26
29
|
- { id: CHK-EMAIL-I7, gen: "total > maxTotal", reason: "exceeds maxTotal", errorRef: M13 }
|
|
27
|
-
- { id: CHK-EMAIL-I8, value: "", reason: "empty (required)", errorRef: M02 }
|
|
30
|
+
- { id: CHK-EMAIL-I8, value: "", reason: "empty (required)", errorRef: M02, when: { required: true } }
|
|
28
31
|
notes:
|
|
29
32
|
- "Normalize: lowercase, NFKC, trim before comparison."
|
|
30
33
|
- "Soft-delete: if a deleted email gets a suffix appended, test maxTotal LOWER than the DB column limit to leave room for the suffix."
|
|
@@ -16,5 +16,5 @@ invalid:
|
|
|
16
16
|
- { id: CHK-FLOAT-I1, gen: "alphabetic characters", reason: "not a number", errorRef: M13 }
|
|
17
17
|
- { id: CHK-FLOAT-I2, gen: "wrong format (multiple dots, +/- wrong position)", reason: "wrong format", errorRef: M13 }
|
|
18
18
|
- { id: CHK-FLOAT-I3, gen: "min-1 / max+1", reason: "out of bounds", errorRef: M13 }
|
|
19
|
-
- { id: CHK-FLOAT-I4, value: "", reason: "empty/multi-space (per spec)", errorRef: M02 }
|
|
19
|
+
- { id: CHK-FLOAT-I4, value: "", reason: "empty/multi-space (per spec)", errorRef: M02, when: { required: true } }
|
|
20
20
|
uses_security: [sqli]
|
|
@@ -6,7 +6,7 @@ constraints:
|
|
|
6
6
|
max: 2147483647 # placeholder
|
|
7
7
|
valid:
|
|
8
8
|
- { id: CHK-INT-01, value: "123", note: "digits only" }
|
|
9
|
-
- { id: CHK-INT-02, value: "-45", note: "leading - (if spec allows negatives)" }
|
|
9
|
+
- { id: CHK-INT-02, value: "-45", note: "leading - (if spec allows negatives)", when: { negativesAllowed: true } }
|
|
10
10
|
boundary:
|
|
11
11
|
- { id: CHK-INT-B1, gen: "min / min+1" }
|
|
12
12
|
- { id: CHK-INT-B2, gen: "max / max-1" }
|
|
@@ -15,7 +15,7 @@ invalid:
|
|
|
15
15
|
- { id: CHK-INT-I2, gen: "full-width (Japanese) digits", reason: "full-width characters", errorRef: M39 }
|
|
16
16
|
- { id: CHK-INT-I3, gen: "+/- in wrong position (mid-string)", reason: "wrong format", errorRef: M13 }
|
|
17
17
|
- { id: CHK-INT-I4, gen: "min-1 / max+1", reason: "out of bounds", errorRef: M13 }
|
|
18
|
-
- { id: CHK-INT-I5, value: "", reason: "empty/multi-space (per spec)", errorRef: M02 }
|
|
18
|
+
- { id: CHK-INT-I5, value: "", reason: "empty/multi-space (per spec)", errorRef: M02, when: { required: true } }
|
|
19
19
|
notes:
|
|
20
20
|
- "Check leading zero (007) is not stripped; redundant spaces before/after handled correctly."
|
|
21
21
|
uses_security: [sqli]
|
|
@@ -17,7 +17,7 @@ boundary:
|
|
|
17
17
|
- { id: CHK-PW-B3, gen: "max length", note: "max" }
|
|
18
18
|
- { id: CHK-PW-B4, gen: "max-1", note: "max-1" }
|
|
19
19
|
invalid:
|
|
20
|
-
- { id: CHK-PW-I1, value: "", reason: "empty/multi-space (required)", errorRef: M02 }
|
|
20
|
+
- { id: CHK-PW-I1, value: "", reason: "empty/multi-space (required)", errorRef: M02, when: { required: true } }
|
|
21
21
|
- { id: CHK-PW-I2, gen: "< min length", reason: "below minimum length", errorRef: M29 }
|
|
22
22
|
- { id: CHK-PW-I3, gen: "> max length", reason: "exceeds maximum length", errorRef: M30 }
|
|
23
23
|
- { id: CHK-PW-I4, value: "passw0rd", reason: "missing uppercase", errorRef: M31 }
|
|
@@ -19,5 +19,5 @@ invalid:
|
|
|
19
19
|
- { id: CHK-PHONE-I4, value: "090 123 4567", reason: "spaces between digits", errorRef: M13 }
|
|
20
20
|
- { id: CHK-PHONE-I5, gen: "+/- not first; () wrong position", reason: "wrong format", errorRef: M13 }
|
|
21
21
|
- { id: CHK-PHONE-I6, gen: "min-1 / max+1", reason: "out of bounds", errorRef: M13 }
|
|
22
|
-
- { id: CHK-PHONE-I7, value: "", reason: "empty/multi-space (per spec)", errorRef: M02 }
|
|
22
|
+
- { id: CHK-PHONE-I7, value: "", reason: "empty/multi-space (per spec)", errorRef: M02, when: { required: true } }
|
|
23
23
|
uses_security: [sqli]
|
|
@@ -12,5 +12,5 @@ boundary:
|
|
|
12
12
|
invalid:
|
|
13
13
|
- { id: CHK-ZIP-I1, gen: "alphabetic / stray characters", reason: "invalid characters", errorRef: M13 }
|
|
14
14
|
- { id: CHK-ZIP-I2, gen: "wrong region format", reason: "wrong format", errorRef: M13 }
|
|
15
|
-
- { id: CHK-ZIP-I3, value: "", reason: "empty (per spec)", errorRef: M02 }
|
|
15
|
+
- { id: CHK-ZIP-I3, value: "", reason: "empty (per spec)", errorRef: M02, when: { required: true } }
|
|
16
16
|
uses_security: [sqli]
|
|
@@ -16,5 +16,5 @@ boundary:
|
|
|
16
16
|
- { id: CHK-STR-B1, gen: "min / min+1 length" }
|
|
17
17
|
- { id: CHK-STR-B2, gen: "max / max-1 length" }
|
|
18
18
|
invalid:
|
|
19
|
-
- { id: CHK-STR-I1, value: "", reason: "empty/multi-space (success/fail per spec)", errorRef: M02 }
|
|
19
|
+
- { id: CHK-STR-I1, value: "", reason: "empty/multi-space (success/fail per spec)", errorRef: M02, when: { required: true } }
|
|
20
20
|
- { id: CHK-STR-I2, gen: "min-1 / max+1", reason: "out of bounds", errorRef: M13 }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sungen/driver-data-factory",
|
|
3
|
-
"version": "3.2.12-beta.
|
|
3
|
+
"version": "3.2.12-beta.11",
|
|
4
4
|
"description": "Sungen Data Factory capability — standardized test-data generation from a field-type + security knowledge catalog (valid/boundary/invalid + traceability), the .overwrite merge layer, and (opt-in) @dataFactory runtime seeding. Design-time knowledge is runtime-free. Plugs into @sun-asterisk/sungen via the capability SPI.",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -9,12 +9,12 @@
|
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
11
|
"build": "rm -rf dist && tsc -p tsconfig.json",
|
|
12
|
-
"test": "tsx tests/run.ts"
|
|
12
|
+
"test": "tsx tests/run.ts && tsx tests/golden.ts"
|
|
13
13
|
},
|
|
14
14
|
"author": "eqe team (engineer & quality) — Sun Asterisk",
|
|
15
15
|
"license": "MIT",
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@sun-asterisk/sungen": "3.2.12-beta.
|
|
17
|
+
"@sun-asterisk/sungen": "3.2.12-beta.11",
|
|
18
18
|
"yaml": "^2.4.0"
|
|
19
19
|
},
|
|
20
20
|
"files": [
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data Factory — applicability (M2b).
|
|
3
|
+
*
|
|
4
|
+
* A catalog case is not universally applicable: an "empty (required)" invalid means nothing for an
|
|
5
|
+
* OPTIONAL field, a negative-number example only applies when the field allows negatives, a katakana
|
|
6
|
+
* or locale-specific case only applies under that locale, and a security payload is an *adversarial*
|
|
7
|
+
* case that belongs in its own bucket rather than mixed into plain `invalid`. Both reviews flagged
|
|
8
|
+
* this: the driver must return `not-applicable` (with a reason) instead of silently emitting a case
|
|
9
|
+
* that doesn't fit — and it must let Core (or a CLI flag) select a reduced profile.
|
|
10
|
+
*
|
|
11
|
+
* Pure + deterministic. Field-derived predicates (`required`, `negativesAllowed`) are always enforced;
|
|
12
|
+
* environment predicates (`locale`, `channel`, `sink`) only filter when the context supplies them.
|
|
13
|
+
*/
|
|
14
|
+
import type { DataPoint, SelectionContext, Profile } from './types';
|
|
15
|
+
import type { FieldDef } from './generator';
|
|
16
|
+
|
|
17
|
+
export interface FieldFacts {
|
|
18
|
+
required?: boolean;
|
|
19
|
+
min?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface Applicability {
|
|
23
|
+
applicable: boolean;
|
|
24
|
+
/** Why it was excluded — shown to the QA ("not-applicable, không âm thầm sinh data"). */
|
|
25
|
+
reason?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Evaluate a point's `when` conditions against the field facts + selection context. */
|
|
29
|
+
export function evaluateWhen(when: Record<string, unknown> | undefined, facts: FieldFacts, ctx: SelectionContext): Applicability {
|
|
30
|
+
if (!when) return { applicable: true };
|
|
31
|
+
// Field-derived: always enforced (a definite field fact can rule the case out).
|
|
32
|
+
if (when.required === true && facts.required === false) {
|
|
33
|
+
return { applicable: false, reason: 'requires a required field; this field is optional' };
|
|
34
|
+
}
|
|
35
|
+
if (when.negativesAllowed === true && Number.isFinite(facts.min) && (facts.min as number) >= 0) {
|
|
36
|
+
return { applicable: false, reason: `requires negatives (min < 0); this field has min=${facts.min}` };
|
|
37
|
+
}
|
|
38
|
+
// Environment-derived: only filter when the context supplies the axis (else permissive).
|
|
39
|
+
if (typeof when.locale === 'string' && ctx.locale && !ctx.locale.toLowerCase().startsWith(String(when.locale).toLowerCase())) {
|
|
40
|
+
return { applicable: false, reason: `locale ${when.locale} ≠ context ${ctx.locale}` };
|
|
41
|
+
}
|
|
42
|
+
if (typeof when.channel === 'string' && ctx.channel && ctx.channel !== when.channel) {
|
|
43
|
+
return { applicable: false, reason: `channel ${when.channel} ≠ context ${ctx.channel}` };
|
|
44
|
+
}
|
|
45
|
+
if (typeof when.sink === 'string' && ctx.sink && ctx.sink !== when.sink) {
|
|
46
|
+
return { applicable: false, reason: `sink ${when.sink} ≠ context ${ctx.sink}` };
|
|
47
|
+
}
|
|
48
|
+
return { applicable: true };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Field facts the field-derived predicates read (required + numeric min after constraint merge). */
|
|
52
|
+
export function fieldFacts(field: FieldDef, mergedConstraints: Record<string, any>): FieldFacts {
|
|
53
|
+
const min = mergedConstraints.min;
|
|
54
|
+
return { required: field.required, min: typeof min === 'number' ? min : undefined };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Is a point kept under the chosen profile? (Applied AFTER `when` applicability.) Rule-based over
|
|
59
|
+
* group + category, so it needs no per-point tagging:
|
|
60
|
+
* - regression (default): everything.
|
|
61
|
+
* - functional: valid + boundary + non-adversarial invalid (drops security/adversarial).
|
|
62
|
+
* - smoke: valid + boundary (the "does it work" set — no invalid, no adversarial).
|
|
63
|
+
* - security-min: valid + adversarial (security sanity).
|
|
64
|
+
*/
|
|
65
|
+
export function inProfile(group: 'valid' | 'boundary' | 'invalid', category: string | undefined, profile: Profile | undefined): boolean {
|
|
66
|
+
const adversarial = category === 'adversarial';
|
|
67
|
+
switch (profile) {
|
|
68
|
+
case undefined:
|
|
69
|
+
case 'regression':
|
|
70
|
+
return true;
|
|
71
|
+
case 'functional':
|
|
72
|
+
return !adversarial;
|
|
73
|
+
case 'smoke':
|
|
74
|
+
return group === 'valid' || group === 'boundary';
|
|
75
|
+
case 'security-min':
|
|
76
|
+
return group === 'valid' || adversarial;
|
|
77
|
+
default:
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
}
|
package/src/boundary.ts
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data Factory — structured boundary expansion + the deterministic validity oracle.
|
|
3
|
+
*
|
|
4
|
+
* This replaces the old prose-parsing (`hint.includes('min')`) that produced semantically WRONG data:
|
|
5
|
+
* - password `< min` (minLength 7) synthesized a 7-char string — which is VALID, not the invalid
|
|
6
|
+
* "below minimum" case it claims to be;
|
|
7
|
+
* - integer `min / min+1` collapsed to a single value, dropping one of the two boundary cases.
|
|
8
|
+
*
|
|
9
|
+
* A boundary/invalid hint is tokenized into one or more BoundaryPoints — one case per point
|
|
10
|
+
* (`min / min+1` → two, `min-1 / max+1` → two) — each carrying the value AND its expected validity,
|
|
11
|
+
* computed by the same oracle that later re-checks the value. So "the rule says valid" and "the value
|
|
12
|
+
* actually satisfies the constraints" can never silently disagree (that disagreement is a golden-test
|
|
13
|
+
* failure). Pure, deterministic; no runtime, no LLM.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type Validity = 'valid' | 'invalid';
|
|
17
|
+
|
|
18
|
+
export interface BoundaryPoint {
|
|
19
|
+
/** Id suffix so one catalog point can expand to several: '' (first), 'b', 'c', … */
|
|
20
|
+
suffix: string;
|
|
21
|
+
value: string | null;
|
|
22
|
+
/** What the oracle expects this value to be — locked by golden tests against the real check. */
|
|
23
|
+
validity: Validity;
|
|
24
|
+
/** Human label for the point, e.g. 'min', 'min+1', 'max+1', 'length-1'. */
|
|
25
|
+
label: string;
|
|
26
|
+
todo?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const STR_TYPES = new Set(['string', 'password', 'phone', 'postcode', 'katakana']);
|
|
30
|
+
const NUM_TYPES = new Set(['number-integer', 'number-float', 'currency']);
|
|
31
|
+
|
|
32
|
+
/** Deterministic filler: a string of exactly `n` chars from a charset seed. */
|
|
33
|
+
export function fillLen(n: number, base = 'a'): string {
|
|
34
|
+
if (!Number.isFinite(n) || n <= 0) return '';
|
|
35
|
+
return base.repeat(Math.ceil(n / base.length)).slice(0, n);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Password seed that satisfies upper+lower+digit for any length ≥ 3. */
|
|
39
|
+
function fillPassword(n: number): string {
|
|
40
|
+
if (n <= 0) return '';
|
|
41
|
+
// Prefix guarantees the character classes; pad with lowercase to reach n.
|
|
42
|
+
const prefix = 'Aa1';
|
|
43
|
+
if (n <= prefix.length) return prefix.slice(0, n);
|
|
44
|
+
return prefix + 'a'.repeat(n - prefix.length);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function seedFor(type: string): string {
|
|
48
|
+
return type === 'password' ? 'PW' : type === 'katakana' ? 'ア' : type === 'phone' || type === 'postcode' ? '0' : 'a';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function fillString(type: string, n: number): string {
|
|
52
|
+
return type === 'password' ? fillPassword(n) : fillLen(n, seedFor(type));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Precision step for a numeric type: 1 for integers/currency(0dp), 10^-dp for floats. */
|
|
56
|
+
function step(type: string, c: Record<string, any>): number {
|
|
57
|
+
if (type === 'number-float' || (type === 'currency' && Number.isFinite(c.decimalPlaces) && c.decimalPlaces > 0)) {
|
|
58
|
+
const dp = Number.isFinite(c.decimalPlaces) ? c.decimalPlaces : 2;
|
|
59
|
+
return Number((1 / Math.pow(10, dp)).toFixed(dp));
|
|
60
|
+
}
|
|
61
|
+
return 1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Round to the type's decimal places so we don't emit float noise like 999999.9899999999. */
|
|
65
|
+
function fmtNum(type: string, c: Record<string, any>, n: number): string {
|
|
66
|
+
if (type === 'number-float' || (type === 'currency' && c.decimalPlaces > 0)) {
|
|
67
|
+
const dp = Number.isFinite(c.decimalPlaces) ? c.decimalPlaces : 2;
|
|
68
|
+
return n.toFixed(dp);
|
|
69
|
+
}
|
|
70
|
+
return String(Math.round(n));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Split a prose hint into per-point segments: "min / min+1" → ["min","min+1"]. */
|
|
74
|
+
function segments(hint: string): string[] {
|
|
75
|
+
return hint
|
|
76
|
+
.split(/[/,]/)
|
|
77
|
+
.map((s) => s.trim())
|
|
78
|
+
.filter(Boolean);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Resolve a single segment to a signed offset off min/max: "< min" → {base:'min',off:-1}. */
|
|
82
|
+
function readOffset(seg: string): { base: 'min' | 'max' | 'length' | null; off: number; label: string } {
|
|
83
|
+
const s = seg.toLowerCase();
|
|
84
|
+
const base: 'min' | 'max' | 'length' | null =
|
|
85
|
+
/\bmax\b|maximum|maxlength|maxlocal|maxtotal/.test(s) ? 'max'
|
|
86
|
+
: /\bmin\b|minimum|minlength/.test(s) ? 'min'
|
|
87
|
+
: /exact|length|byte/.test(s) ? 'length'
|
|
88
|
+
: null;
|
|
89
|
+
// Offset: explicit ±1 wins; then relational (< / over / exceeds → out-of-range).
|
|
90
|
+
let off = 0;
|
|
91
|
+
if (/\+\s*1|plus one|one extra/.test(s)) off = 1;
|
|
92
|
+
else if (/-\s*1|minus one/.test(s)) off = -1;
|
|
93
|
+
else if (/<|below|under|less than|shorter/.test(s)) off = -1;
|
|
94
|
+
else if (/>|over|above|exceed|greater than|longer|too long/.test(s)) off = 1;
|
|
95
|
+
const sign = off > 0 ? '+1' : off < 0 ? '-1' : '';
|
|
96
|
+
const label = (base || 'bound') + sign;
|
|
97
|
+
return { base, off, label };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Expand a length-bounded string hint into one BoundaryPoint per segment.
|
|
102
|
+
* min/max come from the constraints; a point OUTSIDE [min,max] is invalid, inside/on-edge is valid.
|
|
103
|
+
*/
|
|
104
|
+
function expandLength(type: string, hint: string, c: Record<string, any>): BoundaryPoint[] {
|
|
105
|
+
const min = c.minLength ?? c.length ?? c.minlength;
|
|
106
|
+
const max = c.maxLength ?? c.length ?? c.maxlength;
|
|
107
|
+
const segs = segments(hint);
|
|
108
|
+
const out: BoundaryPoint[] = [];
|
|
109
|
+
let idx = 0;
|
|
110
|
+
for (const seg of segs) {
|
|
111
|
+
const { base, off, label } = readOffset(seg);
|
|
112
|
+
const anchor = base === 'max' ? max : base === 'length' ? (c.length ?? max ?? min) : min;
|
|
113
|
+
if (!Number.isFinite(anchor)) {
|
|
114
|
+
out.push({ suffix: suffixOf(idx++), value: null, validity: base === null ? 'valid' : 'valid', label, todo: 'minLength/maxLength/length' });
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const n = anchor + off;
|
|
118
|
+
const lo = Number.isFinite(min) ? min : 0;
|
|
119
|
+
const hi = Number.isFinite(max) ? max : Infinity;
|
|
120
|
+
const validity: Validity = n >= lo && n <= hi && n >= 1 ? 'valid' : 'invalid';
|
|
121
|
+
out.push({ suffix: suffixOf(idx++), value: fillString(type, Math.max(0, n)), validity, label });
|
|
122
|
+
}
|
|
123
|
+
return out.length ? out : [{ suffix: '', value: null, validity: 'valid', label: 'bound', todo: 'minLength/maxLength' }];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Expand a numeric value hint. Float/currency use a precision step, not a fixed ±1. */
|
|
127
|
+
function expandNumeric(type: string, hint: string, c: Record<string, any>): BoundaryPoint[] {
|
|
128
|
+
const min = c.min, max = c.max;
|
|
129
|
+
const st = step(type, c);
|
|
130
|
+
const segs = segments(hint);
|
|
131
|
+
const out: BoundaryPoint[] = [];
|
|
132
|
+
let idx = 0;
|
|
133
|
+
for (const seg of segs) {
|
|
134
|
+
const s = seg.toLowerCase();
|
|
135
|
+
// Decimal-places boundary (float): "exact decimal places" valid; "one extra digit" invalid.
|
|
136
|
+
if (/decimal|precision|extra digit/.test(s)) {
|
|
137
|
+
const dp = Number.isFinite(c.decimalPlaces) ? c.decimalPlaces : 2;
|
|
138
|
+
if (/extra|one extra|\+1|too many/.test(s)) {
|
|
139
|
+
out.push({ suffix: suffixOf(idx++), value: '0.' + '1'.repeat(dp + 1), validity: 'invalid', label: `decimalPlaces+1` });
|
|
140
|
+
} else {
|
|
141
|
+
out.push({ suffix: suffixOf(idx++), value: '0.' + '1'.repeat(Math.max(1, dp)), validity: 'valid', label: `decimalPlaces` });
|
|
142
|
+
}
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const { base, off, label } = readOffset(seg);
|
|
146
|
+
const anchor = base === 'max' ? max : min;
|
|
147
|
+
if (!Number.isFinite(anchor)) {
|
|
148
|
+
out.push({ suffix: suffixOf(idx++), value: null, validity: 'valid', label, todo: 'min/max' });
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const n = anchor + off * st;
|
|
152
|
+
const lo = Number.isFinite(min) ? min : -Infinity;
|
|
153
|
+
const hi = Number.isFinite(max) ? max : Infinity;
|
|
154
|
+
const validity: Validity = n >= lo && n <= hi ? 'valid' : 'invalid';
|
|
155
|
+
out.push({ suffix: suffixOf(idx++), value: fmtNum(type, c, n), validity, label });
|
|
156
|
+
}
|
|
157
|
+
return out.length ? out : [{ suffix: '', value: null, validity: 'valid', label: 'bound', todo: 'min/max' }];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Build an email at a target local or total length while honoring BOTH maxLocal and maxTotal. */
|
|
161
|
+
function synthEmail(c: Record<string, any>, which: 'maxLocal' | 'maxTotal', off: number): string | null {
|
|
162
|
+
const maxLocal = Number(c.maxLocal);
|
|
163
|
+
const maxTotal = Number(c.maxTotal);
|
|
164
|
+
if (which === 'maxLocal') {
|
|
165
|
+
if (!Number.isFinite(maxLocal)) return null;
|
|
166
|
+
const localLen = Math.max(1, maxLocal + off);
|
|
167
|
+
return fillLen(localLen) + '@example.com';
|
|
168
|
+
}
|
|
169
|
+
// maxTotal: keep the local part ≤ maxLocal, pad the DOMAIN to reach the total length.
|
|
170
|
+
if (!Number.isFinite(maxTotal)) return null;
|
|
171
|
+
const total = maxTotal + off;
|
|
172
|
+
const localLen = Number.isFinite(maxLocal) ? Math.min(maxLocal, 24) : 24;
|
|
173
|
+
const fixed = 1 /* @ */ + 4 /* .com */;
|
|
174
|
+
const subLen = total - localLen - fixed;
|
|
175
|
+
if (subLen < 1) return fillLen(Math.max(1, total - fixed)) + '@x.io'.slice(0, Math.max(4, fixed));
|
|
176
|
+
return fillLen(localLen) + '@' + fillLen(subLen) + '.com';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Expand an email length hint (single point per catalog entry today, but honors both constraints). */
|
|
180
|
+
function expandEmail(hint: string, c: Record<string, any>): BoundaryPoint[] {
|
|
181
|
+
const segs = segments(hint);
|
|
182
|
+
const out: BoundaryPoint[] = [];
|
|
183
|
+
let idx = 0;
|
|
184
|
+
for (const seg of segs) {
|
|
185
|
+
const { off, label } = readOffset(seg);
|
|
186
|
+
const s = seg.toLowerCase();
|
|
187
|
+
const which: 'maxLocal' | 'maxTotal' = /total/.test(s) ? 'maxTotal' : 'maxLocal';
|
|
188
|
+
const value = synthEmail(c, which, off);
|
|
189
|
+
if (value == null) { out.push({ suffix: suffixOf(idx++), value: null, validity: 'valid', label, todo: which }); continue; }
|
|
190
|
+
const validity: Validity = satisfiesEmail(value, c) ? 'valid' : 'invalid';
|
|
191
|
+
out.push({ suffix: suffixOf(idx++), value, validity, label: `${which}${off > 0 ? '+1' : off < 0 ? '-1' : ''}` });
|
|
192
|
+
}
|
|
193
|
+
return out.length ? out : [{ suffix: '', value: null, validity: 'valid', label: 'bound', todo: 'maxLocal/maxTotal' }];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function suffixOf(i: number): string {
|
|
197
|
+
return i === 0 ? '' : String.fromCharCode(96 + i); // 0→'' , 1→'a', 2→'b', … (id becomes CHK-INT-B1, CHK-INT-B1a, …)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Public entry: expand a boundary/invalid hint into structured points.
|
|
202
|
+
* Returns one point per segment; each carries the value AND the oracle's expected validity.
|
|
203
|
+
*/
|
|
204
|
+
export function expandBoundaryHint(hint: string, type: string, c: Record<string, any>): BoundaryPoint[] {
|
|
205
|
+
if (type === 'email') return expandEmail(hint, c);
|
|
206
|
+
if (STR_TYPES.has(type)) return expandLength(type, hint, c);
|
|
207
|
+
if (NUM_TYPES.has(type)) return expandNumeric(type, hint, c);
|
|
208
|
+
return [{ suffix: '', value: null, validity: 'valid', label: 'bound', todo: 'value/gen not resolvable' }];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---- The validity oracle: does a value structurally satisfy the constraints? --------------------
|
|
212
|
+
|
|
213
|
+
export function satisfiesEmail(value: string, c: Record<string, any>): boolean {
|
|
214
|
+
const at = value.indexOf('@');
|
|
215
|
+
if (at <= 0 || at === value.length - 1) return false;
|
|
216
|
+
const local = value.slice(0, at);
|
|
217
|
+
if (Number.isFinite(c.maxLocal) && local.length > c.maxLocal) return false;
|
|
218
|
+
if (Number.isFinite(c.maxTotal) && value.length > c.maxTotal) return false;
|
|
219
|
+
// Total-length MIN (a spec often sets an email min length, e.g. 5) — the oracle must enforce it so a
|
|
220
|
+
// below-min email is correctly INVALID (was ignored → the min boundary silently missing).
|
|
221
|
+
const minTotal = c.minLength ?? c.minTotal;
|
|
222
|
+
if (Number.isFinite(minTotal) && value.length < minTotal) return false;
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Build an email of exactly `n` total chars (for the total-length MIN boundary — small n). */
|
|
227
|
+
export function emailOfTotalLength(n: number): string | null {
|
|
228
|
+
if (!Number.isFinite(n) || n < 1) return null;
|
|
229
|
+
if (n < 3) return 'a'.repeat(n); // too short to be a real address → malformed (below-min case)
|
|
230
|
+
const rest = n - 2; // chars after "a@"
|
|
231
|
+
if (rest < 3) return 'a@' + 'b'.repeat(rest); // e.g. n=4 → "a@bb" (no dot → also malformed, below-min)
|
|
232
|
+
return 'a@' + 'b'.repeat(rest - 2) + '.c'; // e.g. n=5 → "a@b.c", n=6 → "a@bb.c"
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function satisfiesLength(type: string, value: string, c: Record<string, any>): boolean {
|
|
236
|
+
const min = c.minLength ?? c.length;
|
|
237
|
+
const max = c.maxLength ?? c.length;
|
|
238
|
+
if (Number.isFinite(min) && value.length < min) return false;
|
|
239
|
+
if (Number.isFinite(max) && value.length > max) return false;
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function satisfiesNumeric(type: string, value: string, c: Record<string, any>): boolean {
|
|
244
|
+
const n = Number(value);
|
|
245
|
+
if (!Number.isFinite(n)) return false;
|
|
246
|
+
if (Number.isFinite(c.min) && n < c.min) return false;
|
|
247
|
+
if (Number.isFinite(c.max) && n > c.max) return false;
|
|
248
|
+
if ((type === 'number-integer') && !Number.isInteger(n)) return false;
|
|
249
|
+
if ((type === 'number-float' || type === 'currency') && Number.isFinite(c.decimalPlaces)) {
|
|
250
|
+
const dec = (value.split('.')[1] || '').length;
|
|
251
|
+
if (dec > c.decimalPlaces) return false;
|
|
252
|
+
}
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The oracle. Given a concrete value and the constraints, decide whether it structurally satisfies
|
|
258
|
+
* them (length/range/precision — NOT charset/format, which the catalog encodes as literal invalids).
|
|
259
|
+
* Returns null when the type has no numeric/length constraint to check against (nothing to assert).
|
|
260
|
+
*/
|
|
261
|
+
export function satisfiesConstraints(type: string, value: string | null, c: Record<string, any>): boolean | null {
|
|
262
|
+
if (value == null) return null;
|
|
263
|
+
if (type === 'email') return satisfiesEmail(value, c);
|
|
264
|
+
if (STR_TYPES.has(type)) {
|
|
265
|
+
if (!Number.isFinite(c.minLength ?? c.length) && !Number.isFinite(c.maxLength ?? c.length)) return null;
|
|
266
|
+
return satisfiesLength(type, value, c);
|
|
267
|
+
}
|
|
268
|
+
if (NUM_TYPES.has(type)) {
|
|
269
|
+
if (!Number.isFinite(c.min) && !Number.isFinite(c.max) && !Number.isFinite(c.decimalPlaces)) return null;
|
|
270
|
+
return satisfiesNumeric(type, value, c);
|
|
271
|
+
}
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ---- BVA completeness: every bounded dimension gets its full min-1/min/min+1/max-1/max/max+1 set ----
|
|
276
|
+
|
|
277
|
+
export interface BvaExtra { label: string; value: string; validity: Validity; group: 'boundary' | 'invalid'; }
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Ensure the canonical Boundary-Value-Analysis set is present for EVERY bounded dimension declared in
|
|
281
|
+
* the constraints — even one the field-type's catalog `gen:` entries don't exercise. This closes the
|
|
282
|
+
* QA-reported gap: email declared `minLength: 5` but the catalog only modelled maxLocal/maxTotal, so
|
|
283
|
+
* the min side (length 4 = min-1 invalid, 5 = min, 6 = min+1) was silently missing. Emits only the
|
|
284
|
+
* MISSING points (dedup by exact value), so a type whose catalog already covers a dimension (string /
|
|
285
|
+
* password / number) yields nothing — no churn.
|
|
286
|
+
*/
|
|
287
|
+
export function ensureBvaComplete(type: string, c: Record<string, any>, existing: Array<string | null>): BvaExtra[] {
|
|
288
|
+
const have = new Set(existing.filter((v): v is string => v != null));
|
|
289
|
+
const out: BvaExtra[] = [];
|
|
290
|
+
const push = (label: string, value: string | null) => {
|
|
291
|
+
if (value == null || have.has(value)) return;
|
|
292
|
+
const ok = satisfiesConstraints(type, value, c);
|
|
293
|
+
const validity: Validity = ok === false ? 'invalid' : 'valid';
|
|
294
|
+
out.push({ label, value, validity, group: validity === 'invalid' ? 'invalid' : 'boundary' });
|
|
295
|
+
have.add(value);
|
|
296
|
+
};
|
|
297
|
+
if (type === 'email') {
|
|
298
|
+
const minTotal = c.minLength ?? c.minTotal;
|
|
299
|
+
if (Number.isFinite(minTotal)) {
|
|
300
|
+
push('minTotal-1', emailOfTotalLength((minTotal as number) - 1));
|
|
301
|
+
push('minTotal', emailOfTotalLength(minTotal as number));
|
|
302
|
+
push('minTotal+1', emailOfTotalLength((minTotal as number) + 1));
|
|
303
|
+
}
|
|
304
|
+
return out;
|
|
305
|
+
}
|
|
306
|
+
if (STR_TYPES.has(type)) {
|
|
307
|
+
const min = c.minLength ?? c.length, max = c.maxLength ?? c.length;
|
|
308
|
+
if (Number.isFinite(min)) { push('minLen-1', fillString(type, min - 1)); push('minLen', fillString(type, min)); push('minLen+1', fillString(type, min + 1)); }
|
|
309
|
+
if (Number.isFinite(max)) { push('maxLen-1', fillString(type, max - 1)); push('maxLen', fillString(type, max)); push('maxLen+1', fillString(type, max + 1)); }
|
|
310
|
+
return out;
|
|
311
|
+
}
|
|
312
|
+
if (NUM_TYPES.has(type)) {
|
|
313
|
+
const min = c.min, max = c.max, st = step(type, c);
|
|
314
|
+
if (Number.isFinite(min)) { push('min-1', fmtNum(type, c, min - st)); push('min', fmtNum(type, c, min)); push('min+1', fmtNum(type, c, min + st)); }
|
|
315
|
+
if (Number.isFinite(max)) { push('max-1', fmtNum(type, c, max - st)); push('max', fmtNum(type, c, max)); push('max+1', fmtNum(type, c, max + st)); }
|
|
316
|
+
return out;
|
|
317
|
+
}
|
|
318
|
+
return out;
|
|
319
|
+
}
|