@sungen/driver-data-factory 3.2.10-beta.6 → 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.
Files changed (37) hide show
  1. package/common/experience/auth-account-states.yaml +19 -0
  2. package/common/field-types/currency.yaml +10 -10
  3. package/common/field-types/email.yaml +19 -16
  4. package/common/field-types/jp/katakana.yaml +12 -12
  5. package/common/field-types/number-float.yaml +10 -10
  6. package/common/field-types/number-integer.yaml +12 -12
  7. package/common/field-types/password.yaml +22 -22
  8. package/common/field-types/phone.yaml +14 -14
  9. package/common/field-types/postcode.yaml +8 -8
  10. package/common/field-types/string.yaml +11 -11
  11. package/common/security/broken-auth.yaml +6 -6
  12. package/common/security/idor.yaml +5 -5
  13. package/common/security/input-validation.yaml +5 -5
  14. package/common/security/sqli.yaml +7 -7
  15. package/common/security/upload.yaml +7 -7
  16. package/common/security/xss.yaml +9 -9
  17. package/common/techniques/boundary.yaml +4 -4
  18. package/common/techniques/decision-table.yaml +4 -4
  19. package/common/techniques/equivalence.yaml +4 -4
  20. package/common/techniques/state-transition.yaml +4 -4
  21. package/package.json +3 -3
  22. package/src/applicability.ts +80 -0
  23. package/src/boundary.ts +319 -0
  24. package/src/catalog-loader.ts +104 -18
  25. package/src/cli.ts +233 -14
  26. package/src/contract.ts +93 -0
  27. package/src/crosscheck.ts +149 -0
  28. package/src/detect.ts +122 -0
  29. package/src/fixture.ts +111 -0
  30. package/src/generator.ts +231 -98
  31. package/src/index.ts +17 -2
  32. package/src/lint.ts +31 -16
  33. package/src/readme.ts +52 -0
  34. package/src/sensor.ts +32 -4
  35. package/src/state.ts +225 -0
  36. package/src/types.ts +82 -1
  37. package/src/validate.ts +77 -0
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Data Factory — applicability (M2b).
3
+ *
4
+ * A catalog case is not universally applicable: an "empty (required)" invalid means nothing for an
5
+ * OPTIONAL field, a negative-number example only applies when the field allows negatives, a katakana
6
+ * or locale-specific case only applies under that locale, and a security payload is an *adversarial*
7
+ * case that belongs in its own bucket rather than mixed into plain `invalid`. Both reviews flagged
8
+ * this: the driver must return `not-applicable` (with a reason) instead of silently emitting a case
9
+ * that doesn't fit — and it must let Core (or a CLI flag) select a reduced profile.
10
+ *
11
+ * Pure + deterministic. Field-derived predicates (`required`, `negativesAllowed`) are always enforced;
12
+ * environment predicates (`locale`, `channel`, `sink`) only filter when the context supplies them.
13
+ */
14
+ import type { DataPoint, SelectionContext, Profile } from './types';
15
+ import type { FieldDef } from './generator';
16
+
17
+ export interface FieldFacts {
18
+ required?: boolean;
19
+ min?: number;
20
+ }
21
+
22
+ export interface Applicability {
23
+ applicable: boolean;
24
+ /** Why it was excluded — shown to the QA ("not-applicable, không âm thầm sinh data"). */
25
+ reason?: string;
26
+ }
27
+
28
+ /** Evaluate a point's `when` conditions against the field facts + selection context. */
29
+ export function evaluateWhen(when: Record<string, unknown> | undefined, facts: FieldFacts, ctx: SelectionContext): Applicability {
30
+ if (!when) return { applicable: true };
31
+ // Field-derived: always enforced (a definite field fact can rule the case out).
32
+ if (when.required === true && facts.required === false) {
33
+ return { applicable: false, reason: 'requires a required field; this field is optional' };
34
+ }
35
+ if (when.negativesAllowed === true && Number.isFinite(facts.min) && (facts.min as number) >= 0) {
36
+ return { applicable: false, reason: `requires negatives (min < 0); this field has min=${facts.min}` };
37
+ }
38
+ // Environment-derived: only filter when the context supplies the axis (else permissive).
39
+ if (typeof when.locale === 'string' && ctx.locale && !ctx.locale.toLowerCase().startsWith(String(when.locale).toLowerCase())) {
40
+ return { applicable: false, reason: `locale ${when.locale} ≠ context ${ctx.locale}` };
41
+ }
42
+ if (typeof when.channel === 'string' && ctx.channel && ctx.channel !== when.channel) {
43
+ return { applicable: false, reason: `channel ${when.channel} ≠ context ${ctx.channel}` };
44
+ }
45
+ if (typeof when.sink === 'string' && ctx.sink && ctx.sink !== when.sink) {
46
+ return { applicable: false, reason: `sink ${when.sink} ≠ context ${ctx.sink}` };
47
+ }
48
+ return { applicable: true };
49
+ }
50
+
51
+ /** Field facts the field-derived predicates read (required + numeric min after constraint merge). */
52
+ export function fieldFacts(field: FieldDef, mergedConstraints: Record<string, any>): FieldFacts {
53
+ const min = mergedConstraints.min;
54
+ return { required: field.required, min: typeof min === 'number' ? min : undefined };
55
+ }
56
+
57
+ /**
58
+ * Is a point kept under the chosen profile? (Applied AFTER `when` applicability.) Rule-based over
59
+ * group + category, so it needs no per-point tagging:
60
+ * - regression (default): everything.
61
+ * - functional: valid + boundary + non-adversarial invalid (drops security/adversarial).
62
+ * - smoke: valid + boundary (the "does it work" set — no invalid, no adversarial).
63
+ * - security-min: valid + adversarial (security sanity).
64
+ */
65
+ export function inProfile(group: 'valid' | 'boundary' | 'invalid', category: string | undefined, profile: Profile | undefined): boolean {
66
+ const adversarial = category === 'adversarial';
67
+ switch (profile) {
68
+ case undefined:
69
+ case 'regression':
70
+ return true;
71
+ case 'functional':
72
+ return !adversarial;
73
+ case 'smoke':
74
+ return group === 'valid' || group === 'boundary';
75
+ case 'security-min':
76
+ return group === 'valid' || adversarial;
77
+ default:
78
+ return true;
79
+ }
80
+ }
@@ -0,0 +1,319 @@
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
+ // Total-length MIN (a spec often sets an email min length, e.g. 5) — the oracle must enforce it so a
220
+ // below-min email is correctly INVALID (was ignored → the min boundary silently missing).
221
+ const minTotal = c.minLength ?? c.minTotal;
222
+ if (Number.isFinite(minTotal) && value.length < minTotal) return false;
223
+ return true;
224
+ }
225
+
226
+ /** Build an email of exactly `n` total chars (for the total-length MIN boundary — small n). */
227
+ export function emailOfTotalLength(n: number): string | null {
228
+ if (!Number.isFinite(n) || n < 1) return null;
229
+ if (n < 3) return 'a'.repeat(n); // too short to be a real address → malformed (below-min case)
230
+ const rest = n - 2; // chars after "a@"
231
+ if (rest < 3) return 'a@' + 'b'.repeat(rest); // e.g. n=4 → "a@bb" (no dot → also malformed, below-min)
232
+ return 'a@' + 'b'.repeat(rest - 2) + '.c'; // e.g. n=5 → "a@b.c", n=6 → "a@bb.c"
233
+ }
234
+
235
+ function satisfiesLength(type: string, value: string, c: Record<string, any>): boolean {
236
+ const min = c.minLength ?? c.length;
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;
241
+ }
242
+
243
+ function satisfiesNumeric(type: string, value: string, c: Record<string, any>): boolean {
244
+ const n = Number(value);
245
+ if (!Number.isFinite(n)) return false;
246
+ if (Number.isFinite(c.min) && n < c.min) return false;
247
+ if (Number.isFinite(c.max) && n > c.max) return false;
248
+ if ((type === 'number-integer') && !Number.isInteger(n)) return false;
249
+ if ((type === 'number-float' || type === 'currency') && Number.isFinite(c.decimalPlaces)) {
250
+ const dec = (value.split('.')[1] || '').length;
251
+ if (dec > c.decimalPlaces) return false;
252
+ }
253
+ return true;
254
+ }
255
+
256
+ /**
257
+ * The oracle. Given a concrete value and the constraints, decide whether it structurally satisfies
258
+ * them (length/range/precision — NOT charset/format, which the catalog encodes as literal invalids).
259
+ * Returns null when the type has no numeric/length constraint to check against (nothing to assert).
260
+ */
261
+ export function satisfiesConstraints(type: string, value: string | null, c: Record<string, any>): boolean | null {
262
+ if (value == null) return null;
263
+ if (type === 'email') return satisfiesEmail(value, c);
264
+ if (STR_TYPES.has(type)) {
265
+ if (!Number.isFinite(c.minLength ?? c.length) && !Number.isFinite(c.maxLength ?? c.length)) return null;
266
+ return satisfiesLength(type, value, c);
267
+ }
268
+ if (NUM_TYPES.has(type)) {
269
+ if (!Number.isFinite(c.min) && !Number.isFinite(c.max) && !Number.isFinite(c.decimalPlaces)) return null;
270
+ return satisfiesNumeric(type, value, c);
271
+ }
272
+ return null;
273
+ }
274
+
275
+ // ---- BVA completeness: every bounded dimension gets its full min-1/min/min+1/max-1/max/max+1 set ----
276
+
277
+ export interface BvaExtra { label: string; value: string; validity: Validity; group: 'boundary' | 'invalid'; }
278
+
279
+ /**
280
+ * Ensure the canonical Boundary-Value-Analysis set is present for EVERY bounded dimension declared in
281
+ * the constraints — even one the field-type's catalog `gen:` entries don't exercise. This closes the
282
+ * QA-reported gap: email declared `minLength: 5` but the catalog only modelled maxLocal/maxTotal, so
283
+ * the min side (length 4 = min-1 invalid, 5 = min, 6 = min+1) was silently missing. Emits only the
284
+ * MISSING points (dedup by exact value), so a type whose catalog already covers a dimension (string /
285
+ * password / number) yields nothing — no churn.
286
+ */
287
+ export function ensureBvaComplete(type: string, c: Record<string, any>, existing: Array<string | null>): BvaExtra[] {
288
+ const have = new Set(existing.filter((v): v is string => v != null));
289
+ const out: BvaExtra[] = [];
290
+ const push = (label: string, value: string | null) => {
291
+ if (value == null || have.has(value)) return;
292
+ const ok = satisfiesConstraints(type, value, c);
293
+ const validity: Validity = ok === false ? 'invalid' : 'valid';
294
+ out.push({ label, value, validity, group: validity === 'invalid' ? 'invalid' : 'boundary' });
295
+ have.add(value);
296
+ };
297
+ if (type === 'email') {
298
+ const minTotal = c.minLength ?? c.minTotal;
299
+ if (Number.isFinite(minTotal)) {
300
+ push('minTotal-1', emailOfTotalLength((minTotal as number) - 1));
301
+ push('minTotal', emailOfTotalLength(minTotal as number));
302
+ push('minTotal+1', emailOfTotalLength((minTotal as number) + 1));
303
+ }
304
+ return out;
305
+ }
306
+ if (STR_TYPES.has(type)) {
307
+ 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)); }
309
+ if (Number.isFinite(max)) { push('maxLen-1', fillString(type, max - 1)); push('maxLen', fillString(type, max)); push('maxLen+1', fillString(type, max + 1)); }
310
+ return out;
311
+ }
312
+ if (NUM_TYPES.has(type)) {
313
+ const min = c.min, max = c.max, st = step(type, c);
314
+ if (Number.isFinite(min)) { push('min-1', fmtNum(type, c, min - st)); push('min', fmtNum(type, c, min)); push('min+1', fmtNum(type, c, min + st)); }
315
+ if (Number.isFinite(max)) { push('max-1', fmtNum(type, c, max - st)); push('max', fmtNum(type, c, max)); push('max+1', fmtNum(type, c, max + st)); }
316
+ return out;
317
+ }
318
+ return out;
319
+ }
@@ -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, Experience, 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 {
@@ -49,30 +49,70 @@ export function loadShippedCatalog(root = commonDir()): Catalog {
49
49
  for (const { data } of readYamlFilesDeep(join(root, 'techniques'))) {
50
50
  if (data?.technique) techniques[data.technique] = data as Technique;
51
51
  }
52
- return { fieldTypes, security, techniques };
52
+ const experience: Record<string, Experience> = {};
53
+ for (const { data } of readYamlFilesDeep(join(root, 'experience'))) {
54
+ if (data?.experience) experience[data.experience] = data as Experience;
55
+ }
56
+ return { fieldTypes, security, techniques, experience };
53
57
  }
54
58
 
55
- /** Merge a field-type's group by DataPoint.id — overlay entries replace same-id, new ids append. */
56
- function mergeGroup(base: DataPoint[] = [], overlay: DataPoint[] = []): DataPoint[] {
59
+ /**
60
+ * Merge a field-type group by DataPoint.id, honoring three explicit operations:
61
+ * - EXTEND: an overlay id not in `base` is appended (`added`).
62
+ * - REPLACE: an overlay id already in `base` wins (`replaced`).
63
+ * - DISABLE: an id in `disableSet` is dropped (`disabled`).
64
+ * Records provenance and stamps each point's `source` so a reader can trace where it came from.
65
+ */
66
+ function mergeGroup(
67
+ base: DataPoint[] = [],
68
+ overlay: DataPoint[] = [],
69
+ disableSet: Set<string>,
70
+ prov: FieldTypeProvenance,
71
+ ): DataPoint[] {
57
72
  const byId = new Map<string, DataPoint>();
58
- for (const dp of base) byId.set(dp.id, dp);
59
- for (const dp of overlay) byId.set(dp.id, { ...byId.get(dp.id), ...dp });
73
+ for (const dp of base) byId.set(dp.id, { ...dp, source: 'shipped' });
74
+ for (const dp of overlay) {
75
+ if (byId.has(dp.id)) prov.replaced.push(dp.id);
76
+ else prov.added.push(dp.id);
77
+ byId.set(dp.id, { ...byId.get(dp.id), ...dp, source: 'overwrite' });
78
+ }
79
+ for (const id of disableSet) {
80
+ if (byId.delete(id)) prov.disabled.push(id);
81
+ }
60
82
  return [...byId.values()];
61
83
  }
62
84
 
63
- function mergeFieldType(base: FieldType, overlay: Partial<FieldType>): FieldType {
85
+ /** An overlay field-type entry may carry a `disable:` list of ids alongside partial field-type data. */
86
+ type FieldTypeOverlay = Partial<FieldType> & { disable?: string[] };
87
+
88
+ function mergeFieldType(base: FieldType, overlay: FieldTypeOverlay, prov: FieldTypeProvenance): FieldType {
89
+ const disableSet = new Set(overlay.disable || []);
90
+ const constraintsOverridden = Object.keys(overlay.constraints || {}).filter(
91
+ (k) => (base.constraints || {})[k] !== (overlay.constraints || {})[k],
92
+ );
93
+ prov.constraintsOverridden = constraintsOverridden;
94
+ prov.securityChanged = overlay.uses_security != null;
95
+ const { disable: _disable, ...rest } = overlay;
64
96
  return {
65
97
  ...base,
66
- ...overlay,
98
+ ...rest,
67
99
  constraints: { ...(base.constraints || {}), ...(overlay.constraints || {}) },
68
100
  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),
101
+ valid: mergeGroup(base.valid, overlay.valid, disableSet, prov),
102
+ boundary: mergeGroup(base.boundary, overlay.boundary, disableSet, prov),
103
+ invalid: mergeGroup(base.invalid, overlay.invalid, disableSet, prov),
72
104
  notes: [...(base.notes || []), ...(overlay.notes || [])],
73
105
  };
74
106
  }
75
107
 
108
+ /** Merge a security bank by payload id (same rules: replace same-id, append new). */
109
+ function mergeSecurity(base: SecurityBank, overlay: Partial<SecurityBank>): SecurityBank {
110
+ const byId = new Map<string, any>();
111
+ for (const p of base.payloads || []) byId.set(p.id, p);
112
+ for (const p of overlay.payloads || []) byId.set(p.id, { ...byId.get(p.id), ...p });
113
+ return { ...base, ...overlay, payloads: [...byId.values()] };
114
+ }
115
+
76
116
  /**
77
117
  * Load the effective catalog = shipped common + project overwrite overlay.
78
118
  * `projectDir` is the QA repo root; overlay is read from `qa/data-factory/common.overwrite.yaml`.
@@ -80,38 +120,84 @@ function mergeFieldType(base: FieldType, overlay: Partial<FieldType>): FieldType
80
120
  export function loadEffectiveCatalog(projectDir: string, root = commonDir()): {
81
121
  catalog: Catalog;
82
122
  warnings: OverwriteWarning[];
123
+ provenance: CatalogProvenance;
83
124
  } {
84
125
  const shipped = loadShippedCatalog(root);
85
126
  const overlayPath = join(projectDir, 'qa', 'data-factory', 'common.overwrite.yaml');
86
127
  const warnings: OverwriteWarning[] = [];
87
- if (!existsSync(overlayPath)) return { catalog: shipped, warnings };
128
+ const provenance: CatalogProvenance = { fieldTypes: {}, newFieldTypes: [], security: { added: [], replaced: [] }, techniques: { added: [], replaced: [] } };
129
+ if (!existsSync(overlayPath)) return { catalog: shipped, warnings, provenance };
88
130
 
89
131
  const overlay = parseYaml(readFileSync(overlayPath, 'utf8')) || {};
90
- const overlayFieldTypes: Record<string, Partial<FieldType>> = overlay.fieldTypes || overlay['field-types'] || {};
132
+ const overlayFieldTypes: Record<string, FieldTypeOverlay> = overlay.fieldTypes || overlay['field-types'] || {};
91
133
  const merged: Catalog = {
92
134
  fieldTypes: { ...shipped.fieldTypes },
93
135
  security: { ...shipped.security },
94
136
  techniques: { ...shipped.techniques },
137
+ experience: { ...shipped.experience },
95
138
  };
139
+
96
140
  for (const [type, ov] of Object.entries(overlayFieldTypes)) {
97
141
  if (merged.fieldTypes[type]) {
98
- merged.fieldTypes[type] = mergeFieldType(merged.fieldTypes[type], ov);
142
+ const prov: FieldTypeProvenance = { type, disabled: [], replaced: [], added: [], constraintsOverridden: [], securityChanged: false };
143
+ // Warn on a disable target that does not exist in the shipped field-type (likely a typo/stale id).
144
+ const known = new Set([...(merged.fieldTypes[type].valid || []), ...(merged.fieldTypes[type].boundary || []), ...(merged.fieldTypes[type].invalid || [])].map((d) => d.id));
145
+ for (const id of ov.disable || []) {
146
+ 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` });
147
+ }
148
+ merged.fieldTypes[type] = mergeFieldType(merged.fieldTypes[type], ov, prov);
149
+ provenance.fieldTypes[type] = prov;
99
150
  // Drift warning: overlay references a security bank the shipped common no longer has.
100
151
  for (const bank of ov.uses_security || []) {
101
152
  if (!merged.security[bank]) {
102
153
  warnings.push({
103
154
  fieldType: type,
104
- detail: `overwrite tham chiếu security bank "${bank}" không còn trong common`,
105
- suggestion: `cập nhật hoặc xoá mục "${type}" trong qa/data-factory/common.overwrite.yaml`,
155
+ detail: `overwrite references security bank "${bank}" that no longer exists in the common catalog`,
156
+ suggestion: `update or remove the "${type}" entry in qa/data-factory/common.overwrite.yaml`,
106
157
  });
107
158
  }
108
159
  }
109
160
  } else {
110
161
  // Wholly new field-type from the user — accept as-is (project-specific type).
111
- merged.fieldTypes[type] = ov as FieldType;
162
+ const { disable: _d, ...rest } = ov;
163
+ merged.fieldTypes[type] = rest as FieldType;
164
+ provenance.newFieldTypes.push(type);
112
165
  }
113
166
  }
114
- return { catalog: merged, warnings };
167
+
168
+ // Security-bank overrides (`security:` in the overwrite) — replace same-id payloads, append new banks.
169
+ const overlaySecurity: Record<string, Partial<SecurityBank>> = overlay.security || {};
170
+ for (const [cls, ov] of Object.entries(overlaySecurity)) {
171
+ if (merged.security[cls]) { merged.security[cls] = mergeSecurity(merged.security[cls], ov); provenance.security.replaced.push(cls); }
172
+ else { merged.security[cls] = { class: cls, payloads: [], ...ov } as SecurityBank; provenance.security.added.push(cls); }
173
+ }
174
+
175
+ // Technique overrides (`techniques:` in the overwrite) — shallow replace/add by name.
176
+ const overlayTech: Record<string, Partial<Technique>> = overlay.techniques || {};
177
+ for (const [name, ov] of Object.entries(overlayTech)) {
178
+ if (merged.techniques[name]) { merged.techniques[name] = { ...merged.techniques[name], ...ov }; provenance.techniques.replaced.push(name); }
179
+ else { merged.techniques[name] = { technique: name, ...ov } as Technique; provenance.techniques.added.push(name); }
180
+ }
181
+
182
+ return { catalog: merged, warnings, provenance };
183
+ }
184
+
185
+ /** Human-readable one-liners describing what `.overwrite` changed (for lint/sensor "no silent overwrite"). */
186
+ export function describeProvenance(p: CatalogProvenance): string[] {
187
+ const lines: string[] = [];
188
+ for (const ft of Object.values(p.fieldTypes)) {
189
+ const parts: string[] = [];
190
+ if (ft.disabled.length) parts.push(`disabled ${ft.disabled.join(', ')}`);
191
+ if (ft.replaced.length) parts.push(`replaced ${ft.replaced.join(', ')}`);
192
+ if (ft.added.length) parts.push(`added ${ft.added.join(', ')}`);
193
+ if (ft.constraintsOverridden.length) parts.push(`constraints[${ft.constraintsOverridden.join(', ')}]`);
194
+ if (ft.securityChanged) parts.push('uses_security replaced');
195
+ if (parts.length) lines.push(`${ft.type}: ${parts.join('; ')}`);
196
+ }
197
+ for (const t of p.newFieldTypes) lines.push(`${t}: new project field-type`);
198
+ 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('; ')}`);
199
+ 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('; ')}`);
200
+ return lines;
115
201
  }
116
202
 
117
203
  /** Stable hash of the shipped common — recorded in df-manifest.json to detect update drift. */