@sungen/driver-data-factory 3.2.10-beta.6 → 3.2.12-beta.10

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.
Files changed (37) hide show
  1. package/common/experience/auth-account-states.yaml +19 -0
  2. package/common/field-types/currency.yaml +10 -10
  3. package/common/field-types/email.yaml +19 -16
  4. package/common/field-types/jp/katakana.yaml +12 -12
  5. package/common/field-types/number-float.yaml +10 -10
  6. package/common/field-types/number-integer.yaml +12 -12
  7. package/common/field-types/password.yaml +22 -22
  8. package/common/field-types/phone.yaml +14 -14
  9. package/common/field-types/postcode.yaml +8 -8
  10. package/common/field-types/string.yaml +11 -11
  11. package/common/security/broken-auth.yaml +6 -6
  12. package/common/security/idor.yaml +5 -5
  13. package/common/security/input-validation.yaml +5 -5
  14. package/common/security/sqli.yaml +7 -7
  15. package/common/security/upload.yaml +7 -7
  16. package/common/security/xss.yaml +9 -9
  17. package/common/techniques/boundary.yaml +4 -4
  18. package/common/techniques/decision-table.yaml +4 -4
  19. package/common/techniques/equivalence.yaml +4 -4
  20. package/common/techniques/state-transition.yaml +4 -4
  21. package/package.json +3 -3
  22. package/src/applicability.ts +80 -0
  23. package/src/boundary.ts +319 -0
  24. package/src/catalog-loader.ts +104 -18
  25. package/src/cli.ts +233 -14
  26. package/src/contract.ts +93 -0
  27. package/src/crosscheck.ts +149 -0
  28. package/src/detect.ts +122 -0
  29. package/src/fixture.ts +111 -0
  30. package/src/generator.ts +231 -98
  31. package/src/index.ts +17 -2
  32. package/src/lint.ts +31 -16
  33. package/src/readme.ts +52 -0
  34. package/src/sensor.ts +32 -4
  35. package/src/state.ts +225 -0
  36. package/src/types.ts +82 -1
  37. package/src/validate.ts +77 -0
package/src/cli.ts CHANGED
@@ -10,11 +10,34 @@ 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
+ import { buildRequest, fingerprint } from './contract';
16
+ import { loadStateRecipes, validateStateRecipe, buildStatePlan, renderPreconditionBlock, hasDatasource, scaffoldRecipe, renderRecipeYaml } from './state';
17
+ import { crosscheckUnit, crosscheckShared } from './crosscheck';
18
+ import { detectPreconditionSignals } from './detect';
15
19
  import { lintFieldMap, summarize } from './lint';
20
+ import { validateGenerated } from './validate';
21
+ import { generateFixture } from './fixture';
22
+ import { ensureDataFactoryReadme } from './readme';
23
+ import type { SelectionContext, Profile } from './types';
16
24
 
17
25
  const FIELDMAP_DIR = (cwd: string) => join(cwd, 'qa', 'data-factory');
26
+
27
+ const PROFILES: Profile[] = ['regression', 'functional', 'smoke', 'security-min'];
28
+
29
+ /** Build a SelectionContext from the shared --profile/--locale/--channel/--sink flags. */
30
+ function ctxFromOpts(o: { profile?: string; locale?: string; channel?: string; sink?: string }): SelectionContext {
31
+ const ctx: SelectionContext = {};
32
+ if (o.profile) {
33
+ if (!PROFILES.includes(o.profile as Profile)) throw new Error(`--profile must be one of ${PROFILES.join(' | ')}`);
34
+ ctx.profile = o.profile as Profile;
35
+ }
36
+ if (o.locale) ctx.locale = o.locale;
37
+ if (o.channel === 'api' || o.channel === 'ui') ctx.channel = o.channel;
38
+ if (o.sink) ctx.sink = o.sink;
39
+ return ctx;
40
+ }
18
41
  const fieldMapPath = (cwd: string, name: string) => join(FIELDMAP_DIR(cwd), `${name}.fields.yaml`);
19
42
 
20
43
  /** Resolve where a unit's test-data lives: screen or flow (mirrors sungen auto-detect). */
@@ -45,24 +68,48 @@ function loadFieldMap(cwd: string, name: string): FieldMap | null {
45
68
  return parseYaml(readFileSync(p, 'utf8')) as FieldMap;
46
69
  }
47
70
 
48
- async function genOne(cwd: string, name: string, opts: { force?: boolean }): Promise<'written' | 'skipped' | 'missing'> {
71
+ async function genOne(cwd: string, name: string, opts: { force?: boolean; ctx?: SelectionContext }): Promise<'written' | 'skipped' | 'missing'> {
49
72
  const map = loadFieldMap(cwd, name);
50
73
  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'; }
51
- const { catalog, warnings } = loadEffectiveCatalog(cwd);
74
+ const { catalog, warnings, provenance } = loadEffectiveCatalog(cwd);
52
75
  for (const w of warnings) console.warn(` ⚠ overwrite: ${w.detail} → ${w.suggestion}`);
76
+ for (const line of describeProvenance(provenance)) console.log(` ⟲ .overwrite → ${line}`);
53
77
 
54
- const expanded = expandFieldMap(map, catalog);
78
+ const expanded = expandFieldMap(map, catalog, opts.ctx || {});
79
+ const na = expanded.flatMap((f) => (f.excluded || []).map((e) => `${f.name}.${e.id}`));
80
+ if (na.length) console.log(` ⊘ ${name}: ${na.length} case(s) not applicable for this context (see _not_applicable in the file)`);
55
81
  const out = testDataPath(cwd, name);
56
82
  if (!out) { console.error(` ✗ ${name}: no qa/screens/${name} or qa/flows/${name}`); return 'missing'; }
57
83
 
58
- if (existsSync(out) && !opts.force) {
59
- const a = (await ask(` ? ${name}: test-data already exists — overwrite? [y/N] `)).toLowerCase();
60
- if (a !== 'y' && a !== 'yes') { console.log(` · ${name}: skipped`); return 'skipped'; }
84
+ const dfData = renderTestData(expanded) as Record<string, any>;
85
+ // Reproducibility fingerprint (M3-C): stamped into the file so a reader/cache can tell whether the
86
+ // catalog/generator/context that produced this data has changed. Same fingerprint ⇒ identical data.
87
+ const fp = fingerprint(buildRequest(map, opts.ctx || {}), catalog);
88
+ dfData._fingerprint = fp;
89
+ const exists = existsSync(out);
90
+
91
+ // MERGE, don't clobber. Data Factory owns only the keys it generates (`_generated_by` + each
92
+ // field name); every OTHER key in the existing test-data (create-test's data, flow-namespaced
93
+ // keys like `login.email`, @cases datasets, overrides) is preserved. A regenerated field key is
94
+ // replaced by its standardized version (that's the point). Fixes the whole-file clobber.
95
+ let final = dfData;
96
+ let mergedInto = false;
97
+ if (exists) {
98
+ if (!opts.force) {
99
+ const a = (await ask(` ? ${name}: test-data exists — merge standardized field data in (keeps other keys)? [y/N] `)).toLowerCase();
100
+ if (a !== 'y' && a !== 'yes') { console.log(` · ${name}: skipped`); return 'skipped'; }
101
+ }
102
+ let existing: Record<string, any> = {};
103
+ try { existing = (parseYaml(readFileSync(out, 'utf8')) as Record<string, any>) || {}; } catch { existing = {}; }
104
+ final = { ...existing, ...dfData }; // DF-owned keys win; all other existing keys preserved
105
+ mergedInto = true;
61
106
  }
62
107
  mkdirSync(dirname(out), { recursive: true });
63
- writeFileSync(out, stringifyYaml(renderTestData(expanded)), 'utf8');
108
+ writeFileSync(out, stringifyYaml(final), 'utf8');
64
109
  const blind = expanded.filter((f) => f.blindSpot).map((f) => f.name);
65
- console.log(` ✓ ${name}: wrote ${out.replace(cwd + '/', '')}${blind.length ? ` (blind spots: ${blind.join(', ')})` : ''}`);
110
+ const verb = mergedInto ? 'merged into' : 'wrote';
111
+ const fieldKeys = expanded.map((f) => f.name).length;
112
+ console.log(` ✓ ${name}: ${verb} ${out.replace(cwd + '/', '')} (${fieldKeys} field key(s)${mergedInto ? ', other keys preserved' : ''})${blind.length ? ` — blind spots: ${blind.join(', ')}` : ''}`);
66
113
  return 'written';
67
114
  }
68
115
 
@@ -76,9 +123,16 @@ export function registerDataFactoryCommand(program: any): void {
76
123
  .option('--all', 'Generate for every field-map under qa/data-factory/ (requires confirm)')
77
124
  .option('--yes', 'Assume yes for the --all confirmation')
78
125
  .option('--force', 'Overwrite existing test-data without asking (implies --yes)')
79
- .action(async (opts: { screen?: string; all?: boolean; yes?: boolean; force?: boolean }) => {
126
+ .option('--profile <name>', `Case selection: ${PROFILES.join(' | ')} (default: regression = all)`)
127
+ .option('--locale <tag>', 'Selection context locale (e.g. ja-JP) — filters locale-gated cases')
128
+ .option('--channel <api|ui>', 'Selection context channel')
129
+ .option('--sink <name>', 'Selection context output sink (e.g. html)')
130
+ .action(async (opts: { screen?: string; all?: boolean; yes?: boolean; force?: boolean; profile?: string; locale?: string; channel?: string; sink?: string }) => {
80
131
  const cwd = process.cwd();
81
- if (opts.screen) { await genOne(cwd, opts.screen, opts); return; }
132
+ ensureDataFactoryReadme(cwd);
133
+ let ctx: SelectionContext;
134
+ try { ctx = ctxFromOpts(opts); } catch (e: any) { console.error(e.message); process.exitCode = 1; return; }
135
+ if (opts.screen) { await genOne(cwd, opts.screen, { ...opts, ctx }); return; }
82
136
  if (!opts.all) {
83
137
  console.error('Specify --screen <name> for one unit, or --all for every unit (with confirmation).');
84
138
  process.exitCode = 1;
@@ -95,7 +149,7 @@ export function registerDataFactoryCommand(program: any): void {
95
149
  if (a !== 'y' && a !== 'yes') { console.log('Cancelled.'); return; }
96
150
  }
97
151
  let written = 0;
98
- for (const n of names) if ((await genOne(cwd, n, { force: opts.force })) === 'written') written++;
152
+ for (const n of names) if ((await genOne(cwd, n, { force: opts.force, ctx })) === 'written') written++;
99
153
  console.log(`Done: ${written}/${names.length} written.`);
100
154
  });
101
155
 
@@ -103,8 +157,15 @@ export function registerDataFactoryCommand(program: any): void {
103
157
  .command('lint')
104
158
  .description('Check test-data coverage (valid/boundary/invalid + traceability) per phuong-phap §6')
105
159
  .option('--screen <name>', 'Lint one unit (default: all field-maps)')
106
- .action((opts: { screen?: string }) => {
160
+ .option('--profile <name>', `Case selection: ${PROFILES.join(' | ')} (default: regression = all)`)
161
+ .option('--locale <tag>', 'Selection context locale (e.g. ja-JP)')
162
+ .option('--channel <api|ui>', 'Selection context channel')
163
+ .option('--sink <name>', 'Selection context output sink')
164
+ .action((opts: { screen?: string; profile?: string; locale?: string; channel?: string; sink?: string }) => {
107
165
  const cwd = process.cwd();
166
+ ensureDataFactoryReadme(cwd);
167
+ let ctx: SelectionContext;
168
+ try { ctx = ctxFromOpts(opts); } catch (e: any) { console.error(e.message); process.exitCode = 1; return; }
108
169
  const names = opts.screen ? [opts.screen] : listFieldMaps(cwd);
109
170
  if (names.length === 0) { console.error('No field-maps under qa/data-factory/*.fields.yaml.'); process.exitCode = 1; return; }
110
171
  const { catalog } = loadEffectiveCatalog(cwd);
@@ -112,7 +173,7 @@ export function registerDataFactoryCommand(program: any): void {
112
173
  for (const name of names) {
113
174
  const map = loadFieldMap(cwd, name);
114
175
  if (!map) { console.error(`✗ ${name}: no field-map`); totalErrors++; continue; }
115
- const { issues } = lintFieldMap(map, catalog);
176
+ const { issues } = lintFieldMap(map, catalog, ctx);
116
177
  const s = summarize(issues);
117
178
  totalErrors += s.errors;
118
179
  const badge = s.errors ? '✗' : s.warns ? '⚠' : '✓';
@@ -124,4 +185,162 @@ export function registerDataFactoryCommand(program: any): void {
124
185
  }
125
186
  if (totalErrors) process.exitCode = 1;
126
187
  });
188
+
189
+ data
190
+ .command('validate')
191
+ .description('Verify every generated value is semantically correct (valid values satisfy the constraints; synthesized invalid values violate them)')
192
+ .option('--screen <name>', 'Validate one unit (default: all field-maps)')
193
+ .option('--profile <name>', `Case selection: ${PROFILES.join(' | ')} (default: regression = all)`)
194
+ .option('--locale <tag>', 'Selection context locale (e.g. ja-JP)')
195
+ .option('--channel <api|ui>', 'Selection context channel')
196
+ .option('--sink <name>', 'Selection context output sink')
197
+ .action((opts: { screen?: string; profile?: string; locale?: string; channel?: string; sink?: string }) => {
198
+ const cwd = process.cwd();
199
+ ensureDataFactoryReadme(cwd);
200
+ let ctx: SelectionContext;
201
+ try { ctx = ctxFromOpts(opts); } catch (e: any) { console.error(e.message); process.exitCode = 1; return; }
202
+ const names = opts.screen ? [opts.screen] : listFieldMaps(cwd);
203
+ if (names.length === 0) { console.error('No field-maps under qa/data-factory/*.fields.yaml.'); process.exitCode = 1; return; }
204
+ const { catalog } = loadEffectiveCatalog(cwd);
205
+ let totalErrors = 0;
206
+ for (const name of names) {
207
+ const map = loadFieldMap(cwd, name);
208
+ if (!map) { console.error(`✗ ${name}: no field-map`); totalErrors++; continue; }
209
+ const findings = validateGenerated(map, catalog, ctx);
210
+ const errors = findings.filter((f) => f.severity === 'error').length;
211
+ const warns = findings.filter((f) => f.severity === 'warn').length;
212
+ totalErrors += errors;
213
+ const badge = errors ? '✗' : warns ? '⚠' : '✓';
214
+ console.log(`${badge} ${name}: ${errors} error · ${warns} warn`);
215
+ for (const f of findings) {
216
+ console.log(`${f.severity === 'error' ? ' ✗' : ' ⚠'} [${f.field}] ${f.code}: ${f.message}`);
217
+ }
218
+ }
219
+ if (totalErrors) process.exitCode = 1;
220
+ });
221
+
222
+ data
223
+ .command('provenance')
224
+ .description('Show what qa/data-factory/common.overwrite.yaml changed vs the shipped company catalog (extend/replace/disable) — "no silent overwrite"')
225
+ .action(() => {
226
+ const cwd = process.cwd();
227
+ ensureDataFactoryReadme(cwd);
228
+ const { provenance, warnings } = loadEffectiveCatalog(cwd);
229
+ const lines = describeProvenance(provenance);
230
+ if (lines.length === 0) {
231
+ console.log('No project overrides — the effective catalog is the shipped company common as-is.');
232
+ } else {
233
+ console.log('Project overrides (qa/data-factory/common.overwrite.yaml):');
234
+ for (const l of lines) console.log(` ⟲ ${l}`);
235
+ }
236
+ for (const w of warnings) console.warn(` ⚠ ${w.detail} → ${w.suggestion}`);
237
+ });
238
+
239
+ data
240
+ .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
+ .option('--screen <name>', 'Cross-check one unit (default: all field-maps + all shared datasets)')
243
+ .action((opts: { screen?: string }) => {
244
+ const cwd = process.cwd();
245
+ ensureDataFactoryReadme(cwd);
246
+ const names = opts.screen ? [opts.screen] : listFieldMaps(cwd);
247
+ const findings = [
248
+ ...names.flatMap((n) => crosscheckUnit(cwd, n)),
249
+ ...(opts.screen ? [] : crosscheckShared(cwd)),
250
+ ];
251
+ const errors = findings.filter((f) => f.severity === 'error').length;
252
+ const warns = findings.filter((f) => f.severity === 'warn').length;
253
+ const badge = errors ? '✗' : warns ? '⚠' : '✓';
254
+ 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}`);
256
+ if (errors) process.exitCode = 1;
257
+ });
258
+
259
+ data
260
+ .command('state')
261
+ .description('Domain/stateful data (M3-A Layer 0): validate the state-recipes in recipes.yaml and render each as a standardized manual precondition block (+ a resolved .state.yaml plan)')
262
+ .option('--recipe <name>', 'Only this recipe (default: all recipes with a `resources`/`shared` graph)')
263
+ .option('--scaffold <unit>', 'Auto-draft a state-recipe SKELETON from the unit\'s spec.md precondition signals (Layer-C auto-draft), then review/fill it')
264
+ .option('--force', 'With --scaffold: overwrite an existing recipe of the same name')
265
+ .action((opts: { recipe?: string; scaffold?: string; force?: boolean }) => {
266
+ const cwd = process.cwd();
267
+ ensureDataFactoryReadme(cwd);
268
+
269
+ // --scaffold: auto-draft a recipe skeleton from the unit's spec precondition signals.
270
+ if (opts.scaffold) {
271
+ const unit = opts.scaffold;
272
+ const specPath = ['screens', 'flows'].map((k) => join(cwd, 'qa', k, unit, 'requirements', 'spec.md')).find(existsSync);
273
+ const specText = specPath ? readFileSync(specPath, 'utf8') : '';
274
+ const signals = detectPreconditionSignals(specText);
275
+ const recipe = scaffoldRecipe(unit, signals);
276
+ if (!recipe) { console.log(`No stateful-precondition signals found in ${unit}'s spec — nothing to scaffold (a screen may simply have no precondition).`); return; }
277
+ const recipesPath = join(FIELDMAP_DIR(cwd), 'recipes.yaml');
278
+ const doc: any = existsSync(recipesPath) ? (parseYaml(readFileSync(recipesPath, 'utf8')) || {}) : {};
279
+ doc.recipes = doc.recipes || {};
280
+ if (doc.recipes[recipe.name] && !opts.force) { console.error(`✗ recipe "${recipe.name}" already exists — edit it, or pass --force to overwrite the scaffold.`); process.exitCode = 1; return; }
281
+ // Splice the human-friendly (commented) block rather than a bare re-serialize, to preserve TODO hints.
282
+ const header = existsSync(recipesPath) ? readFileSync(recipesPath, 'utf8').replace(/\s*$/, '') + '\n' : 'recipes:\n';
283
+ const block = renderRecipeYaml(recipe);
284
+ writeFileSync(recipesPath, (header.includes('recipes:') ? header : 'recipes:\n' + header) + block, 'utf8');
285
+ console.log(` ✓ scaffolded recipe "${recipe.name}" → ${recipesPath.replace(cwd + '/', '')} (${signals.length} signal(s))`);
286
+ console.log(` → REVIEW: name each entity, set desiredState/bindings, confirm domain values with QA, then \`sungen data state --recipe ${recipe.name}\`.`);
287
+ for (const s of signals) console.log(` · signal: ${s}`);
288
+ return;
289
+ }
290
+
291
+ let recipes = loadStateRecipes(cwd);
292
+ if (opts.recipe) recipes = recipes.filter((r) => r.name === opts.recipe);
293
+ if (recipes.length === 0) { console.error('No state-recipes (recipes with a `resources` or `shared` graph) in qa/data-factory/recipes.yaml.'); process.exitCode = 1; return; }
294
+ const ds = hasDatasource(cwd);
295
+ console.log(`Data Factory — domain-state (${recipes.length} recipe(s); datasource ${ds ? 'present → Layer 1 possible' : 'absent → Layer 0 manual preconditions'})`);
296
+ let errors = 0;
297
+ for (const r of recipes) {
298
+ const findings = validateStateRecipe(r);
299
+ errors += findings.filter((f) => f.severity === 'error').length;
300
+ const plan = buildStatePlan(r, { datasource: ds });
301
+ const badge = findings.some((f) => f.severity === 'error') ? '✗' : findings.some((f) => f.severity === 'warn') ? '⚠' : '✓';
302
+ console.log(`\n${badge} ${r.name}${plan.manual ? ' [manual setup]' : ' [auto-provisionable]'}`);
303
+ for (const f of findings) console.log(` ${f.severity === 'error' ? '✗' : f.severity === 'warn' ? '⚠' : '·'} ${f.code}: ${f.message}`);
304
+ for (const line of renderPreconditionBlock(plan).split('\n')) console.log(` ${line}`);
305
+ if (plan.sharedIdentity) console.log(` # shared dataset: "${plan.sharedIdentity.entity}" identical across ${plan.sharedIdentity.screens.join(', ')} (asserted by \`data crosscheck\`)`);
306
+ const planPath = join(FIELDMAP_DIR(cwd), `${r.name}.state.yaml`);
307
+ writeFileSync(planPath, stringifyYaml({ _generated_by: 'sungen data state — resolved domain-state plan (Layer 0). Edit recipes.yaml, not this file.', ...plan }), 'utf8');
308
+ console.log(` ✓ wrote ${planPath.replace(cwd + '/', '')}`);
309
+ }
310
+ if (errors) process.exitCode = 1;
311
+ });
312
+
313
+ data
314
+ .command('fixture')
315
+ .description('Generate a CSV import fixture from the field-map (bulk-import / file-upload testing)')
316
+ .requiredOption('--screen <name>', 'Unit whose field-map to build a fixture from')
317
+ .option('--rows <n>', 'Number of valid rows', '3')
318
+ .option('--invalid', 'Also write <name>.invalid.csv (one seeded-bad row per field) + <name>.invalid.expected.yaml (per-row expected rejection)')
319
+ .option('--force', 'Overwrite existing fixture file(s) without asking')
320
+ .action(async (opts: { screen: string; rows?: string; invalid?: boolean; force?: boolean }) => {
321
+ const cwd = process.cwd();
322
+ ensureDataFactoryReadme(cwd);
323
+ const map = loadFieldMap(cwd, opts.screen);
324
+ if (!map) { console.error(`✗ ${opts.screen}: no field-map at qa/data-factory/${opts.screen}.fields.yaml`); process.exitCode = 1; return; }
325
+ const { catalog } = loadEffectiveCatalog(cwd);
326
+ const res = generateFixture(map, catalog, { rows: parseInt(opts.rows || '3', 10), includeInvalid: !!opts.invalid });
327
+ const dir = join(cwd, 'qa', 'fixtures');
328
+ mkdirSync(dir, { recursive: true });
329
+ const validPath = join(dir, `${opts.screen}.csv`);
330
+ const writes: Array<[string, string]> = [[validPath, res.valid]];
331
+ if (res.invalid) writes.push([join(dir, `${opts.screen}.invalid.csv`), res.invalid]);
332
+ // Sidecar: expected rejection per row — keeps the CSV payload clean (no _violation column).
333
+ if (res.expected) writes.push([join(dir, `${opts.screen}.invalid.expected.yaml`), stringifyYaml(res.expected)]);
334
+ for (const [p] of writes) {
335
+ if (existsSync(p) && !opts.force) {
336
+ const a = (await ask(` ? ${p.replace(cwd + '/', '')} exists — overwrite? [y/N] `)).toLowerCase();
337
+ if (a !== 'y' && a !== 'yes') { console.log(' · skipped'); return; }
338
+ }
339
+ }
340
+ for (const [p, content] of writes) {
341
+ writeFileSync(p, content, 'utf8');
342
+ const label = p.endsWith('.expected.yaml') ? 'expected-rejection sidecar' : `${res.headers.length} columns`;
343
+ console.log(` ✓ wrote ${p.replace(cwd + '/', '')} (${label})`);
344
+ }
345
+ });
127
346
  }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Data Factory — the versioned request/result contract + reproducibility fingerprint (M3-C).
3
+ *
4
+ * Formalizes the generator's input/output so Core and the driver share a checked, versioned contract
5
+ * (not a raw YAML cast) and every generation is reproducible. The **fingerprint** is the connective
6
+ * tissue for M3: same fingerprint ⇒ byte-identical output, and it is the safe cache key for the
7
+ * lifecycle model (a value must never be reused across a different catalog/generator/seed/context).
8
+ *
9
+ * Pure + deterministic; no runtime, no LLM.
10
+ */
11
+ import { createHash } from 'crypto';
12
+ import type { Catalog, SelectionContext } from './types';
13
+ import { expandFieldMap, type FieldMap, type FieldDef, type ExpandedField } from './generator';
14
+
15
+ /** Bump when the generator's SEMANTICS change (boundary synthesis, applicability, render shape). */
16
+ export const GENERATOR_VERSION = '3.3.0-df1';
17
+ /** Bump when the request/result SHAPE changes (breaking contract). */
18
+ export const SCHEMA_VERSION = '1.0';
19
+
20
+ export interface DataFactoryRequest {
21
+ schemaVersion: string;
22
+ unit: { kind: 'screen' | 'flow'; id: string };
23
+ /** Selection context (M2b) — locale/channel/sink/profile. */
24
+ ctx: SelectionContext;
25
+ /** Optional seed for any future randomized synthesis (today generation is fully deterministic). */
26
+ seed?: number;
27
+ fields: FieldDef[];
28
+ }
29
+
30
+ export interface DataFactoryResult {
31
+ schemaVersion: string;
32
+ generatorVersion: string;
33
+ request: DataFactoryRequest;
34
+ /** Reproducibility fingerprint — same fingerprint ⇒ identical `fields`. */
35
+ fingerprint: string;
36
+ fields: ExpandedField[];
37
+ }
38
+
39
+ /** Build a versioned request from a field-map + selection context. */
40
+ export function buildRequest(map: FieldMap, ctx: SelectionContext = {}, opts: { seed?: number } = {}): DataFactoryRequest {
41
+ const kind: 'screen' | 'flow' = map.flow ? 'flow' : 'screen';
42
+ const id = (map.flow || map.screen || 'unit') as string;
43
+ return { schemaVersion: SCHEMA_VERSION, unit: { kind, id }, ctx, seed: opts.seed, fields: map.fields || [] };
44
+ }
45
+
46
+ /** Validate a request; returns path-specific error strings (empty = valid). */
47
+ export function validateRequest(req: DataFactoryRequest): string[] {
48
+ const errs: string[] = [];
49
+ if (req.schemaVersion !== SCHEMA_VERSION) errs.push(`schemaVersion: expected "${SCHEMA_VERSION}", got "${req.schemaVersion}"`);
50
+ if (!req.unit?.id) errs.push('unit.id: required');
51
+ if (!Array.isArray(req.fields)) errs.push('fields: must be an array');
52
+ else req.fields.forEach((f, i) => { if (!f.name) errs.push(`fields[${i}].name: required`); });
53
+ return errs;
54
+ }
55
+
56
+ /** Stable JSON (sorted keys) so the hash is order-independent. */
57
+ function stableStringify(v: unknown): string {
58
+ if (v === null || typeof v !== 'object') return JSON.stringify(v);
59
+ if (Array.isArray(v)) return `[${v.map(stableStringify).join(',')}]`;
60
+ const keys = Object.keys(v as Record<string, unknown>).sort();
61
+ return `{${keys.map((k) => JSON.stringify(k) + ':' + stableStringify((v as Record<string, unknown>)[k])).join(',')}}`;
62
+ }
63
+
64
+ /**
65
+ * Reproducibility fingerprint over (generator version + the effective catalog subset the request uses
66
+ * + the request itself). The catalog subset captures `.overwrite` edits to any referenced field-type,
67
+ * so an overlay change that alters output also changes the fingerprint.
68
+ */
69
+ export function fingerprint(req: DataFactoryRequest, catalog: Catalog): string {
70
+ const referenced = new Set<string>();
71
+ const walk = (fields: FieldDef[]) => { for (const f of fields) { if (f.type) referenced.add(f.type); if (f.fields) walk(f.fields); } };
72
+ walk(req.fields);
73
+ const catalogSubset: Record<string, unknown> = {};
74
+ for (const t of [...referenced].sort()) if (catalog.fieldTypes[t]) catalogSubset[t] = catalog.fieldTypes[t];
75
+ // Security banks a referenced type pulls in also affect output.
76
+ const banks: Record<string, unknown> = {};
77
+ for (const t of referenced) for (const b of catalog.fieldTypes[t]?.uses_security || []) if (catalog.security[b]) banks[b] = catalog.security[b];
78
+ const h = createHash('sha256');
79
+ h.update(GENERATOR_VERSION).update('\0').update(stableStringify({ req, catalogSubset, banks }));
80
+ return h.digest('hex').slice(0, 16);
81
+ }
82
+
83
+ /** Generate: expand + wrap in a fingerprinted, versioned result. The one-call entry for Core. */
84
+ export function generate(map: FieldMap, catalog: Catalog, ctx: SelectionContext = {}, opts: { seed?: number } = {}): DataFactoryResult {
85
+ const request = buildRequest(map, ctx, opts);
86
+ return {
87
+ schemaVersion: SCHEMA_VERSION,
88
+ generatorVersion: GENERATOR_VERSION,
89
+ request,
90
+ fingerprint: fingerprint(request, catalog),
91
+ fields: expandFieldMap(map, catalog, ctx),
92
+ };
93
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Data Factory — Cross-artifact Gate (M3-B).
3
+ *
4
+ * `data validate` checks the DATA in isolation (values are semantically correct). This checks that the
5
+ * TESTCASE and its test-data actually AGREE — the "genuinely new piece" from the harness-merge review:
6
+ * - every `{{var}}` a scenario references resolves to a key in the unit's test-data (no dangling ref);
7
+ * - a cross-screen SHARED dataset (M3-A `shared:`) is present and identical across its member screens
8
+ * (the registration setup→confirmation→register lineage: confirmation must show what setup buffered);
9
+ * - a unit that declares a manual state-precondition (a state-recipe) surfaces it in the feature
10
+ * (a `@manual` tag or a Background) rather than silently dropping the required state.
11
+ *
12
+ * Deterministic; no runtime, no LLM. Invoked from the create-test / create-data-test AI loop (and by
13
+ * `sungen data crosscheck`), exit-1 on an error finding.
14
+ */
15
+ import { readFileSync, existsSync, readdirSync } from 'fs';
16
+ import { join } from 'path';
17
+ import { parse as parseYaml } from 'yaml';
18
+ import { loadStateRecipes, buildStatePlan, hasDatasource } from './state';
19
+
20
+ export interface CrossFinding {
21
+ unit: string;
22
+ scenario?: string;
23
+ severity: 'error' | 'warn' | 'info';
24
+ code: 'DANGLING_VAR' | 'SHARED_MISSING' | 'SHARED_MISMATCH' | 'PRECONDITION_MISSING';
25
+ message: string;
26
+ }
27
+
28
+ /** Resolve a unit's feature dir + test-data path (screen or flow). */
29
+ function unitPaths(cwd: string, name: string): { featureDir: string; testData: string } | null {
30
+ for (const kind of ['screens', 'flows']) {
31
+ const base = join(cwd, 'qa', kind, name);
32
+ if (existsSync(base)) return { featureDir: join(base, 'features'), testData: join(base, 'test-data', `${name}.yaml`) };
33
+ }
34
+ return null;
35
+ }
36
+
37
+ function readFeatureText(featureDir: string): string {
38
+ if (!existsSync(featureDir)) return '';
39
+ return readdirSync(featureDir)
40
+ .filter((f) => f.endsWith('.feature'))
41
+ .map((f) => readFileSync(join(featureDir, f), 'utf8'))
42
+ .join('\n');
43
+ }
44
+
45
+ /** Extract `{{var}}` roots referenced in a feature, excluding dynamic `{{$...}}` runtime vars. */
46
+ export function referencedVars(featureText: string): string[] {
47
+ const out = new Set<string>();
48
+ const re = /\{\{\s*([^}]+?)\s*\}\}/g;
49
+ let m: RegExpExecArray | null;
50
+ while ((m = re.exec(featureText))) {
51
+ const tok = m[1].trim();
52
+ if (tok.startsWith('$')) continue; // {{$timestamp}} etc. — runtime dynamic, not a test-data key
53
+ const root = tok.split(/[.|[]/)[0].trim(); // login.email → login ; name|upper → name
54
+ if (root) out.add(root);
55
+ }
56
+ return [...out];
57
+ }
58
+
59
+ function loadYaml(path: string): Record<string, any> {
60
+ if (!existsSync(path)) return {};
61
+ try { return (parseYaml(readFileSync(path, 'utf8')) as Record<string, any>) || {}; } catch { return {}; }
62
+ }
63
+
64
+ /**
65
+ * Column names supplied per-row by every `@cases:<dataset>` referenced in the feature (where the
66
+ * dataset is a top-level LIST in test-data). A `{{col}}` inside a data-driven scenario resolves to
67
+ * the current row, not a top-level key — so these are valid roots, not dangling. Mirrors the
68
+ * compiler's data-driven lint, which is @cases-aware; without this, any @cases scenario that reads
69
+ * a row column (`{{input}}`, `{{code}}`, …) would be a false-positive DANGLING_VAR.
70
+ */
71
+ export function casesColumns(featureText: string, data: Record<string, any>): Set<string> {
72
+ const cols = new Set<string>();
73
+ const re = /@cases:([A-Za-z0-9_.-]+)/g;
74
+ let m: RegExpExecArray | null;
75
+ while ((m = re.exec(featureText))) {
76
+ const ds = data[m[1]];
77
+ if (!Array.isArray(ds)) continue;
78
+ for (const row of ds) {
79
+ if (row && typeof row === 'object' && !Array.isArray(row)) {
80
+ for (const k of Object.keys(row)) cols.add(k);
81
+ }
82
+ }
83
+ }
84
+ return cols;
85
+ }
86
+
87
+ /** Cross-check one unit's feature ↔ test-data. */
88
+ export function crosscheckUnit(cwd: string, name: string): CrossFinding[] {
89
+ const out: CrossFinding[] = [];
90
+ const paths = unitPaths(cwd, name);
91
+ if (!paths) return out;
92
+ const feature = readFeatureText(paths.featureDir);
93
+ if (!feature) return out;
94
+ const data = loadYaml(paths.testData);
95
+ const topKeys = new Set(Object.keys(data).filter((k) => !k.startsWith('_')));
96
+ const caseCols = casesColumns(feature, data); // @cases row columns resolve per-row, not as top-level keys
97
+
98
+ // 1. Dangling vars — a {{var}} whose root key is absent from test-data (and not a @cases column).
99
+ for (const v of referencedVars(feature)) {
100
+ if (!topKeys.has(v) && !caseCols.has(v)) {
101
+ out.push({ unit: name, severity: 'error', code: 'DANGLING_VAR', message: `scenario references {{${v}}} but no "${v}" key exists in test-data/${name}.yaml` });
102
+ }
103
+ }
104
+
105
+ // 3. Manual state-precondition declared but not surfaced in the feature.
106
+ const ds = hasDatasource(cwd);
107
+ for (const r of loadStateRecipes(cwd)) {
108
+ const plan = buildStatePlan(r, { datasource: ds });
109
+ if (plan.manual && new RegExp(`@dataFactory:${r.name}\\b`).test(feature) === false) {
110
+ const surfaced = /@manual/.test(feature) || /Background:/.test(feature);
111
+ if (!surfaced) out.push({ unit: name, severity: 'warn', code: 'PRECONDITION_MISSING', message: `state-recipe "${r.name}" needs a manual precondition, but the feature has no Background / @manual tag — weave its precondition block (\`sungen data state --recipe ${r.name}\`)` });
112
+ }
113
+ }
114
+ return out;
115
+ }
116
+
117
+ /** Cross-check a cross-screen SHARED dataset: present + identical across all member screens. */
118
+ export function crosscheckShared(cwd: string): CrossFinding[] {
119
+ const out: CrossFinding[] = [];
120
+ for (const r of loadStateRecipes(cwd)) {
121
+ if (!r.shared) continue;
122
+ const { entity, screens } = r.shared;
123
+ const datasets = screens.map((s) => {
124
+ const paths = unitPaths(cwd, s);
125
+ const data = paths ? loadYaml(paths.testData) : {};
126
+ return { screen: s, present: paths != null && entity in data, value: data[entity] };
127
+ });
128
+ const present = datasets.filter((d) => d.present);
129
+ for (const d of datasets) if (!d.present) out.push({ unit: d.screen, severity: 'warn', code: 'SHARED_MISSING', message: `shared dataset "${entity}" (recipe "${r.name}") is not present in ${d.screen}'s test-data` });
130
+ // Identity: every present copy must equal the first.
131
+ if (present.length >= 2) {
132
+ const ref = JSON.stringify(present[0].value);
133
+ for (const d of present.slice(1)) {
134
+ if (JSON.stringify(d.value) !== ref) {
135
+ out.push({ unit: d.screen, severity: 'error', code: 'SHARED_MISMATCH', message: `shared dataset "${entity}" differs between ${present[0].screen} and ${d.screen} — the same values must flow across the ${r.name} journey (e.g. confirmation must show what setup entered)` });
136
+ }
137
+ }
138
+ }
139
+ }
140
+ return out;
141
+ }
142
+
143
+ /** Cross-check the whole project (all units + shared datasets). */
144
+ export function crosscheckAll(cwd: string, units: string[]): CrossFinding[] {
145
+ const out: CrossFinding[] = [];
146
+ for (const u of units) out.push(...crosscheckUnit(cwd, u));
147
+ out.push(...crosscheckShared(cwd));
148
+ return out;
149
+ }