@sungen/driver-data-factory 3.2.12-beta.1 → 3.2.12-beta.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sungen/driver-data-factory",
3
- "version": "3.2.12-beta.1",
3
+ "version": "3.2.12-beta.3",
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.1",
17
+ "@sun-asterisk/sungen": "3.2.12-beta.3",
18
18
  "yaml": "^2.4.0"
19
19
  },
20
20
  "files": [
@@ -0,0 +1,260 @@
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
+ return true;
220
+ }
221
+
222
+ function satisfiesLength(type: string, value: string, c: Record<string, any>): boolean {
223
+ const min = c.minLength ?? c.length;
224
+ const max = c.maxLength ?? c.length;
225
+ if (Number.isFinite(min) && value.length < min) return false;
226
+ if (Number.isFinite(max) && value.length > max) return false;
227
+ return true;
228
+ }
229
+
230
+ function satisfiesNumeric(type: string, value: string, c: Record<string, any>): boolean {
231
+ const n = Number(value);
232
+ if (!Number.isFinite(n)) return false;
233
+ if (Number.isFinite(c.min) && n < c.min) return false;
234
+ if (Number.isFinite(c.max) && n > c.max) return false;
235
+ if ((type === 'number-integer') && !Number.isInteger(n)) return false;
236
+ if ((type === 'number-float' || type === 'currency') && Number.isFinite(c.decimalPlaces)) {
237
+ const dec = (value.split('.')[1] || '').length;
238
+ if (dec > c.decimalPlaces) return false;
239
+ }
240
+ return true;
241
+ }
242
+
243
+ /**
244
+ * The oracle. Given a concrete value and the constraints, decide whether it structurally satisfies
245
+ * them (length/range/precision — NOT charset/format, which the catalog encodes as literal invalids).
246
+ * Returns null when the type has no numeric/length constraint to check against (nothing to assert).
247
+ */
248
+ export function satisfiesConstraints(type: string, value: string | null, c: Record<string, any>): boolean | null {
249
+ if (value == null) return null;
250
+ if (type === 'email') return satisfiesEmail(value, c);
251
+ if (STR_TYPES.has(type)) {
252
+ if (!Number.isFinite(c.minLength ?? c.length) && !Number.isFinite(c.maxLength ?? c.length)) return null;
253
+ return satisfiesLength(type, value, c);
254
+ }
255
+ if (NUM_TYPES.has(type)) {
256
+ if (!Number.isFinite(c.min) && !Number.isFinite(c.max) && !Number.isFinite(c.decimalPlaces)) return null;
257
+ return satisfiesNumeric(type, value, c);
258
+ }
259
+ return null;
260
+ }
package/src/cli.ts CHANGED
@@ -13,6 +13,7 @@ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
13
13
  import { loadEffectiveCatalog } from './catalog-loader';
14
14
  import { expandFieldMap, renderTestData, type FieldMap } from './generator';
15
15
  import { lintFieldMap, summarize } from './lint';
16
+ import { validateGenerated } from './validate';
16
17
  import { generateFixture } from './fixture';
17
18
  import { ensureDataFactoryReadme } from './readme';
18
19
 
@@ -146,6 +147,33 @@ export function registerDataFactoryCommand(program: any): void {
146
147
  if (totalErrors) process.exitCode = 1;
147
148
  });
148
149
 
150
+ data
151
+ .command('validate')
152
+ .description('Verify every generated value is semantically correct (valid values satisfy the constraints; synthesized invalid values violate them)')
153
+ .option('--screen <name>', 'Validate one unit (default: all field-maps)')
154
+ .action((opts: { screen?: string }) => {
155
+ const cwd = process.cwd();
156
+ ensureDataFactoryReadme(cwd);
157
+ const names = opts.screen ? [opts.screen] : listFieldMaps(cwd);
158
+ if (names.length === 0) { console.error('No field-maps under qa/data-factory/*.fields.yaml.'); process.exitCode = 1; return; }
159
+ const { catalog } = loadEffectiveCatalog(cwd);
160
+ let totalErrors = 0;
161
+ for (const name of names) {
162
+ const map = loadFieldMap(cwd, name);
163
+ if (!map) { console.error(`✗ ${name}: no field-map`); totalErrors++; continue; }
164
+ const findings = validateGenerated(map, catalog);
165
+ const errors = findings.filter((f) => f.severity === 'error').length;
166
+ const warns = findings.filter((f) => f.severity === 'warn').length;
167
+ totalErrors += errors;
168
+ const badge = errors ? '✗' : warns ? '⚠' : '✓';
169
+ console.log(`${badge} ${name}: ${errors} error · ${warns} warn`);
170
+ for (const f of findings) {
171
+ console.log(`${f.severity === 'error' ? ' ✗' : ' ⚠'} [${f.field}] ${f.code}: ${f.message}`);
172
+ }
173
+ }
174
+ if (totalErrors) process.exitCode = 1;
175
+ });
176
+
149
177
  data
150
178
  .command('fixture')
151
179
  .description('Generate a CSV import fixture from the field-map (bulk-import / file-upload testing)')
package/src/generator.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  * same output (determinism is an asset). Nothing here reads a live page or an LLM.
11
11
  */
12
12
  import type { Catalog, FieldType, DataPoint } from './types';
13
+ import { expandBoundaryHint, satisfiesConstraints, type Validity } from './boundary';
13
14
 
14
15
  export interface FieldDef {
15
16
  name: string;
@@ -57,6 +58,19 @@ export interface ExpandedPoint {
57
58
  open?: boolean;
58
59
  /** Value could not be synthesized deterministically (missing constraint) — needs input. */
59
60
  todo?: string;
61
+ /**
62
+ * The generator's expectation for this value: does it satisfy the field's constraints (`valid`) or
63
+ * violate them (`invalid`)? Computed by the boundary oracle for synthesized points; `valid` for the
64
+ * happy-path group and `invalid` for declared-invalid values. `validateGenerated` re-checks it — a
65
+ * mismatch (a `valid`-expected value that fails the constraints, or vice versa) is a defect.
66
+ */
67
+ expectedValidity?: Validity;
68
+ /**
69
+ * True when the VALUE was synthesized from a numeric/length boundary (so its whole validity claim is
70
+ * about range). The validator only second-guesses an `invalid` point when it is synthesized — a
71
+ * literal charset/format invalid (or a security payload) legitimately satisfies the length oracle.
72
+ */
73
+ synthesized?: boolean;
60
74
  }
61
75
 
62
76
  export interface ExpandedField {
@@ -70,76 +84,11 @@ export interface ExpandedField {
70
84
  dependsOn?: { field: string; value?: string };
71
85
  }
72
86
 
73
- /** Deterministic filler: a string of exactly `n` chars from a charset seed. */
74
- function fillLen(n: number, base = 'a'): string {
75
- if (!Number.isFinite(n) || n <= 0) return '';
76
- return base.repeat(n).slice(0, n);
77
- }
78
-
79
- /** Build an email of a given total or local length (half-width), with a boundary offset (-1/0/+1). */
80
- function synthEmail(constraints: Record<string, any>, which: 'maxLocal' | 'maxTotal', offset = 0): string | null {
81
- const domain = '@example.com';
82
- if (which === 'maxLocal' && Number.isFinite(constraints.maxLocal)) {
83
- return fillLen(constraints.maxLocal + offset) + domain;
84
- }
85
- if (which === 'maxTotal' && Number.isFinite(constraints.maxTotal)) {
86
- const localLen = Math.max(1, constraints.maxTotal + offset - domain.length);
87
- return fillLen(localLen) + domain;
88
- }
89
- return null;
90
- }
91
-
92
- /** Try to synthesize a boundary/gen value from constraints. Returns null when data is insufficient. */
93
- function synthesize(dp: DataPoint, type: string, c: Record<string, any>): { value: string | null; todo?: string } {
94
- if (dp.value != null) return { value: dp.value };
95
- const hint = `${dp.gen || ''} ${dp.note || ''}`.toLowerCase();
96
-
97
- // Email length boundaries. `>` / "over" → over-boundary (invalid, offset +1).
98
- if (type === 'email') {
99
- const over = hint.includes('>') || hint.includes('over');
100
- const offset = over ? 1 : hint.includes('-1') ? -1 : hint.includes('+1') ? 1 : 0;
101
- if (hint.includes('maxlocal') || (hint.includes('local') && (hint.includes('max') || over))) {
102
- const v = synthEmail(c, 'maxLocal', offset); return v ? { value: v } : { value: null, todo: 'maxLocal' };
103
- }
104
- if (hint.includes('maxtotal') || hint.includes('total')) {
105
- const v = synthEmail(c, 'maxTotal', offset); return v ? { value: v } : { value: null, todo: 'maxTotal' };
106
- }
107
- }
108
-
109
- // String-length types: min / max length boundaries.
110
- const strTypes = ['string', 'password', 'phone', 'postcode', 'katakana'];
111
- if (strTypes.includes(type)) {
112
- const min = c.minLength ?? c.length;
113
- const max = c.maxLength ?? c.length;
114
- if (hint.includes('min') && Number.isFinite(min)) {
115
- const n = hint.includes('+1') ? min + 1 : min;
116
- return { value: fillLen(n, type === 'password' ? 'Aa1' : 'a') };
117
- }
118
- if (hint.includes('max') && Number.isFinite(max)) {
119
- const n = hint.includes('-1') ? max - 1 : max;
120
- return { value: fillLen(n, type === 'password' ? 'Aa1' : 'a') };
121
- }
122
- return { value: null, todo: 'minLength/maxLength' };
123
- }
124
-
125
- // Numeric types: min / max value boundaries.
126
- const numTypes = ['number-integer', 'number-float', 'currency'];
127
- if (numTypes.includes(type)) {
128
- const min = c.min, max = c.max;
129
- if (hint.includes('min') && Number.isFinite(min)) {
130
- if (hint.includes('-1')) return { value: String(min - 1) };
131
- if (hint.includes('+1')) return { value: String(min + 1) };
132
- return { value: String(min) };
133
- }
134
- if (hint.includes('max') && Number.isFinite(max)) {
135
- if (hint.includes('+1')) return { value: String(max + 1) };
136
- if (hint.includes('-1')) return { value: String(max - 1) };
137
- return { value: String(max) };
138
- }
139
- return { value: null, todo: 'min/max' };
140
- }
141
-
142
- return { value: null, todo: 'value/gen not resolvable' };
87
+ /** A gen hint that references a numeric/length boundary the generator can expand structurally. */
88
+ function isBoundaryHint(hint: string): boolean {
89
+ return /\bmin\b|\bmax\b|minimum|maximum|length|byte|exact|decimal|precision|local|total|over|below|under|exceed|\+\s*1|-\s*1|<|>/.test(
90
+ hint.toLowerCase(),
91
+ );
143
92
  }
144
93
 
145
94
  function resolveError(dp: DataPoint, errorMap?: Record<string, string>): { expectedError?: string; open?: boolean } {
@@ -179,26 +128,60 @@ export function expandField(field: FieldDef, catalog: Catalog): ExpandedField {
179
128
  if (!ft) return { name: field.name, type: field.type, blindSpot: true, points: [], ...(field.dependsOn ? { dependsOn: field.dependsOn } : {}) };
180
129
 
181
130
  const c = { ...(ft.constraints || {}), ...(field.constraints || {}) };
131
+ const type = field.type as string;
182
132
  const points: ExpandedPoint[] = [];
183
133
 
184
- const push = (dp: DataPoint, group: ExpandedPoint['group']) => {
185
- const s = synthesize(dp, field.type, c);
186
- // Uniqueness: only the VALID (happy-path create) value gets a fresh-per-run suffix. Boundary/invalid
187
- // stay literal — they test rules, not record creation.
188
- const value = group === 'valid' && field.unique && s.value != null
189
- ? makeUnique(s.value, field.type)
190
- : s.value;
191
- points.push({
134
+ // Default validity for a group: the happy path is valid; the invalid group is invalid; a boundary
135
+ // point's validity is decided per-point by the oracle (in/out of range).
136
+ const groupValidity = (group: ExpandedPoint['group']): Validity => (group === 'invalid' ? 'invalid' : 'valid');
137
+
138
+ const emit = (p: Omit<ExpandedPoint, 'expectedValidity'> & { expectedValidity: Validity }) => points.push(p);
139
+
140
+ const pushLiteral = (dp: DataPoint, group: ExpandedPoint['group'], value: string | null, opts: { validity?: Validity; label?: string; todo?: string } = {}) => {
141
+ const isUniqueValid = group === 'valid' && field.unique && value != null;
142
+ const v = isUniqueValid ? makeUnique(value!, type) : value;
143
+ // Oracle: if the constraints can judge this value, they win; else fall back to declared/group validity.
144
+ const oracle = satisfiesConstraints(type, isUniqueValid ? value : v, c);
145
+ const validity: Validity = dp.validity ?? (oracle == null ? (opts.validity ?? groupValidity(group)) : oracle ? 'valid' : 'invalid');
146
+ emit({
192
147
  id: dp.id,
193
148
  group,
194
- value,
195
- todo: s.todo,
149
+ value: v,
150
+ todo: opts.todo,
196
151
  reason: dp.reason,
197
- note: field.unique && group === 'valid' && s.value != null ? `${dp.note ? dp.note + ' · ' : ''}fresh per run` : dp.note,
152
+ note: isUniqueValid ? `${dp.note ? dp.note + ' · ' : ''}fresh per run` : (opts.label ? `${dp.note ? dp.note + ' · ' : ''}${opts.label}` : dp.note),
153
+ expectedValidity: validity,
198
154
  ...resolveError(dp, field.errorMap),
199
155
  });
200
156
  };
201
157
 
158
+ const push = (dp: DataPoint, group: ExpandedPoint['group']) => {
159
+ // Literal value in the catalog/overwrite → one point (oracle still judges its validity).
160
+ if (dp.value != null) return pushLiteral(dp, group, dp.value);
161
+
162
+ const hint = `${dp.gen || ''} ${dp.note || ''}`.trim();
163
+ // A boundary/length/numeric hint → expand to one case PER boundary point, each with its validity.
164
+ if (hint && isBoundaryHint(hint)) {
165
+ const pts = expandBoundaryHint(hint, type, c);
166
+ for (const bp of pts) {
167
+ emit({
168
+ id: dp.id + bp.suffix,
169
+ group,
170
+ value: bp.value,
171
+ todo: bp.todo,
172
+ reason: dp.reason,
173
+ note: bp.label && !bp.todo ? `${dp.note ? dp.note + ' · ' : ''}${bp.label}` : dp.note,
174
+ expectedValidity: dp.validity ?? bp.validity,
175
+ synthesized: bp.value != null,
176
+ ...resolveError(dp, field.errorMap),
177
+ });
178
+ }
179
+ return;
180
+ }
181
+ // A non-boundary gen (charset/full-width/format) — can't synthesize deterministically; keep open.
182
+ pushLiteral(dp, group, null, { validity: groupValidity(group), todo: dp.gen ? `synthesize: ${dp.gen}` : 'value/gen not resolvable' });
183
+ };
184
+
202
185
  for (const dp of ft.valid || []) push(dp, 'valid');
203
186
  for (const dp of ft.boundary || []) push(dp, 'boundary');
204
187
  for (const dp of ft.invalid || []) {
@@ -218,6 +201,8 @@ export function expandField(field: FieldDef, catalog: Catalog): ExpandedField {
218
201
  todo: p.value == null ? `security technique: ${p.technique || bank}` : undefined,
219
202
  reason: `${bank}: ${(p.reason as string) || (p.kind as string) || (p.technique as string) || ''}`.trim(),
220
203
  note: b.oracle,
204
+ // Adversarial payloads are treated as invalid input (must be rejected/escaped, never accepted).
205
+ expectedValidity: 'invalid',
221
206
  });
222
207
  }
223
208
  }
@@ -259,8 +244,8 @@ function renderOne(f: ExpandedField): Record<string, any> {
259
244
  return {
260
245
  ...dep,
261
246
  valid: valid[0]?.value ?? null,
262
- boundary: boundary.map((p) => trim({ id: p.id, value: p.value, note: p.note, todo: p.todo })),
263
- invalid: invalid.map((p) => trim({ id: p.id, value: p.value, expected_error: p.expectedError, open: p.open, reason: p.reason, todo: p.todo })),
247
+ boundary: boundary.map((p) => trim({ id: p.id, value: p.value, expected: p.expectedValidity, note: p.note, todo: p.todo })),
248
+ invalid: invalid.map((p) => trim({ id: p.id, value: p.value, expected: p.expectedValidity, expected_error: p.expectedError, open: p.open, reason: p.reason, todo: p.todo })),
264
249
  };
265
250
  }
266
251
 
package/src/index.ts CHANGED
@@ -23,6 +23,10 @@ export { expandField, expandFieldMap, renderTestData } from './generator';
23
23
  export type { FieldDef, FieldMap, ExpandedField, ExpandedPoint } from './generator';
24
24
  export { lintFieldMap, summarize } from './lint';
25
25
  export type { LintIssue } from './lint';
26
+ export { validateGenerated, validatePoints } from './validate';
27
+ export type { ValidationFinding } from './validate';
28
+ export { expandBoundaryHint, satisfiesConstraints } from './boundary';
29
+ export type { Validity, BoundaryPoint } from './boundary';
26
30
  export { registerDataFactoryCommand } from './cli';
27
31
  export { generateFixture } from './fixture';
28
32
  export type { FixtureResult } from './fixture';
package/src/sensor.ts CHANGED
@@ -17,6 +17,7 @@ import { parse as parseYaml } from 'yaml';
17
17
  import type { Sensor } from '@sun-asterisk/sungen';
18
18
  import { loadEffectiveCatalog } from './catalog-loader';
19
19
  import { lintFieldMap, summarize } from './lint';
20
+ import { validateGenerated } from './validate';
20
21
  import type { FieldMap } from './generator';
21
22
 
22
23
  export const dataFactoryCoverageSensor: Sensor<{ dir: string; cwd: string }> = {
@@ -49,6 +50,17 @@ export const dataFactoryCoverageSensor: Sensor<{ dir: string; cwd: string }> = {
49
50
  severity: 'warn',
50
51
  });
51
52
  }
53
+ // Semantic validity (M1): a value labeled valid that violates the constraints (or a synthesized
54
+ // boundary that lands in-range) — the "structurally right but semantically wrong" class.
55
+ for (const v of validateGenerated(map, catalog)) {
56
+ findings.push({
57
+ sensorId: 'data-factory-coverage',
58
+ capability: 'data-factory',
59
+ scenario: v.field,
60
+ message: `DATA-FACTORY-${v.code}: ${v.message}`,
61
+ severity: v.severity,
62
+ });
63
+ }
52
64
  if (findings.length) {
53
65
  const s = summarize(issues);
54
66
  findings.unshift({
package/src/types.ts CHANGED
@@ -18,6 +18,12 @@ export interface DataPoint {
18
18
  /** Pull values from a security bank (e.g. `security/sqli`) into this group. */
19
19
  from?: string;
20
20
  note?: string;
21
+ /**
22
+ * Optional explicit validity for this point (`valid` | `invalid`). Normally the generator's oracle
23
+ * computes it from the constraints; set this only to pin an intent the constraints can't express
24
+ * (e.g. a format-invalid value whose length happens to sit inside the range).
25
+ */
26
+ validity?: 'valid' | 'invalid';
21
27
  }
22
28
 
23
29
  export interface FieldType {
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Data Factory — the deterministic data-quality validator (the Driver's contribution to the harness).
3
+ *
4
+ * `lint.ts` answers "is the coverage enough?" (≥1 valid, boundary present, error codes mapped).
5
+ * This module answers the harder question both reviews flagged: "is each generated value SEMANTICALLY
6
+ * correct?" — i.e. does a value labeled `valid` actually satisfy the constraints, and does a value
7
+ * labeled `invalid` actually violate them? It is the machine oracle a future Master "Data Gate"
8
+ * (see docs/spec/data-factory-quality-roadmap.md) calls; today it powers the golden tests and the
9
+ * `sungen data validate` CLI. Pure + deterministic — no AI, no runtime.
10
+ */
11
+ import type { Catalog } from './types';
12
+ import { expandFieldMap, type FieldMap, type FieldDef, type ExpandedField, type ExpandedPoint } from './generator';
13
+ import { satisfiesConstraints } from './boundary';
14
+
15
+ export interface ValidationFinding {
16
+ field: string;
17
+ pointId: string;
18
+ severity: 'error' | 'warn';
19
+ code: 'VALUE_NOT_VALID' | 'INVALID_LOOKS_VALID';
20
+ message: string;
21
+ }
22
+
23
+ /** Validate the values a set of expanded points, given the effective constraints for that field. */
24
+ export function validatePoints(field: string, type: string | undefined, points: ExpandedPoint[], c: Record<string, any>): ValidationFinding[] {
25
+ const out: ValidationFinding[] = [];
26
+ if (!type) return out;
27
+ for (const p of points) {
28
+ if (p.value == null) continue; // open/todo — lint handles these
29
+ const oracle = satisfiesConstraints(type, p.value, c);
30
+ if (oracle == null) continue; // no length/range constraint to judge against
31
+ if (p.expectedValidity === 'valid' && oracle === false) {
32
+ out.push({
33
+ field, pointId: p.id, severity: 'error', code: 'VALUE_NOT_VALID',
34
+ message: `${p.id}: value ${JSON.stringify(p.value)} is expected VALID but violates the field constraints (${describe(type, c)})`,
35
+ });
36
+ } else if (p.expectedValidity === 'invalid' && oracle === true && p.synthesized) {
37
+ // Only second-guess a SYNTHESIZED (boundary/range) invalid: its whole claim is about range, so
38
+ // satisfying the range means the boundary was mis-synthesized. Literal charset/format invalids and
39
+ // security payloads legitimately pass the length oracle — not a finding.
40
+ out.push({
41
+ field, pointId: p.id, severity: 'warn', code: 'INVALID_LOOKS_VALID',
42
+ message: `${p.id}: value ${JSON.stringify(p.value)} is expected INVALID (out of range) but satisfies the constraints (${describe(type, c)}) — the boundary was mis-synthesized`,
43
+ });
44
+ }
45
+ }
46
+ return out;
47
+ }
48
+
49
+ function describe(type: string, c: Record<string, any>): string {
50
+ const parts: string[] = [];
51
+ if (Number.isFinite(c.minLength ?? c.length)) parts.push(`minLen=${c.minLength ?? c.length}`);
52
+ if (Number.isFinite(c.maxLength ?? c.length)) parts.push(`maxLen=${c.maxLength ?? c.length}`);
53
+ if (Number.isFinite(c.min)) parts.push(`min=${c.min}`);
54
+ if (Number.isFinite(c.max)) parts.push(`max=${c.max}`);
55
+ if (Number.isFinite(c.maxLocal)) parts.push(`maxLocal=${c.maxLocal}`);
56
+ if (Number.isFinite(c.maxTotal)) parts.push(`maxTotal=${c.maxTotal}`);
57
+ if (Number.isFinite(c.decimalPlaces)) parts.push(`decimalPlaces=${c.decimalPlaces}`);
58
+ return parts.join(', ') || type;
59
+ }
60
+
61
+ /** Validate a whole field-map: expand it, then check every value against its expected validity. */
62
+ export function validateGenerated(map: FieldMap, catalog: Catalog): ValidationFinding[] {
63
+ const expanded = expandFieldMap(map, catalog);
64
+ const out: ValidationFinding[] = [];
65
+ const walk = (efs: ExpandedField[], defs: FieldDef[], prefix: string) => {
66
+ for (const ef of efs) {
67
+ const def = defs.find((d) => d.name === ef.name);
68
+ const path = prefix ? `${prefix}.${ef.name}` : ef.name;
69
+ if (ef.children) { walk(ef.children, def?.fields || [], path); continue; }
70
+ const ft = ef.type ? catalog.fieldTypes[ef.type] : undefined;
71
+ const c = { ...(ft?.constraints || {}), ...(def?.constraints || {}) };
72
+ out.push(...validatePoints(path, ef.type, ef.points, c));
73
+ }
74
+ };
75
+ walk(expanded, map.fields, '');
76
+ return out;
77
+ }