@sungen/driver-data-factory 3.2.12-beta.1 → 3.2.12-beta.11

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