@sungen/driver-data-factory 3.2.12-beta.1 → 3.2.12-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/common/experience/auth-account-states.yaml +19 -0
- package/common/field-types/currency.yaml +1 -1
- package/common/field-types/email.yaml +4 -1
- package/common/field-types/number-float.yaml +1 -1
- package/common/field-types/number-integer.yaml +2 -2
- package/common/field-types/password.yaml +1 -1
- package/common/field-types/phone.yaml +1 -1
- package/common/field-types/postcode.yaml +1 -1
- package/common/field-types/string.yaml +1 -1
- package/package.json +3 -3
- package/src/applicability.ts +80 -0
- package/src/boundary.ts +319 -0
- package/src/catalog-loader.ts +102 -16
- package/src/cli.ts +181 -11
- package/src/contract.ts +93 -0
- package/src/crosscheck.ts +149 -0
- package/src/detect.ts +122 -0
- package/src/fixture.ts +42 -10
- package/src/generator.ts +161 -89
- package/src/index.ts +16 -3
- package/src/lint.ts +8 -3
- package/src/sensor.ts +32 -4
- package/src/state.ts +225 -0
- package/src/types.ts +81 -0
- package/src/validate.ts +77 -0
package/src/generator.ts
CHANGED
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
* payloads, and resolving placeholder error codes through the field's `errorMap`. Same input →
|
|
10
10
|
* same output (determinism is an asset). Nothing here reads a live page or an LLM.
|
|
11
11
|
*/
|
|
12
|
-
import type { Catalog, FieldType, DataPoint } from './types';
|
|
12
|
+
import type { Catalog, FieldType, DataPoint, SelectionContext } from './types';
|
|
13
|
+
import { expandBoundaryHint, satisfiesConstraints, ensureBvaComplete, type Validity } from './boundary';
|
|
14
|
+
import { evaluateWhen, fieldFacts, inProfile } from './applicability';
|
|
13
15
|
|
|
14
16
|
export interface FieldDef {
|
|
15
17
|
name: string;
|
|
@@ -57,6 +59,23 @@ export interface ExpandedPoint {
|
|
|
57
59
|
open?: boolean;
|
|
58
60
|
/** Value could not be synthesized deterministically (missing constraint) — needs input. */
|
|
59
61
|
todo?: string;
|
|
62
|
+
/**
|
|
63
|
+
* The generator's expectation for this value: does it satisfy the field's constraints (`valid`) or
|
|
64
|
+
* violate them (`invalid`)? Computed by the boundary oracle for synthesized points; `valid` for the
|
|
65
|
+
* happy-path group and `invalid` for declared-invalid values. `validateGenerated` re-checks it — a
|
|
66
|
+
* mismatch (a `valid`-expected value that fails the constraints, or vice versa) is a defect.
|
|
67
|
+
*/
|
|
68
|
+
expectedValidity?: Validity;
|
|
69
|
+
/**
|
|
70
|
+
* True when the VALUE was synthesized from a numeric/length boundary (so its whole validity claim is
|
|
71
|
+
* about range). The validator only second-guesses an `invalid` point when it is synthesized — a
|
|
72
|
+
* literal charset/format invalid (or a security payload) legitimately satisfies the length oracle.
|
|
73
|
+
*/
|
|
74
|
+
synthesized?: boolean;
|
|
75
|
+
/** Provenance: `overwrite` when the project's `.overwrite` replaced/added this point (else shipped). */
|
|
76
|
+
source?: 'shipped' | 'overwrite';
|
|
77
|
+
/** Bucket: `adversarial` = a security/injection payload (rendered separately from plain invalid). */
|
|
78
|
+
category?: 'adversarial';
|
|
60
79
|
}
|
|
61
80
|
|
|
62
81
|
export interface ExpandedField {
|
|
@@ -68,78 +87,36 @@ export interface ExpandedField {
|
|
|
68
87
|
children?: ExpandedField[];
|
|
69
88
|
/** Recorded inter-field dependency (metadata only — not resolved deterministically). */
|
|
70
89
|
dependsOn?: { field: string; value?: string };
|
|
90
|
+
/** Cases NOT materialized for the selection context (applicability) — surfaced, never silent. */
|
|
91
|
+
excluded?: Array<{ id: string; reason: string }>;
|
|
71
92
|
}
|
|
72
93
|
|
|
73
|
-
/**
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
if (
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (
|
|
86
|
-
|
|
87
|
-
|
|
94
|
+
/**
|
|
95
|
+
* Coerce an out-of-range numeric VALID literal into range, so a project's `valid` representative is
|
|
96
|
+
* actually valid for its constraints (a generic catalog example like `123` under `max: 100` becomes
|
|
97
|
+
* `100`). Returns null when in range already, or the type isn't numeric (non-numeric mismatches are
|
|
98
|
+
* left for the validator to flag — the field-map should supply a fitting value).
|
|
99
|
+
*/
|
|
100
|
+
function coerceValidNumeric(type: string, value: string, c: Record<string, any>): string | null {
|
|
101
|
+
if (!['number-integer', 'number-float', 'currency'].includes(type)) return null;
|
|
102
|
+
const n = Number(value);
|
|
103
|
+
if (!Number.isFinite(n)) return null;
|
|
104
|
+
let out = n;
|
|
105
|
+
if (Number.isFinite(c.min) && out < c.min) out = c.min;
|
|
106
|
+
if (Number.isFinite(c.max) && out > c.max) out = c.max;
|
|
107
|
+
if (out === n) return null; // already in range
|
|
108
|
+
if (type === 'number-float' || (type === 'currency' && c.decimalPlaces > 0)) {
|
|
109
|
+
const dp = Number.isFinite(c.decimalPlaces) ? c.decimalPlaces : 2;
|
|
110
|
+
return out.toFixed(dp);
|
|
88
111
|
}
|
|
89
|
-
return
|
|
112
|
+
return String(Math.round(out));
|
|
90
113
|
}
|
|
91
114
|
|
|
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' };
|
|
115
|
+
/** A gen hint that references a numeric/length boundary the generator can expand structurally. */
|
|
116
|
+
function isBoundaryHint(hint: string): boolean {
|
|
117
|
+
return /\bmin\b|\bmax\b|minimum|maximum|length|byte|exact|decimal|precision|local|total|over|below|under|exceed|\+\s*1|-\s*1|<|>/.test(
|
|
118
|
+
hint.toLowerCase(),
|
|
119
|
+
);
|
|
143
120
|
}
|
|
144
121
|
|
|
145
122
|
function resolveError(dp: DataPoint, errorMap?: Record<string, string>): { expectedError?: string; open?: boolean } {
|
|
@@ -163,7 +140,7 @@ function makeUnique(value: string, type: string): string {
|
|
|
163
140
|
}
|
|
164
141
|
|
|
165
142
|
/** Expand one field into valid/boundary/invalid data points using the effective catalog. */
|
|
166
|
-
export function expandField(field: FieldDef, catalog: Catalog): ExpandedField {
|
|
143
|
+
export function expandField(field: FieldDef, catalog: Catalog, ctx: SelectionContext = {}): ExpandedField {
|
|
167
144
|
// Group / nested field: no own type — expand each sub-field recursively.
|
|
168
145
|
if (field.fields && field.fields.length) {
|
|
169
146
|
return {
|
|
@@ -171,7 +148,7 @@ export function expandField(field: FieldDef, catalog: Catalog): ExpandedField {
|
|
|
171
148
|
type: undefined,
|
|
172
149
|
blindSpot: false,
|
|
173
150
|
points: [],
|
|
174
|
-
children: field.fields.map((sub) => expandField(sub, catalog)),
|
|
151
|
+
children: field.fields.map((sub) => expandField(sub, catalog, ctx)),
|
|
175
152
|
...(field.dependsOn ? { dependsOn: field.dependsOn } : {}),
|
|
176
153
|
};
|
|
177
154
|
}
|
|
@@ -179,26 +156,88 @@ export function expandField(field: FieldDef, catalog: Catalog): ExpandedField {
|
|
|
179
156
|
if (!ft) return { name: field.name, type: field.type, blindSpot: true, points: [], ...(field.dependsOn ? { dependsOn: field.dependsOn } : {}) };
|
|
180
157
|
|
|
181
158
|
const c = { ...(ft.constraints || {}), ...(field.constraints || {}) };
|
|
159
|
+
const type = field.type as string;
|
|
182
160
|
const points: ExpandedPoint[] = [];
|
|
161
|
+
const excluded: Array<{ id: string; reason: string }> = [];
|
|
162
|
+
const facts = fieldFacts(field, c);
|
|
183
163
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
164
|
+
// Gate a catalog case by applicability (`when`) then by the selection profile. A `when` miss is
|
|
165
|
+
// reported as excluded (not silently dropped); a profile miss is an explicit selection (not reported).
|
|
166
|
+
const admit = (dp: DataPoint, group: ExpandedPoint['group'], category?: 'adversarial'): boolean => {
|
|
167
|
+
const ap = evaluateWhen(dp.when, facts, ctx);
|
|
168
|
+
if (!ap.applicable) { excluded.push({ id: dp.id, reason: ap.reason || 'not applicable' }); return false; }
|
|
169
|
+
return inProfile(group, category, ctx.profile);
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// Default validity for a group: the happy path is valid; the invalid group is invalid; a boundary
|
|
173
|
+
// point's validity is decided per-point by the oracle (in/out of range).
|
|
174
|
+
const groupValidity = (group: ExpandedPoint['group']): Validity => (group === 'invalid' ? 'invalid' : 'valid');
|
|
175
|
+
|
|
176
|
+
const emit = (p: Omit<ExpandedPoint, 'expectedValidity'> & { expectedValidity: Validity }) => points.push(p);
|
|
177
|
+
|
|
178
|
+
const pushLiteral = (dp: DataPoint, group: ExpandedPoint['group'], value: string | null, opts: { validity?: Validity; label?: string; todo?: string } = {}) => {
|
|
179
|
+
// A valid representative must be valid for the project: clamp an out-of-range numeric literal.
|
|
180
|
+
let coerceNote: string | undefined;
|
|
181
|
+
if (group === 'valid' && value != null && satisfiesConstraints(type, value, c) === false) {
|
|
182
|
+
const fixed = coerceValidNumeric(type, value, c);
|
|
183
|
+
if (fixed != null) { value = fixed; coerceNote = 'adjusted to fit constraints'; }
|
|
184
|
+
}
|
|
185
|
+
const isUniqueValid = group === 'valid' && field.unique && value != null;
|
|
186
|
+
const v = isUniqueValid ? makeUnique(value!, type) : value;
|
|
187
|
+
// Expected validity reflects INTENT by group, so the validator (validateGenerated) can catch a
|
|
188
|
+
// mismatch against the oracle instead of the mismatch being silently absorbed here:
|
|
189
|
+
// - valid group → `valid` (a value that actually violates the constraints is a VALUE_NOT_VALID).
|
|
190
|
+
// - invalid group → `invalid` (declared: charset/format/blocklist/behavior — the length oracle
|
|
191
|
+
// cannot "prove" such a literal valid, so it must NOT flip it to valid).
|
|
192
|
+
// - boundary group → the oracle decides (that group is precisely about range).
|
|
193
|
+
let validity: Validity;
|
|
194
|
+
if (dp.validity) validity = dp.validity;
|
|
195
|
+
else if (group === 'boundary') {
|
|
196
|
+
const oracle = satisfiesConstraints(type, isUniqueValid ? value : v, c);
|
|
197
|
+
validity = oracle == null ? (opts.validity ?? 'valid') : oracle ? 'valid' : 'invalid';
|
|
198
|
+
} else validity = opts.validity ?? groupValidity(group);
|
|
199
|
+
emit({
|
|
192
200
|
id: dp.id,
|
|
193
201
|
group,
|
|
194
|
-
value,
|
|
195
|
-
todo:
|
|
202
|
+
value: v,
|
|
203
|
+
todo: opts.todo,
|
|
196
204
|
reason: dp.reason,
|
|
197
|
-
note:
|
|
205
|
+
note: isUniqueValid ? `${dp.note ? dp.note + ' · ' : ''}fresh per run` : coerceNote ? `${dp.note ? dp.note + ' · ' : ''}${coerceNote}` : (opts.label ? `${dp.note ? dp.note + ' · ' : ''}${opts.label}` : dp.note),
|
|
206
|
+
expectedValidity: validity,
|
|
207
|
+
...(dp.source === 'overwrite' ? { source: 'overwrite' as const } : {}),
|
|
198
208
|
...resolveError(dp, field.errorMap),
|
|
199
209
|
});
|
|
200
210
|
};
|
|
201
211
|
|
|
212
|
+
const push = (dp: DataPoint, group: ExpandedPoint['group']) => {
|
|
213
|
+
if (!admit(dp, group)) return; // not applicable / out of profile
|
|
214
|
+
// Literal value in the catalog/overwrite → one point (oracle still judges its validity).
|
|
215
|
+
if (dp.value != null) return pushLiteral(dp, group, dp.value);
|
|
216
|
+
|
|
217
|
+
const hint = `${dp.gen || ''} ${dp.note || ''}`.trim();
|
|
218
|
+
// A boundary/length/numeric hint → expand to one case PER boundary point, each with its validity.
|
|
219
|
+
if (hint && isBoundaryHint(hint)) {
|
|
220
|
+
const pts = expandBoundaryHint(hint, type, c);
|
|
221
|
+
for (const bp of pts) {
|
|
222
|
+
emit({
|
|
223
|
+
id: dp.id + bp.suffix,
|
|
224
|
+
group,
|
|
225
|
+
value: bp.value,
|
|
226
|
+
todo: bp.todo,
|
|
227
|
+
reason: dp.reason,
|
|
228
|
+
note: bp.label && !bp.todo ? `${dp.note ? dp.note + ' · ' : ''}${bp.label}` : dp.note,
|
|
229
|
+
expectedValidity: dp.validity ?? bp.validity,
|
|
230
|
+
synthesized: bp.value != null,
|
|
231
|
+
...(dp.source === 'overwrite' ? { source: 'overwrite' as const } : {}),
|
|
232
|
+
...resolveError(dp, field.errorMap),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
// A non-boundary gen (charset/full-width/format) — can't synthesize deterministically; keep open.
|
|
238
|
+
pushLiteral(dp, group, null, { validity: groupValidity(group), todo: dp.gen ? `synthesize: ${dp.gen}` : 'value/gen not resolvable' });
|
|
239
|
+
};
|
|
240
|
+
|
|
202
241
|
for (const dp of ft.valid || []) push(dp, 'valid');
|
|
203
242
|
for (const dp of ft.boundary || []) push(dp, 'boundary');
|
|
204
243
|
for (const dp of ft.invalid || []) {
|
|
@@ -206,11 +245,12 @@ export function expandField(field: FieldDef, catalog: Catalog): ExpandedField {
|
|
|
206
245
|
push(dp, 'invalid');
|
|
207
246
|
}
|
|
208
247
|
|
|
209
|
-
// Append security payloads referenced by uses_security.
|
|
248
|
+
// Append security payloads referenced by uses_security — the `adversarial` category (own bucket).
|
|
210
249
|
for (const bank of ft.uses_security || []) {
|
|
211
250
|
const b = catalog.security[bank];
|
|
212
251
|
if (!b) continue;
|
|
213
252
|
for (const p of b.payloads || []) {
|
|
253
|
+
if (!admit(p as DataPoint, 'invalid', 'adversarial')) continue;
|
|
214
254
|
points.push({
|
|
215
255
|
id: p.id,
|
|
216
256
|
group: 'invalid',
|
|
@@ -218,16 +258,43 @@ export function expandField(field: FieldDef, catalog: Catalog): ExpandedField {
|
|
|
218
258
|
todo: p.value == null ? `security technique: ${p.technique || bank}` : undefined,
|
|
219
259
|
reason: `${bank}: ${(p.reason as string) || (p.kind as string) || (p.technique as string) || ''}`.trim(),
|
|
220
260
|
note: b.oracle,
|
|
261
|
+
// Adversarial payloads are treated as invalid input (must be rejected/escaped, never accepted).
|
|
262
|
+
expectedValidity: 'invalid',
|
|
263
|
+
category: 'adversarial',
|
|
221
264
|
});
|
|
222
265
|
}
|
|
223
266
|
}
|
|
224
267
|
|
|
225
|
-
|
|
268
|
+
// BVA completeness: for every bounded dimension in the constraints, ensure the full
|
|
269
|
+
// min-1/min/min+1/max-1/max/max+1 set exists — auto-adding only what the catalog left out (e.g. an
|
|
270
|
+
// email `minLength` the catalog didn't model). Only in-range points are `boundary`; out-of-range are
|
|
271
|
+
// `invalid`. Appended after the catalog cases so it never overrides an authored one.
|
|
272
|
+
const idCode = type.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
|
273
|
+
for (const ex of ensureBvaComplete(type, c, points.map((p) => p.value))) {
|
|
274
|
+
points.push({
|
|
275
|
+
id: `CHK-${idCode}-BVA-${ex.label}`,
|
|
276
|
+
group: ex.group,
|
|
277
|
+
value: ex.value,
|
|
278
|
+
expectedValidity: ex.validity,
|
|
279
|
+
synthesized: true,
|
|
280
|
+
note: `BVA (auto): ${ex.label}`,
|
|
281
|
+
...(ex.validity === 'invalid' ? { reason: `out of bounds (${ex.label})` } : {}),
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
name: field.name,
|
|
287
|
+
type: field.type,
|
|
288
|
+
blindSpot: false,
|
|
289
|
+
points,
|
|
290
|
+
...(field.dependsOn ? { dependsOn: field.dependsOn } : {}),
|
|
291
|
+
...(excluded.length ? { excluded } : {}),
|
|
292
|
+
};
|
|
226
293
|
}
|
|
227
294
|
|
|
228
295
|
/** Expand a whole field-map. */
|
|
229
|
-
export function expandFieldMap(map: FieldMap, catalog: Catalog): ExpandedField[] {
|
|
230
|
-
return (map.fields || []).map((f) => expandField(f, catalog));
|
|
296
|
+
export function expandFieldMap(map: FieldMap, catalog: Catalog, ctx: SelectionContext = {}): ExpandedField[] {
|
|
297
|
+
return (map.fields || []).map((f) => expandField(f, catalog, ctx));
|
|
231
298
|
}
|
|
232
299
|
|
|
233
300
|
/**
|
|
@@ -255,13 +322,18 @@ function renderOne(f: ExpandedField): Record<string, any> {
|
|
|
255
322
|
}
|
|
256
323
|
const valid = f.points.filter((p) => p.group === 'valid');
|
|
257
324
|
const boundary = f.points.filter((p) => p.group === 'boundary');
|
|
258
|
-
|
|
259
|
-
|
|
325
|
+
// Plain invalid excludes the adversarial (security) cases — those get their own bucket.
|
|
326
|
+
const invalid = f.points.filter((p) => p.group === 'invalid' && p.category !== 'adversarial');
|
|
327
|
+
const adversarial = f.points.filter((p) => p.category === 'adversarial');
|
|
328
|
+
const row: Record<string, any> = {
|
|
260
329
|
...dep,
|
|
261
330
|
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 })),
|
|
331
|
+
boundary: boundary.map((p) => trim({ id: p.id, value: p.value, expected: p.expectedValidity, via: p.source === 'overwrite' ? '.overwrite' : undefined, note: p.note, todo: p.todo })),
|
|
332
|
+
invalid: invalid.map((p) => trim({ id: p.id, value: p.value, expected: p.expectedValidity, via: p.source === 'overwrite' ? '.overwrite' : undefined, expected_error: p.expectedError, open: p.open, reason: p.reason, todo: p.todo })),
|
|
264
333
|
};
|
|
334
|
+
if (adversarial.length) row.adversarial = adversarial.map((p) => trim({ id: p.id, value: p.value, reason: p.reason, note: p.note, todo: p.todo }));
|
|
335
|
+
if (f.excluded?.length) row._not_applicable = f.excluded.map((e) => `${e.id}: ${e.reason}`);
|
|
336
|
+
return row;
|
|
265
337
|
}
|
|
266
338
|
|
|
267
339
|
function trim(o: Record<string, any>): Record<string, any> {
|
package/src/index.ts
CHANGED
|
@@ -18,17 +18,30 @@ 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, ensureBvaComplete, emailOfTotalLength } from './boundary';
|
|
29
|
+
export type { Validity, BoundaryPoint, BvaExtra } from './boundary';
|
|
30
|
+
export { evaluateWhen, inProfile, fieldFacts } from './applicability';
|
|
31
|
+
export { generate, buildRequest, validateRequest, fingerprint, GENERATOR_VERSION, SCHEMA_VERSION } from './contract';
|
|
32
|
+
export type { DataFactoryRequest, DataFactoryResult } from './contract';
|
|
33
|
+
export { loadStateRecipes, validateStateRecipe, buildStatePlan, renderPreconditionBlock, topoSort, hasDatasource, scaffoldRecipe, renderRecipeYaml, guessProvenance } from './state';
|
|
34
|
+
export type { StateRecipe, StateResource, StatePlan, StateFinding, Provenance, Lifecycle } from './state';
|
|
35
|
+
export { crosscheckUnit, crosscheckShared, crosscheckAll, referencedVars } from './crosscheck';
|
|
36
|
+
export type { CrossFinding } from './crosscheck';
|
|
37
|
+
export { detectUnit, hasInputFields, hasFieldMap, detectPreconditionSignals, hasStateDeclaration } from './detect';
|
|
38
|
+
export type { DetectFinding } from './detect';
|
|
26
39
|
export { registerDataFactoryCommand } from './cli';
|
|
27
40
|
export { generateFixture } from './fixture';
|
|
28
|
-
export type { FixtureResult } from './fixture';
|
|
41
|
+
export type { FixtureResult, FixtureExpected, ExpectedRow } from './fixture';
|
|
29
42
|
export { dataFactoryCoverageSensor } from './sensor';
|
|
30
43
|
export { dfPreconditionCodegen } from './precondition';
|
|
31
|
-
export type { Catalog, FieldType, SecurityBank, Technique, DataPoint, OverwriteWarning } from './types';
|
|
44
|
+
export type { Catalog, FieldType, SecurityBank, Technique, Experience, DataPoint, OverwriteWarning, CatalogProvenance, FieldTypeProvenance, SelectionContext, Profile } from './types';
|
|
32
45
|
|
|
33
46
|
/** Register the Data Factory capability. */
|
|
34
47
|
export function register(registry: CapabilityRegistry): void {
|
package/src/lint.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* placeholders). It also surfaces blind spots (type not in catalog) and — via the catalog loader —
|
|
7
7
|
* stale `.overwrite` warnings. Pure functions; no side effects.
|
|
8
8
|
*/
|
|
9
|
-
import type { Catalog } from './types';
|
|
9
|
+
import type { Catalog, SelectionContext } from './types';
|
|
10
10
|
import type { FieldMap, ExpandedField, FieldDef } from './generator';
|
|
11
11
|
import { expandFieldMap } from './generator';
|
|
12
12
|
|
|
@@ -19,8 +19,8 @@ export interface LintIssue {
|
|
|
19
19
|
|
|
20
20
|
const BOUNDED = new Set(['email', 'string', 'password', 'phone', 'postcode', 'katakana', 'number-integer', 'number-float', 'currency']);
|
|
21
21
|
|
|
22
|
-
export function lintFieldMap(map: FieldMap, catalog: Catalog): { issues: LintIssue[]; expanded: ExpandedField[] } {
|
|
23
|
-
const expanded = expandFieldMap(map, catalog);
|
|
22
|
+
export function lintFieldMap(map: FieldMap, catalog: Catalog, ctx: SelectionContext = {}): { issues: LintIssue[]; expanded: ExpandedField[] } {
|
|
23
|
+
const expanded = expandFieldMap(map, catalog, ctx);
|
|
24
24
|
const issues: LintIssue[] = [];
|
|
25
25
|
|
|
26
26
|
// Flatten nested groups to leaf fields (dotted path), pairing each with its FieldDef (for `required`).
|
|
@@ -65,6 +65,11 @@ export function lintFieldMap(map: FieldMap, catalog: Catalog): { issues: LintIss
|
|
|
65
65
|
if (def?.required && valid.length === 0) {
|
|
66
66
|
issues.push({ field: path, severity: 'error', code: 'REQUIRED_NO_VALID', message: 'required field with no valid value' });
|
|
67
67
|
}
|
|
68
|
+
// Applicability (M2b): cases NOT materialized for this field/context — surfaced, never silent.
|
|
69
|
+
if (f.excluded?.length) {
|
|
70
|
+
issues.push({ field: path, severity: 'info', code: 'NOT_APPLICABLE',
|
|
71
|
+
message: `${f.excluded.length} case(s) not applicable here: ${f.excluded.map((e) => `${e.id} (${e.reason})`).join('; ')}` });
|
|
72
|
+
}
|
|
68
73
|
}
|
|
69
74
|
return { issues, expanded };
|
|
70
75
|
}
|
package/src/sensor.ts
CHANGED
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
* coverage lint as `sungen data lint` and surfaces the gaps in the generate output — so weak
|
|
6
6
|
* test-data coverage (missing boundary/invalid, unresolved values, unmapped error codes, blind
|
|
7
7
|
* spots) is visible in the standard harness flow, not only when you remember to run `data lint`.
|
|
8
|
-
*
|
|
8
|
+
*
|
|
9
|
+
* Auto-detection (Layer A): even with NO field-map, it NOTICES what's missing — a unit with input
|
|
10
|
+
* fields but no standardized data, or a spec that describes a stateful precondition the test never
|
|
11
|
+
* declares — so Sungen nudges QA instead of staying silent (was: "no field-map → nothing to say").
|
|
9
12
|
*
|
|
10
13
|
* Advisory, not gate: the ContextRouter scopes gate sensors by the annotation tags a feature carries,
|
|
11
14
|
* and test-data generation is a design-time concern with no `.feature` tag — so gating here would
|
|
@@ -17,6 +20,8 @@ import { parse as parseYaml } from 'yaml';
|
|
|
17
20
|
import type { Sensor } from '@sun-asterisk/sungen';
|
|
18
21
|
import { loadEffectiveCatalog } from './catalog-loader';
|
|
19
22
|
import { lintFieldMap, summarize } from './lint';
|
|
23
|
+
import { validateGenerated } from './validate';
|
|
24
|
+
import { detectUnit } from './detect';
|
|
20
25
|
import type { FieldMap } from './generator';
|
|
21
26
|
|
|
22
27
|
export const dataFactoryCoverageSensor: Sensor<{ dir: string; cwd: string }> = {
|
|
@@ -25,11 +30,21 @@ export const dataFactoryCoverageSensor: Sensor<{ dir: string; cwd: string }> = {
|
|
|
25
30
|
kind: 'advisory',
|
|
26
31
|
run: ({ dir, cwd }) => {
|
|
27
32
|
const name = basename(dir);
|
|
33
|
+
// Layer A auto-detection — runs regardless of whether a field-map exists, so a unit that needs
|
|
34
|
+
// standardization or a declared precondition is NOTICED, not silently skipped.
|
|
35
|
+
const detected = detectUnit(cwd, dir, name).map((d) => ({
|
|
36
|
+
sensorId: 'data-factory-coverage',
|
|
37
|
+
capability: 'data-factory',
|
|
38
|
+
scenario: d.scenario,
|
|
39
|
+
message: d.message,
|
|
40
|
+
severity: d.severity,
|
|
41
|
+
}));
|
|
42
|
+
|
|
28
43
|
const fieldMapPath = join(cwd, 'qa', 'data-factory', `${name}.fields.yaml`);
|
|
29
|
-
if (!existsSync(fieldMapPath)) return
|
|
44
|
+
if (!existsSync(fieldMapPath)) return detected; // no field-map: report only what detection noticed
|
|
30
45
|
let map: FieldMap;
|
|
31
|
-
try { map = parseYaml(readFileSync(fieldMapPath, 'utf8')) as FieldMap; } catch { return
|
|
32
|
-
if (!map || !Array.isArray(map.fields)) return
|
|
46
|
+
try { map = parseYaml(readFileSync(fieldMapPath, 'utf8')) as FieldMap; } catch { return detected; }
|
|
47
|
+
if (!map || !Array.isArray(map.fields)) return detected;
|
|
33
48
|
|
|
34
49
|
const { catalog, warnings } = loadEffectiveCatalog(cwd);
|
|
35
50
|
const { issues } = lintFieldMap(map, catalog);
|
|
@@ -49,6 +64,17 @@ export const dataFactoryCoverageSensor: Sensor<{ dir: string; cwd: string }> = {
|
|
|
49
64
|
severity: 'warn',
|
|
50
65
|
});
|
|
51
66
|
}
|
|
67
|
+
// Semantic validity (M1): a value labeled valid that violates the constraints (or a synthesized
|
|
68
|
+
// boundary that lands in-range) — the "structurally right but semantically wrong" class.
|
|
69
|
+
for (const v of validateGenerated(map, catalog)) {
|
|
70
|
+
findings.push({
|
|
71
|
+
sensorId: 'data-factory-coverage',
|
|
72
|
+
capability: 'data-factory',
|
|
73
|
+
scenario: v.field,
|
|
74
|
+
message: `DATA-FACTORY-${v.code}: ${v.message}`,
|
|
75
|
+
severity: v.severity,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
52
78
|
if (findings.length) {
|
|
53
79
|
const s = summarize(issues);
|
|
54
80
|
findings.unshift({
|
|
@@ -59,6 +85,8 @@ export const dataFactoryCoverageSensor: Sensor<{ dir: string; cwd: string }> = {
|
|
|
59
85
|
severity: s.errors ? 'warn' : 'info',
|
|
60
86
|
});
|
|
61
87
|
}
|
|
88
|
+
// Detection findings (e.g. an undeclared stateful precondition) are still relevant with a field-map.
|
|
89
|
+
findings.push(...detected);
|
|
62
90
|
return findings;
|
|
63
91
|
},
|
|
64
92
|
};
|