@sungen/driver-data-factory 3.2.10-beta.6 → 3.2.12-beta.10

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.
Files changed (37) hide show
  1. package/common/experience/auth-account-states.yaml +19 -0
  2. package/common/field-types/currency.yaml +10 -10
  3. package/common/field-types/email.yaml +19 -16
  4. package/common/field-types/jp/katakana.yaml +12 -12
  5. package/common/field-types/number-float.yaml +10 -10
  6. package/common/field-types/number-integer.yaml +12 -12
  7. package/common/field-types/password.yaml +22 -22
  8. package/common/field-types/phone.yaml +14 -14
  9. package/common/field-types/postcode.yaml +8 -8
  10. package/common/field-types/string.yaml +11 -11
  11. package/common/security/broken-auth.yaml +6 -6
  12. package/common/security/idor.yaml +5 -5
  13. package/common/security/input-validation.yaml +5 -5
  14. package/common/security/sqli.yaml +7 -7
  15. package/common/security/upload.yaml +7 -7
  16. package/common/security/xss.yaml +9 -9
  17. package/common/techniques/boundary.yaml +4 -4
  18. package/common/techniques/decision-table.yaml +4 -4
  19. package/common/techniques/equivalence.yaml +4 -4
  20. package/common/techniques/state-transition.yaml +4 -4
  21. package/package.json +3 -3
  22. package/src/applicability.ts +80 -0
  23. package/src/boundary.ts +319 -0
  24. package/src/catalog-loader.ts +104 -18
  25. package/src/cli.ts +233 -14
  26. package/src/contract.ts +93 -0
  27. package/src/crosscheck.ts +149 -0
  28. package/src/detect.ts +122 -0
  29. package/src/fixture.ts +111 -0
  30. package/src/generator.ts +231 -98
  31. package/src/index.ts +17 -2
  32. package/src/lint.ts +31 -16
  33. package/src/readme.ts +52 -0
  34. package/src/sensor.ts +32 -4
  35. package/src/state.ts +225 -0
  36. package/src/types.ts +82 -1
  37. package/src/validate.ts +77 -0
package/src/detect.ts ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Data Factory — auto-detection (Layer A of the auto-orchestration design).
3
+ *
4
+ * Makes Sungen NOTICE what test-data work a unit needs, without QA having to know to run a command:
5
+ * - a unit that HAS input fields but has NO field-map → its data was never standardized;
6
+ * - a spec that describes a STATEFUL PRECONDITION (a required entity/state) with no state-recipe and
7
+ * no Background/@manual precondition → the stateful setup is undeclared.
8
+ * Both are surfaced as advisory findings at `sungen generate`/`sungen audit` (the DF advisory sensor).
9
+ * Deterministic heuristics over spec.md + the feature; no runtime, no LLM. Advisory only — a nudge +
10
+ * an auto-draft prompt, never a hard fail (the fuzzy precondition scan must not block).
11
+ */
12
+ import { join } from 'path';
13
+ import { existsSync, readFileSync, readdirSync } from 'fs';
14
+ import { loadStateRecipes } from './state';
15
+
16
+ export interface DetectFinding {
17
+ code: 'UNSTANDARDIZED' | 'PRECONDITION_UNDECLARED';
18
+ scenario: string;
19
+ message: string;
20
+ severity: 'warn' | 'info';
21
+ }
22
+
23
+ function readIfExists(path: string): string {
24
+ try { return existsSync(path) ? readFileSync(path, 'utf8') : ''; } catch { return ''; }
25
+ }
26
+
27
+ function readSpec(dir: string): string {
28
+ return readIfExists(join(dir, 'requirements', 'spec.md'));
29
+ }
30
+
31
+ function readFeature(dir: string): string {
32
+ const fdir = join(dir, 'features');
33
+ if (!existsSync(fdir)) return '';
34
+ try {
35
+ return readdirSync(fdir).filter((f) => f.endsWith('.feature')).map((f) => readIfExists(join(fdir, f))).join('\n');
36
+ } catch { return ''; }
37
+ }
38
+
39
+ /**
40
+ * Does the unit take user input? Strongest signal: a Gherkin `fill [X] field` / `type` step; plus a
41
+ * spec field table row typed as input/select/textarea. A pure navigation / list / capture screen has
42
+ * none → we must NOT nag it ("no input fields → nothing to generate").
43
+ */
44
+ export function hasInputFields(specText: string, featureText: string): boolean {
45
+ if (/\b(fill|type|enter|input|select|choose)\b\s+\[[^\]]+\]\s*(field|input|dropdown|textbox)?/i.test(featureText)) return true;
46
+ if (/\|\s*(input|select|textarea|checkbox|radio|combobox)\b/i.test(specText)) return true;
47
+ if (/\binput\s*\(|\bselect\s*\(|\btextarea\b/i.test(specText)) return true;
48
+ return false;
49
+ }
50
+
51
+ /** A field-map exists for this unit. */
52
+ export function hasFieldMap(cwd: string, name: string): boolean {
53
+ return existsSync(join(cwd, 'qa', 'data-factory', `${name}.fields.yaml`));
54
+ }
55
+
56
+ /** Precondition signal phrases — a spec sentence hinting the test needs a pre-existing entity/state. */
57
+ const PRECONDITION_SIGNALS = [
58
+ 'requires', 'required before', 'precondition', 'pre-condition', 'prerequisite',
59
+ 'logged in', 'authenticated', 'signed in', 'must be logged',
60
+ 'already exists', 'already registered', 'existing ', 'pre-existing', 'must exist',
61
+ 'reset token', 'verification', 'provisional account', 'session-storage', 'session storage',
62
+ 'buffer', 'seeded', 'must have', 'previously', 'valid token', 'auth token',
63
+ ];
64
+
65
+ /** Extract spec sentences that look like a stateful precondition (deduped, capped). */
66
+ export function detectPreconditionSignals(specText: string): string[] {
67
+ if (!specText) return [];
68
+ const hits: string[] = [];
69
+ const seen = new Set<string>();
70
+ // Split into sentence-ish units (lines / bullet items / sentences).
71
+ const units = specText.split(/\n|(?<=[.。])\s+/).map((s) => s.replace(/\s+/g, ' ').trim()).filter(Boolean);
72
+ for (const u of units) {
73
+ const low = u.toLowerCase();
74
+ // Skip markdown headings/table separators, and spec-template meta lines (US labels, rationale)
75
+ // that mention auth/priority only descriptively — not an actual precondition.
76
+ if (/^#{1,6}\s|^\|?\s*-{3,}/.test(u)) continue;
77
+ if (/^\*\*(why|independent test|acceptance|business context|target users|priority)\b/i.test(u)) continue;
78
+ if (/\bpriority\b/i.test(low) && /\bp[0-9]\b/i.test(low)) continue;
79
+ const sig = PRECONDITION_SIGNALS.find((s) => low.includes(s));
80
+ if (sig) {
81
+ const key = low.slice(0, 60);
82
+ if (!seen.has(key)) { seen.add(key); hits.push(u.length > 160 ? u.slice(0, 157) + '…' : u); }
83
+ }
84
+ if (hits.length >= 4) break;
85
+ }
86
+ return hits;
87
+ }
88
+
89
+ /** Is a stateful precondition already declared — a state-recipe with resources, or a Background/@manual? */
90
+ export function hasStateDeclaration(cwd: string, featureText: string): boolean {
91
+ if (/^\s*Background:/m.test(featureText) || /@manual\b/.test(featureText)) return true;
92
+ return loadStateRecipes(cwd).some((r) => (r.resources && r.resources.length) || r.shared);
93
+ }
94
+
95
+ /** Run Layer-A detection for one unit dir. Returns advisory findings (possibly empty). */
96
+ export function detectUnit(cwd: string, dir: string, name: string): DetectFinding[] {
97
+ const spec = readSpec(dir);
98
+ const feature = readFeature(dir);
99
+ const out: DetectFinding[] = [];
100
+
101
+ // 1. Inputs exist but never standardized.
102
+ if (!hasFieldMap(cwd, name) && hasInputFields(spec, feature)) {
103
+ out.push({
104
+ code: 'UNSTANDARDIZED',
105
+ scenario: name,
106
+ severity: 'warn',
107
+ message: `DATA-FACTORY-UNSTANDARDIZED: ${name} has input fields but no standardized test-data (no qa/data-factory/${name}.fields.yaml) — run \`/sungen:create-data-test ${name}\` (or it is authored inline by \`/sungen:create-test\`) so values are standardized valid/boundary/invalid, not hand-invented.`,
108
+ });
109
+ }
110
+
111
+ // 2. Stateful precondition described in the spec but not declared anywhere.
112
+ const signals = detectPreconditionSignals(spec);
113
+ if (signals.length && !hasStateDeclaration(cwd, feature)) {
114
+ out.push({
115
+ code: 'PRECONDITION_UNDECLARED',
116
+ scenario: name,
117
+ severity: 'info',
118
+ message: `DATA-FACTORY-PRECONDITION-UNDECLARED: ${name}'s spec describes a stateful precondition but the test declares none (no state-recipe, no Background/@manual). Declare it with a state-recipe + \`sungen data state\` so it renders a @manual:data-setup Background. Signals: ${signals.map((s) => `"${s}"`).join('; ')}`,
119
+ });
120
+ }
121
+ return out;
122
+ }
package/src/fixture.ts ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Data Factory — CSV/file fixture generator (5b).
3
+ *
4
+ * Turns a field-map into a **bulk-import fixture file** (a CSV whose columns are the fields and whose
5
+ * rows are standardized data), for testing CSV/Excel import, bulk upload, etc. — the `CSV / Import /
6
+ * Export` checklist area. Two products:
7
+ * • the VALID fixture — N rows of valid values (should import cleanly). Unique fields get a
8
+ * per-row suffix so rows don't collide.
9
+ * • the INVALID fixture — one row per field's first invalid case (all-valid except that one field).
10
+ * It carries the SAME headers as the valid file (no extra column) so a schema-strict importer
11
+ * fails on the DATA, not on an unexpected column. The "which row is bad and why" lives in a
12
+ * SIDECAR (`<name>.invalid.expected.yaml`), keyed by 0-based data-row index — the expected result
13
+ * never alters the payload under test.
14
+ *
15
+ * Deterministic: same field-map + catalog → same CSV. Columns are the flattened leaf paths (nested
16
+ * groups → dotted headers, e.g. `address.city`). Reference the file from an `@api` upload (`files:`)
17
+ * or a UI file-input step.
18
+ */
19
+ import type { Catalog } from './types';
20
+ import { expandFieldMap, type FieldMap, type ExpandedField } from './generator';
21
+
22
+ interface Leaf { path: string; field: ExpandedField }
23
+
24
+ /** Flatten nested groups to leaf fields with dotted-path headers. */
25
+ function leaves(expanded: ExpandedField[], prefix = ''): Leaf[] {
26
+ const out: Leaf[] = [];
27
+ for (const f of expanded) {
28
+ const path = prefix ? `${prefix}.${f.name}` : f.name;
29
+ if (f.children) out.push(...leaves(f.children, path));
30
+ else out.push({ path, field: f });
31
+ }
32
+ return out;
33
+ }
34
+
35
+ const validOf = (f: ExpandedField): string => f.points.find((p) => p.group === 'valid' && p.value != null)?.value ?? '';
36
+ // First concrete invalid, preferring a plain (non-adversarial) format/boundary invalid over a
37
+ // security payload — a bulk-import row-rejection test wants a data violation, not an injection string.
38
+ const firstInvalid = (f: ExpandedField) =>
39
+ f.points.find((p) => p.group === 'invalid' && p.category !== 'adversarial' && p.value != null) ??
40
+ f.points.find((p) => p.group === 'invalid' && p.value != null);
41
+
42
+ /** RFC-4180-ish CSV cell quoting. */
43
+ function cell(v: string): string {
44
+ return /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
45
+ }
46
+ function row(cells: string[]): string {
47
+ return cells.map(cell).join(',');
48
+ }
49
+
50
+ /** One expected-rejection entry in the sidecar, keyed to a 0-based data row of the invalid CSV. */
51
+ export interface ExpectedRow {
52
+ row: number;
53
+ field: string;
54
+ chk: string;
55
+ reason: string;
56
+ expected_error?: string;
57
+ }
58
+ export interface FixtureExpected {
59
+ _note: string;
60
+ rows: ExpectedRow[];
61
+ }
62
+
63
+ export interface FixtureResult {
64
+ valid: string; // CSV text (header + N valid rows)
65
+ invalid?: string; // CSV text (SAME headers as valid + one bad row per field — no extra column)
66
+ expected?: FixtureExpected; // sidecar: per-row expected rejection (0-based data-row index)
67
+ headers: string[];
68
+ }
69
+
70
+ /** Build the CSV fixture(s) from a field-map. */
71
+ export function generateFixture(map: FieldMap, catalog: Catalog, opts: { rows?: number; includeInvalid?: boolean } = {}): FixtureResult {
72
+ const rows = Math.max(1, opts.rows ?? 3);
73
+ const ls = leaves(expandFieldMap(map, catalog));
74
+ const headers = ls.map((l) => l.path);
75
+
76
+ // VALID rows — resolve the valid value per field; a `unique` field's `{{$timestamp}}` token is a
77
+ // runtime var, not valid in a static file, so replace it with the row index to keep rows distinct.
78
+ const validRows: string[] = [];
79
+ for (let i = 1; i <= rows; i++) {
80
+ validRows.push(row(ls.map((l) => validOf(l.field).replace(/\{\{\$timestamp\}\}/g, String(i)))));
81
+ }
82
+ const valid = [row(headers), ...validRows].join('\n') + '\n';
83
+
84
+ let invalid: string | undefined;
85
+ let expected: FixtureExpected | undefined;
86
+ if (opts.includeInvalid) {
87
+ const invRows: string[] = [];
88
+ const expectedRows: ExpectedRow[] = [];
89
+ for (const target of ls) {
90
+ const bad = firstInvalid(target.field);
91
+ if (!bad) continue; // no concrete invalid value to seed
92
+ // Same columns as the valid file — only the target cell is bad. No `_violation` column: a
93
+ // schema-strict importer must fail on the data, not reject the file for an unexpected column.
94
+ const cells = ls.map((l) => (l === target ? (bad.value as string) : validOf(l.field).replace(/\{\{\$timestamp\}\}/g, '1')));
95
+ expectedRows.push(trimExpected({ row: invRows.length, field: target.path, chk: bad.id, reason: bad.reason || 'invalid', expected_error: bad.expectedError }));
96
+ invRows.push(row(cells));
97
+ }
98
+ invalid = [row(headers), ...invRows].join('\n') + '\n';
99
+ expected = {
100
+ _note: 'Each row of the .invalid.csv should be REJECTED. `row` is the 0-based data-row index (header excluded). The CSV carries no extra columns so a strict importer fails on the data, not the schema.',
101
+ rows: expectedRows,
102
+ };
103
+ }
104
+
105
+ return { valid, invalid, expected, headers };
106
+ }
107
+
108
+ function trimExpected(e: ExpectedRow): ExpectedRow {
109
+ if (e.expected_error == null) { const { expected_error, ...rest } = e; return rest as ExpectedRow; }
110
+ return e;
111
+ }
package/src/generator.ts CHANGED
@@ -9,17 +9,37 @@
9
9
  * payloads, and resolving placeholder error codes through the field's `errorMap`. Same input →
10
10
  * same output (determinism is an asset). Nothing here reads a live page or an LLM.
11
11
  */
12
- import type { Catalog, FieldType, DataPoint } from './types';
12
+ import type { Catalog, FieldType, DataPoint, SelectionContext } from './types';
13
+ import { expandBoundaryHint, satisfiesConstraints, ensureBvaComplete, type Validity } from './boundary';
14
+ import { evaluateWhen, fieldFacts, inProfile } from './applicability';
13
15
 
14
16
  export interface FieldDef {
15
17
  name: string;
16
- /** Catalog field-type id (email/password/…). A type absent from the catalog → blind spot. */
17
- type: string;
18
+ /** Catalog field-type id (email/password/…). Omit for a GROUP (a field with nested `fields`). A type absent from the catalog → blind spot. */
19
+ type?: string;
18
20
  required?: boolean;
19
21
  /** Project overrides merged over the catalog constraints (maxLength, min, max, …). */
20
22
  constraints?: Record<string, any>;
21
23
  /** Placeholder errorRef (Mxx) → real project message code. Unmapped refs are flagged open. */
22
24
  errorMap?: Record<string, string>;
25
+ /**
26
+ * Field must be fresh/unique per test run (e.g. a register email/username that can't collide
27
+ * with a record left by a previous run). When true, the VALID value(s) get a runtime `{{$timestamp}}`
28
+ * suffix so `resolveDynamicVars` produces a new value every run — no cross-run cache, no flakiness.
29
+ */
30
+ unique?: boolean;
31
+ /**
32
+ * Nested / group field (complex form): sub-fields expanded recursively under this name
33
+ * (e.g. `address` → `city`, `postcode`). A field with `fields` is a group and needs no `type`.
34
+ */
35
+ fields?: FieldDef[];
36
+ /**
37
+ * Inter-field dependency: this field's rule/format depends on another field's value
38
+ * (e.g. postcode format `dependsOn: { field: country, value: JP }`). Carried through as metadata
39
+ * so the scenario weave + QA honor the relationship — the generator does not resolve the runtime
40
+ * condition (that is scenario/@cases logic), it records it.
41
+ */
42
+ dependsOn?: { field: string; value?: string };
23
43
  }
24
44
 
25
45
  export interface FieldMap {
@@ -39,85 +59,64 @@ export interface ExpandedPoint {
39
59
  open?: boolean;
40
60
  /** Value could not be synthesized deterministically (missing constraint) — needs input. */
41
61
  todo?: string;
62
+ /**
63
+ * The generator's expectation for this value: does it satisfy the field's constraints (`valid`) or
64
+ * violate them (`invalid`)? Computed by the boundary oracle for synthesized points; `valid` for the
65
+ * happy-path group and `invalid` for declared-invalid values. `validateGenerated` re-checks it — a
66
+ * mismatch (a `valid`-expected value that fails the constraints, or vice versa) is a defect.
67
+ */
68
+ expectedValidity?: Validity;
69
+ /**
70
+ * True when the VALUE was synthesized from a numeric/length boundary (so its whole validity claim is
71
+ * about range). The validator only second-guesses an `invalid` point when it is synthesized — a
72
+ * literal charset/format invalid (or a security payload) legitimately satisfies the length oracle.
73
+ */
74
+ synthesized?: boolean;
75
+ /** Provenance: `overwrite` when the project's `.overwrite` replaced/added this point (else shipped). */
76
+ source?: 'shipped' | 'overwrite';
77
+ /** Bucket: `adversarial` = a security/injection payload (rendered separately from plain invalid). */
78
+ category?: 'adversarial';
42
79
  }
43
80
 
44
81
  export interface ExpandedField {
45
82
  name: string;
46
- type: string;
83
+ type?: string;
47
84
  blindSpot: boolean;
48
85
  points: ExpandedPoint[];
86
+ /** Nested sub-fields (a group field). When present, `points` is empty and children hold the data. */
87
+ children?: ExpandedField[];
88
+ /** Recorded inter-field dependency (metadata only — not resolved deterministically). */
89
+ dependsOn?: { field: string; value?: string };
90
+ /** Cases NOT materialized for the selection context (applicability) — surfaced, never silent. */
91
+ excluded?: Array<{ id: string; reason: string }>;
49
92
  }
50
93
 
51
- /** Deterministic filler: a string of exactly `n` chars from a charset seed. */
52
- function fillLen(n: number, base = 'a'): string {
53
- if (!Number.isFinite(n) || n <= 0) return '';
54
- return base.repeat(n).slice(0, n);
55
- }
56
-
57
- /** Build an email of a given total or local length (half-width), with a boundary offset (-1/0/+1). */
58
- function synthEmail(constraints: Record<string, any>, which: 'maxLocal' | 'maxTotal', offset = 0): string | null {
59
- const domain = '@example.com';
60
- if (which === 'maxLocal' && Number.isFinite(constraints.maxLocal)) {
61
- return fillLen(constraints.maxLocal + offset) + domain;
62
- }
63
- if (which === 'maxTotal' && Number.isFinite(constraints.maxTotal)) {
64
- const localLen = Math.max(1, constraints.maxTotal + offset - domain.length);
65
- return fillLen(localLen) + domain;
94
+ /**
95
+ * Coerce an out-of-range numeric VALID literal into range, so a project's `valid` representative is
96
+ * actually valid for its constraints (a generic catalog example like `123` under `max: 100` becomes
97
+ * `100`). Returns null when in range already, or the type isn't numeric (non-numeric mismatches are
98
+ * left for the validator to flag — the field-map should supply a fitting value).
99
+ */
100
+ function coerceValidNumeric(type: string, value: string, c: Record<string, any>): string | null {
101
+ if (!['number-integer', 'number-float', 'currency'].includes(type)) return null;
102
+ const n = Number(value);
103
+ if (!Number.isFinite(n)) return null;
104
+ let out = n;
105
+ if (Number.isFinite(c.min) && out < c.min) out = c.min;
106
+ if (Number.isFinite(c.max) && out > c.max) out = c.max;
107
+ if (out === n) return null; // already in range
108
+ if (type === 'number-float' || (type === 'currency' && c.decimalPlaces > 0)) {
109
+ const dp = Number.isFinite(c.decimalPlaces) ? c.decimalPlaces : 2;
110
+ return out.toFixed(dp);
66
111
  }
67
- return null;
112
+ return String(Math.round(out));
68
113
  }
69
114
 
70
- /** Try to synthesize a boundary/gen value from constraints. Returns null when data is insufficient. */
71
- function synthesize(dp: DataPoint, type: string, c: Record<string, any>): { value: string | null; todo?: string } {
72
- if (dp.value != null) return { value: dp.value };
73
- const hint = `${dp.gen || ''} ${dp.note || ''}`.toLowerCase();
74
-
75
- // Email length boundaries. `>` / "vượt" → over-boundary (invalid, offset +1).
76
- if (type === 'email') {
77
- const over = hint.includes('>') || hint.includes('vượt') || hint.includes('over');
78
- const offset = over ? 1 : hint.includes('-1') ? -1 : hint.includes('+1') ? 1 : 0;
79
- if (hint.includes('maxlocal') || (hint.includes('local') && (hint.includes('max') || over))) {
80
- const v = synthEmail(c, 'maxLocal', offset); return v ? { value: v } : { value: null, todo: 'maxLocal' };
81
- }
82
- if (hint.includes('maxtotal') || (hint.includes('tổng') && (hint.includes('max') || over)) || (hint.includes('total'))) {
83
- const v = synthEmail(c, 'maxTotal', offset); return v ? { value: v } : { value: null, todo: 'maxTotal' };
84
- }
85
- }
86
-
87
- // String-length types: min / max length boundaries.
88
- const strTypes = ['string', 'password', 'phone', 'postcode', 'katakana'];
89
- if (strTypes.includes(type)) {
90
- const min = c.minLength ?? c.length;
91
- const max = c.maxLength ?? c.length;
92
- if (hint.includes('min') && Number.isFinite(min)) {
93
- const n = hint.includes('+1') ? min + 1 : min;
94
- return { value: fillLen(n, type === 'password' ? 'Aa1' : 'a') };
95
- }
96
- if (hint.includes('max') && Number.isFinite(max)) {
97
- const n = hint.includes('-1') ? max - 1 : max;
98
- return { value: fillLen(n, type === 'password' ? 'Aa1' : 'a') };
99
- }
100
- return { value: null, todo: 'minLength/maxLength' };
101
- }
102
-
103
- // Numeric types: min / max value boundaries.
104
- const numTypes = ['number-integer', 'number-float', 'currency'];
105
- if (numTypes.includes(type)) {
106
- const min = c.min, max = c.max;
107
- if (hint.includes('min') && Number.isFinite(min)) {
108
- if (hint.includes('-1')) return { value: String(min - 1) };
109
- if (hint.includes('+1')) return { value: String(min + 1) };
110
- return { value: String(min) };
111
- }
112
- if (hint.includes('max') && Number.isFinite(max)) {
113
- if (hint.includes('+1')) return { value: String(max + 1) };
114
- if (hint.includes('-1')) return { value: String(max - 1) };
115
- return { value: String(max) };
116
- }
117
- return { value: null, todo: 'min/max' };
118
- }
119
-
120
- return { value: null, todo: 'value/gen not resolvable' };
115
+ /** A gen hint that references a numeric/length boundary the generator can expand structurally. */
116
+ function isBoundaryHint(hint: string): boolean {
117
+ return /\bmin\b|\bmax\b|minimum|maximum|length|byte|exact|decimal|precision|local|total|over|below|under|exceed|\+\s*1|-\s*1|<|>/.test(
118
+ hint.toLowerCase(),
119
+ );
121
120
  }
122
121
 
123
122
  function resolveError(dp: DataPoint, errorMap?: Record<string, string>): { expectedError?: string; open?: boolean } {
@@ -126,27 +125,119 @@ function resolveError(dp: DataPoint, errorMap?: Record<string, string>): { expec
126
125
  return { expectedError: dp.errorRef, open: true }; // placeholder survives; flagged open to confirm
127
126
  }
128
127
 
128
+ /**
129
+ * Make a valid value fresh-per-run by injecting a `{{$timestamp}}` token (resolved at runtime by the
130
+ * TestDataLoader's resolveDynamicVars). Email keeps validity via a `+tag` in the local part; other
131
+ * types append the token. Deterministic at compile time (same token string) — uniqueness is at run time.
132
+ */
133
+ function makeUnique(value: string, type: string): string {
134
+ if (value.includes('{{$')) return value; // already dynamic
135
+ if (type === 'email' && value.includes('@')) {
136
+ const at = value.indexOf('@');
137
+ return `${value.slice(0, at)}+{{$timestamp}}${value.slice(at)}`;
138
+ }
139
+ return `${value}{{$timestamp}}`;
140
+ }
141
+
129
142
  /** Expand one field into valid/boundary/invalid data points using the effective catalog. */
130
- export function expandField(field: FieldDef, catalog: Catalog): ExpandedField {
131
- const ft: FieldType | undefined = catalog.fieldTypes[field.type];
132
- if (!ft) return { name: field.name, type: field.type, blindSpot: true, points: [] };
143
+ export function expandField(field: FieldDef, catalog: Catalog, ctx: SelectionContext = {}): ExpandedField {
144
+ // Group / nested field: no own type — expand each sub-field recursively.
145
+ if (field.fields && field.fields.length) {
146
+ return {
147
+ name: field.name,
148
+ type: undefined,
149
+ blindSpot: false,
150
+ points: [],
151
+ children: field.fields.map((sub) => expandField(sub, catalog, ctx)),
152
+ ...(field.dependsOn ? { dependsOn: field.dependsOn } : {}),
153
+ };
154
+ }
155
+ const ft: FieldType | undefined = field.type ? catalog.fieldTypes[field.type] : undefined;
156
+ if (!ft) return { name: field.name, type: field.type, blindSpot: true, points: [], ...(field.dependsOn ? { dependsOn: field.dependsOn } : {}) };
133
157
 
134
158
  const c = { ...(ft.constraints || {}), ...(field.constraints || {}) };
159
+ const type = field.type as string;
135
160
  const points: ExpandedPoint[] = [];
161
+ const excluded: Array<{ id: string; reason: string }> = [];
162
+ const facts = fieldFacts(field, c);
136
163
 
137
- const push = (dp: DataPoint, group: ExpandedPoint['group']) => {
138
- const s = synthesize(dp, field.type, c);
139
- points.push({
164
+ // Gate a catalog case by applicability (`when`) then by the selection profile. A `when` miss is
165
+ // reported as excluded (not silently dropped); a profile miss is an explicit selection (not reported).
166
+ const admit = (dp: DataPoint, group: ExpandedPoint['group'], category?: 'adversarial'): boolean => {
167
+ const ap = evaluateWhen(dp.when, facts, ctx);
168
+ if (!ap.applicable) { excluded.push({ id: dp.id, reason: ap.reason || 'not applicable' }); return false; }
169
+ return inProfile(group, category, ctx.profile);
170
+ };
171
+
172
+ // Default validity for a group: the happy path is valid; the invalid group is invalid; a boundary
173
+ // point's validity is decided per-point by the oracle (in/out of range).
174
+ const groupValidity = (group: ExpandedPoint['group']): Validity => (group === 'invalid' ? 'invalid' : 'valid');
175
+
176
+ const emit = (p: Omit<ExpandedPoint, 'expectedValidity'> & { expectedValidity: Validity }) => points.push(p);
177
+
178
+ const pushLiteral = (dp: DataPoint, group: ExpandedPoint['group'], value: string | null, opts: { validity?: Validity; label?: string; todo?: string } = {}) => {
179
+ // A valid representative must be valid for the project: clamp an out-of-range numeric literal.
180
+ let coerceNote: string | undefined;
181
+ if (group === 'valid' && value != null && satisfiesConstraints(type, value, c) === false) {
182
+ const fixed = coerceValidNumeric(type, value, c);
183
+ if (fixed != null) { value = fixed; coerceNote = 'adjusted to fit constraints'; }
184
+ }
185
+ const isUniqueValid = group === 'valid' && field.unique && value != null;
186
+ const v = isUniqueValid ? makeUnique(value!, type) : value;
187
+ // Expected validity reflects INTENT by group, so the validator (validateGenerated) can catch a
188
+ // mismatch against the oracle instead of the mismatch being silently absorbed here:
189
+ // - valid group → `valid` (a value that actually violates the constraints is a VALUE_NOT_VALID).
190
+ // - invalid group → `invalid` (declared: charset/format/blocklist/behavior — the length oracle
191
+ // cannot "prove" such a literal valid, so it must NOT flip it to valid).
192
+ // - boundary group → the oracle decides (that group is precisely about range).
193
+ let validity: Validity;
194
+ if (dp.validity) validity = dp.validity;
195
+ else if (group === 'boundary') {
196
+ const oracle = satisfiesConstraints(type, isUniqueValid ? value : v, c);
197
+ validity = oracle == null ? (opts.validity ?? 'valid') : oracle ? 'valid' : 'invalid';
198
+ } else validity = opts.validity ?? groupValidity(group);
199
+ emit({
140
200
  id: dp.id,
141
201
  group,
142
- value: s.value,
143
- todo: s.todo,
202
+ value: v,
203
+ todo: opts.todo,
144
204
  reason: dp.reason,
145
- note: dp.note,
205
+ note: isUniqueValid ? `${dp.note ? dp.note + ' · ' : ''}fresh per run` : coerceNote ? `${dp.note ? dp.note + ' · ' : ''}${coerceNote}` : (opts.label ? `${dp.note ? dp.note + ' · ' : ''}${opts.label}` : dp.note),
206
+ expectedValidity: validity,
207
+ ...(dp.source === 'overwrite' ? { source: 'overwrite' as const } : {}),
146
208
  ...resolveError(dp, field.errorMap),
147
209
  });
148
210
  };
149
211
 
212
+ const push = (dp: DataPoint, group: ExpandedPoint['group']) => {
213
+ if (!admit(dp, group)) return; // not applicable / out of profile
214
+ // Literal value in the catalog/overwrite → one point (oracle still judges its validity).
215
+ if (dp.value != null) return pushLiteral(dp, group, dp.value);
216
+
217
+ const hint = `${dp.gen || ''} ${dp.note || ''}`.trim();
218
+ // A boundary/length/numeric hint → expand to one case PER boundary point, each with its validity.
219
+ if (hint && isBoundaryHint(hint)) {
220
+ const pts = expandBoundaryHint(hint, type, c);
221
+ for (const bp of pts) {
222
+ emit({
223
+ id: dp.id + bp.suffix,
224
+ group,
225
+ value: bp.value,
226
+ todo: bp.todo,
227
+ reason: dp.reason,
228
+ note: bp.label && !bp.todo ? `${dp.note ? dp.note + ' · ' : ''}${bp.label}` : dp.note,
229
+ expectedValidity: dp.validity ?? bp.validity,
230
+ synthesized: bp.value != null,
231
+ ...(dp.source === 'overwrite' ? { source: 'overwrite' as const } : {}),
232
+ ...resolveError(dp, field.errorMap),
233
+ });
234
+ }
235
+ return;
236
+ }
237
+ // A non-boundary gen (charset/full-width/format) — can't synthesize deterministically; keep open.
238
+ pushLiteral(dp, group, null, { validity: groupValidity(group), todo: dp.gen ? `synthesize: ${dp.gen}` : 'value/gen not resolvable' });
239
+ };
240
+
150
241
  for (const dp of ft.valid || []) push(dp, 'valid');
151
242
  for (const dp of ft.boundary || []) push(dp, 'boundary');
152
243
  for (const dp of ft.invalid || []) {
@@ -154,11 +245,12 @@ export function expandField(field: FieldDef, catalog: Catalog): ExpandedField {
154
245
  push(dp, 'invalid');
155
246
  }
156
247
 
157
- // Append security payloads referenced by uses_security.
248
+ // Append security payloads referenced by uses_security — the `adversarial` category (own bucket).
158
249
  for (const bank of ft.uses_security || []) {
159
250
  const b = catalog.security[bank];
160
251
  if (!b) continue;
161
252
  for (const p of b.payloads || []) {
253
+ if (!admit(p as DataPoint, 'invalid', 'adversarial')) continue;
162
254
  points.push({
163
255
  id: p.id,
164
256
  group: 'invalid',
@@ -166,16 +258,43 @@ export function expandField(field: FieldDef, catalog: Catalog): ExpandedField {
166
258
  todo: p.value == null ? `security technique: ${p.technique || bank}` : undefined,
167
259
  reason: `${bank}: ${(p.reason as string) || (p.kind as string) || (p.technique as string) || ''}`.trim(),
168
260
  note: b.oracle,
261
+ // Adversarial payloads are treated as invalid input (must be rejected/escaped, never accepted).
262
+ expectedValidity: 'invalid',
263
+ category: 'adversarial',
169
264
  });
170
265
  }
171
266
  }
172
267
 
173
- return { name: field.name, type: field.type, blindSpot: false, points };
268
+ // BVA completeness: for every bounded dimension in the constraints, ensure the full
269
+ // min-1/min/min+1/max-1/max/max+1 set exists — auto-adding only what the catalog left out (e.g. an
270
+ // email `minLength` the catalog didn't model). Only in-range points are `boundary`; out-of-range are
271
+ // `invalid`. Appended after the catalog cases so it never overrides an authored one.
272
+ const idCode = type.toUpperCase().replace(/[^A-Z0-9]/g, '');
273
+ for (const ex of ensureBvaComplete(type, c, points.map((p) => p.value))) {
274
+ points.push({
275
+ id: `CHK-${idCode}-BVA-${ex.label}`,
276
+ group: ex.group,
277
+ value: ex.value,
278
+ expectedValidity: ex.validity,
279
+ synthesized: true,
280
+ note: `BVA (auto): ${ex.label}`,
281
+ ...(ex.validity === 'invalid' ? { reason: `out of bounds (${ex.label})` } : {}),
282
+ });
283
+ }
284
+
285
+ return {
286
+ name: field.name,
287
+ type: field.type,
288
+ blindSpot: false,
289
+ points,
290
+ ...(field.dependsOn ? { dependsOn: field.dependsOn } : {}),
291
+ ...(excluded.length ? { excluded } : {}),
292
+ };
174
293
  }
175
294
 
176
295
  /** Expand a whole field-map. */
177
- export function expandFieldMap(map: FieldMap, catalog: Catalog): ExpandedField[] {
178
- return (map.fields || []).map((f) => expandField(f, catalog));
296
+ export function expandFieldMap(map: FieldMap, catalog: Catalog, ctx: SelectionContext = {}): ExpandedField[] {
297
+ return (map.fields || []).map((f) => expandField(f, catalog, ctx));
179
298
  }
180
299
 
181
300
  /**
@@ -186,23 +305,37 @@ export function renderTestData(expanded: ExpandedField[]): Record<string, any> {
186
305
  const out: Record<string, any> = {
187
306
  _generated_by: 'sungen data gen — standardized test-data (valid/boundary/invalid + CHK trace). Do not hand-edit values; edit the .fields.yaml or common.overwrite.yaml and re-run.',
188
307
  };
189
- for (const f of expanded) {
190
- if (f.blindSpot) {
191
- out[f.name] = { _blind_spot: `type "${f.type}" not in Data Factory catalog — provide qa/data-factory/project/${f.name}.yaml` };
192
- continue;
193
- }
194
- const valid = f.points.filter((p) => p.group === 'valid');
195
- const boundary = f.points.filter((p) => p.group === 'boundary');
196
- const invalid = f.points.filter((p) => p.group === 'invalid');
197
- out[f.name] = {
198
- valid: valid[0]?.value ?? null,
199
- boundary: boundary.map((p) => trim({ id: p.id, value: p.value, note: p.note, todo: p.todo })),
200
- invalid: invalid.map((p) => trim({ id: p.id, value: p.value, expected_error: p.expectedError, open: p.open, reason: p.reason, todo: p.todo })),
201
- };
202
- }
308
+ for (const f of expanded) out[f.name] = renderOne(f);
203
309
  return out;
204
310
  }
205
311
 
312
+ /** Render one expanded field — recurses for nested groups; carries a dependsOn note. */
313
+ function renderOne(f: ExpandedField): Record<string, any> {
314
+ const dep = f.dependsOn ? { _depends_on: f.dependsOn.value != null ? `${f.dependsOn.field} = ${f.dependsOn.value}` : f.dependsOn.field } : {};
315
+ if (f.children) {
316
+ const group: Record<string, any> = { ...dep };
317
+ for (const c of f.children) group[c.name] = renderOne(c);
318
+ return group;
319
+ }
320
+ if (f.blindSpot) {
321
+ return { _blind_spot: `type "${f.type}" not in Data Factory catalog — provide qa/data-factory/project/${f.name}.yaml`, ...dep };
322
+ }
323
+ const valid = f.points.filter((p) => p.group === 'valid');
324
+ const boundary = f.points.filter((p) => p.group === 'boundary');
325
+ // Plain invalid excludes the adversarial (security) cases — those get their own bucket.
326
+ const invalid = f.points.filter((p) => p.group === 'invalid' && p.category !== 'adversarial');
327
+ const adversarial = f.points.filter((p) => p.category === 'adversarial');
328
+ const row: Record<string, any> = {
329
+ ...dep,
330
+ valid: valid[0]?.value ?? null,
331
+ boundary: boundary.map((p) => trim({ id: p.id, value: p.value, expected: p.expectedValidity, via: p.source === 'overwrite' ? '.overwrite' : undefined, note: p.note, todo: p.todo })),
332
+ invalid: invalid.map((p) => trim({ id: p.id, value: p.value, expected: p.expectedValidity, via: p.source === 'overwrite' ? '.overwrite' : undefined, expected_error: p.expectedError, open: p.open, reason: p.reason, todo: p.todo })),
333
+ };
334
+ if (adversarial.length) row.adversarial = adversarial.map((p) => trim({ id: p.id, value: p.value, reason: p.reason, note: p.note, todo: p.todo }));
335
+ if (f.excluded?.length) row._not_applicable = f.excluded.map((e) => `${e.id}: ${e.reason}`);
336
+ return row;
337
+ }
338
+
206
339
  function trim(o: Record<string, any>): Record<string, any> {
207
340
  const r: Record<string, any> = {};
208
341
  for (const [k, v] of Object.entries(o)) if (v != null) r[k] = v;