@sungen/driver-data-factory 3.2.12-beta.2 → 3.2.12-beta.4

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.2",
3
+ "version": "3.2.12-beta.4",
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.2",
17
+ "@sun-asterisk/sungen": "3.2.12-beta.4",
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
+ }
@@ -14,7 +14,7 @@ import { readFileSync, readdirSync, existsSync, statSync } from 'fs';
14
14
  import { join } from 'path';
15
15
  import { createHash } from 'crypto';
16
16
  import { parse as parseYaml } from 'yaml';
17
- import type { Catalog, FieldType, SecurityBank, Technique, DataPoint, OverwriteWarning } from './types';
17
+ import type { Catalog, FieldType, SecurityBank, Technique, DataPoint, OverwriteWarning, CatalogProvenance, FieldTypeProvenance } from './types';
18
18
 
19
19
  /** Resolve the packaged `common/` dir (works from src/ in dev via tsx and from dist/ when built). */
20
20
  export function commonDir(): string {
@@ -52,27 +52,63 @@ export function loadShippedCatalog(root = commonDir()): Catalog {
52
52
  return { fieldTypes, security, techniques };
53
53
  }
54
54
 
55
- /** Merge a field-type's group by DataPoint.id — overlay entries replace same-id, new ids append. */
56
- function mergeGroup(base: DataPoint[] = [], overlay: DataPoint[] = []): DataPoint[] {
55
+ /**
56
+ * Merge a field-type group by DataPoint.id, honoring three explicit operations:
57
+ * - EXTEND: an overlay id not in `base` is appended (`added`).
58
+ * - REPLACE: an overlay id already in `base` wins (`replaced`).
59
+ * - DISABLE: an id in `disableSet` is dropped (`disabled`).
60
+ * Records provenance and stamps each point's `source` so a reader can trace where it came from.
61
+ */
62
+ function mergeGroup(
63
+ base: DataPoint[] = [],
64
+ overlay: DataPoint[] = [],
65
+ disableSet: Set<string>,
66
+ prov: FieldTypeProvenance,
67
+ ): DataPoint[] {
57
68
  const byId = new Map<string, DataPoint>();
58
- for (const dp of base) byId.set(dp.id, dp);
59
- for (const dp of overlay) byId.set(dp.id, { ...byId.get(dp.id), ...dp });
69
+ for (const dp of base) byId.set(dp.id, { ...dp, source: 'shipped' });
70
+ for (const dp of overlay) {
71
+ if (byId.has(dp.id)) prov.replaced.push(dp.id);
72
+ else prov.added.push(dp.id);
73
+ byId.set(dp.id, { ...byId.get(dp.id), ...dp, source: 'overwrite' });
74
+ }
75
+ for (const id of disableSet) {
76
+ if (byId.delete(id)) prov.disabled.push(id);
77
+ }
60
78
  return [...byId.values()];
61
79
  }
62
80
 
63
- function mergeFieldType(base: FieldType, overlay: Partial<FieldType>): FieldType {
81
+ /** An overlay field-type entry may carry a `disable:` list of ids alongside partial field-type data. */
82
+ type FieldTypeOverlay = Partial<FieldType> & { disable?: string[] };
83
+
84
+ function mergeFieldType(base: FieldType, overlay: FieldTypeOverlay, prov: FieldTypeProvenance): FieldType {
85
+ const disableSet = new Set(overlay.disable || []);
86
+ const constraintsOverridden = Object.keys(overlay.constraints || {}).filter(
87
+ (k) => (base.constraints || {})[k] !== (overlay.constraints || {})[k],
88
+ );
89
+ prov.constraintsOverridden = constraintsOverridden;
90
+ prov.securityChanged = overlay.uses_security != null;
91
+ const { disable: _disable, ...rest } = overlay;
64
92
  return {
65
93
  ...base,
66
- ...overlay,
94
+ ...rest,
67
95
  constraints: { ...(base.constraints || {}), ...(overlay.constraints || {}) },
68
96
  uses_security: overlay.uses_security ?? base.uses_security,
69
- valid: mergeGroup(base.valid, overlay.valid),
70
- boundary: mergeGroup(base.boundary, overlay.boundary),
71
- invalid: mergeGroup(base.invalid, overlay.invalid),
97
+ valid: mergeGroup(base.valid, overlay.valid, disableSet, prov),
98
+ boundary: mergeGroup(base.boundary, overlay.boundary, disableSet, prov),
99
+ invalid: mergeGroup(base.invalid, overlay.invalid, disableSet, prov),
72
100
  notes: [...(base.notes || []), ...(overlay.notes || [])],
73
101
  };
74
102
  }
75
103
 
104
+ /** Merge a security bank by payload id (same rules: replace same-id, append new). */
105
+ function mergeSecurity(base: SecurityBank, overlay: Partial<SecurityBank>): SecurityBank {
106
+ const byId = new Map<string, any>();
107
+ for (const p of base.payloads || []) byId.set(p.id, p);
108
+ for (const p of overlay.payloads || []) byId.set(p.id, { ...byId.get(p.id), ...p });
109
+ return { ...base, ...overlay, payloads: [...byId.values()] };
110
+ }
111
+
76
112
  /**
77
113
  * Load the effective catalog = shipped common + project overwrite overlay.
78
114
  * `projectDir` is the QA repo root; overlay is read from `qa/data-factory/common.overwrite.yaml`.
@@ -80,22 +116,32 @@ function mergeFieldType(base: FieldType, overlay: Partial<FieldType>): FieldType
80
116
  export function loadEffectiveCatalog(projectDir: string, root = commonDir()): {
81
117
  catalog: Catalog;
82
118
  warnings: OverwriteWarning[];
119
+ provenance: CatalogProvenance;
83
120
  } {
84
121
  const shipped = loadShippedCatalog(root);
85
122
  const overlayPath = join(projectDir, 'qa', 'data-factory', 'common.overwrite.yaml');
86
123
  const warnings: OverwriteWarning[] = [];
87
- if (!existsSync(overlayPath)) return { catalog: shipped, warnings };
124
+ const provenance: CatalogProvenance = { fieldTypes: {}, newFieldTypes: [], security: { added: [], replaced: [] }, techniques: { added: [], replaced: [] } };
125
+ if (!existsSync(overlayPath)) return { catalog: shipped, warnings, provenance };
88
126
 
89
127
  const overlay = parseYaml(readFileSync(overlayPath, 'utf8')) || {};
90
- const overlayFieldTypes: Record<string, Partial<FieldType>> = overlay.fieldTypes || overlay['field-types'] || {};
128
+ const overlayFieldTypes: Record<string, FieldTypeOverlay> = overlay.fieldTypes || overlay['field-types'] || {};
91
129
  const merged: Catalog = {
92
130
  fieldTypes: { ...shipped.fieldTypes },
93
131
  security: { ...shipped.security },
94
132
  techniques: { ...shipped.techniques },
95
133
  };
134
+
96
135
  for (const [type, ov] of Object.entries(overlayFieldTypes)) {
97
136
  if (merged.fieldTypes[type]) {
98
- merged.fieldTypes[type] = mergeFieldType(merged.fieldTypes[type], ov);
137
+ const prov: FieldTypeProvenance = { type, disabled: [], replaced: [], added: [], constraintsOverridden: [], securityChanged: false };
138
+ // Warn on a disable target that does not exist in the shipped field-type (likely a typo/stale id).
139
+ const known = new Set([...(merged.fieldTypes[type].valid || []), ...(merged.fieldTypes[type].boundary || []), ...(merged.fieldTypes[type].invalid || [])].map((d) => d.id));
140
+ for (const id of ov.disable || []) {
141
+ if (!known.has(id)) warnings.push({ fieldType: type, detail: `disable target "${id}" does not exist in the shipped "${type}" catalog`, suggestion: `remove the stale id from the "${type}".disable list in qa/data-factory/common.overwrite.yaml` });
142
+ }
143
+ merged.fieldTypes[type] = mergeFieldType(merged.fieldTypes[type], ov, prov);
144
+ provenance.fieldTypes[type] = prov;
99
145
  // Drift warning: overlay references a security bank the shipped common no longer has.
100
146
  for (const bank of ov.uses_security || []) {
101
147
  if (!merged.security[bank]) {
@@ -108,10 +154,45 @@ export function loadEffectiveCatalog(projectDir: string, root = commonDir()): {
108
154
  }
109
155
  } else {
110
156
  // Wholly new field-type from the user — accept as-is (project-specific type).
111
- merged.fieldTypes[type] = ov as FieldType;
157
+ const { disable: _d, ...rest } = ov;
158
+ merged.fieldTypes[type] = rest as FieldType;
159
+ provenance.newFieldTypes.push(type);
112
160
  }
113
161
  }
114
- return { catalog: merged, warnings };
162
+
163
+ // Security-bank overrides (`security:` in the overwrite) — replace same-id payloads, append new banks.
164
+ const overlaySecurity: Record<string, Partial<SecurityBank>> = overlay.security || {};
165
+ for (const [cls, ov] of Object.entries(overlaySecurity)) {
166
+ if (merged.security[cls]) { merged.security[cls] = mergeSecurity(merged.security[cls], ov); provenance.security.replaced.push(cls); }
167
+ else { merged.security[cls] = { class: cls, payloads: [], ...ov } as SecurityBank; provenance.security.added.push(cls); }
168
+ }
169
+
170
+ // Technique overrides (`techniques:` in the overwrite) — shallow replace/add by name.
171
+ const overlayTech: Record<string, Partial<Technique>> = overlay.techniques || {};
172
+ for (const [name, ov] of Object.entries(overlayTech)) {
173
+ if (merged.techniques[name]) { merged.techniques[name] = { ...merged.techniques[name], ...ov }; provenance.techniques.replaced.push(name); }
174
+ else { merged.techniques[name] = { technique: name, ...ov } as Technique; provenance.techniques.added.push(name); }
175
+ }
176
+
177
+ return { catalog: merged, warnings, provenance };
178
+ }
179
+
180
+ /** Human-readable one-liners describing what `.overwrite` changed (for lint/sensor "no silent overwrite"). */
181
+ export function describeProvenance(p: CatalogProvenance): string[] {
182
+ const lines: string[] = [];
183
+ for (const ft of Object.values(p.fieldTypes)) {
184
+ const parts: string[] = [];
185
+ if (ft.disabled.length) parts.push(`disabled ${ft.disabled.join(', ')}`);
186
+ if (ft.replaced.length) parts.push(`replaced ${ft.replaced.join(', ')}`);
187
+ if (ft.added.length) parts.push(`added ${ft.added.join(', ')}`);
188
+ if (ft.constraintsOverridden.length) parts.push(`constraints[${ft.constraintsOverridden.join(', ')}]`);
189
+ if (ft.securityChanged) parts.push('uses_security replaced');
190
+ if (parts.length) lines.push(`${ft.type}: ${parts.join('; ')}`);
191
+ }
192
+ for (const t of p.newFieldTypes) lines.push(`${t}: new project field-type`);
193
+ if (p.security.replaced.length || p.security.added.length) lines.push(`security: ${[...p.security.replaced.map((s) => `replaced ${s}`), ...p.security.added.map((s) => `added ${s}`)].join('; ')}`);
194
+ if (p.techniques.replaced.length || p.techniques.added.length) lines.push(`techniques: ${[...p.techniques.replaced.map((s) => `replaced ${s}`), ...p.techniques.added.map((s) => `added ${s}`)].join('; ')}`);
195
+ return lines;
115
196
  }
116
197
 
117
198
  /** Stable hash of the shipped common — recorded in df-manifest.json to detect update drift. */
package/src/cli.ts CHANGED
@@ -10,9 +10,10 @@ import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from
10
10
  import { join, dirname } from 'path';
11
11
  import { createInterface } from 'readline';
12
12
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
13
- import { loadEffectiveCatalog } from './catalog-loader';
13
+ import { loadEffectiveCatalog, describeProvenance } 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
 
@@ -50,8 +51,9 @@ function loadFieldMap(cwd: string, name: string): FieldMap | null {
50
51
  async function genOne(cwd: string, name: string, opts: { force?: boolean }): Promise<'written' | 'skipped' | 'missing'> {
51
52
  const map = loadFieldMap(cwd, name);
52
53
  if (!map) { console.error(` ✗ ${name}: no field-map at qa/data-factory/${name}.fields.yaml (author it first, e.g. via /sungen:create-data-test)`); return 'missing'; }
53
- const { catalog, warnings } = loadEffectiveCatalog(cwd);
54
+ const { catalog, warnings, provenance } = loadEffectiveCatalog(cwd);
54
55
  for (const w of warnings) console.warn(` ⚠ overwrite: ${w.detail} → ${w.suggestion}`);
56
+ for (const line of describeProvenance(provenance)) console.log(` ⟲ .overwrite → ${line}`);
55
57
 
56
58
  const expanded = expandFieldMap(map, catalog);
57
59
  const out = testDataPath(cwd, name);
@@ -146,6 +148,50 @@ export function registerDataFactoryCommand(program: any): void {
146
148
  if (totalErrors) process.exitCode = 1;
147
149
  });
148
150
 
151
+ data
152
+ .command('validate')
153
+ .description('Verify every generated value is semantically correct (valid values satisfy the constraints; synthesized invalid values violate them)')
154
+ .option('--screen <name>', 'Validate one unit (default: all field-maps)')
155
+ .action((opts: { screen?: string }) => {
156
+ const cwd = process.cwd();
157
+ ensureDataFactoryReadme(cwd);
158
+ const names = opts.screen ? [opts.screen] : listFieldMaps(cwd);
159
+ if (names.length === 0) { console.error('No field-maps under qa/data-factory/*.fields.yaml.'); process.exitCode = 1; return; }
160
+ const { catalog } = loadEffectiveCatalog(cwd);
161
+ let totalErrors = 0;
162
+ for (const name of names) {
163
+ const map = loadFieldMap(cwd, name);
164
+ if (!map) { console.error(`✗ ${name}: no field-map`); totalErrors++; continue; }
165
+ const findings = validateGenerated(map, catalog);
166
+ const errors = findings.filter((f) => f.severity === 'error').length;
167
+ const warns = findings.filter((f) => f.severity === 'warn').length;
168
+ totalErrors += errors;
169
+ const badge = errors ? '✗' : warns ? '⚠' : '✓';
170
+ console.log(`${badge} ${name}: ${errors} error · ${warns} warn`);
171
+ for (const f of findings) {
172
+ console.log(`${f.severity === 'error' ? ' ✗' : ' ⚠'} [${f.field}] ${f.code}: ${f.message}`);
173
+ }
174
+ }
175
+ if (totalErrors) process.exitCode = 1;
176
+ });
177
+
178
+ data
179
+ .command('provenance')
180
+ .description('Show what qa/data-factory/common.overwrite.yaml changed vs the shipped company catalog (extend/replace/disable) — "no silent overwrite"')
181
+ .action(() => {
182
+ const cwd = process.cwd();
183
+ ensureDataFactoryReadme(cwd);
184
+ const { provenance, warnings } = loadEffectiveCatalog(cwd);
185
+ const lines = describeProvenance(provenance);
186
+ if (lines.length === 0) {
187
+ console.log('No project overrides — the effective catalog is the shipped company common as-is.');
188
+ } else {
189
+ console.log('Project overrides (qa/data-factory/common.overwrite.yaml):');
190
+ for (const l of lines) console.log(` ⟲ ${l}`);
191
+ }
192
+ for (const w of warnings) console.warn(` ⚠ ${w.detail} → ${w.suggestion}`);
193
+ });
194
+
149
195
  data
150
196
  .command('fixture')
151
197
  .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,21 @@ 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;
74
+ /** Provenance: `overwrite` when the project's `.overwrite` replaced/added this point (else shipped). */
75
+ source?: 'shipped' | 'overwrite';
60
76
  }
61
77
 
62
78
  export interface ExpandedField {
@@ -70,76 +86,32 @@ export interface ExpandedField {
70
86
  dependsOn?: { field: string; value?: string };
71
87
  }
72
88
 
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;
89
+ /**
90
+ * Coerce an out-of-range numeric VALID literal into range, so a project's `valid` representative is
91
+ * actually valid for its constraints (a generic catalog example like `123` under `max: 100` becomes
92
+ * `100`). Returns null when in range already, or the type isn't numeric (non-numeric mismatches are
93
+ * left for the validator to flag — the field-map should supply a fitting value).
94
+ */
95
+ function coerceValidNumeric(type: string, value: string, c: Record<string, any>): string | null {
96
+ if (!['number-integer', 'number-float', 'currency'].includes(type)) return null;
97
+ const n = Number(value);
98
+ if (!Number.isFinite(n)) return null;
99
+ let out = n;
100
+ if (Number.isFinite(c.min) && out < c.min) out = c.min;
101
+ if (Number.isFinite(c.max) && out > c.max) out = c.max;
102
+ if (out === n) return null; // already in range
103
+ if (type === 'number-float' || (type === 'currency' && c.decimalPlaces > 0)) {
104
+ const dp = Number.isFinite(c.decimalPlaces) ? c.decimalPlaces : 2;
105
+ return out.toFixed(dp);
88
106
  }
89
- return null;
107
+ return String(Math.round(out));
90
108
  }
91
109
 
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' };
110
+ /** A gen hint that references a numeric/length boundary the generator can expand structurally. */
111
+ function isBoundaryHint(hint: string): boolean {
112
+ return /\bmin\b|\bmax\b|minimum|maximum|length|byte|exact|decimal|precision|local|total|over|below|under|exceed|\+\s*1|-\s*1|<|>/.test(
113
+ hint.toLowerCase(),
114
+ );
143
115
  }
144
116
 
145
117
  function resolveError(dp: DataPoint, errorMap?: Record<string, string>): { expectedError?: string; open?: boolean } {
@@ -179,26 +151,77 @@ export function expandField(field: FieldDef, catalog: Catalog): ExpandedField {
179
151
  if (!ft) return { name: field.name, type: field.type, blindSpot: true, points: [], ...(field.dependsOn ? { dependsOn: field.dependsOn } : {}) };
180
152
 
181
153
  const c = { ...(ft.constraints || {}), ...(field.constraints || {}) };
154
+ const type = field.type as string;
182
155
  const points: ExpandedPoint[] = [];
183
156
 
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({
157
+ // Default validity for a group: the happy path is valid; the invalid group is invalid; a boundary
158
+ // point's validity is decided per-point by the oracle (in/out of range).
159
+ const groupValidity = (group: ExpandedPoint['group']): Validity => (group === 'invalid' ? 'invalid' : 'valid');
160
+
161
+ const emit = (p: Omit<ExpandedPoint, 'expectedValidity'> & { expectedValidity: Validity }) => points.push(p);
162
+
163
+ const pushLiteral = (dp: DataPoint, group: ExpandedPoint['group'], value: string | null, opts: { validity?: Validity; label?: string; todo?: string } = {}) => {
164
+ // A valid representative must be valid for the project: clamp an out-of-range numeric literal.
165
+ let coerceNote: string | undefined;
166
+ if (group === 'valid' && value != null && satisfiesConstraints(type, value, c) === false) {
167
+ const fixed = coerceValidNumeric(type, value, c);
168
+ if (fixed != null) { value = fixed; coerceNote = 'adjusted to fit constraints'; }
169
+ }
170
+ const isUniqueValid = group === 'valid' && field.unique && value != null;
171
+ const v = isUniqueValid ? makeUnique(value!, type) : value;
172
+ // Expected validity reflects INTENT by group, so the validator (validateGenerated) can catch a
173
+ // mismatch against the oracle instead of the mismatch being silently absorbed here:
174
+ // - valid group → `valid` (a value that actually violates the constraints is a VALUE_NOT_VALID).
175
+ // - invalid group → `invalid` (declared: charset/format/blocklist/behavior — the length oracle
176
+ // cannot "prove" such a literal valid, so it must NOT flip it to valid).
177
+ // - boundary group → the oracle decides (that group is precisely about range).
178
+ let validity: Validity;
179
+ if (dp.validity) validity = dp.validity;
180
+ else if (group === 'boundary') {
181
+ const oracle = satisfiesConstraints(type, isUniqueValid ? value : v, c);
182
+ validity = oracle == null ? (opts.validity ?? 'valid') : oracle ? 'valid' : 'invalid';
183
+ } else validity = opts.validity ?? groupValidity(group);
184
+ emit({
192
185
  id: dp.id,
193
186
  group,
194
- value,
195
- todo: s.todo,
187
+ value: v,
188
+ todo: opts.todo,
196
189
  reason: dp.reason,
197
- note: field.unique && group === 'valid' && s.value != null ? `${dp.note ? dp.note + ' · ' : ''}fresh per run` : dp.note,
190
+ 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),
191
+ expectedValidity: validity,
192
+ ...(dp.source === 'overwrite' ? { source: 'overwrite' as const } : {}),
198
193
  ...resolveError(dp, field.errorMap),
199
194
  });
200
195
  };
201
196
 
197
+ const push = (dp: DataPoint, group: ExpandedPoint['group']) => {
198
+ // Literal value in the catalog/overwrite → one point (oracle still judges its validity).
199
+ if (dp.value != null) return pushLiteral(dp, group, dp.value);
200
+
201
+ const hint = `${dp.gen || ''} ${dp.note || ''}`.trim();
202
+ // A boundary/length/numeric hint → expand to one case PER boundary point, each with its validity.
203
+ if (hint && isBoundaryHint(hint)) {
204
+ const pts = expandBoundaryHint(hint, type, c);
205
+ for (const bp of pts) {
206
+ emit({
207
+ id: dp.id + bp.suffix,
208
+ group,
209
+ value: bp.value,
210
+ todo: bp.todo,
211
+ reason: dp.reason,
212
+ note: bp.label && !bp.todo ? `${dp.note ? dp.note + ' · ' : ''}${bp.label}` : dp.note,
213
+ expectedValidity: dp.validity ?? bp.validity,
214
+ synthesized: bp.value != null,
215
+ ...(dp.source === 'overwrite' ? { source: 'overwrite' as const } : {}),
216
+ ...resolveError(dp, field.errorMap),
217
+ });
218
+ }
219
+ return;
220
+ }
221
+ // A non-boundary gen (charset/full-width/format) — can't synthesize deterministically; keep open.
222
+ pushLiteral(dp, group, null, { validity: groupValidity(group), todo: dp.gen ? `synthesize: ${dp.gen}` : 'value/gen not resolvable' });
223
+ };
224
+
202
225
  for (const dp of ft.valid || []) push(dp, 'valid');
203
226
  for (const dp of ft.boundary || []) push(dp, 'boundary');
204
227
  for (const dp of ft.invalid || []) {
@@ -218,6 +241,8 @@ export function expandField(field: FieldDef, catalog: Catalog): ExpandedField {
218
241
  todo: p.value == null ? `security technique: ${p.technique || bank}` : undefined,
219
242
  reason: `${bank}: ${(p.reason as string) || (p.kind as string) || (p.technique as string) || ''}`.trim(),
220
243
  note: b.oracle,
244
+ // Adversarial payloads are treated as invalid input (must be rejected/escaped, never accepted).
245
+ expectedValidity: 'invalid',
221
246
  });
222
247
  }
223
248
  }
@@ -259,8 +284,8 @@ function renderOne(f: ExpandedField): Record<string, any> {
259
284
  return {
260
285
  ...dep,
261
286
  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 })),
287
+ 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 })),
288
+ 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 })),
264
289
  };
265
290
  }
266
291
 
package/src/index.ts CHANGED
@@ -18,17 +18,21 @@ import { registerDataFactoryCommand } from './cli';
18
18
  import { dataFactoryCoverageSensor } from './sensor';
19
19
  import { dfPreconditionCodegen } from './precondition';
20
20
 
21
- export { loadShippedCatalog, loadEffectiveCatalog, shippedCommonHash, commonDir } from './catalog-loader';
21
+ export { loadShippedCatalog, loadEffectiveCatalog, shippedCommonHash, commonDir, describeProvenance } from './catalog-loader';
22
22
  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';
29
33
  export { dataFactoryCoverageSensor } from './sensor';
30
34
  export { dfPreconditionCodegen } from './precondition';
31
- export type { Catalog, FieldType, SecurityBank, Technique, DataPoint, OverwriteWarning } from './types';
35
+ export type { Catalog, FieldType, SecurityBank, Technique, DataPoint, OverwriteWarning, CatalogProvenance, FieldTypeProvenance } from './types';
32
36
 
33
37
  /** Register the Data Factory capability. */
34
38
  export function register(registry: CapabilityRegistry): void {
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,18 @@ 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';
27
+ /**
28
+ * Where this point ended up coming from after the `.overwrite` merge — `shipped` (company common) or
29
+ * `overwrite` (the project replaced/added it). Set by the loader; carried through to the generated
30
+ * value so a reader can answer "why is this value here?". Not authored by hand.
31
+ */
32
+ source?: 'shipped' | 'overwrite';
21
33
  }
22
34
 
23
35
  export interface FieldType {
@@ -60,3 +72,27 @@ export interface OverwriteWarning {
60
72
  detail: string;
61
73
  suggestion: string;
62
74
  }
75
+
76
+ /** What the `.overwrite` layer changed for one field-type (provenance — "no silent overwrite"). */
77
+ export interface FieldTypeProvenance {
78
+ type: string;
79
+ /** Ids removed via `disable:` (an id that was not in the shipped common is reported as a warning). */
80
+ disabled: string[];
81
+ /** Ids that existed in shipped AND the overlay — the overlay value won. */
82
+ replaced: string[];
83
+ /** Ids new from the overlay (a project-specific case). */
84
+ added: string[];
85
+ /** Constraint keys the overlay changed. */
86
+ constraintsOverridden: string[];
87
+ /** True when the overlay replaced `uses_security`. */
88
+ securityChanged: boolean;
89
+ }
90
+
91
+ /** Catalog-wide provenance of the shipped-common → project-overwrite merge. */
92
+ export interface CatalogProvenance {
93
+ fieldTypes: Record<string, FieldTypeProvenance>;
94
+ /** Wholly new field-types added by the project (not in shipped common). */
95
+ newFieldTypes: string[];
96
+ security: { added: string[]; replaced: string[] };
97
+ techniques: { added: string[]; replaced: string[] };
98
+ }
@@ -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
+ }