@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 +3 -3
- package/src/boundary.ts +260 -0
- package/src/catalog-loader.ts +96 -15
- package/src/cli.ts +48 -2
- package/src/generator.ts +105 -80
- package/src/index.ts +6 -2
- package/src/sensor.ts +12 -0
- package/src/types.ts +36 -0
- package/src/validate.ts +77 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sungen/driver-data-factory",
|
|
3
|
-
"version": "3.2.12-beta.
|
|
3
|
+
"version": "3.2.12-beta.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.
|
|
17
|
+
"@sun-asterisk/sungen": "3.2.12-beta.4",
|
|
18
18
|
"yaml": "^2.4.0"
|
|
19
19
|
},
|
|
20
20
|
"files": [
|
package/src/boundary.ts
ADDED
|
@@ -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/catalog-loader.ts
CHANGED
|
@@ -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
|
-
/**
|
|
56
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
157
|
+
const { disable: _d, ...rest } = ov;
|
|
158
|
+
merged.fieldTypes[type] = rest as FieldType;
|
|
159
|
+
provenance.newFieldTypes.push(type);
|
|
112
160
|
}
|
|
113
161
|
}
|
|
114
|
-
|
|
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
|
-
/**
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
if (
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
107
|
+
return String(Math.round(out));
|
|
90
108
|
}
|
|
91
109
|
|
|
92
|
-
/**
|
|
93
|
-
function
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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:
|
|
187
|
+
value: v,
|
|
188
|
+
todo: opts.todo,
|
|
196
189
|
reason: dp.reason,
|
|
197
|
-
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
|
+
}
|
package/src/validate.ts
ADDED
|
@@ -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
|
+
}
|