@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.
@@ -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, Experience, 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 {
@@ -49,30 +49,70 @@ export function loadShippedCatalog(root = commonDir()): Catalog {
49
49
  for (const { data } of readYamlFilesDeep(join(root, 'techniques'))) {
50
50
  if (data?.technique) techniques[data.technique] = data as Technique;
51
51
  }
52
- return { fieldTypes, security, techniques };
52
+ const experience: Record<string, Experience> = {};
53
+ for (const { data } of readYamlFilesDeep(join(root, 'experience'))) {
54
+ if (data?.experience) experience[data.experience] = data as Experience;
55
+ }
56
+ return { fieldTypes, security, techniques, experience };
53
57
  }
54
58
 
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[] {
59
+ /**
60
+ * Merge a field-type group by DataPoint.id, honoring three explicit operations:
61
+ * - EXTEND: an overlay id not in `base` is appended (`added`).
62
+ * - REPLACE: an overlay id already in `base` wins (`replaced`).
63
+ * - DISABLE: an id in `disableSet` is dropped (`disabled`).
64
+ * Records provenance and stamps each point's `source` so a reader can trace where it came from.
65
+ */
66
+ function mergeGroup(
67
+ base: DataPoint[] = [],
68
+ overlay: DataPoint[] = [],
69
+ disableSet: Set<string>,
70
+ prov: FieldTypeProvenance,
71
+ ): DataPoint[] {
57
72
  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 });
73
+ for (const dp of base) byId.set(dp.id, { ...dp, source: 'shipped' });
74
+ for (const dp of overlay) {
75
+ if (byId.has(dp.id)) prov.replaced.push(dp.id);
76
+ else prov.added.push(dp.id);
77
+ byId.set(dp.id, { ...byId.get(dp.id), ...dp, source: 'overwrite' });
78
+ }
79
+ for (const id of disableSet) {
80
+ if (byId.delete(id)) prov.disabled.push(id);
81
+ }
60
82
  return [...byId.values()];
61
83
  }
62
84
 
63
- function mergeFieldType(base: FieldType, overlay: Partial<FieldType>): FieldType {
85
+ /** An overlay field-type entry may carry a `disable:` list of ids alongside partial field-type data. */
86
+ type FieldTypeOverlay = Partial<FieldType> & { disable?: string[] };
87
+
88
+ function mergeFieldType(base: FieldType, overlay: FieldTypeOverlay, prov: FieldTypeProvenance): FieldType {
89
+ const disableSet = new Set(overlay.disable || []);
90
+ const constraintsOverridden = Object.keys(overlay.constraints || {}).filter(
91
+ (k) => (base.constraints || {})[k] !== (overlay.constraints || {})[k],
92
+ );
93
+ prov.constraintsOverridden = constraintsOverridden;
94
+ prov.securityChanged = overlay.uses_security != null;
95
+ const { disable: _disable, ...rest } = overlay;
64
96
  return {
65
97
  ...base,
66
- ...overlay,
98
+ ...rest,
67
99
  constraints: { ...(base.constraints || {}), ...(overlay.constraints || {}) },
68
100
  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),
101
+ valid: mergeGroup(base.valid, overlay.valid, disableSet, prov),
102
+ boundary: mergeGroup(base.boundary, overlay.boundary, disableSet, prov),
103
+ invalid: mergeGroup(base.invalid, overlay.invalid, disableSet, prov),
72
104
  notes: [...(base.notes || []), ...(overlay.notes || [])],
73
105
  };
74
106
  }
75
107
 
108
+ /** Merge a security bank by payload id (same rules: replace same-id, append new). */
109
+ function mergeSecurity(base: SecurityBank, overlay: Partial<SecurityBank>): SecurityBank {
110
+ const byId = new Map<string, any>();
111
+ for (const p of base.payloads || []) byId.set(p.id, p);
112
+ for (const p of overlay.payloads || []) byId.set(p.id, { ...byId.get(p.id), ...p });
113
+ return { ...base, ...overlay, payloads: [...byId.values()] };
114
+ }
115
+
76
116
  /**
77
117
  * Load the effective catalog = shipped common + project overwrite overlay.
78
118
  * `projectDir` is the QA repo root; overlay is read from `qa/data-factory/common.overwrite.yaml`.
@@ -80,22 +120,33 @@ function mergeFieldType(base: FieldType, overlay: Partial<FieldType>): FieldType
80
120
  export function loadEffectiveCatalog(projectDir: string, root = commonDir()): {
81
121
  catalog: Catalog;
82
122
  warnings: OverwriteWarning[];
123
+ provenance: CatalogProvenance;
83
124
  } {
84
125
  const shipped = loadShippedCatalog(root);
85
126
  const overlayPath = join(projectDir, 'qa', 'data-factory', 'common.overwrite.yaml');
86
127
  const warnings: OverwriteWarning[] = [];
87
- if (!existsSync(overlayPath)) return { catalog: shipped, warnings };
128
+ const provenance: CatalogProvenance = { fieldTypes: {}, newFieldTypes: [], security: { added: [], replaced: [] }, techniques: { added: [], replaced: [] } };
129
+ if (!existsSync(overlayPath)) return { catalog: shipped, warnings, provenance };
88
130
 
89
131
  const overlay = parseYaml(readFileSync(overlayPath, 'utf8')) || {};
90
- const overlayFieldTypes: Record<string, Partial<FieldType>> = overlay.fieldTypes || overlay['field-types'] || {};
132
+ const overlayFieldTypes: Record<string, FieldTypeOverlay> = overlay.fieldTypes || overlay['field-types'] || {};
91
133
  const merged: Catalog = {
92
134
  fieldTypes: { ...shipped.fieldTypes },
93
135
  security: { ...shipped.security },
94
136
  techniques: { ...shipped.techniques },
137
+ experience: { ...shipped.experience },
95
138
  };
139
+
96
140
  for (const [type, ov] of Object.entries(overlayFieldTypes)) {
97
141
  if (merged.fieldTypes[type]) {
98
- merged.fieldTypes[type] = mergeFieldType(merged.fieldTypes[type], ov);
142
+ const prov: FieldTypeProvenance = { type, disabled: [], replaced: [], added: [], constraintsOverridden: [], securityChanged: false };
143
+ // Warn on a disable target that does not exist in the shipped field-type (likely a typo/stale id).
144
+ const known = new Set([...(merged.fieldTypes[type].valid || []), ...(merged.fieldTypes[type].boundary || []), ...(merged.fieldTypes[type].invalid || [])].map((d) => d.id));
145
+ for (const id of ov.disable || []) {
146
+ 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` });
147
+ }
148
+ merged.fieldTypes[type] = mergeFieldType(merged.fieldTypes[type], ov, prov);
149
+ provenance.fieldTypes[type] = prov;
99
150
  // Drift warning: overlay references a security bank the shipped common no longer has.
100
151
  for (const bank of ov.uses_security || []) {
101
152
  if (!merged.security[bank]) {
@@ -108,10 +159,45 @@ export function loadEffectiveCatalog(projectDir: string, root = commonDir()): {
108
159
  }
109
160
  } else {
110
161
  // Wholly new field-type from the user — accept as-is (project-specific type).
111
- merged.fieldTypes[type] = ov as FieldType;
162
+ const { disable: _d, ...rest } = ov;
163
+ merged.fieldTypes[type] = rest as FieldType;
164
+ provenance.newFieldTypes.push(type);
112
165
  }
113
166
  }
114
- return { catalog: merged, warnings };
167
+
168
+ // Security-bank overrides (`security:` in the overwrite) — replace same-id payloads, append new banks.
169
+ const overlaySecurity: Record<string, Partial<SecurityBank>> = overlay.security || {};
170
+ for (const [cls, ov] of Object.entries(overlaySecurity)) {
171
+ if (merged.security[cls]) { merged.security[cls] = mergeSecurity(merged.security[cls], ov); provenance.security.replaced.push(cls); }
172
+ else { merged.security[cls] = { class: cls, payloads: [], ...ov } as SecurityBank; provenance.security.added.push(cls); }
173
+ }
174
+
175
+ // Technique overrides (`techniques:` in the overwrite) — shallow replace/add by name.
176
+ const overlayTech: Record<string, Partial<Technique>> = overlay.techniques || {};
177
+ for (const [name, ov] of Object.entries(overlayTech)) {
178
+ if (merged.techniques[name]) { merged.techniques[name] = { ...merged.techniques[name], ...ov }; provenance.techniques.replaced.push(name); }
179
+ else { merged.techniques[name] = { technique: name, ...ov } as Technique; provenance.techniques.added.push(name); }
180
+ }
181
+
182
+ return { catalog: merged, warnings, provenance };
183
+ }
184
+
185
+ /** Human-readable one-liners describing what `.overwrite` changed (for lint/sensor "no silent overwrite"). */
186
+ export function describeProvenance(p: CatalogProvenance): string[] {
187
+ const lines: string[] = [];
188
+ for (const ft of Object.values(p.fieldTypes)) {
189
+ const parts: string[] = [];
190
+ if (ft.disabled.length) parts.push(`disabled ${ft.disabled.join(', ')}`);
191
+ if (ft.replaced.length) parts.push(`replaced ${ft.replaced.join(', ')}`);
192
+ if (ft.added.length) parts.push(`added ${ft.added.join(', ')}`);
193
+ if (ft.constraintsOverridden.length) parts.push(`constraints[${ft.constraintsOverridden.join(', ')}]`);
194
+ if (ft.securityChanged) parts.push('uses_security replaced');
195
+ if (parts.length) lines.push(`${ft.type}: ${parts.join('; ')}`);
196
+ }
197
+ for (const t of p.newFieldTypes) lines.push(`${t}: new project field-type`);
198
+ 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('; ')}`);
199
+ 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('; ')}`);
200
+ return lines;
115
201
  }
116
202
 
117
203
  /** Stable hash of the shipped common — recorded in df-manifest.json to detect update drift. */
package/src/cli.ts CHANGED
@@ -10,13 +10,35 @@ 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 { coverageUnit } from './coverage';
19
+ import { detectPreconditionSignals } from './detect';
15
20
  import { lintFieldMap, summarize } from './lint';
21
+ import { validateGenerated } from './validate';
16
22
  import { generateFixture } from './fixture';
17
23
  import { ensureDataFactoryReadme } from './readme';
24
+ import type { SelectionContext, Profile } from './types';
18
25
 
19
26
  const FIELDMAP_DIR = (cwd: string) => join(cwd, 'qa', 'data-factory');
27
+
28
+ const PROFILES: Profile[] = ['regression', 'functional', 'smoke', 'security-min'];
29
+
30
+ /** Build a SelectionContext from the shared --profile/--locale/--channel/--sink flags. */
31
+ function ctxFromOpts(o: { profile?: string; locale?: string; channel?: string; sink?: string }): SelectionContext {
32
+ const ctx: SelectionContext = {};
33
+ if (o.profile) {
34
+ if (!PROFILES.includes(o.profile as Profile)) throw new Error(`--profile must be one of ${PROFILES.join(' | ')}`);
35
+ ctx.profile = o.profile as Profile;
36
+ }
37
+ if (o.locale) ctx.locale = o.locale;
38
+ if (o.channel === 'api' || o.channel === 'ui') ctx.channel = o.channel;
39
+ if (o.sink) ctx.sink = o.sink;
40
+ return ctx;
41
+ }
20
42
  const fieldMapPath = (cwd: string, name: string) => join(FIELDMAP_DIR(cwd), `${name}.fields.yaml`);
21
43
 
22
44
  /** Resolve where a unit's test-data lives: screen or flow (mirrors sungen auto-detect). */
@@ -47,17 +69,24 @@ function loadFieldMap(cwd: string, name: string): FieldMap | null {
47
69
  return parseYaml(readFileSync(p, 'utf8')) as FieldMap;
48
70
  }
49
71
 
50
- async function genOne(cwd: string, name: string, opts: { force?: boolean }): Promise<'written' | 'skipped' | 'missing'> {
72
+ async function genOne(cwd: string, name: string, opts: { force?: boolean; ctx?: SelectionContext }): Promise<'written' | 'skipped' | 'missing'> {
51
73
  const map = loadFieldMap(cwd, name);
52
74
  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'; }
53
- const { catalog, warnings } = loadEffectiveCatalog(cwd);
75
+ const { catalog, warnings, provenance } = loadEffectiveCatalog(cwd);
54
76
  for (const w of warnings) console.warn(` ⚠ overwrite: ${w.detail} → ${w.suggestion}`);
77
+ for (const line of describeProvenance(provenance)) console.log(` ⟲ .overwrite → ${line}`);
55
78
 
56
- const expanded = expandFieldMap(map, catalog);
79
+ const expanded = expandFieldMap(map, catalog, opts.ctx || {});
80
+ const na = expanded.flatMap((f) => (f.excluded || []).map((e) => `${f.name}.${e.id}`));
81
+ if (na.length) console.log(` ⊘ ${name}: ${na.length} case(s) not applicable for this context (see _not_applicable in the file)`);
57
82
  const out = testDataPath(cwd, name);
58
83
  if (!out) { console.error(` ✗ ${name}: no qa/screens/${name} or qa/flows/${name}`); return 'missing'; }
59
84
 
60
85
  const dfData = renderTestData(expanded) as Record<string, any>;
86
+ // Reproducibility fingerprint (M3-C): stamped into the file so a reader/cache can tell whether the
87
+ // catalog/generator/context that produced this data has changed. Same fingerprint ⇒ identical data.
88
+ const fp = fingerprint(buildRequest(map, opts.ctx || {}), catalog);
89
+ dfData._fingerprint = fp;
61
90
  const exists = existsSync(out);
62
91
 
63
92
  // MERGE, don't clobber. Data Factory owns only the keys it generates (`_generated_by` + each
@@ -95,10 +124,16 @@ export function registerDataFactoryCommand(program: any): void {
95
124
  .option('--all', 'Generate for every field-map under qa/data-factory/ (requires confirm)')
96
125
  .option('--yes', 'Assume yes for the --all confirmation')
97
126
  .option('--force', 'Overwrite existing test-data without asking (implies --yes)')
98
- .action(async (opts: { screen?: string; all?: boolean; yes?: boolean; force?: boolean }) => {
127
+ .option('--profile <name>', `Case selection: ${PROFILES.join(' | ')} (default: regression = all)`)
128
+ .option('--locale <tag>', 'Selection context locale (e.g. ja-JP) — filters locale-gated cases')
129
+ .option('--channel <api|ui>', 'Selection context channel')
130
+ .option('--sink <name>', 'Selection context output sink (e.g. html)')
131
+ .action(async (opts: { screen?: string; all?: boolean; yes?: boolean; force?: boolean; profile?: string; locale?: string; channel?: string; sink?: string }) => {
99
132
  const cwd = process.cwd();
100
133
  ensureDataFactoryReadme(cwd);
101
- if (opts.screen) { await genOne(cwd, opts.screen, opts); return; }
134
+ let ctx: SelectionContext;
135
+ try { ctx = ctxFromOpts(opts); } catch (e: any) { console.error(e.message); process.exitCode = 1; return; }
136
+ if (opts.screen) { await genOne(cwd, opts.screen, { ...opts, ctx }); return; }
102
137
  if (!opts.all) {
103
138
  console.error('Specify --screen <name> for one unit, or --all for every unit (with confirmation).');
104
139
  process.exitCode = 1;
@@ -115,7 +150,7 @@ export function registerDataFactoryCommand(program: any): void {
115
150
  if (a !== 'y' && a !== 'yes') { console.log('Cancelled.'); return; }
116
151
  }
117
152
  let written = 0;
118
- for (const n of names) if ((await genOne(cwd, n, { force: opts.force })) === 'written') written++;
153
+ for (const n of names) if ((await genOne(cwd, n, { force: opts.force, ctx })) === 'written') written++;
119
154
  console.log(`Done: ${written}/${names.length} written.`);
120
155
  });
121
156
 
@@ -123,9 +158,15 @@ export function registerDataFactoryCommand(program: any): void {
123
158
  .command('lint')
124
159
  .description('Check test-data coverage (valid/boundary/invalid + traceability) per phuong-phap §6')
125
160
  .option('--screen <name>', 'Lint one unit (default: all field-maps)')
126
- .action((opts: { screen?: string }) => {
161
+ .option('--profile <name>', `Case selection: ${PROFILES.join(' | ')} (default: regression = all)`)
162
+ .option('--locale <tag>', 'Selection context locale (e.g. ja-JP)')
163
+ .option('--channel <api|ui>', 'Selection context channel')
164
+ .option('--sink <name>', 'Selection context output sink')
165
+ .action((opts: { screen?: string; profile?: string; locale?: string; channel?: string; sink?: string }) => {
127
166
  const cwd = process.cwd();
128
167
  ensureDataFactoryReadme(cwd);
168
+ let ctx: SelectionContext;
169
+ try { ctx = ctxFromOpts(opts); } catch (e: any) { console.error(e.message); process.exitCode = 1; return; }
129
170
  const names = opts.screen ? [opts.screen] : listFieldMaps(cwd);
130
171
  if (names.length === 0) { console.error('No field-maps under qa/data-factory/*.fields.yaml.'); process.exitCode = 1; return; }
131
172
  const { catalog } = loadEffectiveCatalog(cwd);
@@ -133,7 +174,7 @@ export function registerDataFactoryCommand(program: any): void {
133
174
  for (const name of names) {
134
175
  const map = loadFieldMap(cwd, name);
135
176
  if (!map) { console.error(`✗ ${name}: no field-map`); totalErrors++; continue; }
136
- const { issues } = lintFieldMap(map, catalog);
177
+ const { issues } = lintFieldMap(map, catalog, ctx);
137
178
  const s = summarize(issues);
138
179
  totalErrors += s.errors;
139
180
  const badge = s.errors ? '✗' : s.warns ? '⚠' : '✓';
@@ -146,12 +187,138 @@ export function registerDataFactoryCommand(program: any): void {
146
187
  if (totalErrors) process.exitCode = 1;
147
188
  });
148
189
 
190
+ data
191
+ .command('validate')
192
+ .description('Verify every generated value is semantically correct (valid values satisfy the constraints; synthesized invalid values violate them)')
193
+ .option('--screen <name>', 'Validate one unit (default: all field-maps)')
194
+ .option('--profile <name>', `Case selection: ${PROFILES.join(' | ')} (default: regression = all)`)
195
+ .option('--locale <tag>', 'Selection context locale (e.g. ja-JP)')
196
+ .option('--channel <api|ui>', 'Selection context channel')
197
+ .option('--sink <name>', 'Selection context output sink')
198
+ .action((opts: { screen?: string; profile?: string; locale?: string; channel?: string; sink?: string }) => {
199
+ const cwd = process.cwd();
200
+ ensureDataFactoryReadme(cwd);
201
+ let ctx: SelectionContext;
202
+ try { ctx = ctxFromOpts(opts); } catch (e: any) { console.error(e.message); process.exitCode = 1; return; }
203
+ const names = opts.screen ? [opts.screen] : listFieldMaps(cwd);
204
+ if (names.length === 0) { console.error('No field-maps under qa/data-factory/*.fields.yaml.'); process.exitCode = 1; return; }
205
+ const { catalog } = loadEffectiveCatalog(cwd);
206
+ let totalErrors = 0;
207
+ for (const name of names) {
208
+ const map = loadFieldMap(cwd, name);
209
+ if (!map) { console.error(`✗ ${name}: no field-map`); totalErrors++; continue; }
210
+ const findings = validateGenerated(map, catalog, ctx);
211
+ const errors = findings.filter((f) => f.severity === 'error').length;
212
+ const warns = findings.filter((f) => f.severity === 'warn').length;
213
+ totalErrors += errors;
214
+ const badge = errors ? '✗' : warns ? '⚠' : '✓';
215
+ console.log(`${badge} ${name}: ${errors} error · ${warns} warn`);
216
+ for (const f of findings) {
217
+ console.log(`${f.severity === 'error' ? ' ✗' : ' ⚠'} [${f.field}] ${f.code}: ${f.message}`);
218
+ }
219
+ }
220
+ if (totalErrors) process.exitCode = 1;
221
+ });
222
+
223
+ data
224
+ .command('provenance')
225
+ .description('Show what qa/data-factory/common.overwrite.yaml changed vs the shipped company catalog (extend/replace/disable) — "no silent overwrite"')
226
+ .action(() => {
227
+ const cwd = process.cwd();
228
+ ensureDataFactoryReadme(cwd);
229
+ const { provenance, warnings } = loadEffectiveCatalog(cwd);
230
+ const lines = describeProvenance(provenance);
231
+ if (lines.length === 0) {
232
+ console.log('No project overrides — the effective catalog is the shipped company common as-is.');
233
+ } else {
234
+ console.log('Project overrides (qa/data-factory/common.overwrite.yaml):');
235
+ for (const l of lines) console.log(` ⟲ ${l}`);
236
+ }
237
+ for (const w of warnings) console.warn(` ⚠ ${w.detail} → ${w.suggestion}`);
238
+ });
239
+
240
+ data
241
+ .command('crosscheck')
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')
243
+ .option('--screen <name>', 'Cross-check one unit (default: all field-maps + all shared datasets)')
244
+ .action((opts: { screen?: string }) => {
245
+ const cwd = process.cwd();
246
+ ensureDataFactoryReadme(cwd);
247
+ const names = opts.screen ? [opts.screen] : listFieldMaps(cwd);
248
+ const { catalog } = loadEffectiveCatalog(cwd);
249
+ const findings = [
250
+ ...names.flatMap((n) => crosscheckUnit(cwd, n)),
251
+ ...(opts.screen ? [] : crosscheckShared(cwd)),
252
+ ...names.flatMap((n) => coverageUnit(cwd, n, catalog)),
253
+ ];
254
+ const errors = findings.filter((f) => f.severity === 'error').length;
255
+ const warns = findings.filter((f) => f.severity === 'warn').length;
256
+ const badge = errors ? '✗' : warns ? '⚠' : '✓';
257
+ console.log(`${badge} crosscheck: ${errors} error · ${warns} warn (${names.length} unit(s))`);
258
+ for (const f of findings) console.log(` ${f.severity === 'error' ? '✗' : f.severity === 'warn' ? '⚠' : '·'} [${(f as any).unit}] ${f.code}: ${f.message}`);
259
+ if (errors) process.exitCode = 1;
260
+ });
261
+
262
+ data
263
+ .command('state')
264
+ .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)')
265
+ .option('--recipe <name>', 'Only this recipe (default: all recipes with a `resources`/`shared` graph)')
266
+ .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')
267
+ .option('--force', 'With --scaffold: overwrite an existing recipe of the same name')
268
+ .action((opts: { recipe?: string; scaffold?: string; force?: boolean }) => {
269
+ const cwd = process.cwd();
270
+ ensureDataFactoryReadme(cwd);
271
+
272
+ // --scaffold: auto-draft a recipe skeleton from the unit's spec precondition signals.
273
+ if (opts.scaffold) {
274
+ const unit = opts.scaffold;
275
+ const specPath = ['screens', 'flows'].map((k) => join(cwd, 'qa', k, unit, 'requirements', 'spec.md')).find(existsSync);
276
+ const specText = specPath ? readFileSync(specPath, 'utf8') : '';
277
+ const signals = detectPreconditionSignals(specText);
278
+ const recipe = scaffoldRecipe(unit, signals);
279
+ if (!recipe) { console.log(`No stateful-precondition signals found in ${unit}'s spec — nothing to scaffold (a screen may simply have no precondition).`); return; }
280
+ const recipesPath = join(FIELDMAP_DIR(cwd), 'recipes.yaml');
281
+ const doc: any = existsSync(recipesPath) ? (parseYaml(readFileSync(recipesPath, 'utf8')) || {}) : {};
282
+ doc.recipes = doc.recipes || {};
283
+ 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; }
284
+ // Splice the human-friendly (commented) block rather than a bare re-serialize, to preserve TODO hints.
285
+ const header = existsSync(recipesPath) ? readFileSync(recipesPath, 'utf8').replace(/\s*$/, '') + '\n' : 'recipes:\n';
286
+ const block = renderRecipeYaml(recipe);
287
+ writeFileSync(recipesPath, (header.includes('recipes:') ? header : 'recipes:\n' + header) + block, 'utf8');
288
+ console.log(` ✓ scaffolded recipe "${recipe.name}" → ${recipesPath.replace(cwd + '/', '')} (${signals.length} signal(s))`);
289
+ console.log(` → REVIEW: name each entity, set desiredState/bindings, confirm domain values with QA, then \`sungen data state --recipe ${recipe.name}\`.`);
290
+ for (const s of signals) console.log(` · signal: ${s}`);
291
+ return;
292
+ }
293
+
294
+ let recipes = loadStateRecipes(cwd);
295
+ if (opts.recipe) recipes = recipes.filter((r) => r.name === opts.recipe);
296
+ 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; }
297
+ const ds = hasDatasource(cwd);
298
+ console.log(`Data Factory — domain-state (${recipes.length} recipe(s); datasource ${ds ? 'present → Layer 1 possible' : 'absent → Layer 0 manual preconditions'})`);
299
+ let errors = 0;
300
+ for (const r of recipes) {
301
+ const findings = validateStateRecipe(r);
302
+ errors += findings.filter((f) => f.severity === 'error').length;
303
+ const plan = buildStatePlan(r, { datasource: ds });
304
+ const badge = findings.some((f) => f.severity === 'error') ? '✗' : findings.some((f) => f.severity === 'warn') ? '⚠' : '✓';
305
+ console.log(`\n${badge} ${r.name}${plan.manual ? ' [manual setup]' : ' [auto-provisionable]'}`);
306
+ for (const f of findings) console.log(` ${f.severity === 'error' ? '✗' : f.severity === 'warn' ? '⚠' : '·'} ${f.code}: ${f.message}`);
307
+ for (const line of renderPreconditionBlock(plan).split('\n')) console.log(` ${line}`);
308
+ if (plan.sharedIdentity) console.log(` # shared dataset: "${plan.sharedIdentity.entity}" identical across ${plan.sharedIdentity.screens.join(', ')} (asserted by \`data crosscheck\`)`);
309
+ const planPath = join(FIELDMAP_DIR(cwd), `${r.name}.state.yaml`);
310
+ writeFileSync(planPath, stringifyYaml({ _generated_by: 'sungen data state — resolved domain-state plan (Layer 0). Edit recipes.yaml, not this file.', ...plan }), 'utf8');
311
+ console.log(` ✓ wrote ${planPath.replace(cwd + '/', '')}`);
312
+ }
313
+ if (errors) process.exitCode = 1;
314
+ });
315
+
149
316
  data
150
317
  .command('fixture')
151
318
  .description('Generate a CSV import fixture from the field-map (bulk-import / file-upload testing)')
152
319
  .requiredOption('--screen <name>', 'Unit whose field-map to build a fixture from')
153
320
  .option('--rows <n>', 'Number of valid rows', '3')
154
- .option('--invalid', 'Also write <name>.invalid.csv (one seeded-bad row per field + _violation column)')
321
+ .option('--invalid', 'Also write <name>.invalid.csv (one seeded-bad row per field) + <name>.invalid.expected.yaml (per-row expected rejection)')
155
322
  .option('--force', 'Overwrite existing fixture file(s) without asking')
156
323
  .action(async (opts: { screen: string; rows?: string; invalid?: boolean; force?: boolean }) => {
157
324
  const cwd = process.cwd();
@@ -165,12 +332,18 @@ export function registerDataFactoryCommand(program: any): void {
165
332
  const validPath = join(dir, `${opts.screen}.csv`);
166
333
  const writes: Array<[string, string]> = [[validPath, res.valid]];
167
334
  if (res.invalid) writes.push([join(dir, `${opts.screen}.invalid.csv`), res.invalid]);
335
+ // Sidecar: expected rejection per row — keeps the CSV payload clean (no _violation column).
336
+ if (res.expected) writes.push([join(dir, `${opts.screen}.invalid.expected.yaml`), stringifyYaml(res.expected)]);
168
337
  for (const [p] of writes) {
169
338
  if (existsSync(p) && !opts.force) {
170
339
  const a = (await ask(` ? ${p.replace(cwd + '/', '')} exists — overwrite? [y/N] `)).toLowerCase();
171
340
  if (a !== 'y' && a !== 'yes') { console.log(' · skipped'); return; }
172
341
  }
173
342
  }
174
- for (const [p, content] of writes) { writeFileSync(p, content, 'utf8'); console.log(` ✓ wrote ${p.replace(cwd + '/', '')} (${res.headers.length} columns)`); }
343
+ for (const [p, content] of writes) {
344
+ writeFileSync(p, content, 'utf8');
345
+ const label = p.endsWith('.expected.yaml') ? 'expected-rejection sidecar' : `${res.headers.length} columns`;
346
+ console.log(` ✓ wrote ${p.replace(cwd + '/', '')} (${label})`);
347
+ }
175
348
  });
176
349
  }
@@ -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
+ }