@sungen/driver-data-factory 3.2.12-beta.10 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sungen/driver-data-factory",
3
- "version": "3.2.12-beta.10",
3
+ "version": "3.2.12-beta.11",
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.10",
17
+ "@sun-asterisk/sungen": "3.2.12-beta.11",
18
18
  "yaml": "^2.4.0"
19
19
  },
20
20
  "files": [
package/src/cli.ts CHANGED
@@ -15,6 +15,7 @@ import { expandFieldMap, renderTestData, type FieldMap } from './generator';
15
15
  import { buildRequest, fingerprint } from './contract';
16
16
  import { loadStateRecipes, validateStateRecipe, buildStatePlan, renderPreconditionBlock, hasDatasource, scaffoldRecipe, renderRecipeYaml } from './state';
17
17
  import { crosscheckUnit, crosscheckShared } from './crosscheck';
18
+ import { coverageUnit } from './coverage';
18
19
  import { detectPreconditionSignals } from './detect';
19
20
  import { lintFieldMap, summarize } from './lint';
20
21
  import { validateGenerated } from './validate';
@@ -238,21 +239,23 @@ export function registerDataFactoryCommand(program: any): void {
238
239
 
239
240
  data
240
241
  .command('crosscheck')
241
- .description('Cross-artifact Gate (M3-B): verify the testcase and its test-data agree — no dangling {{var}}, shared datasets identical across the journey, declared state-preconditions surfaced')
242
+ .description('Cross-artifact + coverage Gate: testcase ↔ data agree (no dangling {{var}}, shared datasets identical) AND the authored data COVERS the required BVA + the login experience checklist')
242
243
  .option('--screen <name>', 'Cross-check one unit (default: all field-maps + all shared datasets)')
243
244
  .action((opts: { screen?: string }) => {
244
245
  const cwd = process.cwd();
245
246
  ensureDataFactoryReadme(cwd);
246
247
  const names = opts.screen ? [opts.screen] : listFieldMaps(cwd);
248
+ const { catalog } = loadEffectiveCatalog(cwd);
247
249
  const findings = [
248
250
  ...names.flatMap((n) => crosscheckUnit(cwd, n)),
249
251
  ...(opts.screen ? [] : crosscheckShared(cwd)),
252
+ ...names.flatMap((n) => coverageUnit(cwd, n, catalog)),
250
253
  ];
251
254
  const errors = findings.filter((f) => f.severity === 'error').length;
252
255
  const warns = findings.filter((f) => f.severity === 'warn').length;
253
256
  const badge = errors ? '✗' : warns ? '⚠' : '✓';
254
257
  console.log(`${badge} crosscheck: ${errors} error · ${warns} warn (${names.length} unit(s))`);
255
- for (const f of findings) console.log(` ${f.severity === 'error' ? '✗' : f.severity === 'warn' ? '⚠' : '·'} [${f.unit}] ${f.code}: ${f.message}`);
258
+ for (const f of findings) console.log(` ${f.severity === 'error' ? '✗' : f.severity === 'warn' ? '⚠' : '·'} [${(f as any).unit}] ${f.code}: ${f.message}`);
256
259
  if (errors) process.exitCode = 1;
257
260
  });
258
261
 
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Data Factory — coverage check (does the AUTHORED test-data actually cover the standardized set?).
3
+ *
4
+ * `data validate` checks the DF-generated field block is internally correct; `data crosscheck` checks
5
+ * testcase ↔ data agree. But `/sungen:create-test` often AUTHORS login/flow test-data by hand (seeded
6
+ * accounts + `@cases`) instead of merging `data gen`'s output — so the deterministic BVA the generator
7
+ * would produce (e.g. email length 4/5/6 for `minLength: 5`) and the experience checklist (soft-deleted
8
+ * login…) can be silently missed. This check reconciles the authored values against BOTH:
9
+ * - the BVA the field-map's constraints REQUIRE (per bounded dimension), and
10
+ * - the auth-account-states experience checklist (for a login/auth unit).
11
+ * It matches fuzzily (authored keys are free-form): length for length dims, numeric value for numeric
12
+ * dims, keyword for experience. Advisory (warn) — a visible nudge the create-test loop must address or
13
+ * the QA waives; never blocks. Pure + deterministic.
14
+ */
15
+ import { readFileSync, existsSync, readdirSync } from 'fs';
16
+ import { join } from 'path';
17
+ import { parse as parseYaml } from 'yaml';
18
+ import type { Catalog } from './types';
19
+ import { expandField, type FieldMap, type FieldDef } from './generator';
20
+
21
+ export interface CoverageFinding {
22
+ unit: string;
23
+ severity: 'warn' | 'info';
24
+ code: 'BVA_UNCOVERED' | 'EXPERIENCE_UNCOVERED';
25
+ field?: string;
26
+ message: string;
27
+ }
28
+
29
+ const STR_LEN_TYPES = new Set(['email', 'string', 'password', 'phone', 'postcode', 'katakana']);
30
+ const NUM_TYPES = new Set(['number-integer', 'number-float', 'currency']);
31
+
32
+ function unitTestDataPath(cwd: string, name: string): string | null {
33
+ for (const kind of ['screens', 'flows']) {
34
+ const p = join(cwd, 'qa', kind, name, 'test-data', `${name}.yaml`);
35
+ if (existsSync(p)) return p;
36
+ }
37
+ return null;
38
+ }
39
+ function unitFeatureText(cwd: string, name: string): string {
40
+ for (const kind of ['screens', 'flows']) {
41
+ const dir = join(cwd, 'qa', kind, name, 'features');
42
+ if (!existsSync(dir)) continue;
43
+ try { return readdirSync(dir).filter((f) => f.endsWith('.feature')).map((f) => readFileSync(join(dir, f), 'utf8')).join('\n'); } catch { return ''; }
44
+ }
45
+ return '';
46
+ }
47
+ function loadYaml(p: string): Record<string, any> {
48
+ if (!existsSync(p)) return {};
49
+ try { return (parseYaml(readFileSync(p, 'utf8')) as Record<string, any>) || {}; } catch { return {}; }
50
+ }
51
+
52
+ /** Every string value anywhere in the test-data (top-level scalars + nested + `@cases` rows). */
53
+ function collectStrings(data: Record<string, any>): string[] {
54
+ const out: string[] = [];
55
+ const walk = (v: unknown) => {
56
+ if (typeof v === 'string') out.push(v);
57
+ else if (Array.isArray(v)) v.forEach(walk);
58
+ else if (v && typeof v === 'object') Object.values(v as Record<string, unknown>).forEach(walk);
59
+ };
60
+ for (const [k, v] of Object.entries(data)) { if (k.startsWith('_')) continue; walk(v); }
61
+ return out;
62
+ }
63
+
64
+ /** Short human label for a boundary point (from its note / id). */
65
+ function pointLabel(p: { id: string; note?: string }): string {
66
+ if (p.note) { const tail = p.note.split(/·|:/).pop()?.trim(); if (tail) return tail; }
67
+ return p.id.replace(/^CHK-[A-Z0-9]+-/, '');
68
+ }
69
+
70
+ /** BVA coverage for one field: which required boundary points are not represented among authored values. */
71
+ function bvaGaps(field: FieldDef, catalog: Catalog, authored: string[]): string[] {
72
+ if (!field.type) return [];
73
+ const ef = expandField(field, catalog);
74
+ const synth = ef.points.filter((p) => p.synthesized && p.value != null);
75
+ if (!synth.length) return [];
76
+ const gaps: string[] = [];
77
+ const seen = new Set<string>();
78
+ if (STR_LEN_TYPES.has(field.type)) {
79
+ // Type-aware filter so an unrelated string of the same LENGTH doesn't falsely "cover" the boundary
80
+ // (e.g. a 5-char URL must not count as covering an email `minLength`-5 case). Email → values with
81
+ // an `@`; katakana → values with kana; others (generic string) stay loose.
82
+ const plausible =
83
+ field.type === 'email' ? authored.filter((s) => s.includes('@'))
84
+ : field.type === 'katakana' ? authored.filter((s) => /[゠-ヿヲ-゚]/.test(s))
85
+ : authored;
86
+ const authoredLens = new Set(plausible.map((s) => s.length));
87
+ for (const p of synth) {
88
+ const len = (p.value as string).length;
89
+ const key = `len:${len}`;
90
+ if (seen.has(key) || authoredLens.has(len)) { seen.add(key); continue; }
91
+ seen.add(key);
92
+ gaps.push(`${pointLabel(p)} (length ${len}, ${p.expectedValidity})`);
93
+ }
94
+ } else if (NUM_TYPES.has(field.type)) {
95
+ const authoredNums = new Set(authored.map((s) => Number(s)).filter((n) => Number.isFinite(n)));
96
+ for (const p of synth) {
97
+ const n = Number(p.value);
98
+ const key = `num:${n}`;
99
+ if (seen.has(key) || authoredNums.has(n)) { seen.add(key); continue; }
100
+ seen.add(key);
101
+ gaps.push(`${pointLabel(p)} (value ${p.value}, ${p.expectedValidity})`);
102
+ }
103
+ }
104
+ return gaps;
105
+ }
106
+
107
+ /** Keyword patterns proving an auth-account-state is covered somewhere in the data/feature. */
108
+ const STATE_COVERED: Record<string, RegExp> = {
109
+ active: /valid|active|success|happy/i,
110
+ 'wrong-password': /wrong.?pass|password.?mismatch|incorrect.?pass|bad.?pass/i,
111
+ 'non-existent-account': /unregister|non.?exist|not.*(exist|found|in nj)|no account/i,
112
+ 'soft-deleted': /soft.?delet|deleted.?account|marked.?deleted|deleted_at/i,
113
+ 'locked-or-suspended': /lock|suspend|inactive|disabled account/i,
114
+ 'unverified-email': /unverif|not.?verified|pending.?verif|email.?verification/i,
115
+ 'password-expired': /expir/i,
116
+ // valid-with-surrounding-whitespace is handled by a VALUE-level check (an actual email with
117
+ // leading/trailing space), not this blob regex — the word "trim" alone (from the empty-trim case)
118
+ // must NOT count as covering the valid-after-trim case.
119
+ };
120
+
121
+ /** Coverage check for one unit — BVA gaps per field + auth-experience gaps for a login/auth unit. */
122
+ export function coverageUnit(cwd: string, name: string, catalog: Catalog): CoverageFinding[] {
123
+ const fmPath = join(cwd, 'qa', 'data-factory', `${name}.fields.yaml`);
124
+ if (!existsSync(fmPath)) return [];
125
+ const map = loadYaml(fmPath) as FieldMap;
126
+ if (!map || !Array.isArray(map.fields)) return [];
127
+ const tdPath = unitTestDataPath(cwd, name);
128
+ const data = tdPath ? loadYaml(tdPath) : {};
129
+ const authored = collectStrings(data);
130
+ const feature = unitFeatureText(cwd, name);
131
+ const blob = (authored.join('\n') + '\n' + JSON.stringify(data) + '\n' + feature).toLowerCase();
132
+ const out: CoverageFinding[] = [];
133
+
134
+ // 1. BVA coverage per field (flatten nested groups to leaves).
135
+ const leaves: FieldDef[] = [];
136
+ const walk = (fs: FieldDef[]) => { for (const f of fs) { if (f.fields) walk(f.fields); else leaves.push(f); } };
137
+ walk(map.fields);
138
+ for (const f of leaves) {
139
+ const gaps = bvaGaps(f, catalog, authored);
140
+ if (gaps.length) {
141
+ out.push({ unit: name, field: f.name, severity: 'warn', code: 'BVA_UNCOVERED',
142
+ message: `field "${f.name}" (${f.type}): ${gaps.length} boundary case(s) not represented in test-data — ${gaps.join('; ')}. The field-map's constraints require them (e.g. minLength → a below-min & at-min value); author them or waive with a reason.` });
143
+ }
144
+ }
145
+
146
+ // 2. Auth-experience coverage — only for a login/auth unit (has an email + a password field).
147
+ const isLogin = leaves.some((f) => f.type === 'email') && leaves.some((f) => f.type === 'password');
148
+ const exp = catalog.experience?.['auth-account-states'];
149
+ if (isLogin && exp) {
150
+ // An actual email value carrying leading/trailing whitespace = the valid-after-trim case covered.
151
+ const hasWhitespaceEmail = authored.some((s) => s.includes('@') && s !== s.trim());
152
+ const missing: string[] = [];
153
+ for (const c of exp.cases || []) {
154
+ const state = String((c as any).state || c.id);
155
+ if (state === 'valid-with-surrounding-whitespace') { if (!hasWhitespaceEmail) missing.push(state); continue; }
156
+ const re = STATE_COVERED[state];
157
+ if (re && !re.test(blob)) missing.push(state);
158
+ }
159
+ if (missing.length) {
160
+ out.push({ unit: name, severity: 'warn', code: 'EXPERIENCE_UNCOVERED',
161
+ message: `login unit: auth-account-states not covered — ${missing.join(', ')}. Each needs a credential-state scenario (a seeded account in that state → the correct rejection/route). Add them or waive per spec (some may be N/A).` });
162
+ }
163
+ }
164
+ return out;
165
+ }
package/src/index.ts CHANGED
@@ -36,6 +36,8 @@ export { crosscheckUnit, crosscheckShared, crosscheckAll, referencedVars } from
36
36
  export type { CrossFinding } from './crosscheck';
37
37
  export { detectUnit, hasInputFields, hasFieldMap, detectPreconditionSignals, hasStateDeclaration } from './detect';
38
38
  export type { DetectFinding } from './detect';
39
+ export { coverageUnit } from './coverage';
40
+ export type { CoverageFinding } from './coverage';
39
41
  export { registerDataFactoryCommand } from './cli';
40
42
  export { generateFixture } from './fixture';
41
43
  export type { FixtureResult, FixtureExpected, ExpectedRow } from './fixture';