@sungen/driver-data-factory 3.2.12-beta.1 → 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.
@@ -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,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';
16
21
  import { generateFixture } from './fixture';
17
22
  import { ensureDataFactoryReadme } from './readme';
23
+ import type { SelectionContext, Profile } from './types';
18
24
 
19
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
+ }
20
41
  const fieldMapPath = (cwd: string, name: string) => join(FIELDMAP_DIR(cwd), `${name}.fields.yaml`);
21
42
 
22
43
  /** Resolve where a unit's test-data lives: screen or flow (mirrors sungen auto-detect). */
@@ -47,17 +68,24 @@ function loadFieldMap(cwd: string, name: string): FieldMap | null {
47
68
  return parseYaml(readFileSync(p, 'utf8')) as FieldMap;
48
69
  }
49
70
 
50
- 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'> {
51
72
  const map = loadFieldMap(cwd, name);
52
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'; }
53
- const { catalog, warnings } = loadEffectiveCatalog(cwd);
74
+ const { catalog, warnings, provenance } = loadEffectiveCatalog(cwd);
54
75
  for (const w of warnings) console.warn(` ⚠ overwrite: ${w.detail} → ${w.suggestion}`);
76
+ for (const line of describeProvenance(provenance)) console.log(` ⟲ .overwrite → ${line}`);
55
77
 
56
- 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)`);
57
81
  const out = testDataPath(cwd, name);
58
82
  if (!out) { console.error(` ✗ ${name}: no qa/screens/${name} or qa/flows/${name}`); return 'missing'; }
59
83
 
60
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;
61
89
  const exists = existsSync(out);
62
90
 
63
91
  // MERGE, don't clobber. Data Factory owns only the keys it generates (`_generated_by` + each
@@ -95,10 +123,16 @@ export function registerDataFactoryCommand(program: any): void {
95
123
  .option('--all', 'Generate for every field-map under qa/data-factory/ (requires confirm)')
96
124
  .option('--yes', 'Assume yes for the --all confirmation')
97
125
  .option('--force', 'Overwrite existing test-data without asking (implies --yes)')
98
- .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 }) => {
99
131
  const cwd = process.cwd();
100
132
  ensureDataFactoryReadme(cwd);
101
- if (opts.screen) { await genOne(cwd, opts.screen, opts); return; }
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; }
102
136
  if (!opts.all) {
103
137
  console.error('Specify --screen <name> for one unit, or --all for every unit (with confirmation).');
104
138
  process.exitCode = 1;
@@ -115,7 +149,7 @@ export function registerDataFactoryCommand(program: any): void {
115
149
  if (a !== 'y' && a !== 'yes') { console.log('Cancelled.'); return; }
116
150
  }
117
151
  let written = 0;
118
- 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++;
119
153
  console.log(`Done: ${written}/${names.length} written.`);
120
154
  });
121
155
 
@@ -123,9 +157,15 @@ export function registerDataFactoryCommand(program: any): void {
123
157
  .command('lint')
124
158
  .description('Check test-data coverage (valid/boundary/invalid + traceability) per phuong-phap §6')
125
159
  .option('--screen <name>', 'Lint one unit (default: all field-maps)')
126
- .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 }) => {
127
165
  const cwd = process.cwd();
128
166
  ensureDataFactoryReadme(cwd);
167
+ let ctx: SelectionContext;
168
+ try { ctx = ctxFromOpts(opts); } catch (e: any) { console.error(e.message); process.exitCode = 1; return; }
129
169
  const names = opts.screen ? [opts.screen] : listFieldMaps(cwd);
130
170
  if (names.length === 0) { console.error('No field-maps under qa/data-factory/*.fields.yaml.'); process.exitCode = 1; return; }
131
171
  const { catalog } = loadEffectiveCatalog(cwd);
@@ -133,7 +173,7 @@ export function registerDataFactoryCommand(program: any): void {
133
173
  for (const name of names) {
134
174
  const map = loadFieldMap(cwd, name);
135
175
  if (!map) { console.error(`✗ ${name}: no field-map`); totalErrors++; continue; }
136
- const { issues } = lintFieldMap(map, catalog);
176
+ const { issues } = lintFieldMap(map, catalog, ctx);
137
177
  const s = summarize(issues);
138
178
  totalErrors += s.errors;
139
179
  const badge = s.errors ? '✗' : s.warns ? '⚠' : '✓';
@@ -146,12 +186,136 @@ export function registerDataFactoryCommand(program: any): void {
146
186
  if (totalErrors) process.exitCode = 1;
147
187
  });
148
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
+
149
313
  data
150
314
  .command('fixture')
151
315
  .description('Generate a CSV import fixture from the field-map (bulk-import / file-upload testing)')
152
316
  .requiredOption('--screen <name>', 'Unit whose field-map to build a fixture from')
153
317
  .option('--rows <n>', 'Number of valid rows', '3')
154
- .option('--invalid', 'Also write <name>.invalid.csv (one seeded-bad row per field + _violation column)')
318
+ .option('--invalid', 'Also write <name>.invalid.csv (one seeded-bad row per field) + <name>.invalid.expected.yaml (per-row expected rejection)')
155
319
  .option('--force', 'Overwrite existing fixture file(s) without asking')
156
320
  .action(async (opts: { screen: string; rows?: string; invalid?: boolean; force?: boolean }) => {
157
321
  const cwd = process.cwd();
@@ -165,12 +329,18 @@ export function registerDataFactoryCommand(program: any): void {
165
329
  const validPath = join(dir, `${opts.screen}.csv`);
166
330
  const writes: Array<[string, string]> = [[validPath, res.valid]];
167
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)]);
168
334
  for (const [p] of writes) {
169
335
  if (existsSync(p) && !opts.force) {
170
336
  const a = (await ask(` ? ${p.replace(cwd + '/', '')} exists — overwrite? [y/N] `)).toLowerCase();
171
337
  if (a !== 'y' && a !== 'yes') { console.log(' · skipped'); return; }
172
338
  }
173
339
  }
174
- for (const [p, content] of writes) { writeFileSync(p, content, 'utf8'); console.log(` ✓ wrote ${p.replace(cwd + '/', '')} (${res.headers.length} columns)`); }
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
+ }
175
345
  });
176
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
+ }