@sungen/driver-data-factory 3.2.12-beta.4 → 3.2.12-beta.5
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/field-types/currency.yaml +1 -1
- package/common/field-types/email.yaml +1 -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 +2 -2
- package/src/applicability.ts +80 -0
- package/src/cli.ts +45 -9
- package/src/generator.ts +39 -9
- package/src/index.ts +2 -1
- package/src/lint.ts +8 -3
- package/src/types.ts +30 -0
- package/src/validate.ts +3 -3
|
@@ -14,7 +14,7 @@ boundary:
|
|
|
14
14
|
invalid:
|
|
15
15
|
- { id: CHK-CUR-I1, gen: "alphabetic / stray characters", reason: "not a number", errorRef: M13 }
|
|
16
16
|
- { id: CHK-CUR-I2, gen: "min-1 / max+1", reason: "out of bounds", errorRef: M13 }
|
|
17
|
-
- { id: CHK-CUR-I3, value: "", reason: "empty (per spec)", errorRef: M02 }
|
|
17
|
+
- { id: CHK-CUR-I3, value: "", reason: "empty (per spec)", errorRef: M02, when: { required: true } }
|
|
18
18
|
notes:
|
|
19
19
|
- "Check separately: leading zero (0100) not stripped; thousands separator display format."
|
|
20
20
|
uses_security: [sqli]
|
|
@@ -24,7 +24,7 @@ invalid:
|
|
|
24
24
|
- { id: CHK-EMAIL-I5, value: "user@example.com", reason: "full-width characters", errorRef: M39 }
|
|
25
25
|
- { id: CHK-EMAIL-I6, gen: "local > maxLocal", reason: "exceeds maxLocal", errorRef: M13 }
|
|
26
26
|
- { id: CHK-EMAIL-I7, gen: "total > maxTotal", reason: "exceeds maxTotal", errorRef: M13 }
|
|
27
|
-
- { id: CHK-EMAIL-I8, value: "", reason: "empty (required)", errorRef: M02 }
|
|
27
|
+
- { id: CHK-EMAIL-I8, value: "", reason: "empty (required)", errorRef: M02, when: { required: true } }
|
|
28
28
|
notes:
|
|
29
29
|
- "Normalize: lowercase, NFKC, trim before comparison."
|
|
30
30
|
- "Soft-delete: if a deleted email gets a suffix appended, test maxTotal LOWER than the DB column limit to leave room for the suffix."
|
|
@@ -16,5 +16,5 @@ invalid:
|
|
|
16
16
|
- { id: CHK-FLOAT-I1, gen: "alphabetic characters", reason: "not a number", errorRef: M13 }
|
|
17
17
|
- { id: CHK-FLOAT-I2, gen: "wrong format (multiple dots, +/- wrong position)", reason: "wrong format", errorRef: M13 }
|
|
18
18
|
- { id: CHK-FLOAT-I3, gen: "min-1 / max+1", reason: "out of bounds", errorRef: M13 }
|
|
19
|
-
- { id: CHK-FLOAT-I4, value: "", reason: "empty/multi-space (per spec)", errorRef: M02 }
|
|
19
|
+
- { id: CHK-FLOAT-I4, value: "", reason: "empty/multi-space (per spec)", errorRef: M02, when: { required: true } }
|
|
20
20
|
uses_security: [sqli]
|
|
@@ -6,7 +6,7 @@ constraints:
|
|
|
6
6
|
max: 2147483647 # placeholder
|
|
7
7
|
valid:
|
|
8
8
|
- { id: CHK-INT-01, value: "123", note: "digits only" }
|
|
9
|
-
- { id: CHK-INT-02, value: "-45", note: "leading - (if spec allows negatives)" }
|
|
9
|
+
- { id: CHK-INT-02, value: "-45", note: "leading - (if spec allows negatives)", when: { negativesAllowed: true } }
|
|
10
10
|
boundary:
|
|
11
11
|
- { id: CHK-INT-B1, gen: "min / min+1" }
|
|
12
12
|
- { id: CHK-INT-B2, gen: "max / max-1" }
|
|
@@ -15,7 +15,7 @@ invalid:
|
|
|
15
15
|
- { id: CHK-INT-I2, gen: "full-width (Japanese) digits", reason: "full-width characters", errorRef: M39 }
|
|
16
16
|
- { id: CHK-INT-I3, gen: "+/- in wrong position (mid-string)", reason: "wrong format", errorRef: M13 }
|
|
17
17
|
- { id: CHK-INT-I4, gen: "min-1 / max+1", reason: "out of bounds", errorRef: M13 }
|
|
18
|
-
- { id: CHK-INT-I5, value: "", reason: "empty/multi-space (per spec)", errorRef: M02 }
|
|
18
|
+
- { id: CHK-INT-I5, value: "", reason: "empty/multi-space (per spec)", errorRef: M02, when: { required: true } }
|
|
19
19
|
notes:
|
|
20
20
|
- "Check leading zero (007) is not stripped; redundant spaces before/after handled correctly."
|
|
21
21
|
uses_security: [sqli]
|
|
@@ -17,7 +17,7 @@ boundary:
|
|
|
17
17
|
- { id: CHK-PW-B3, gen: "max length", note: "max" }
|
|
18
18
|
- { id: CHK-PW-B4, gen: "max-1", note: "max-1" }
|
|
19
19
|
invalid:
|
|
20
|
-
- { id: CHK-PW-I1, value: "", reason: "empty/multi-space (required)", errorRef: M02 }
|
|
20
|
+
- { id: CHK-PW-I1, value: "", reason: "empty/multi-space (required)", errorRef: M02, when: { required: true } }
|
|
21
21
|
- { id: CHK-PW-I2, gen: "< min length", reason: "below minimum length", errorRef: M29 }
|
|
22
22
|
- { id: CHK-PW-I3, gen: "> max length", reason: "exceeds maximum length", errorRef: M30 }
|
|
23
23
|
- { id: CHK-PW-I4, value: "passw0rd", reason: "missing uppercase", errorRef: M31 }
|
|
@@ -19,5 +19,5 @@ invalid:
|
|
|
19
19
|
- { id: CHK-PHONE-I4, value: "090 123 4567", reason: "spaces between digits", errorRef: M13 }
|
|
20
20
|
- { id: CHK-PHONE-I5, gen: "+/- not first; () wrong position", reason: "wrong format", errorRef: M13 }
|
|
21
21
|
- { id: CHK-PHONE-I6, gen: "min-1 / max+1", reason: "out of bounds", errorRef: M13 }
|
|
22
|
-
- { id: CHK-PHONE-I7, value: "", reason: "empty/multi-space (per spec)", errorRef: M02 }
|
|
22
|
+
- { id: CHK-PHONE-I7, value: "", reason: "empty/multi-space (per spec)", errorRef: M02, when: { required: true } }
|
|
23
23
|
uses_security: [sqli]
|
|
@@ -12,5 +12,5 @@ boundary:
|
|
|
12
12
|
invalid:
|
|
13
13
|
- { id: CHK-ZIP-I1, gen: "alphabetic / stray characters", reason: "invalid characters", errorRef: M13 }
|
|
14
14
|
- { id: CHK-ZIP-I2, gen: "wrong region format", reason: "wrong format", errorRef: M13 }
|
|
15
|
-
- { id: CHK-ZIP-I3, value: "", reason: "empty (per spec)", errorRef: M02 }
|
|
15
|
+
- { id: CHK-ZIP-I3, value: "", reason: "empty (per spec)", errorRef: M02, when: { required: true } }
|
|
16
16
|
uses_security: [sqli]
|
|
@@ -16,5 +16,5 @@ boundary:
|
|
|
16
16
|
- { id: CHK-STR-B1, gen: "min / min+1 length" }
|
|
17
17
|
- { id: CHK-STR-B2, gen: "max / max-1 length" }
|
|
18
18
|
invalid:
|
|
19
|
-
- { id: CHK-STR-I1, value: "", reason: "empty/multi-space (success/fail per spec)", errorRef: M02 }
|
|
19
|
+
- { id: CHK-STR-I1, value: "", reason: "empty/multi-space (success/fail per spec)", errorRef: M02, when: { required: true } }
|
|
20
20
|
- { id: CHK-STR-I2, gen: "min-1 / max+1", reason: "out of bounds", errorRef: M13 }
|
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.5",
|
|
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.2.12-beta.
|
|
17
|
+
"@sun-asterisk/sungen": "3.2.12-beta.5",
|
|
18
18
|
"yaml": "^2.4.0"
|
|
19
19
|
},
|
|
20
20
|
"files": [
|
|
@@ -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
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -16,8 +16,24 @@ import { lintFieldMap, summarize } from './lint';
|
|
|
16
16
|
import { validateGenerated } from './validate';
|
|
17
17
|
import { generateFixture } from './fixture';
|
|
18
18
|
import { ensureDataFactoryReadme } from './readme';
|
|
19
|
+
import type { SelectionContext, Profile } from './types';
|
|
19
20
|
|
|
20
21
|
const FIELDMAP_DIR = (cwd: string) => join(cwd, 'qa', 'data-factory');
|
|
22
|
+
|
|
23
|
+
const PROFILES: Profile[] = ['regression', 'functional', 'smoke', 'security-min'];
|
|
24
|
+
|
|
25
|
+
/** Build a SelectionContext from the shared --profile/--locale/--channel/--sink flags. */
|
|
26
|
+
function ctxFromOpts(o: { profile?: string; locale?: string; channel?: string; sink?: string }): SelectionContext {
|
|
27
|
+
const ctx: SelectionContext = {};
|
|
28
|
+
if (o.profile) {
|
|
29
|
+
if (!PROFILES.includes(o.profile as Profile)) throw new Error(`--profile must be one of ${PROFILES.join(' | ')}`);
|
|
30
|
+
ctx.profile = o.profile as Profile;
|
|
31
|
+
}
|
|
32
|
+
if (o.locale) ctx.locale = o.locale;
|
|
33
|
+
if (o.channel === 'api' || o.channel === 'ui') ctx.channel = o.channel;
|
|
34
|
+
if (o.sink) ctx.sink = o.sink;
|
|
35
|
+
return ctx;
|
|
36
|
+
}
|
|
21
37
|
const fieldMapPath = (cwd: string, name: string) => join(FIELDMAP_DIR(cwd), `${name}.fields.yaml`);
|
|
22
38
|
|
|
23
39
|
/** Resolve where a unit's test-data lives: screen or flow (mirrors sungen auto-detect). */
|
|
@@ -48,14 +64,16 @@ function loadFieldMap(cwd: string, name: string): FieldMap | null {
|
|
|
48
64
|
return parseYaml(readFileSync(p, 'utf8')) as FieldMap;
|
|
49
65
|
}
|
|
50
66
|
|
|
51
|
-
async function genOne(cwd: string, name: string, opts: { force?: boolean }): Promise<'written' | 'skipped' | 'missing'> {
|
|
67
|
+
async function genOne(cwd: string, name: string, opts: { force?: boolean; ctx?: SelectionContext }): Promise<'written' | 'skipped' | 'missing'> {
|
|
52
68
|
const map = loadFieldMap(cwd, name);
|
|
53
69
|
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'; }
|
|
54
70
|
const { catalog, warnings, provenance } = loadEffectiveCatalog(cwd);
|
|
55
71
|
for (const w of warnings) console.warn(` ⚠ overwrite: ${w.detail} → ${w.suggestion}`);
|
|
56
72
|
for (const line of describeProvenance(provenance)) console.log(` ⟲ .overwrite → ${line}`);
|
|
57
73
|
|
|
58
|
-
const expanded = expandFieldMap(map, catalog);
|
|
74
|
+
const expanded = expandFieldMap(map, catalog, opts.ctx || {});
|
|
75
|
+
const na = expanded.flatMap((f) => (f.excluded || []).map((e) => `${f.name}.${e.id}`));
|
|
76
|
+
if (na.length) console.log(` ⊘ ${name}: ${na.length} case(s) not applicable for this context (see _not_applicable in the file)`);
|
|
59
77
|
const out = testDataPath(cwd, name);
|
|
60
78
|
if (!out) { console.error(` ✗ ${name}: no qa/screens/${name} or qa/flows/${name}`); return 'missing'; }
|
|
61
79
|
|
|
@@ -97,10 +115,16 @@ export function registerDataFactoryCommand(program: any): void {
|
|
|
97
115
|
.option('--all', 'Generate for every field-map under qa/data-factory/ (requires confirm)')
|
|
98
116
|
.option('--yes', 'Assume yes for the --all confirmation')
|
|
99
117
|
.option('--force', 'Overwrite existing test-data without asking (implies --yes)')
|
|
100
|
-
.
|
|
118
|
+
.option('--profile <name>', `Case selection: ${PROFILES.join(' | ')} (default: regression = all)`)
|
|
119
|
+
.option('--locale <tag>', 'Selection context locale (e.g. ja-JP) — filters locale-gated cases')
|
|
120
|
+
.option('--channel <api|ui>', 'Selection context channel')
|
|
121
|
+
.option('--sink <name>', 'Selection context output sink (e.g. html)')
|
|
122
|
+
.action(async (opts: { screen?: string; all?: boolean; yes?: boolean; force?: boolean; profile?: string; locale?: string; channel?: string; sink?: string }) => {
|
|
101
123
|
const cwd = process.cwd();
|
|
102
124
|
ensureDataFactoryReadme(cwd);
|
|
103
|
-
|
|
125
|
+
let ctx: SelectionContext;
|
|
126
|
+
try { ctx = ctxFromOpts(opts); } catch (e: any) { console.error(e.message); process.exitCode = 1; return; }
|
|
127
|
+
if (opts.screen) { await genOne(cwd, opts.screen, { ...opts, ctx }); return; }
|
|
104
128
|
if (!opts.all) {
|
|
105
129
|
console.error('Specify --screen <name> for one unit, or --all for every unit (with confirmation).');
|
|
106
130
|
process.exitCode = 1;
|
|
@@ -117,7 +141,7 @@ export function registerDataFactoryCommand(program: any): void {
|
|
|
117
141
|
if (a !== 'y' && a !== 'yes') { console.log('Cancelled.'); return; }
|
|
118
142
|
}
|
|
119
143
|
let written = 0;
|
|
120
|
-
for (const n of names) if ((await genOne(cwd, n, { force: opts.force })) === 'written') written++;
|
|
144
|
+
for (const n of names) if ((await genOne(cwd, n, { force: opts.force, ctx })) === 'written') written++;
|
|
121
145
|
console.log(`Done: ${written}/${names.length} written.`);
|
|
122
146
|
});
|
|
123
147
|
|
|
@@ -125,9 +149,15 @@ export function registerDataFactoryCommand(program: any): void {
|
|
|
125
149
|
.command('lint')
|
|
126
150
|
.description('Check test-data coverage (valid/boundary/invalid + traceability) per phuong-phap §6')
|
|
127
151
|
.option('--screen <name>', 'Lint one unit (default: all field-maps)')
|
|
128
|
-
.
|
|
152
|
+
.option('--profile <name>', `Case selection: ${PROFILES.join(' | ')} (default: regression = all)`)
|
|
153
|
+
.option('--locale <tag>', 'Selection context locale (e.g. ja-JP)')
|
|
154
|
+
.option('--channel <api|ui>', 'Selection context channel')
|
|
155
|
+
.option('--sink <name>', 'Selection context output sink')
|
|
156
|
+
.action((opts: { screen?: string; profile?: string; locale?: string; channel?: string; sink?: string }) => {
|
|
129
157
|
const cwd = process.cwd();
|
|
130
158
|
ensureDataFactoryReadme(cwd);
|
|
159
|
+
let ctx: SelectionContext;
|
|
160
|
+
try { ctx = ctxFromOpts(opts); } catch (e: any) { console.error(e.message); process.exitCode = 1; return; }
|
|
131
161
|
const names = opts.screen ? [opts.screen] : listFieldMaps(cwd);
|
|
132
162
|
if (names.length === 0) { console.error('No field-maps under qa/data-factory/*.fields.yaml.'); process.exitCode = 1; return; }
|
|
133
163
|
const { catalog } = loadEffectiveCatalog(cwd);
|
|
@@ -135,7 +165,7 @@ export function registerDataFactoryCommand(program: any): void {
|
|
|
135
165
|
for (const name of names) {
|
|
136
166
|
const map = loadFieldMap(cwd, name);
|
|
137
167
|
if (!map) { console.error(`✗ ${name}: no field-map`); totalErrors++; continue; }
|
|
138
|
-
const { issues } = lintFieldMap(map, catalog);
|
|
168
|
+
const { issues } = lintFieldMap(map, catalog, ctx);
|
|
139
169
|
const s = summarize(issues);
|
|
140
170
|
totalErrors += s.errors;
|
|
141
171
|
const badge = s.errors ? '✗' : s.warns ? '⚠' : '✓';
|
|
@@ -152,9 +182,15 @@ export function registerDataFactoryCommand(program: any): void {
|
|
|
152
182
|
.command('validate')
|
|
153
183
|
.description('Verify every generated value is semantically correct (valid values satisfy the constraints; synthesized invalid values violate them)')
|
|
154
184
|
.option('--screen <name>', 'Validate one unit (default: all field-maps)')
|
|
155
|
-
.
|
|
185
|
+
.option('--profile <name>', `Case selection: ${PROFILES.join(' | ')} (default: regression = all)`)
|
|
186
|
+
.option('--locale <tag>', 'Selection context locale (e.g. ja-JP)')
|
|
187
|
+
.option('--channel <api|ui>', 'Selection context channel')
|
|
188
|
+
.option('--sink <name>', 'Selection context output sink')
|
|
189
|
+
.action((opts: { screen?: string; profile?: string; locale?: string; channel?: string; sink?: string }) => {
|
|
156
190
|
const cwd = process.cwd();
|
|
157
191
|
ensureDataFactoryReadme(cwd);
|
|
192
|
+
let ctx: SelectionContext;
|
|
193
|
+
try { ctx = ctxFromOpts(opts); } catch (e: any) { console.error(e.message); process.exitCode = 1; return; }
|
|
158
194
|
const names = opts.screen ? [opts.screen] : listFieldMaps(cwd);
|
|
159
195
|
if (names.length === 0) { console.error('No field-maps under qa/data-factory/*.fields.yaml.'); process.exitCode = 1; return; }
|
|
160
196
|
const { catalog } = loadEffectiveCatalog(cwd);
|
|
@@ -162,7 +198,7 @@ export function registerDataFactoryCommand(program: any): void {
|
|
|
162
198
|
for (const name of names) {
|
|
163
199
|
const map = loadFieldMap(cwd, name);
|
|
164
200
|
if (!map) { console.error(`✗ ${name}: no field-map`); totalErrors++; continue; }
|
|
165
|
-
const findings = validateGenerated(map, catalog);
|
|
201
|
+
const findings = validateGenerated(map, catalog, ctx);
|
|
166
202
|
const errors = findings.filter((f) => f.severity === 'error').length;
|
|
167
203
|
const warns = findings.filter((f) => f.severity === 'warn').length;
|
|
168
204
|
totalErrors += errors;
|
package/src/generator.ts
CHANGED
|
@@ -9,8 +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
13
|
import { expandBoundaryHint, satisfiesConstraints, type Validity } from './boundary';
|
|
14
|
+
import { evaluateWhen, fieldFacts, inProfile } from './applicability';
|
|
14
15
|
|
|
15
16
|
export interface FieldDef {
|
|
16
17
|
name: string;
|
|
@@ -73,6 +74,8 @@ export interface ExpandedPoint {
|
|
|
73
74
|
synthesized?: boolean;
|
|
74
75
|
/** Provenance: `overwrite` when the project's `.overwrite` replaced/added this point (else shipped). */
|
|
75
76
|
source?: 'shipped' | 'overwrite';
|
|
77
|
+
/** Bucket: `adversarial` = a security/injection payload (rendered separately from plain invalid). */
|
|
78
|
+
category?: 'adversarial';
|
|
76
79
|
}
|
|
77
80
|
|
|
78
81
|
export interface ExpandedField {
|
|
@@ -84,6 +87,8 @@ export interface ExpandedField {
|
|
|
84
87
|
children?: ExpandedField[];
|
|
85
88
|
/** Recorded inter-field dependency (metadata only — not resolved deterministically). */
|
|
86
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 }>;
|
|
87
92
|
}
|
|
88
93
|
|
|
89
94
|
/**
|
|
@@ -135,7 +140,7 @@ function makeUnique(value: string, type: string): string {
|
|
|
135
140
|
}
|
|
136
141
|
|
|
137
142
|
/** Expand one field into valid/boundary/invalid data points using the effective catalog. */
|
|
138
|
-
export function expandField(field: FieldDef, catalog: Catalog): ExpandedField {
|
|
143
|
+
export function expandField(field: FieldDef, catalog: Catalog, ctx: SelectionContext = {}): ExpandedField {
|
|
139
144
|
// Group / nested field: no own type — expand each sub-field recursively.
|
|
140
145
|
if (field.fields && field.fields.length) {
|
|
141
146
|
return {
|
|
@@ -143,7 +148,7 @@ export function expandField(field: FieldDef, catalog: Catalog): ExpandedField {
|
|
|
143
148
|
type: undefined,
|
|
144
149
|
blindSpot: false,
|
|
145
150
|
points: [],
|
|
146
|
-
children: field.fields.map((sub) => expandField(sub, catalog)),
|
|
151
|
+
children: field.fields.map((sub) => expandField(sub, catalog, ctx)),
|
|
147
152
|
...(field.dependsOn ? { dependsOn: field.dependsOn } : {}),
|
|
148
153
|
};
|
|
149
154
|
}
|
|
@@ -153,6 +158,16 @@ export function expandField(field: FieldDef, catalog: Catalog): ExpandedField {
|
|
|
153
158
|
const c = { ...(ft.constraints || {}), ...(field.constraints || {}) };
|
|
154
159
|
const type = field.type as string;
|
|
155
160
|
const points: ExpandedPoint[] = [];
|
|
161
|
+
const excluded: Array<{ id: string; reason: string }> = [];
|
|
162
|
+
const facts = fieldFacts(field, c);
|
|
163
|
+
|
|
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
|
+
};
|
|
156
171
|
|
|
157
172
|
// Default validity for a group: the happy path is valid; the invalid group is invalid; a boundary
|
|
158
173
|
// point's validity is decided per-point by the oracle (in/out of range).
|
|
@@ -195,6 +210,7 @@ export function expandField(field: FieldDef, catalog: Catalog): ExpandedField {
|
|
|
195
210
|
};
|
|
196
211
|
|
|
197
212
|
const push = (dp: DataPoint, group: ExpandedPoint['group']) => {
|
|
213
|
+
if (!admit(dp, group)) return; // not applicable / out of profile
|
|
198
214
|
// Literal value in the catalog/overwrite → one point (oracle still judges its validity).
|
|
199
215
|
if (dp.value != null) return pushLiteral(dp, group, dp.value);
|
|
200
216
|
|
|
@@ -229,11 +245,12 @@ export function expandField(field: FieldDef, catalog: Catalog): ExpandedField {
|
|
|
229
245
|
push(dp, 'invalid');
|
|
230
246
|
}
|
|
231
247
|
|
|
232
|
-
// Append security payloads referenced by uses_security.
|
|
248
|
+
// Append security payloads referenced by uses_security — the `adversarial` category (own bucket).
|
|
233
249
|
for (const bank of ft.uses_security || []) {
|
|
234
250
|
const b = catalog.security[bank];
|
|
235
251
|
if (!b) continue;
|
|
236
252
|
for (const p of b.payloads || []) {
|
|
253
|
+
if (!admit(p as DataPoint, 'invalid', 'adversarial')) continue;
|
|
237
254
|
points.push({
|
|
238
255
|
id: p.id,
|
|
239
256
|
group: 'invalid',
|
|
@@ -243,16 +260,24 @@ export function expandField(field: FieldDef, catalog: Catalog): ExpandedField {
|
|
|
243
260
|
note: b.oracle,
|
|
244
261
|
// Adversarial payloads are treated as invalid input (must be rejected/escaped, never accepted).
|
|
245
262
|
expectedValidity: 'invalid',
|
|
263
|
+
category: 'adversarial',
|
|
246
264
|
});
|
|
247
265
|
}
|
|
248
266
|
}
|
|
249
267
|
|
|
250
|
-
return {
|
|
268
|
+
return {
|
|
269
|
+
name: field.name,
|
|
270
|
+
type: field.type,
|
|
271
|
+
blindSpot: false,
|
|
272
|
+
points,
|
|
273
|
+
...(field.dependsOn ? { dependsOn: field.dependsOn } : {}),
|
|
274
|
+
...(excluded.length ? { excluded } : {}),
|
|
275
|
+
};
|
|
251
276
|
}
|
|
252
277
|
|
|
253
278
|
/** Expand a whole field-map. */
|
|
254
|
-
export function expandFieldMap(map: FieldMap, catalog: Catalog): ExpandedField[] {
|
|
255
|
-
return (map.fields || []).map((f) => expandField(f, catalog));
|
|
279
|
+
export function expandFieldMap(map: FieldMap, catalog: Catalog, ctx: SelectionContext = {}): ExpandedField[] {
|
|
280
|
+
return (map.fields || []).map((f) => expandField(f, catalog, ctx));
|
|
256
281
|
}
|
|
257
282
|
|
|
258
283
|
/**
|
|
@@ -280,13 +305,18 @@ function renderOne(f: ExpandedField): Record<string, any> {
|
|
|
280
305
|
}
|
|
281
306
|
const valid = f.points.filter((p) => p.group === 'valid');
|
|
282
307
|
const boundary = f.points.filter((p) => p.group === 'boundary');
|
|
283
|
-
|
|
284
|
-
|
|
308
|
+
// Plain invalid excludes the adversarial (security) cases — those get their own bucket.
|
|
309
|
+
const invalid = f.points.filter((p) => p.group === 'invalid' && p.category !== 'adversarial');
|
|
310
|
+
const adversarial = f.points.filter((p) => p.category === 'adversarial');
|
|
311
|
+
const row: Record<string, any> = {
|
|
285
312
|
...dep,
|
|
286
313
|
valid: valid[0]?.value ?? null,
|
|
287
314
|
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
315
|
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 })),
|
|
289
316
|
};
|
|
317
|
+
if (adversarial.length) row.adversarial = adversarial.map((p) => trim({ id: p.id, value: p.value, reason: p.reason, note: p.note, todo: p.todo }));
|
|
318
|
+
if (f.excluded?.length) row._not_applicable = f.excluded.map((e) => `${e.id}: ${e.reason}`);
|
|
319
|
+
return row;
|
|
290
320
|
}
|
|
291
321
|
|
|
292
322
|
function trim(o: Record<string, any>): Record<string, any> {
|
package/src/index.ts
CHANGED
|
@@ -27,12 +27,13 @@ export { validateGenerated, validatePoints } from './validate';
|
|
|
27
27
|
export type { ValidationFinding } from './validate';
|
|
28
28
|
export { expandBoundaryHint, satisfiesConstraints } from './boundary';
|
|
29
29
|
export type { Validity, BoundaryPoint } from './boundary';
|
|
30
|
+
export { evaluateWhen, inProfile, fieldFacts } from './applicability';
|
|
30
31
|
export { registerDataFactoryCommand } from './cli';
|
|
31
32
|
export { generateFixture } from './fixture';
|
|
32
33
|
export type { FixtureResult } from './fixture';
|
|
33
34
|
export { dataFactoryCoverageSensor } from './sensor';
|
|
34
35
|
export { dfPreconditionCodegen } from './precondition';
|
|
35
|
-
export type { Catalog, FieldType, SecurityBank, Technique, DataPoint, OverwriteWarning, CatalogProvenance, FieldTypeProvenance } from './types';
|
|
36
|
+
export type { Catalog, FieldType, SecurityBank, Technique, DataPoint, OverwriteWarning, CatalogProvenance, FieldTypeProvenance, SelectionContext, Profile } from './types';
|
|
36
37
|
|
|
37
38
|
/** Register the Data Factory capability. */
|
|
38
39
|
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/types.ts
CHANGED
|
@@ -30,6 +30,21 @@ export interface DataPoint {
|
|
|
30
30
|
* value so a reader can answer "why is this value here?". Not authored by hand.
|
|
31
31
|
*/
|
|
32
32
|
source?: 'shipped' | 'overwrite';
|
|
33
|
+
/**
|
|
34
|
+
* Applicability conditions (M2b). The point is materialized only when ALL conditions hold for the
|
|
35
|
+
* field + selection context. Field-derived keys are always enforced; environment keys only filter
|
|
36
|
+
* when the context supplies them (a missing context value = "no filter", so the point is kept):
|
|
37
|
+
* - `required: true` — applies only to a REQUIRED field (dropped when the field is explicitly optional).
|
|
38
|
+
* - `negativesAllowed: true` — applies only when the numeric field allows negatives (min < 0 / no min).
|
|
39
|
+
* - `locale: <prefix>` — applies only when ctx.locale starts with the prefix (e.g. `ja`).
|
|
40
|
+
* - `channel: api|ui` / `sink: html|text|…` — applies only when ctx.channel/ctx.sink matches.
|
|
41
|
+
*/
|
|
42
|
+
when?: Record<string, unknown>;
|
|
43
|
+
/**
|
|
44
|
+
* Bucket for output + profile selection. `adversarial` = a security/injection payload (kept out of
|
|
45
|
+
* the plain `invalid` bucket so a QA sees attack cases separately). Default (unset) = functional.
|
|
46
|
+
*/
|
|
47
|
+
category?: 'adversarial';
|
|
33
48
|
}
|
|
34
49
|
|
|
35
50
|
export interface FieldType {
|
|
@@ -66,6 +81,21 @@ export interface Catalog {
|
|
|
66
81
|
techniques: Record<string, Technique>;
|
|
67
82
|
}
|
|
68
83
|
|
|
84
|
+
/** Named case-selection profiles (which groups/categories a run wants). */
|
|
85
|
+
export type Profile = 'regression' | 'functional' | 'smoke' | 'security-min';
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The selection context Core (or a CLI flag) passes to the generator to pick applicable cases.
|
|
89
|
+
* All fields optional — an absent field means "no filter on that axis" (permissive default).
|
|
90
|
+
*/
|
|
91
|
+
export interface SelectionContext {
|
|
92
|
+
locale?: string;
|
|
93
|
+
channel?: 'api' | 'ui';
|
|
94
|
+
sink?: string;
|
|
95
|
+
/** `regression` (default) = every case; see Profile for the reduced sets. */
|
|
96
|
+
profile?: Profile;
|
|
97
|
+
}
|
|
98
|
+
|
|
69
99
|
/** A warning that a project's common.overwrite.yaml entry looks stale against the shipped common. */
|
|
70
100
|
export interface OverwriteWarning {
|
|
71
101
|
fieldType: string;
|
package/src/validate.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* (see docs/spec/data-factory-quality-roadmap.md) calls; today it powers the golden tests and the
|
|
9
9
|
* `sungen data validate` CLI. Pure + deterministic — no AI, no runtime.
|
|
10
10
|
*/
|
|
11
|
-
import type { Catalog } from './types';
|
|
11
|
+
import type { Catalog, SelectionContext } from './types';
|
|
12
12
|
import { expandFieldMap, type FieldMap, type FieldDef, type ExpandedField, type ExpandedPoint } from './generator';
|
|
13
13
|
import { satisfiesConstraints } from './boundary';
|
|
14
14
|
|
|
@@ -59,8 +59,8 @@ function describe(type: string, c: Record<string, any>): string {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
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);
|
|
62
|
+
export function validateGenerated(map: FieldMap, catalog: Catalog, ctx: SelectionContext = {}): ValidationFinding[] {
|
|
63
|
+
const expanded = expandFieldMap(map, catalog, ctx);
|
|
64
64
|
const out: ValidationFinding[] = [];
|
|
65
65
|
const walk = (efs: ExpandedField[], defs: FieldDef[], prefix: string) => {
|
|
66
66
|
for (const ef of efs) {
|