@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.
- package/common/experience/auth-account-states.yaml +19 -0
- package/common/field-types/currency.yaml +1 -1
- package/common/field-types/email.yaml +4 -1
- package/common/field-types/number-float.yaml +1 -1
- package/common/field-types/number-integer.yaml +2 -2
- package/common/field-types/password.yaml +1 -1
- package/common/field-types/phone.yaml +1 -1
- package/common/field-types/postcode.yaml +1 -1
- package/common/field-types/string.yaml +1 -1
- package/package.json +3 -3
- package/src/applicability.ts +80 -0
- package/src/boundary.ts +319 -0
- package/src/catalog-loader.ts +102 -16
- package/src/cli.ts +181 -11
- package/src/contract.ts +93 -0
- package/src/crosscheck.ts +149 -0
- package/src/detect.ts +122 -0
- package/src/fixture.ts +42 -10
- package/src/generator.ts +161 -89
- package/src/index.ts +16 -3
- package/src/lint.ts +8 -3
- package/src/sensor.ts +32 -4
- package/src/state.ts +225 -0
- package/src/types.ts +81 -0
- package/src/validate.ts +77 -0
package/src/catalog-loader.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
56
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
162
|
+
const { disable: _d, ...rest } = ov;
|
|
163
|
+
merged.fieldTypes[type] = rest as FieldType;
|
|
164
|
+
provenance.newFieldTypes.push(type);
|
|
112
165
|
}
|
|
113
166
|
}
|
|
114
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
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
|
-
.
|
|
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 +
|
|
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) {
|
|
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
|
}
|
package/src/contract.ts
ADDED
|
@@ -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
|
+
}
|