@sungen/driver-data-factory 3.2.34-beta.1 → 3.3.0-alpha.1
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 +2 -2
- package/src/boundary.ts +43 -21
- package/src/catalog-loader.ts +24 -5
- package/src/generator.ts +34 -7
- package/src/lint.ts +12 -0
- package/src/types.ts +6 -0
- package/src/validate.ts +4 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sungen/driver-data-factory",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.0-alpha.1",
|
|
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",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"author": "eqe team (engineer & quality) — Sun Asterisk",
|
|
15
15
|
"license": "MIT",
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@sun-asterisk/sungen": "3.
|
|
17
|
+
"@sun-asterisk/sungen": "3.3.0-alpha.1",
|
|
18
18
|
"yaml": "^2.4.0"
|
|
19
19
|
},
|
|
20
20
|
"files": [
|
package/src/boundary.ts
CHANGED
|
@@ -90,8 +90,9 @@ function readOffset(seg: string): { base: 'min' | 'max' | 'length' | null; off:
|
|
|
90
90
|
let off = 0;
|
|
91
91
|
if (/\+\s*1|plus one|one extra/.test(s)) off = 1;
|
|
92
92
|
else if (/-\s*1|minus one/.test(s)) off = -1;
|
|
93
|
-
|
|
94
|
-
else if (
|
|
93
|
+
// `<=` / `>=` include the bound itself (off 0), only a strict `<` / `>` steps past it (#904).
|
|
94
|
+
else if (/<(?!=)|below|under|less than|shorter/.test(s)) off = -1;
|
|
95
|
+
else if (/>(?!=)|over|above|exceed|greater than|longer|too long/.test(s)) off = 1;
|
|
95
96
|
const sign = off > 0 ? '+1' : off < 0 ? '-1' : '';
|
|
96
97
|
const label = (base || 'bound') + sign;
|
|
97
98
|
return { base, off, label };
|
|
@@ -101,7 +102,23 @@ function readOffset(seg: string): { base: 'min' | 'max' | 'length' | null; off:
|
|
|
101
102
|
* Expand a length-bounded string hint into one BoundaryPoint per segment.
|
|
102
103
|
* min/max come from the constraints; a point OUTSIDE [min,max] is invalid, inside/on-edge is valid.
|
|
103
104
|
*/
|
|
104
|
-
|
|
105
|
+
/** What a length judgement needs beyond the constraints: whether the field must be filled at all. */
|
|
106
|
+
export interface FieldContext { required?: boolean }
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Is a length within the field's rules? Length 0 is "not provided", which `required` decides — not
|
|
110
|
+
* `minLength` (an HTML `minlength` is not enforced on an empty optional field). Only an explicit
|
|
111
|
+
* `required: false` accepts it: an unset `required` counts as required, the same rule the catalog's
|
|
112
|
+
* own empty case (`when: { required: true }`) is admitted by, so the two never disagree on "".
|
|
113
|
+
* Any other length is judged by [minLength, maxLength]. A hard `n >= 1` used to make "" invalid even
|
|
114
|
+
* for an optional field, so a Bio that may be cleared got an "expect an error" oracle (#901).
|
|
115
|
+
*/
|
|
116
|
+
function lengthValid(n: number, min: number | undefined, max: number | undefined, ctx: FieldContext): boolean {
|
|
117
|
+
if (n === 0) return ctx.required === false;
|
|
118
|
+
return (!Number.isFinite(min) || n >= (min as number)) && (!Number.isFinite(max) || n <= (max as number));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function expandLength(type: string, hint: string, c: Record<string, any>, ctx: FieldContext = {}): BoundaryPoint[] {
|
|
105
122
|
const min = c.minLength ?? c.length ?? c.minlength;
|
|
106
123
|
const max = c.maxLength ?? c.length ?? c.maxlength;
|
|
107
124
|
const segs = segments(hint);
|
|
@@ -109,16 +126,23 @@ function expandLength(type: string, hint: string, c: Record<string, any>): Bound
|
|
|
109
126
|
let idx = 0;
|
|
110
127
|
for (const seg of segs) {
|
|
111
128
|
const { base, off, label } = readOffset(seg);
|
|
129
|
+
// The suffix follows the SEGMENT, skipped or not, so `CHK-STR-I2a` is max+1 whether or not its
|
|
130
|
+
// min-1 sibling exists — a per-field `disable:` names points by these ids (#902).
|
|
131
|
+
const suffix = suffixOf(idx++);
|
|
112
132
|
const anchor = base === 'max' ? max : base === 'length' ? (c.length ?? max ?? min) : min;
|
|
113
133
|
if (!Number.isFinite(anchor)) {
|
|
114
|
-
out.push({ suffix
|
|
134
|
+
out.push({ suffix, value: null, validity: 'valid', label, todo: 'minLength/maxLength/length' });
|
|
115
135
|
continue;
|
|
116
136
|
}
|
|
117
137
|
const n = anchor + off;
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
138
|
+
// No such length: min-1 of `minLength: 0`. It used to be clamped to "", a second empty-string
|
|
139
|
+
// case with the opposite meaning (#901).
|
|
140
|
+
if (n < 0) continue;
|
|
141
|
+
const valid = lengthValid(n, min, max, ctx);
|
|
142
|
+
// A below-min point exists to be OUT of bounds. One that is not — an optional field's empty value
|
|
143
|
+
// under `minLength: 1` — is no boundary violation, so it is not emitted as one.
|
|
144
|
+
if (off < 0 && base !== 'max' && valid) continue;
|
|
145
|
+
out.push({ suffix, value: fillString(type, n), validity: valid ? 'valid' : 'invalid', label });
|
|
122
146
|
}
|
|
123
147
|
return out.length ? out : [{ suffix: '', value: null, validity: 'valid', label: 'bound', todo: 'minLength/maxLength' }];
|
|
124
148
|
}
|
|
@@ -201,9 +225,9 @@ function suffixOf(i: number): string {
|
|
|
201
225
|
* Public entry: expand a boundary/invalid hint into structured points.
|
|
202
226
|
* Returns one point per segment; each carries the value AND the oracle's expected validity.
|
|
203
227
|
*/
|
|
204
|
-
export function expandBoundaryHint(hint: string, type: string, c: Record<string, any
|
|
228
|
+
export function expandBoundaryHint(hint: string, type: string, c: Record<string, any>, ctx: FieldContext = {}): BoundaryPoint[] {
|
|
205
229
|
if (type === 'email') return expandEmail(hint, c);
|
|
206
|
-
if (STR_TYPES.has(type)) return expandLength(type, hint, c);
|
|
230
|
+
if (STR_TYPES.has(type)) return expandLength(type, hint, c, ctx);
|
|
207
231
|
if (NUM_TYPES.has(type)) return expandNumeric(type, hint, c);
|
|
208
232
|
return [{ suffix: '', value: null, validity: 'valid', label: 'bound', todo: 'value/gen not resolvable' }];
|
|
209
233
|
}
|
|
@@ -232,12 +256,8 @@ export function emailOfTotalLength(n: number): string | null {
|
|
|
232
256
|
return 'a@' + 'b'.repeat(rest - 2) + '.c'; // e.g. n=5 → "a@b.c", n=6 → "a@bb.c"
|
|
233
257
|
}
|
|
234
258
|
|
|
235
|
-
function satisfiesLength(type: string, value: string, c: Record<string, any
|
|
236
|
-
|
|
237
|
-
const max = c.maxLength ?? c.length;
|
|
238
|
-
if (Number.isFinite(min) && value.length < min) return false;
|
|
239
|
-
if (Number.isFinite(max) && value.length > max) return false;
|
|
240
|
-
return true;
|
|
259
|
+
function satisfiesLength(type: string, value: string, c: Record<string, any>, ctx: FieldContext = {}): boolean {
|
|
260
|
+
return lengthValid(value.length, c.minLength ?? c.length, c.maxLength ?? c.length, ctx);
|
|
241
261
|
}
|
|
242
262
|
|
|
243
263
|
function satisfiesNumeric(type: string, value: string, c: Record<string, any>): boolean {
|
|
@@ -258,12 +278,12 @@ function satisfiesNumeric(type: string, value: string, c: Record<string, any>):
|
|
|
258
278
|
* them (length/range/precision — NOT charset/format, which the catalog encodes as literal invalids).
|
|
259
279
|
* Returns null when the type has no numeric/length constraint to check against (nothing to assert).
|
|
260
280
|
*/
|
|
261
|
-
export function satisfiesConstraints(type: string, value: string | null, c: Record<string, any
|
|
281
|
+
export function satisfiesConstraints(type: string, value: string | null, c: Record<string, any>, ctx: FieldContext = {}): boolean | null {
|
|
262
282
|
if (value == null) return null;
|
|
263
283
|
if (type === 'email') return satisfiesEmail(value, c);
|
|
264
284
|
if (STR_TYPES.has(type)) {
|
|
265
285
|
if (!Number.isFinite(c.minLength ?? c.length) && !Number.isFinite(c.maxLength ?? c.length)) return null;
|
|
266
|
-
return satisfiesLength(type, value, c);
|
|
286
|
+
return satisfiesLength(type, value, c, ctx);
|
|
267
287
|
}
|
|
268
288
|
if (NUM_TYPES.has(type)) {
|
|
269
289
|
if (!Number.isFinite(c.min) && !Number.isFinite(c.max) && !Number.isFinite(c.decimalPlaces)) return null;
|
|
@@ -284,12 +304,14 @@ export interface BvaExtra { label: string; value: string; validity: Validity; gr
|
|
|
284
304
|
* MISSING points (dedup by exact value), so a type whose catalog already covers a dimension (string /
|
|
285
305
|
* password / number) yields nothing — no churn.
|
|
286
306
|
*/
|
|
287
|
-
export function ensureBvaComplete(type: string, c: Record<string, any>, existing: Array<string | null
|
|
307
|
+
export function ensureBvaComplete(type: string, c: Record<string, any>, existing: Array<string | null>, ctx: FieldContext = {}): BvaExtra[] {
|
|
288
308
|
const have = new Set(existing.filter((v): v is string => v != null));
|
|
289
309
|
const out: BvaExtra[] = [];
|
|
290
310
|
const push = (label: string, value: string | null) => {
|
|
291
311
|
if (value == null || have.has(value)) return;
|
|
292
|
-
const ok = satisfiesConstraints(type, value, c);
|
|
312
|
+
const ok = satisfiesConstraints(type, value, c, ctx);
|
|
313
|
+
// A below-min point that is in range (an optional field's empty value) is not a boundary case (#901).
|
|
314
|
+
if (label === 'minLen-1' && ok !== false) return;
|
|
293
315
|
const validity: Validity = ok === false ? 'invalid' : 'valid';
|
|
294
316
|
out.push({ label, value, validity, group: validity === 'invalid' ? 'invalid' : 'boundary' });
|
|
295
317
|
have.add(value);
|
|
@@ -305,7 +327,7 @@ export function ensureBvaComplete(type: string, c: Record<string, any>, existing
|
|
|
305
327
|
}
|
|
306
328
|
if (STR_TYPES.has(type)) {
|
|
307
329
|
const min = c.minLength ?? c.length, max = c.maxLength ?? c.length;
|
|
308
|
-
if (Number.isFinite(min)) { push('minLen-1', fillString(type, min - 1)); push('minLen', fillString(type, min)); push('minLen+1', fillString(type, min + 1)); }
|
|
330
|
+
if (Number.isFinite(min)) { if (min >= 1) push('minLen-1', fillString(type, min - 1)); push('minLen', fillString(type, min)); push('minLen+1', fillString(type, min + 1)); }
|
|
309
331
|
if (Number.isFinite(max)) { push('maxLen-1', fillString(type, max - 1)); push('maxLen', fillString(type, max)); push('maxLen+1', fillString(type, max + 1)); }
|
|
310
332
|
return out;
|
|
311
333
|
}
|
package/src/catalog-loader.ts
CHANGED
|
@@ -22,24 +22,41 @@ export function commonDir(): string {
|
|
|
22
22
|
return join(__dirname, '..', 'common');
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
function readYamlFilesDeep(dir: string): Array<{ file: string; data: any }> {
|
|
26
|
-
const out: Array<{ file: string; data: any }> = [];
|
|
25
|
+
function readYamlFilesDeep(dir: string): Array<{ file: string; data: any; text: string }> {
|
|
26
|
+
const out: Array<{ file: string; data: any; text: string }> = [];
|
|
27
27
|
if (!existsSync(dir)) return out;
|
|
28
28
|
for (const name of readdirSync(dir)) {
|
|
29
29
|
const full = join(dir, name);
|
|
30
30
|
if (statSync(full).isDirectory()) out.push(...readYamlFilesDeep(full));
|
|
31
31
|
else if (name.endsWith('.yaml') || name.endsWith('.yml')) {
|
|
32
|
-
|
|
32
|
+
const text = readFileSync(full, 'utf8');
|
|
33
|
+
out.push({ file: full, data: parseYaml(text), text });
|
|
33
34
|
}
|
|
34
35
|
}
|
|
35
36
|
return out;
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
/** The keys of the top-level `constraints:` block whose line carries a `# placeholder` comment. */
|
|
40
|
+
export function placeholderKeys(text: string): string[] {
|
|
41
|
+
const keys: string[] = [];
|
|
42
|
+
let inBlock = false;
|
|
43
|
+
for (const line of text.split(/\r?\n/)) {
|
|
44
|
+
if (/^constraints:\s*(#.*)?$/.test(line)) { inBlock = true; continue; }
|
|
45
|
+
if (!inBlock || !line.trim() || /^\s*#/.test(line)) continue;
|
|
46
|
+
if (!/^\s/.test(line)) break; // the next top-level key ends the block
|
|
47
|
+
const m = line.match(/^\s+([A-Za-z_][\w-]*)\s*:.*#\s*placeholder\b/i);
|
|
48
|
+
if (m) keys.push(m[1]);
|
|
49
|
+
}
|
|
50
|
+
return keys;
|
|
51
|
+
}
|
|
52
|
+
|
|
38
53
|
/** Load the shipped common catalog (no project overlay). */
|
|
39
54
|
export function loadShippedCatalog(root = commonDir()): Catalog {
|
|
40
55
|
const fieldTypes: Record<string, FieldType> = {};
|
|
41
|
-
for (const { data } of readYamlFilesDeep(join(root, 'field-types'))) {
|
|
42
|
-
if (data?.type)
|
|
56
|
+
for (const { data, text } of readYamlFilesDeep(join(root, 'field-types'))) {
|
|
57
|
+
if (!data?.type) continue;
|
|
58
|
+
const placeholders = placeholderKeys(text);
|
|
59
|
+
fieldTypes[data.type] = { ...data, ...(placeholders.length ? { placeholderConstraints: placeholders } : {}) } as FieldType;
|
|
43
60
|
}
|
|
44
61
|
const security: Record<string, SecurityBank> = {};
|
|
45
62
|
for (const { data } of readYamlFilesDeep(join(root, 'security'))) {
|
|
@@ -97,6 +114,8 @@ function mergeFieldType(base: FieldType, overlay: FieldTypeOverlay, prov: FieldT
|
|
|
97
114
|
...base,
|
|
98
115
|
...rest,
|
|
99
116
|
constraints: { ...(base.constraints || {}), ...(overlay.constraints || {}) },
|
|
117
|
+
// A constraint the project's overwrite sets is a real value, no longer the shipped placeholder.
|
|
118
|
+
placeholderConstraints: (base.placeholderConstraints || []).filter((k) => !(k in (overlay.constraints || {}))),
|
|
100
119
|
uses_security: overlay.uses_security ?? base.uses_security,
|
|
101
120
|
valid: mergeGroup(base.valid, overlay.valid, disableSet, prov),
|
|
102
121
|
boundary: mergeGroup(base.boundary, overlay.boundary, disableSet, prov),
|
package/src/generator.ts
CHANGED
|
@@ -18,6 +18,12 @@ export interface FieldDef {
|
|
|
18
18
|
/** Catalog field-type id (email/password/…). Omit for a GROUP (a field with nested `fields`). A type absent from the catalog → blind spot. */
|
|
19
19
|
type?: string;
|
|
20
20
|
required?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Point ids to drop for THIS field only (#902) — an expanded id such as `CHK-STR-I2` (min-1) or
|
|
23
|
+
* `CHK-STR-I2a` (max+1). The overwrite's `disable:` removes a case for every field of the type;
|
|
24
|
+
* this removes one boundary for one field. Dropped points are reported in `excluded`, never silent.
|
|
25
|
+
*/
|
|
26
|
+
disable?: string[];
|
|
21
27
|
/** Project overrides merged over the catalog constraints (maxLength, min, max, …). */
|
|
22
28
|
constraints?: Record<string, any>;
|
|
23
29
|
/** Placeholder errorRef (Mxx) → real project message code. Unmapped refs are flagged open. */
|
|
@@ -89,6 +95,10 @@ export interface ExpandedField {
|
|
|
89
95
|
dependsOn?: { field: string; value?: string };
|
|
90
96
|
/** Cases NOT materialized for the selection context (applicability) — surfaced, never silent. */
|
|
91
97
|
excluded?: Array<{ id: string; reason: string }>;
|
|
98
|
+
/** Catalog placeholder constraints this field relies on (it set no value of its own) — e.g. `minLength`. */
|
|
99
|
+
placeholders?: string[];
|
|
100
|
+
/** `disable:` ids that matched no point generated for this field (a typo, or a case not emitted here). */
|
|
101
|
+
disableUnmatched?: string[];
|
|
92
102
|
}
|
|
93
103
|
|
|
94
104
|
/**
|
|
@@ -112,9 +122,15 @@ function coerceValidNumeric(type: string, value: string, c: Record<string, any>)
|
|
|
112
122
|
return String(Math.round(out));
|
|
113
123
|
}
|
|
114
124
|
|
|
115
|
-
/**
|
|
125
|
+
/**
|
|
126
|
+
* A gen hint that references a numeric/length boundary the generator can expand structurally.
|
|
127
|
+
* `<` / `>` count only as a comparison against a named bound (`< min length`, `local > maxLocal`):
|
|
128
|
+
* a bare one is a character in a charset list (`special chars …<>?`, `forbidden chars …<>\?`), and
|
|
129
|
+
* matching it turned those cases into meaningless `bound-1 / bound` lengths (#904). A bare number
|
|
130
|
+
* (`> 20 chars`) is not read either — the expansion has no way to use it — so it stays an open case.
|
|
131
|
+
*/
|
|
116
132
|
function isBoundaryHint(hint: string): boolean {
|
|
117
|
-
return /\bmin\b|\bmax\b|minimum|maximum|length|byte|exact|decimal|precision|local|total|over|below|under|exceed|\+\s*1|-\s*1
|
|
133
|
+
return /\bmin\b|\bmax\b|minimum|maximum|length|byte|exact|decimal|precision|local|total|over|below|under|exceed|\+\s*1|-\s*1|[<>]=?\s*(?:min|max|length|local|total)/.test(
|
|
118
134
|
hint.toLowerCase(),
|
|
119
135
|
);
|
|
120
136
|
}
|
|
@@ -160,6 +176,8 @@ export function expandField(field: FieldDef, catalog: Catalog, ctx: SelectionCon
|
|
|
160
176
|
const points: ExpandedPoint[] = [];
|
|
161
177
|
const excluded: Array<{ id: string; reason: string }> = [];
|
|
162
178
|
const facts = fieldFacts(field, c);
|
|
179
|
+
// Length 0 is judged by `required`, not by minLength (#901) — every oracle call gets the field context.
|
|
180
|
+
const fctx = { required: field.required };
|
|
163
181
|
|
|
164
182
|
// Gate a catalog case by applicability (`when`) then by the selection profile. A `when` miss is
|
|
165
183
|
// reported as excluded (not silently dropped); a profile miss is an explicit selection (not reported).
|
|
@@ -178,7 +196,7 @@ export function expandField(field: FieldDef, catalog: Catalog, ctx: SelectionCon
|
|
|
178
196
|
const pushLiteral = (dp: DataPoint, group: ExpandedPoint['group'], value: string | null, opts: { validity?: Validity; label?: string; todo?: string } = {}) => {
|
|
179
197
|
// A valid representative must be valid for the project: clamp an out-of-range numeric literal.
|
|
180
198
|
let coerceNote: string | undefined;
|
|
181
|
-
if (group === 'valid' && value != null && satisfiesConstraints(type, value, c) === false) {
|
|
199
|
+
if (group === 'valid' && value != null && satisfiesConstraints(type, value, c, fctx) === false) {
|
|
182
200
|
const fixed = coerceValidNumeric(type, value, c);
|
|
183
201
|
if (fixed != null) { value = fixed; coerceNote = 'adjusted to fit constraints'; }
|
|
184
202
|
}
|
|
@@ -193,7 +211,7 @@ export function expandField(field: FieldDef, catalog: Catalog, ctx: SelectionCon
|
|
|
193
211
|
let validity: Validity;
|
|
194
212
|
if (dp.validity) validity = dp.validity;
|
|
195
213
|
else if (group === 'boundary') {
|
|
196
|
-
const oracle = satisfiesConstraints(type, isUniqueValid ? value : v, c);
|
|
214
|
+
const oracle = satisfiesConstraints(type, isUniqueValid ? value : v, c, fctx);
|
|
197
215
|
validity = oracle == null ? (opts.validity ?? 'valid') : oracle ? 'valid' : 'invalid';
|
|
198
216
|
} else validity = opts.validity ?? groupValidity(group);
|
|
199
217
|
emit({
|
|
@@ -217,7 +235,7 @@ export function expandField(field: FieldDef, catalog: Catalog, ctx: SelectionCon
|
|
|
217
235
|
const hint = `${dp.gen || ''} ${dp.note || ''}`.trim();
|
|
218
236
|
// A boundary/length/numeric hint → expand to one case PER boundary point, each with its validity.
|
|
219
237
|
if (hint && isBoundaryHint(hint)) {
|
|
220
|
-
const pts = expandBoundaryHint(hint, type, c);
|
|
238
|
+
const pts = expandBoundaryHint(hint, type, c, fctx);
|
|
221
239
|
for (const bp of pts) {
|
|
222
240
|
emit({
|
|
223
241
|
id: dp.id + bp.suffix,
|
|
@@ -270,7 +288,7 @@ export function expandField(field: FieldDef, catalog: Catalog, ctx: SelectionCon
|
|
|
270
288
|
// email `minLength` the catalog didn't model). Only in-range points are `boundary`; out-of-range are
|
|
271
289
|
// `invalid`. Appended after the catalog cases so it never overrides an authored one.
|
|
272
290
|
const idCode = type.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
|
273
|
-
for (const ex of ensureBvaComplete(type, c, points.map((p) => p.value))) {
|
|
291
|
+
for (const ex of ensureBvaComplete(type, c, points.map((p) => p.value), fctx)) {
|
|
274
292
|
points.push({
|
|
275
293
|
id: `CHK-${idCode}-BVA-${ex.label}`,
|
|
276
294
|
group: ex.group,
|
|
@@ -282,13 +300,22 @@ export function expandField(field: FieldDef, catalog: Catalog, ctx: SelectionCon
|
|
|
282
300
|
});
|
|
283
301
|
}
|
|
284
302
|
|
|
303
|
+
// Field-level disable (#902): drop exactly the named points, and say so.
|
|
304
|
+
const disabled = new Set(field.disable || []);
|
|
305
|
+
const kept = disabled.size ? points.filter((p) => !disabled.has(p.id)) : points;
|
|
306
|
+
for (const p of points) if (disabled.has(p.id)) excluded.push({ id: p.id, reason: 'disabled for this field' });
|
|
307
|
+
const disableUnmatched = [...disabled].filter((id) => !points.some((p) => p.id === id));
|
|
308
|
+
|
|
309
|
+
const placeholders = (ft.placeholderConstraints || []).filter((k) => !(field.constraints && k in field.constraints));
|
|
285
310
|
return {
|
|
286
311
|
name: field.name,
|
|
287
312
|
type: field.type,
|
|
288
313
|
blindSpot: false,
|
|
289
|
-
points,
|
|
314
|
+
points: kept,
|
|
290
315
|
...(field.dependsOn ? { dependsOn: field.dependsOn } : {}),
|
|
291
316
|
...(excluded.length ? { excluded } : {}),
|
|
317
|
+
...(placeholders.length ? { placeholders } : {}),
|
|
318
|
+
...(disableUnmatched.length ? { disableUnmatched } : {}),
|
|
292
319
|
};
|
|
293
320
|
}
|
|
294
321
|
|
package/src/lint.ts
CHANGED
|
@@ -85,6 +85,18 @@ export function lintFieldMap(map: FieldMap, catalog: Catalog, ctx: SelectionCont
|
|
|
85
85
|
message: `${live.join(', ')} is a real mailbox provider — a registration or reset test using it sends mail to whoever owns that address. Use the project domain (@sun-asterisk.com) or the reserved @example.com.` });
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
// A generic catalog stand-in (string minLength 1, phone 8..15 …) silently became this field's rule.
|
|
89
|
+
if (f.placeholders?.length) {
|
|
90
|
+
const vals = f.placeholders.map((k) => `${k}: ${JSON.stringify(f.type ? catalog.fieldTypes[f.type]?.constraints?.[k] : undefined)}`);
|
|
91
|
+
issues.push({ field: path, severity: 'warn', code: 'PLACEHOLDER_CONSTRAINT',
|
|
92
|
+
message: `uses the catalog's placeholder ${vals.join(', ')} — set the real value from the spec in the field-map's constraints (the boundary cases are built from it)` });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (f.disableUnmatched?.length) {
|
|
96
|
+
issues.push({ field: path, severity: 'warn', code: 'DISABLE_UNMATCHED',
|
|
97
|
+
message: `disable: ${f.disableUnmatched.join(', ')} matches no case generated for this field — check the id (a boundary point carries its suffix, e.g. CHK-STR-I2a)` });
|
|
98
|
+
}
|
|
99
|
+
|
|
88
100
|
// Applicability (M2b): cases NOT materialized for this field/context — surfaced, never silent.
|
|
89
101
|
if (f.excluded?.length) {
|
|
90
102
|
issues.push({ field: path, severity: 'info', code: 'NOT_APPLICABLE',
|
package/src/types.ts
CHANGED
|
@@ -52,6 +52,12 @@ export interface FieldType {
|
|
|
52
52
|
description?: string;
|
|
53
53
|
format?: string;
|
|
54
54
|
constraints?: Record<string, unknown>;
|
|
55
|
+
/**
|
|
56
|
+
* Constraint keys the shipped catalog marks `# placeholder` (a generic stand-in such as string
|
|
57
|
+
* `minLength: 1`). Read from the yaml text by the loader — the parsed yaml has no comments. A field
|
|
58
|
+
* that relies on one without its own value is warned about (lint PLACEHOLDER_CONSTRAINT).
|
|
59
|
+
*/
|
|
60
|
+
placeholderConstraints?: string[];
|
|
55
61
|
/** Security banks whose payloads are appended to the `invalid` group. */
|
|
56
62
|
uses_security?: string[];
|
|
57
63
|
valid?: DataPoint[];
|
package/src/validate.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import type { Catalog, SelectionContext } from './types';
|
|
12
12
|
import { expandFieldMap, type FieldMap, type FieldDef, type ExpandedField, type ExpandedPoint } from './generator';
|
|
13
|
-
import { satisfiesConstraints } from './boundary';
|
|
13
|
+
import { satisfiesConstraints, type FieldContext } from './boundary';
|
|
14
14
|
|
|
15
15
|
export interface ValidationFinding {
|
|
16
16
|
field: string;
|
|
@@ -21,12 +21,12 @@ export interface ValidationFinding {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
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
|
|
24
|
+
export function validatePoints(field: string, type: string | undefined, points: ExpandedPoint[], c: Record<string, any>, ctx: FieldContext = {}): ValidationFinding[] {
|
|
25
25
|
const out: ValidationFinding[] = [];
|
|
26
26
|
if (!type) return out;
|
|
27
27
|
for (const p of points) {
|
|
28
28
|
if (p.value == null) continue; // open/todo — lint handles these
|
|
29
|
-
const oracle = satisfiesConstraints(type, p.value, c);
|
|
29
|
+
const oracle = satisfiesConstraints(type, p.value, c, ctx);
|
|
30
30
|
if (oracle == null) continue; // no length/range constraint to judge against
|
|
31
31
|
if (p.expectedValidity === 'valid' && oracle === false) {
|
|
32
32
|
out.push({
|
|
@@ -69,7 +69,7 @@ export function validateGenerated(map: FieldMap, catalog: Catalog, ctx: Selectio
|
|
|
69
69
|
if (ef.children) { walk(ef.children, def?.fields || [], path); continue; }
|
|
70
70
|
const ft = ef.type ? catalog.fieldTypes[ef.type] : undefined;
|
|
71
71
|
const c = { ...(ft?.constraints || {}), ...(def?.constraints || {}) };
|
|
72
|
-
out.push(...validatePoints(path, ef.type, ef.points, c));
|
|
72
|
+
out.push(...validatePoints(path, ef.type, ef.points, c, { required: def?.required }));
|
|
73
73
|
}
|
|
74
74
|
};
|
|
75
75
|
walk(expanded, map.fields, '');
|