@sungen/driver-data-factory 3.2.12-beta.3 → 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.3",
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.3",
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
+ }
@@ -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, 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 {
@@ -52,27 +52,63 @@ export function loadShippedCatalog(root = commonDir()): Catalog {
52
52
  return { fieldTypes, security, techniques };
53
53
  }
54
54
 
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[] {
55
+ /**
56
+ * Merge a field-type group by DataPoint.id, honoring three explicit operations:
57
+ * - EXTEND: an overlay id not in `base` is appended (`added`).
58
+ * - REPLACE: an overlay id already in `base` wins (`replaced`).
59
+ * - DISABLE: an id in `disableSet` is dropped (`disabled`).
60
+ * Records provenance and stamps each point's `source` so a reader can trace where it came from.
61
+ */
62
+ function mergeGroup(
63
+ base: DataPoint[] = [],
64
+ overlay: DataPoint[] = [],
65
+ disableSet: Set<string>,
66
+ prov: FieldTypeProvenance,
67
+ ): DataPoint[] {
57
68
  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 });
69
+ for (const dp of base) byId.set(dp.id, { ...dp, source: 'shipped' });
70
+ for (const dp of overlay) {
71
+ if (byId.has(dp.id)) prov.replaced.push(dp.id);
72
+ else prov.added.push(dp.id);
73
+ byId.set(dp.id, { ...byId.get(dp.id), ...dp, source: 'overwrite' });
74
+ }
75
+ for (const id of disableSet) {
76
+ if (byId.delete(id)) prov.disabled.push(id);
77
+ }
60
78
  return [...byId.values()];
61
79
  }
62
80
 
63
- function mergeFieldType(base: FieldType, overlay: Partial<FieldType>): FieldType {
81
+ /** An overlay field-type entry may carry a `disable:` list of ids alongside partial field-type data. */
82
+ type FieldTypeOverlay = Partial<FieldType> & { disable?: string[] };
83
+
84
+ function mergeFieldType(base: FieldType, overlay: FieldTypeOverlay, prov: FieldTypeProvenance): FieldType {
85
+ const disableSet = new Set(overlay.disable || []);
86
+ const constraintsOverridden = Object.keys(overlay.constraints || {}).filter(
87
+ (k) => (base.constraints || {})[k] !== (overlay.constraints || {})[k],
88
+ );
89
+ prov.constraintsOverridden = constraintsOverridden;
90
+ prov.securityChanged = overlay.uses_security != null;
91
+ const { disable: _disable, ...rest } = overlay;
64
92
  return {
65
93
  ...base,
66
- ...overlay,
94
+ ...rest,
67
95
  constraints: { ...(base.constraints || {}), ...(overlay.constraints || {}) },
68
96
  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),
97
+ valid: mergeGroup(base.valid, overlay.valid, disableSet, prov),
98
+ boundary: mergeGroup(base.boundary, overlay.boundary, disableSet, prov),
99
+ invalid: mergeGroup(base.invalid, overlay.invalid, disableSet, prov),
72
100
  notes: [...(base.notes || []), ...(overlay.notes || [])],
73
101
  };
74
102
  }
75
103
 
104
+ /** Merge a security bank by payload id (same rules: replace same-id, append new). */
105
+ function mergeSecurity(base: SecurityBank, overlay: Partial<SecurityBank>): SecurityBank {
106
+ const byId = new Map<string, any>();
107
+ for (const p of base.payloads || []) byId.set(p.id, p);
108
+ for (const p of overlay.payloads || []) byId.set(p.id, { ...byId.get(p.id), ...p });
109
+ return { ...base, ...overlay, payloads: [...byId.values()] };
110
+ }
111
+
76
112
  /**
77
113
  * Load the effective catalog = shipped common + project overwrite overlay.
78
114
  * `projectDir` is the QA repo root; overlay is read from `qa/data-factory/common.overwrite.yaml`.
@@ -80,22 +116,32 @@ function mergeFieldType(base: FieldType, overlay: Partial<FieldType>): FieldType
80
116
  export function loadEffectiveCatalog(projectDir: string, root = commonDir()): {
81
117
  catalog: Catalog;
82
118
  warnings: OverwriteWarning[];
119
+ provenance: CatalogProvenance;
83
120
  } {
84
121
  const shipped = loadShippedCatalog(root);
85
122
  const overlayPath = join(projectDir, 'qa', 'data-factory', 'common.overwrite.yaml');
86
123
  const warnings: OverwriteWarning[] = [];
87
- if (!existsSync(overlayPath)) return { catalog: shipped, warnings };
124
+ const provenance: CatalogProvenance = { fieldTypes: {}, newFieldTypes: [], security: { added: [], replaced: [] }, techniques: { added: [], replaced: [] } };
125
+ if (!existsSync(overlayPath)) return { catalog: shipped, warnings, provenance };
88
126
 
89
127
  const overlay = parseYaml(readFileSync(overlayPath, 'utf8')) || {};
90
- const overlayFieldTypes: Record<string, Partial<FieldType>> = overlay.fieldTypes || overlay['field-types'] || {};
128
+ const overlayFieldTypes: Record<string, FieldTypeOverlay> = overlay.fieldTypes || overlay['field-types'] || {};
91
129
  const merged: Catalog = {
92
130
  fieldTypes: { ...shipped.fieldTypes },
93
131
  security: { ...shipped.security },
94
132
  techniques: { ...shipped.techniques },
95
133
  };
134
+
96
135
  for (const [type, ov] of Object.entries(overlayFieldTypes)) {
97
136
  if (merged.fieldTypes[type]) {
98
- merged.fieldTypes[type] = mergeFieldType(merged.fieldTypes[type], ov);
137
+ const prov: FieldTypeProvenance = { type, disabled: [], replaced: [], added: [], constraintsOverridden: [], securityChanged: false };
138
+ // Warn on a disable target that does not exist in the shipped field-type (likely a typo/stale id).
139
+ const known = new Set([...(merged.fieldTypes[type].valid || []), ...(merged.fieldTypes[type].boundary || []), ...(merged.fieldTypes[type].invalid || [])].map((d) => d.id));
140
+ for (const id of ov.disable || []) {
141
+ 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` });
142
+ }
143
+ merged.fieldTypes[type] = mergeFieldType(merged.fieldTypes[type], ov, prov);
144
+ provenance.fieldTypes[type] = prov;
99
145
  // Drift warning: overlay references a security bank the shipped common no longer has.
100
146
  for (const bank of ov.uses_security || []) {
101
147
  if (!merged.security[bank]) {
@@ -108,10 +154,45 @@ export function loadEffectiveCatalog(projectDir: string, root = commonDir()): {
108
154
  }
109
155
  } else {
110
156
  // Wholly new field-type from the user — accept as-is (project-specific type).
111
- merged.fieldTypes[type] = ov as FieldType;
157
+ const { disable: _d, ...rest } = ov;
158
+ merged.fieldTypes[type] = rest as FieldType;
159
+ provenance.newFieldTypes.push(type);
112
160
  }
113
161
  }
114
- return { catalog: merged, warnings };
162
+
163
+ // Security-bank overrides (`security:` in the overwrite) — replace same-id payloads, append new banks.
164
+ const overlaySecurity: Record<string, Partial<SecurityBank>> = overlay.security || {};
165
+ for (const [cls, ov] of Object.entries(overlaySecurity)) {
166
+ if (merged.security[cls]) { merged.security[cls] = mergeSecurity(merged.security[cls], ov); provenance.security.replaced.push(cls); }
167
+ else { merged.security[cls] = { class: cls, payloads: [], ...ov } as SecurityBank; provenance.security.added.push(cls); }
168
+ }
169
+
170
+ // Technique overrides (`techniques:` in the overwrite) — shallow replace/add by name.
171
+ const overlayTech: Record<string, Partial<Technique>> = overlay.techniques || {};
172
+ for (const [name, ov] of Object.entries(overlayTech)) {
173
+ if (merged.techniques[name]) { merged.techniques[name] = { ...merged.techniques[name], ...ov }; provenance.techniques.replaced.push(name); }
174
+ else { merged.techniques[name] = { technique: name, ...ov } as Technique; provenance.techniques.added.push(name); }
175
+ }
176
+
177
+ return { catalog: merged, warnings, provenance };
178
+ }
179
+
180
+ /** Human-readable one-liners describing what `.overwrite` changed (for lint/sensor "no silent overwrite"). */
181
+ export function describeProvenance(p: CatalogProvenance): string[] {
182
+ const lines: string[] = [];
183
+ for (const ft of Object.values(p.fieldTypes)) {
184
+ const parts: string[] = [];
185
+ if (ft.disabled.length) parts.push(`disabled ${ft.disabled.join(', ')}`);
186
+ if (ft.replaced.length) parts.push(`replaced ${ft.replaced.join(', ')}`);
187
+ if (ft.added.length) parts.push(`added ${ft.added.join(', ')}`);
188
+ if (ft.constraintsOverridden.length) parts.push(`constraints[${ft.constraintsOverridden.join(', ')}]`);
189
+ if (ft.securityChanged) parts.push('uses_security replaced');
190
+ if (parts.length) lines.push(`${ft.type}: ${parts.join('; ')}`);
191
+ }
192
+ for (const t of p.newFieldTypes) lines.push(`${t}: new project field-type`);
193
+ 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('; ')}`);
194
+ 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('; ')}`);
195
+ return lines;
115
196
  }
116
197
 
117
198
  /** Stable hash of the shipped common — recorded in df-manifest.json to detect update drift. */
package/src/cli.ts CHANGED
@@ -10,14 +10,30 @@ import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from
10
10
  import { join, dirname } from 'path';
11
11
  import { createInterface } from 'readline';
12
12
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
13
- import { loadEffectiveCatalog } from './catalog-loader';
13
+ import { loadEffectiveCatalog, describeProvenance } from './catalog-loader';
14
14
  import { expandFieldMap, renderTestData, type FieldMap } from './generator';
15
15
  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,13 +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
- const { catalog, warnings } = loadEffectiveCatalog(cwd);
70
+ const { catalog, warnings, provenance } = loadEffectiveCatalog(cwd);
55
71
  for (const w of warnings) console.warn(` ⚠ overwrite: ${w.detail} → ${w.suggestion}`);
72
+ for (const line of describeProvenance(provenance)) console.log(` ⟲ .overwrite → ${line}`);
56
73
 
57
- 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)`);
58
77
  const out = testDataPath(cwd, name);
59
78
  if (!out) { console.error(` ✗ ${name}: no qa/screens/${name} or qa/flows/${name}`); return 'missing'; }
60
79
 
@@ -96,10 +115,16 @@ export function registerDataFactoryCommand(program: any): void {
96
115
  .option('--all', 'Generate for every field-map under qa/data-factory/ (requires confirm)')
97
116
  .option('--yes', 'Assume yes for the --all confirmation')
98
117
  .option('--force', 'Overwrite existing test-data without asking (implies --yes)')
99
- .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 }) => {
100
123
  const cwd = process.cwd();
101
124
  ensureDataFactoryReadme(cwd);
102
- 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; }
103
128
  if (!opts.all) {
104
129
  console.error('Specify --screen <name> for one unit, or --all for every unit (with confirmation).');
105
130
  process.exitCode = 1;
@@ -116,7 +141,7 @@ export function registerDataFactoryCommand(program: any): void {
116
141
  if (a !== 'y' && a !== 'yes') { console.log('Cancelled.'); return; }
117
142
  }
118
143
  let written = 0;
119
- 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++;
120
145
  console.log(`Done: ${written}/${names.length} written.`);
121
146
  });
122
147
 
@@ -124,9 +149,15 @@ export function registerDataFactoryCommand(program: any): void {
124
149
  .command('lint')
125
150
  .description('Check test-data coverage (valid/boundary/invalid + traceability) per phuong-phap §6')
126
151
  .option('--screen <name>', 'Lint one unit (default: all field-maps)')
127
- .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 }) => {
128
157
  const cwd = process.cwd();
129
158
  ensureDataFactoryReadme(cwd);
159
+ let ctx: SelectionContext;
160
+ try { ctx = ctxFromOpts(opts); } catch (e: any) { console.error(e.message); process.exitCode = 1; return; }
130
161
  const names = opts.screen ? [opts.screen] : listFieldMaps(cwd);
131
162
  if (names.length === 0) { console.error('No field-maps under qa/data-factory/*.fields.yaml.'); process.exitCode = 1; return; }
132
163
  const { catalog } = loadEffectiveCatalog(cwd);
@@ -134,7 +165,7 @@ export function registerDataFactoryCommand(program: any): void {
134
165
  for (const name of names) {
135
166
  const map = loadFieldMap(cwd, name);
136
167
  if (!map) { console.error(`✗ ${name}: no field-map`); totalErrors++; continue; }
137
- const { issues } = lintFieldMap(map, catalog);
168
+ const { issues } = lintFieldMap(map, catalog, ctx);
138
169
  const s = summarize(issues);
139
170
  totalErrors += s.errors;
140
171
  const badge = s.errors ? '✗' : s.warns ? '⚠' : '✓';
@@ -151,9 +182,15 @@ export function registerDataFactoryCommand(program: any): void {
151
182
  .command('validate')
152
183
  .description('Verify every generated value is semantically correct (valid values satisfy the constraints; synthesized invalid values violate them)')
153
184
  .option('--screen <name>', 'Validate one unit (default: all field-maps)')
154
- .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 }) => {
155
190
  const cwd = process.cwd();
156
191
  ensureDataFactoryReadme(cwd);
192
+ let ctx: SelectionContext;
193
+ try { ctx = ctxFromOpts(opts); } catch (e: any) { console.error(e.message); process.exitCode = 1; return; }
157
194
  const names = opts.screen ? [opts.screen] : listFieldMaps(cwd);
158
195
  if (names.length === 0) { console.error('No field-maps under qa/data-factory/*.fields.yaml.'); process.exitCode = 1; return; }
159
196
  const { catalog } = loadEffectiveCatalog(cwd);
@@ -161,7 +198,7 @@ export function registerDataFactoryCommand(program: any): void {
161
198
  for (const name of names) {
162
199
  const map = loadFieldMap(cwd, name);
163
200
  if (!map) { console.error(`✗ ${name}: no field-map`); totalErrors++; continue; }
164
- const findings = validateGenerated(map, catalog);
201
+ const findings = validateGenerated(map, catalog, ctx);
165
202
  const errors = findings.filter((f) => f.severity === 'error').length;
166
203
  const warns = findings.filter((f) => f.severity === 'warn').length;
167
204
  totalErrors += errors;
@@ -174,6 +211,23 @@ export function registerDataFactoryCommand(program: any): void {
174
211
  if (totalErrors) process.exitCode = 1;
175
212
  });
176
213
 
214
+ data
215
+ .command('provenance')
216
+ .description('Show what qa/data-factory/common.overwrite.yaml changed vs the shipped company catalog (extend/replace/disable) — "no silent overwrite"')
217
+ .action(() => {
218
+ const cwd = process.cwd();
219
+ ensureDataFactoryReadme(cwd);
220
+ const { provenance, warnings } = loadEffectiveCatalog(cwd);
221
+ const lines = describeProvenance(provenance);
222
+ if (lines.length === 0) {
223
+ console.log('No project overrides — the effective catalog is the shipped company common as-is.');
224
+ } else {
225
+ console.log('Project overrides (qa/data-factory/common.overwrite.yaml):');
226
+ for (const l of lines) console.log(` ⟲ ${l}`);
227
+ }
228
+ for (const w of warnings) console.warn(` ⚠ ${w.detail} → ${w.suggestion}`);
229
+ });
230
+
177
231
  data
178
232
  .command('fixture')
179
233
  .description('Generate a CSV import fixture from the field-map (bulk-import / file-upload testing)')
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;
@@ -71,6 +72,10 @@ export interface ExpandedPoint {
71
72
  * literal charset/format invalid (or a security payload) legitimately satisfies the length oracle.
72
73
  */
73
74
  synthesized?: boolean;
75
+ /** Provenance: `overwrite` when the project's `.overwrite` replaced/added this point (else shipped). */
76
+ source?: 'shipped' | 'overwrite';
77
+ /** Bucket: `adversarial` = a security/injection payload (rendered separately from plain invalid). */
78
+ category?: 'adversarial';
74
79
  }
75
80
 
76
81
  export interface ExpandedField {
@@ -82,6 +87,29 @@ export interface ExpandedField {
82
87
  children?: ExpandedField[];
83
88
  /** Recorded inter-field dependency (metadata only — not resolved deterministically). */
84
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 }>;
92
+ }
93
+
94
+ /**
95
+ * Coerce an out-of-range numeric VALID literal into range, so a project's `valid` representative is
96
+ * actually valid for its constraints (a generic catalog example like `123` under `max: 100` becomes
97
+ * `100`). Returns null when in range already, or the type isn't numeric (non-numeric mismatches are
98
+ * left for the validator to flag — the field-map should supply a fitting value).
99
+ */
100
+ function coerceValidNumeric(type: string, value: string, c: Record<string, any>): string | null {
101
+ if (!['number-integer', 'number-float', 'currency'].includes(type)) return null;
102
+ const n = Number(value);
103
+ if (!Number.isFinite(n)) return null;
104
+ let out = n;
105
+ if (Number.isFinite(c.min) && out < c.min) out = c.min;
106
+ if (Number.isFinite(c.max) && out > c.max) out = c.max;
107
+ if (out === n) return null; // already in range
108
+ if (type === 'number-float' || (type === 'currency' && c.decimalPlaces > 0)) {
109
+ const dp = Number.isFinite(c.decimalPlaces) ? c.decimalPlaces : 2;
110
+ return out.toFixed(dp);
111
+ }
112
+ return String(Math.round(out));
85
113
  }
86
114
 
87
115
  /** A gen hint that references a numeric/length boundary the generator can expand structurally. */
@@ -112,7 +140,7 @@ function makeUnique(value: string, type: string): string {
112
140
  }
113
141
 
114
142
  /** Expand one field into valid/boundary/invalid data points using the effective catalog. */
115
- export function expandField(field: FieldDef, catalog: Catalog): ExpandedField {
143
+ export function expandField(field: FieldDef, catalog: Catalog, ctx: SelectionContext = {}): ExpandedField {
116
144
  // Group / nested field: no own type — expand each sub-field recursively.
117
145
  if (field.fields && field.fields.length) {
118
146
  return {
@@ -120,7 +148,7 @@ export function expandField(field: FieldDef, catalog: Catalog): ExpandedField {
120
148
  type: undefined,
121
149
  blindSpot: false,
122
150
  points: [],
123
- children: field.fields.map((sub) => expandField(sub, catalog)),
151
+ children: field.fields.map((sub) => expandField(sub, catalog, ctx)),
124
152
  ...(field.dependsOn ? { dependsOn: field.dependsOn } : {}),
125
153
  };
126
154
  }
@@ -130,6 +158,16 @@ export function expandField(field: FieldDef, catalog: Catalog): ExpandedField {
130
158
  const c = { ...(ft.constraints || {}), ...(field.constraints || {}) };
131
159
  const type = field.type as string;
132
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
+ };
133
171
 
134
172
  // Default validity for a group: the happy path is valid; the invalid group is invalid; a boundary
135
173
  // point's validity is decided per-point by the oracle (in/out of range).
@@ -138,24 +176,41 @@ export function expandField(field: FieldDef, catalog: Catalog): ExpandedField {
138
176
  const emit = (p: Omit<ExpandedPoint, 'expectedValidity'> & { expectedValidity: Validity }) => points.push(p);
139
177
 
140
178
  const pushLiteral = (dp: DataPoint, group: ExpandedPoint['group'], value: string | null, opts: { validity?: Validity; label?: string; todo?: string } = {}) => {
179
+ // A valid representative must be valid for the project: clamp an out-of-range numeric literal.
180
+ let coerceNote: string | undefined;
181
+ if (group === 'valid' && value != null && satisfiesConstraints(type, value, c) === false) {
182
+ const fixed = coerceValidNumeric(type, value, c);
183
+ if (fixed != null) { value = fixed; coerceNote = 'adjusted to fit constraints'; }
184
+ }
141
185
  const isUniqueValid = group === 'valid' && field.unique && value != null;
142
186
  const v = isUniqueValid ? makeUnique(value!, type) : value;
143
- // Oracle: if the constraints can judge this value, they win; else fall back to declared/group validity.
144
- const oracle = satisfiesConstraints(type, isUniqueValid ? value : v, c);
145
- const validity: Validity = dp.validity ?? (oracle == null ? (opts.validity ?? groupValidity(group)) : oracle ? 'valid' : 'invalid');
187
+ // Expected validity reflects INTENT by group, so the validator (validateGenerated) can catch a
188
+ // mismatch against the oracle instead of the mismatch being silently absorbed here:
189
+ // - valid group → `valid` (a value that actually violates the constraints is a VALUE_NOT_VALID).
190
+ // - invalid group → `invalid` (declared: charset/format/blocklist/behavior — the length oracle
191
+ // cannot "prove" such a literal valid, so it must NOT flip it to valid).
192
+ // - boundary group → the oracle decides (that group is precisely about range).
193
+ let validity: Validity;
194
+ if (dp.validity) validity = dp.validity;
195
+ else if (group === 'boundary') {
196
+ const oracle = satisfiesConstraints(type, isUniqueValid ? value : v, c);
197
+ validity = oracle == null ? (opts.validity ?? 'valid') : oracle ? 'valid' : 'invalid';
198
+ } else validity = opts.validity ?? groupValidity(group);
146
199
  emit({
147
200
  id: dp.id,
148
201
  group,
149
202
  value: v,
150
203
  todo: opts.todo,
151
204
  reason: dp.reason,
152
- note: isUniqueValid ? `${dp.note ? dp.note + ' · ' : ''}fresh per run` : (opts.label ? `${dp.note ? dp.note + ' · ' : ''}${opts.label}` : dp.note),
205
+ note: isUniqueValid ? `${dp.note ? dp.note + ' · ' : ''}fresh per run` : coerceNote ? `${dp.note ? dp.note + ' · ' : ''}${coerceNote}` : (opts.label ? `${dp.note ? dp.note + ' · ' : ''}${opts.label}` : dp.note),
153
206
  expectedValidity: validity,
207
+ ...(dp.source === 'overwrite' ? { source: 'overwrite' as const } : {}),
154
208
  ...resolveError(dp, field.errorMap),
155
209
  });
156
210
  };
157
211
 
158
212
  const push = (dp: DataPoint, group: ExpandedPoint['group']) => {
213
+ if (!admit(dp, group)) return; // not applicable / out of profile
159
214
  // Literal value in the catalog/overwrite → one point (oracle still judges its validity).
160
215
  if (dp.value != null) return pushLiteral(dp, group, dp.value);
161
216
 
@@ -173,6 +228,7 @@ export function expandField(field: FieldDef, catalog: Catalog): ExpandedField {
173
228
  note: bp.label && !bp.todo ? `${dp.note ? dp.note + ' · ' : ''}${bp.label}` : dp.note,
174
229
  expectedValidity: dp.validity ?? bp.validity,
175
230
  synthesized: bp.value != null,
231
+ ...(dp.source === 'overwrite' ? { source: 'overwrite' as const } : {}),
176
232
  ...resolveError(dp, field.errorMap),
177
233
  });
178
234
  }
@@ -189,11 +245,12 @@ export function expandField(field: FieldDef, catalog: Catalog): ExpandedField {
189
245
  push(dp, 'invalid');
190
246
  }
191
247
 
192
- // Append security payloads referenced by uses_security.
248
+ // Append security payloads referenced by uses_security — the `adversarial` category (own bucket).
193
249
  for (const bank of ft.uses_security || []) {
194
250
  const b = catalog.security[bank];
195
251
  if (!b) continue;
196
252
  for (const p of b.payloads || []) {
253
+ if (!admit(p as DataPoint, 'invalid', 'adversarial')) continue;
197
254
  points.push({
198
255
  id: p.id,
199
256
  group: 'invalid',
@@ -203,16 +260,24 @@ export function expandField(field: FieldDef, catalog: Catalog): ExpandedField {
203
260
  note: b.oracle,
204
261
  // Adversarial payloads are treated as invalid input (must be rejected/escaped, never accepted).
205
262
  expectedValidity: 'invalid',
263
+ category: 'adversarial',
206
264
  });
207
265
  }
208
266
  }
209
267
 
210
- 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
+ };
211
276
  }
212
277
 
213
278
  /** Expand a whole field-map. */
214
- export function expandFieldMap(map: FieldMap, catalog: Catalog): ExpandedField[] {
215
- 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));
216
281
  }
217
282
 
218
283
  /**
@@ -240,13 +305,18 @@ function renderOne(f: ExpandedField): Record<string, any> {
240
305
  }
241
306
  const valid = f.points.filter((p) => p.group === 'valid');
242
307
  const boundary = f.points.filter((p) => p.group === 'boundary');
243
- const invalid = f.points.filter((p) => p.group === 'invalid');
244
- 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> = {
245
312
  ...dep,
246
313
  valid: valid[0]?.value ?? null,
247
- boundary: boundary.map((p) => trim({ id: p.id, value: p.value, expected: p.expectedValidity, note: p.note, todo: p.todo })),
248
- invalid: invalid.map((p) => trim({ id: p.id, value: p.value, expected: p.expectedValidity, expected_error: p.expectedError, open: p.open, reason: p.reason, todo: p.todo })),
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 })),
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 })),
249
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;
250
320
  }
251
321
 
252
322
  function trim(o: Record<string, any>): Record<string, any> {
package/src/index.ts CHANGED
@@ -18,7 +18,7 @@ import { registerDataFactoryCommand } from './cli';
18
18
  import { dataFactoryCoverageSensor } from './sensor';
19
19
  import { dfPreconditionCodegen } from './precondition';
20
20
 
21
- export { loadShippedCatalog, loadEffectiveCatalog, shippedCommonHash, commonDir } from './catalog-loader';
21
+ export { loadShippedCatalog, loadEffectiveCatalog, shippedCommonHash, commonDir, describeProvenance } from './catalog-loader';
22
22
  export { expandField, expandFieldMap, renderTestData } from './generator';
23
23
  export type { FieldDef, FieldMap, ExpandedField, ExpandedPoint } from './generator';
24
24
  export { lintFieldMap, summarize } from './lint';
@@ -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 } 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
@@ -24,6 +24,27 @@ export interface DataPoint {
24
24
  * (e.g. a format-invalid value whose length happens to sit inside the range).
25
25
  */
26
26
  validity?: 'valid' | 'invalid';
27
+ /**
28
+ * Where this point ended up coming from after the `.overwrite` merge — `shipped` (company common) or
29
+ * `overwrite` (the project replaced/added it). Set by the loader; carried through to the generated
30
+ * value so a reader can answer "why is this value here?". Not authored by hand.
31
+ */
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';
27
48
  }
28
49
 
29
50
  export interface FieldType {
@@ -60,9 +81,48 @@ export interface Catalog {
60
81
  techniques: Record<string, Technique>;
61
82
  }
62
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
+
63
99
  /** A warning that a project's common.overwrite.yaml entry looks stale against the shipped common. */
64
100
  export interface OverwriteWarning {
65
101
  fieldType: string;
66
102
  detail: string;
67
103
  suggestion: string;
68
104
  }
105
+
106
+ /** What the `.overwrite` layer changed for one field-type (provenance — "no silent overwrite"). */
107
+ export interface FieldTypeProvenance {
108
+ type: string;
109
+ /** Ids removed via `disable:` (an id that was not in the shipped common is reported as a warning). */
110
+ disabled: string[];
111
+ /** Ids that existed in shipped AND the overlay — the overlay value won. */
112
+ replaced: string[];
113
+ /** Ids new from the overlay (a project-specific case). */
114
+ added: string[];
115
+ /** Constraint keys the overlay changed. */
116
+ constraintsOverridden: string[];
117
+ /** True when the overlay replaced `uses_security`. */
118
+ securityChanged: boolean;
119
+ }
120
+
121
+ /** Catalog-wide provenance of the shipped-common → project-overwrite merge. */
122
+ export interface CatalogProvenance {
123
+ fieldTypes: Record<string, FieldTypeProvenance>;
124
+ /** Wholly new field-types added by the project (not in shipped common). */
125
+ newFieldTypes: string[];
126
+ security: { added: string[]; replaced: string[] };
127
+ techniques: { added: string[]; replaced: string[] };
128
+ }
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) {