@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.
@@ -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.4",
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.4",
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
- .action(async (opts: { screen?: string; all?: boolean; yes?: boolean; force?: boolean }) => {
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
- if (opts.screen) { await genOne(cwd, opts.screen, opts); return; }
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
- .action((opts: { screen?: string }) => {
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
- .action((opts: { screen?: string }) => {
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 { name: field.name, type: field.type, blindSpot: false, points, ...(field.dependsOn ? { dependsOn: field.dependsOn } : {}) };
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
- const invalid = f.points.filter((p) => p.group === 'invalid');
284
- return {
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) {