@sungen/driver-data-factory 3.2.12-beta.5 → 3.2.12-beta.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/cli.ts +64 -2
- package/src/contract.ts +93 -0
- package/src/crosscheck.ts +125 -0
- package/src/fixture.ts +42 -10
- package/src/index.ts +7 -1
- package/src/state.ts +178 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sungen/driver-data-factory",
|
|
3
|
-
"version": "3.2.12-beta.
|
|
3
|
+
"version": "3.2.12-beta.7",
|
|
4
4
|
"description": "Sungen Data Factory capability — standardized test-data generation from a field-type + security knowledge catalog (valid/boundary/invalid + traceability), the .overwrite merge layer, and (opt-in) @dataFactory runtime seeding. Design-time knowledge is runtime-free. Plugs into @sun-asterisk/sungen via the capability SPI.",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"author": "eqe team (engineer & quality) — Sun Asterisk",
|
|
15
15
|
"license": "MIT",
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@sun-asterisk/sungen": "3.2.12-beta.
|
|
17
|
+
"@sun-asterisk/sungen": "3.2.12-beta.7",
|
|
18
18
|
"yaml": "^2.4.0"
|
|
19
19
|
},
|
|
20
20
|
"files": [
|
package/src/cli.ts
CHANGED
|
@@ -12,6 +12,9 @@ import { createInterface } from 'readline';
|
|
|
12
12
|
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
13
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 } from './state';
|
|
17
|
+
import { crosscheckUnit, crosscheckShared } from './crosscheck';
|
|
15
18
|
import { lintFieldMap, summarize } from './lint';
|
|
16
19
|
import { validateGenerated } from './validate';
|
|
17
20
|
import { generateFixture } from './fixture';
|
|
@@ -78,6 +81,10 @@ async function genOne(cwd: string, name: string, opts: { force?: boolean; ctx?:
|
|
|
78
81
|
if (!out) { console.error(` ✗ ${name}: no qa/screens/${name} or qa/flows/${name}`); return 'missing'; }
|
|
79
82
|
|
|
80
83
|
const dfData = renderTestData(expanded) as Record<string, any>;
|
|
84
|
+
// Reproducibility fingerprint (M3-C): stamped into the file so a reader/cache can tell whether the
|
|
85
|
+
// catalog/generator/context that produced this data has changed. Same fingerprint ⇒ identical data.
|
|
86
|
+
const fp = fingerprint(buildRequest(map, opts.ctx || {}), catalog);
|
|
87
|
+
dfData._fingerprint = fp;
|
|
81
88
|
const exists = existsSync(out);
|
|
82
89
|
|
|
83
90
|
// MERGE, don't clobber. Data Factory owns only the keys it generates (`_generated_by` + each
|
|
@@ -228,12 +235,61 @@ export function registerDataFactoryCommand(program: any): void {
|
|
|
228
235
|
for (const w of warnings) console.warn(` ⚠ ${w.detail} → ${w.suggestion}`);
|
|
229
236
|
});
|
|
230
237
|
|
|
238
|
+
data
|
|
239
|
+
.command('crosscheck')
|
|
240
|
+
.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')
|
|
241
|
+
.option('--screen <name>', 'Cross-check one unit (default: all field-maps + all shared datasets)')
|
|
242
|
+
.action((opts: { screen?: string }) => {
|
|
243
|
+
const cwd = process.cwd();
|
|
244
|
+
ensureDataFactoryReadme(cwd);
|
|
245
|
+
const names = opts.screen ? [opts.screen] : listFieldMaps(cwd);
|
|
246
|
+
const findings = [
|
|
247
|
+
...names.flatMap((n) => crosscheckUnit(cwd, n)),
|
|
248
|
+
...(opts.screen ? [] : crosscheckShared(cwd)),
|
|
249
|
+
];
|
|
250
|
+
const errors = findings.filter((f) => f.severity === 'error').length;
|
|
251
|
+
const warns = findings.filter((f) => f.severity === 'warn').length;
|
|
252
|
+
const badge = errors ? '✗' : warns ? '⚠' : '✓';
|
|
253
|
+
console.log(`${badge} crosscheck: ${errors} error · ${warns} warn (${names.length} unit(s))`);
|
|
254
|
+
for (const f of findings) console.log(` ${f.severity === 'error' ? '✗' : f.severity === 'warn' ? '⚠' : '·'} [${f.unit}] ${f.code}: ${f.message}`);
|
|
255
|
+
if (errors) process.exitCode = 1;
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
data
|
|
259
|
+
.command('state')
|
|
260
|
+
.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)')
|
|
261
|
+
.option('--recipe <name>', 'Only this recipe (default: all recipes with a `resources`/`shared` graph)')
|
|
262
|
+
.action((opts: { recipe?: string }) => {
|
|
263
|
+
const cwd = process.cwd();
|
|
264
|
+
ensureDataFactoryReadme(cwd);
|
|
265
|
+
let recipes = loadStateRecipes(cwd);
|
|
266
|
+
if (opts.recipe) recipes = recipes.filter((r) => r.name === opts.recipe);
|
|
267
|
+
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; }
|
|
268
|
+
const ds = hasDatasource(cwd);
|
|
269
|
+
console.log(`Data Factory — domain-state (${recipes.length} recipe(s); datasource ${ds ? 'present → Layer 1 possible' : 'absent → Layer 0 manual preconditions'})`);
|
|
270
|
+
let errors = 0;
|
|
271
|
+
for (const r of recipes) {
|
|
272
|
+
const findings = validateStateRecipe(r);
|
|
273
|
+
errors += findings.filter((f) => f.severity === 'error').length;
|
|
274
|
+
const plan = buildStatePlan(r, { datasource: ds });
|
|
275
|
+
const badge = findings.some((f) => f.severity === 'error') ? '✗' : findings.some((f) => f.severity === 'warn') ? '⚠' : '✓';
|
|
276
|
+
console.log(`\n${badge} ${r.name}${plan.manual ? ' [manual setup]' : ' [auto-provisionable]'}`);
|
|
277
|
+
for (const f of findings) console.log(` ${f.severity === 'error' ? '✗' : f.severity === 'warn' ? '⚠' : '·'} ${f.code}: ${f.message}`);
|
|
278
|
+
for (const line of renderPreconditionBlock(plan).split('\n')) console.log(` ${line}`);
|
|
279
|
+
if (plan.sharedIdentity) console.log(` # shared dataset: "${plan.sharedIdentity.entity}" identical across ${plan.sharedIdentity.screens.join(', ')} (asserted by \`data crosscheck\`)`);
|
|
280
|
+
const planPath = join(FIELDMAP_DIR(cwd), `${r.name}.state.yaml`);
|
|
281
|
+
writeFileSync(planPath, stringifyYaml({ _generated_by: 'sungen data state — resolved domain-state plan (Layer 0). Edit recipes.yaml, not this file.', ...plan }), 'utf8');
|
|
282
|
+
console.log(` ✓ wrote ${planPath.replace(cwd + '/', '')}`);
|
|
283
|
+
}
|
|
284
|
+
if (errors) process.exitCode = 1;
|
|
285
|
+
});
|
|
286
|
+
|
|
231
287
|
data
|
|
232
288
|
.command('fixture')
|
|
233
289
|
.description('Generate a CSV import fixture from the field-map (bulk-import / file-upload testing)')
|
|
234
290
|
.requiredOption('--screen <name>', 'Unit whose field-map to build a fixture from')
|
|
235
291
|
.option('--rows <n>', 'Number of valid rows', '3')
|
|
236
|
-
.option('--invalid', 'Also write <name>.invalid.csv (one seeded-bad row per field +
|
|
292
|
+
.option('--invalid', 'Also write <name>.invalid.csv (one seeded-bad row per field) + <name>.invalid.expected.yaml (per-row expected rejection)')
|
|
237
293
|
.option('--force', 'Overwrite existing fixture file(s) without asking')
|
|
238
294
|
.action(async (opts: { screen: string; rows?: string; invalid?: boolean; force?: boolean }) => {
|
|
239
295
|
const cwd = process.cwd();
|
|
@@ -247,12 +303,18 @@ export function registerDataFactoryCommand(program: any): void {
|
|
|
247
303
|
const validPath = join(dir, `${opts.screen}.csv`);
|
|
248
304
|
const writes: Array<[string, string]> = [[validPath, res.valid]];
|
|
249
305
|
if (res.invalid) writes.push([join(dir, `${opts.screen}.invalid.csv`), res.invalid]);
|
|
306
|
+
// Sidecar: expected rejection per row — keeps the CSV payload clean (no _violation column).
|
|
307
|
+
if (res.expected) writes.push([join(dir, `${opts.screen}.invalid.expected.yaml`), stringifyYaml(res.expected)]);
|
|
250
308
|
for (const [p] of writes) {
|
|
251
309
|
if (existsSync(p) && !opts.force) {
|
|
252
310
|
const a = (await ask(` ? ${p.replace(cwd + '/', '')} exists — overwrite? [y/N] `)).toLowerCase();
|
|
253
311
|
if (a !== 'y' && a !== 'yes') { console.log(' · skipped'); return; }
|
|
254
312
|
}
|
|
255
313
|
}
|
|
256
|
-
for (const [p, content] of writes) {
|
|
314
|
+
for (const [p, content] of writes) {
|
|
315
|
+
writeFileSync(p, content, 'utf8');
|
|
316
|
+
const label = p.endsWith('.expected.yaml') ? 'expected-rejection sidecar' : `${res.headers.length} columns`;
|
|
317
|
+
console.log(` ✓ wrote ${p.replace(cwd + '/', '')} (${label})`);
|
|
318
|
+
}
|
|
257
319
|
});
|
|
258
320
|
}
|
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
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data Factory — Cross-artifact Gate (M3-B).
|
|
3
|
+
*
|
|
4
|
+
* `data validate` checks the DATA in isolation (values are semantically correct). This checks that the
|
|
5
|
+
* TESTCASE and its test-data actually AGREE — the "genuinely new piece" from the harness-merge review:
|
|
6
|
+
* - every `{{var}}` a scenario references resolves to a key in the unit's test-data (no dangling ref);
|
|
7
|
+
* - a cross-screen SHARED dataset (M3-A `shared:`) is present and identical across its member screens
|
|
8
|
+
* (the registration setup→confirmation→register lineage: confirmation must show what setup buffered);
|
|
9
|
+
* - a unit that declares a manual state-precondition (a state-recipe) surfaces it in the feature
|
|
10
|
+
* (a `@manual` tag or a Background) rather than silently dropping the required state.
|
|
11
|
+
*
|
|
12
|
+
* Deterministic; no runtime, no LLM. Invoked from the create-test / create-data-test AI loop (and by
|
|
13
|
+
* `sungen data crosscheck`), exit-1 on an error finding.
|
|
14
|
+
*/
|
|
15
|
+
import { readFileSync, existsSync, readdirSync } from 'fs';
|
|
16
|
+
import { join } from 'path';
|
|
17
|
+
import { parse as parseYaml } from 'yaml';
|
|
18
|
+
import { loadStateRecipes, buildStatePlan, hasDatasource } from './state';
|
|
19
|
+
|
|
20
|
+
export interface CrossFinding {
|
|
21
|
+
unit: string;
|
|
22
|
+
scenario?: string;
|
|
23
|
+
severity: 'error' | 'warn' | 'info';
|
|
24
|
+
code: 'DANGLING_VAR' | 'SHARED_MISSING' | 'SHARED_MISMATCH' | 'PRECONDITION_MISSING';
|
|
25
|
+
message: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Resolve a unit's feature dir + test-data path (screen or flow). */
|
|
29
|
+
function unitPaths(cwd: string, name: string): { featureDir: string; testData: string } | null {
|
|
30
|
+
for (const kind of ['screens', 'flows']) {
|
|
31
|
+
const base = join(cwd, 'qa', kind, name);
|
|
32
|
+
if (existsSync(base)) return { featureDir: join(base, 'features'), testData: join(base, 'test-data', `${name}.yaml`) };
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readFeatureText(featureDir: string): string {
|
|
38
|
+
if (!existsSync(featureDir)) return '';
|
|
39
|
+
return readdirSync(featureDir)
|
|
40
|
+
.filter((f) => f.endsWith('.feature'))
|
|
41
|
+
.map((f) => readFileSync(join(featureDir, f), 'utf8'))
|
|
42
|
+
.join('\n');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Extract `{{var}}` roots referenced in a feature, excluding dynamic `{{$...}}` runtime vars. */
|
|
46
|
+
export function referencedVars(featureText: string): string[] {
|
|
47
|
+
const out = new Set<string>();
|
|
48
|
+
const re = /\{\{\s*([^}]+?)\s*\}\}/g;
|
|
49
|
+
let m: RegExpExecArray | null;
|
|
50
|
+
while ((m = re.exec(featureText))) {
|
|
51
|
+
const tok = m[1].trim();
|
|
52
|
+
if (tok.startsWith('$')) continue; // {{$timestamp}} etc. — runtime dynamic, not a test-data key
|
|
53
|
+
const root = tok.split(/[.|[]/)[0].trim(); // login.email → login ; name|upper → name
|
|
54
|
+
if (root) out.add(root);
|
|
55
|
+
}
|
|
56
|
+
return [...out];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function loadYaml(path: string): Record<string, any> {
|
|
60
|
+
if (!existsSync(path)) return {};
|
|
61
|
+
try { return (parseYaml(readFileSync(path, 'utf8')) as Record<string, any>) || {}; } catch { return {}; }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Cross-check one unit's feature ↔ test-data. */
|
|
65
|
+
export function crosscheckUnit(cwd: string, name: string): CrossFinding[] {
|
|
66
|
+
const out: CrossFinding[] = [];
|
|
67
|
+
const paths = unitPaths(cwd, name);
|
|
68
|
+
if (!paths) return out;
|
|
69
|
+
const feature = readFeatureText(paths.featureDir);
|
|
70
|
+
if (!feature) return out;
|
|
71
|
+
const data = loadYaml(paths.testData);
|
|
72
|
+
const topKeys = new Set(Object.keys(data).filter((k) => !k.startsWith('_')));
|
|
73
|
+
|
|
74
|
+
// 1. Dangling vars — a {{var}} whose root key is absent from test-data.
|
|
75
|
+
for (const v of referencedVars(feature)) {
|
|
76
|
+
if (!topKeys.has(v)) {
|
|
77
|
+
out.push({ unit: name, severity: 'error', code: 'DANGLING_VAR', message: `scenario references {{${v}}} but no "${v}" key exists in test-data/${name}.yaml` });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 3. Manual state-precondition declared but not surfaced in the feature.
|
|
82
|
+
const ds = hasDatasource(cwd);
|
|
83
|
+
for (const r of loadStateRecipes(cwd)) {
|
|
84
|
+
const plan = buildStatePlan(r, { datasource: ds });
|
|
85
|
+
if (plan.manual && new RegExp(`@dataFactory:${r.name}\\b`).test(feature) === false) {
|
|
86
|
+
const surfaced = /@manual/.test(feature) || /Background:/.test(feature);
|
|
87
|
+
if (!surfaced) out.push({ unit: name, severity: 'warn', code: 'PRECONDITION_MISSING', message: `state-recipe "${r.name}" needs a manual precondition, but the feature has no Background / @manual tag — weave its precondition block (\`sungen data state --recipe ${r.name}\`)` });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Cross-check a cross-screen SHARED dataset: present + identical across all member screens. */
|
|
94
|
+
export function crosscheckShared(cwd: string): CrossFinding[] {
|
|
95
|
+
const out: CrossFinding[] = [];
|
|
96
|
+
for (const r of loadStateRecipes(cwd)) {
|
|
97
|
+
if (!r.shared) continue;
|
|
98
|
+
const { entity, screens } = r.shared;
|
|
99
|
+
const datasets = screens.map((s) => {
|
|
100
|
+
const paths = unitPaths(cwd, s);
|
|
101
|
+
const data = paths ? loadYaml(paths.testData) : {};
|
|
102
|
+
return { screen: s, present: paths != null && entity in data, value: data[entity] };
|
|
103
|
+
});
|
|
104
|
+
const present = datasets.filter((d) => d.present);
|
|
105
|
+
for (const d of datasets) if (!d.present) out.push({ unit: d.screen, severity: 'warn', code: 'SHARED_MISSING', message: `shared dataset "${entity}" (recipe "${r.name}") is not present in ${d.screen}'s test-data` });
|
|
106
|
+
// Identity: every present copy must equal the first.
|
|
107
|
+
if (present.length >= 2) {
|
|
108
|
+
const ref = JSON.stringify(present[0].value);
|
|
109
|
+
for (const d of present.slice(1)) {
|
|
110
|
+
if (JSON.stringify(d.value) !== ref) {
|
|
111
|
+
out.push({ unit: d.screen, severity: 'error', code: 'SHARED_MISMATCH', message: `shared dataset "${entity}" differs between ${present[0].screen} and ${d.screen} — the same values must flow across the ${r.name} journey (e.g. confirmation must show what setup entered)` });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Cross-check the whole project (all units + shared datasets). */
|
|
120
|
+
export function crosscheckAll(cwd: string, units: string[]): CrossFinding[] {
|
|
121
|
+
const out: CrossFinding[] = [];
|
|
122
|
+
for (const u of units) out.push(...crosscheckUnit(cwd, u));
|
|
123
|
+
out.push(...crosscheckShared(cwd));
|
|
124
|
+
return out;
|
|
125
|
+
}
|
package/src/fixture.ts
CHANGED
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
* Export` checklist area. Two products:
|
|
7
7
|
* • the VALID fixture — N rows of valid values (should import cleanly). Unique fields get a
|
|
8
8
|
* per-row suffix so rows don't collide.
|
|
9
|
-
* • the INVALID fixture — one row per field's first invalid case (all-valid except that one field)
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* • the INVALID fixture — one row per field's first invalid case (all-valid except that one field).
|
|
10
|
+
* It carries the SAME headers as the valid file (no extra column) so a schema-strict importer
|
|
11
|
+
* fails on the DATA, not on an unexpected column. The "which row is bad and why" lives in a
|
|
12
|
+
* SIDECAR (`<name>.invalid.expected.yaml`), keyed by 0-based data-row index — the expected result
|
|
13
|
+
* never alters the payload under test.
|
|
12
14
|
*
|
|
13
15
|
* Deterministic: same field-map + catalog → same CSV. Columns are the flattened leaf paths (nested
|
|
14
16
|
* groups → dotted headers, e.g. `address.city`). Reference the file from an `@api` upload (`files:`)
|
|
@@ -31,7 +33,11 @@ function leaves(expanded: ExpandedField[], prefix = ''): Leaf[] {
|
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
const validOf = (f: ExpandedField): string => f.points.find((p) => p.group === 'valid' && p.value != null)?.value ?? '';
|
|
34
|
-
|
|
36
|
+
// First concrete invalid, preferring a plain (non-adversarial) format/boundary invalid over a
|
|
37
|
+
// security payload — a bulk-import row-rejection test wants a data violation, not an injection string.
|
|
38
|
+
const firstInvalid = (f: ExpandedField) =>
|
|
39
|
+
f.points.find((p) => p.group === 'invalid' && p.category !== 'adversarial' && p.value != null) ??
|
|
40
|
+
f.points.find((p) => p.group === 'invalid' && p.value != null);
|
|
35
41
|
|
|
36
42
|
/** RFC-4180-ish CSV cell quoting. */
|
|
37
43
|
function cell(v: string): string {
|
|
@@ -41,9 +47,23 @@ function row(cells: string[]): string {
|
|
|
41
47
|
return cells.map(cell).join(',');
|
|
42
48
|
}
|
|
43
49
|
|
|
50
|
+
/** One expected-rejection entry in the sidecar, keyed to a 0-based data row of the invalid CSV. */
|
|
51
|
+
export interface ExpectedRow {
|
|
52
|
+
row: number;
|
|
53
|
+
field: string;
|
|
54
|
+
chk: string;
|
|
55
|
+
reason: string;
|
|
56
|
+
expected_error?: string;
|
|
57
|
+
}
|
|
58
|
+
export interface FixtureExpected {
|
|
59
|
+
_note: string;
|
|
60
|
+
rows: ExpectedRow[];
|
|
61
|
+
}
|
|
62
|
+
|
|
44
63
|
export interface FixtureResult {
|
|
45
64
|
valid: string; // CSV text (header + N valid rows)
|
|
46
|
-
invalid?: string; // CSV text (
|
|
65
|
+
invalid?: string; // CSV text (SAME headers as valid + one bad row per field — no extra column)
|
|
66
|
+
expected?: FixtureExpected; // sidecar: per-row expected rejection (0-based data-row index)
|
|
47
67
|
headers: string[];
|
|
48
68
|
}
|
|
49
69
|
|
|
@@ -62,18 +82,30 @@ export function generateFixture(map: FieldMap, catalog: Catalog, opts: { rows?:
|
|
|
62
82
|
const valid = [row(headers), ...validRows].join('\n') + '\n';
|
|
63
83
|
|
|
64
84
|
let invalid: string | undefined;
|
|
85
|
+
let expected: FixtureExpected | undefined;
|
|
65
86
|
if (opts.includeInvalid) {
|
|
66
|
-
const invHeaders = [...headers, '_violation'];
|
|
67
87
|
const invRows: string[] = [];
|
|
88
|
+
const expectedRows: ExpectedRow[] = [];
|
|
68
89
|
for (const target of ls) {
|
|
69
90
|
const bad = firstInvalid(target.field);
|
|
70
91
|
if (!bad) continue; // no concrete invalid value to seed
|
|
92
|
+
// Same columns as the valid file — only the target cell is bad. No `_violation` column: a
|
|
93
|
+
// schema-strict importer must fail on the data, not reject the file for an unexpected column.
|
|
71
94
|
const cells = ls.map((l) => (l === target ? (bad.value as string) : validOf(l.field).replace(/\{\{\$timestamp\}\}/g, '1')));
|
|
72
|
-
|
|
73
|
-
invRows.push(row(
|
|
95
|
+
expectedRows.push(trimExpected({ row: invRows.length, field: target.path, chk: bad.id, reason: bad.reason || 'invalid', expected_error: bad.expectedError }));
|
|
96
|
+
invRows.push(row(cells));
|
|
74
97
|
}
|
|
75
|
-
invalid = [row(
|
|
98
|
+
invalid = [row(headers), ...invRows].join('\n') + '\n';
|
|
99
|
+
expected = {
|
|
100
|
+
_note: 'Each row of the .invalid.csv should be REJECTED. `row` is the 0-based data-row index (header excluded). The CSV carries no extra columns so a strict importer fails on the data, not the schema.',
|
|
101
|
+
rows: expectedRows,
|
|
102
|
+
};
|
|
76
103
|
}
|
|
77
104
|
|
|
78
|
-
return { valid, invalid, headers };
|
|
105
|
+
return { valid, invalid, expected, headers };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function trimExpected(e: ExpectedRow): ExpectedRow {
|
|
109
|
+
if (e.expected_error == null) { const { expected_error, ...rest } = e; return rest as ExpectedRow; }
|
|
110
|
+
return e;
|
|
79
111
|
}
|
package/src/index.ts
CHANGED
|
@@ -28,9 +28,15 @@ export type { ValidationFinding } from './validate';
|
|
|
28
28
|
export { expandBoundaryHint, satisfiesConstraints } from './boundary';
|
|
29
29
|
export type { Validity, BoundaryPoint } from './boundary';
|
|
30
30
|
export { evaluateWhen, inProfile, fieldFacts } from './applicability';
|
|
31
|
+
export { generate, buildRequest, validateRequest, fingerprint, GENERATOR_VERSION, SCHEMA_VERSION } from './contract';
|
|
32
|
+
export type { DataFactoryRequest, DataFactoryResult } from './contract';
|
|
33
|
+
export { loadStateRecipes, validateStateRecipe, buildStatePlan, renderPreconditionBlock, topoSort, hasDatasource } from './state';
|
|
34
|
+
export type { StateRecipe, StateResource, StatePlan, StateFinding, Provenance, Lifecycle } from './state';
|
|
35
|
+
export { crosscheckUnit, crosscheckShared, crosscheckAll, referencedVars } from './crosscheck';
|
|
36
|
+
export type { CrossFinding } from './crosscheck';
|
|
31
37
|
export { registerDataFactoryCommand } from './cli';
|
|
32
38
|
export { generateFixture } from './fixture';
|
|
33
|
-
export type { FixtureResult } from './fixture';
|
|
39
|
+
export type { FixtureResult, FixtureExpected, ExpectedRow } from './fixture';
|
|
34
40
|
export { dataFactoryCoverageSensor } from './sensor';
|
|
35
41
|
export { dfPreconditionCodegen } from './precondition';
|
|
36
42
|
export type { Catalog, FieldType, SecurityBank, Technique, DataPoint, OverwriteWarning, CatalogProvenance, FieldTypeProvenance, SelectionContext, Profile } from './types';
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data Factory — domain/stateful data, Layer 0: the DECLARATION (M3-A).
|
|
3
|
+
*
|
|
4
|
+
* Grounded in a real spec-only project (`examples/nextjp`, web-only, no datasource): domain/stateful
|
|
5
|
+
* preconditions are everywhere in spec + viewpoint, and QA already writes them by hand in prose + tags
|
|
6
|
+
* (e.g. "requires a valid, unused reset token ≤24h; seeded via fixture / prior flow; where it cannot be
|
|
7
|
+
* seeded, treat as @manual"). This module standardizes that: a `state-recipe` (entity graph + relations
|
|
8
|
+
* + desired state + bindings + lifecycle + PROVENANCE) becomes a reviewable, traceable **manual
|
|
9
|
+
* precondition** rendered into the testcase — working with only spec + viewpoint, no DB/API, no Core.
|
|
10
|
+
*
|
|
11
|
+
* Layer 1 (opt-in runtime provisioning) reuses the SAME declaration where an api/db datasource exists;
|
|
12
|
+
* this file does not execute anything — it validates + renders the declaration deterministically.
|
|
13
|
+
*/
|
|
14
|
+
import { readFileSync, existsSync } from 'fs';
|
|
15
|
+
import { join } from 'path';
|
|
16
|
+
import { parse as parseYaml } from 'yaml';
|
|
17
|
+
|
|
18
|
+
/** WHERE a resource's state comes from — NOT only api/db (nextjp: prior-flow / fixture / manual dominate). */
|
|
19
|
+
export type Provenance = 'prior-flow' | 'fixture' | 'client-storage' | 'server-context' | 'api' | 'db' | 'manual';
|
|
20
|
+
export type Lifecycle = 'reusable' | 'unique-per-run' | 'consumable' | 'leased' | 'expiring' | 'mutable-state';
|
|
21
|
+
|
|
22
|
+
const PROVENANCES: Provenance[] = ['prior-flow', 'fixture', 'client-storage', 'server-context', 'api', 'db', 'manual'];
|
|
23
|
+
const LIFECYCLES: Lifecycle[] = ['reusable', 'unique-per-run', 'consumable', 'leased', 'expiring', 'mutable-state'];
|
|
24
|
+
/** Provenances that need a runtime datasource — Layer 1. The rest resolve at design time (Layer 0). */
|
|
25
|
+
const RUNTIME_PROVENANCE = new Set<Provenance>(['api', 'db']);
|
|
26
|
+
|
|
27
|
+
export interface StateResource {
|
|
28
|
+
ref: string;
|
|
29
|
+
entity: string;
|
|
30
|
+
provenance?: Provenance; // default 'manual'
|
|
31
|
+
provider?: string; // @api:x / @query:x — required only for api/db provenance (Layer 1)
|
|
32
|
+
transition?: string; // endpoint that moves the entity to desiredState (Layer 1)
|
|
33
|
+
dependsOn?: string[];
|
|
34
|
+
with?: Record<string, unknown>;
|
|
35
|
+
desiredState?: string;
|
|
36
|
+
lifecycle?: Lifecycle;
|
|
37
|
+
note?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface StateRecipe {
|
|
41
|
+
name: string;
|
|
42
|
+
description?: string;
|
|
43
|
+
resources?: StateResource[];
|
|
44
|
+
bindings?: Record<string, string>;
|
|
45
|
+
cleanup?: string | { keep: true; reason: string };
|
|
46
|
+
/** 1b — cross-screen flow-shared dataset: `entity`'s data is shared verbatim across `screens`. */
|
|
47
|
+
shared?: { entity: string; screens: string[] };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface StateFinding {
|
|
51
|
+
recipe: string;
|
|
52
|
+
severity: 'error' | 'warn' | 'info';
|
|
53
|
+
code: string;
|
|
54
|
+
message: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface StatePlan {
|
|
58
|
+
recipe: string;
|
|
59
|
+
/** Topologically ordered resources (dependencies first). */
|
|
60
|
+
order: string[];
|
|
61
|
+
resources: StateResource[];
|
|
62
|
+
bindings: Record<string, string>;
|
|
63
|
+
/** True when NO resource can be auto-provisioned here (no api/db provenance, or missing datasource). */
|
|
64
|
+
manual: boolean;
|
|
65
|
+
/** The identity pairs a shared dataset implies (for the Cross-artifact Gate). */
|
|
66
|
+
sharedIdentity?: { entity: string; screens: string[] };
|
|
67
|
+
/** Human-readable precondition lines (Gherkin Given/And) for weaving into a Background. */
|
|
68
|
+
preconditionLines: string[];
|
|
69
|
+
cleanup?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Load state-recipes (recipes with a `resources` graph) from qa/data-factory/recipes.yaml. */
|
|
73
|
+
export function loadStateRecipes(cwd: string): StateRecipe[] {
|
|
74
|
+
const p = join(cwd, 'qa', 'data-factory', 'recipes.yaml');
|
|
75
|
+
if (!existsSync(p)) return [];
|
|
76
|
+
const doc = parseYaml(readFileSync(p, 'utf8')) || {};
|
|
77
|
+
const recipes = doc.recipes || {};
|
|
78
|
+
const out: StateRecipe[] = [];
|
|
79
|
+
for (const [name, r] of Object.entries<any>(recipes)) {
|
|
80
|
+
if (r && (Array.isArray(r.resources) || r.shared)) out.push({ name, ...r });
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Topologically sort resources by dependsOn. Returns { order, cycle } — cycle non-empty ⇒ error. */
|
|
86
|
+
export function topoSort(resources: StateResource[]): { order: string[]; cycle: string[] } {
|
|
87
|
+
const byRef = new Map(resources.map((r) => [r.ref, r]));
|
|
88
|
+
const state = new Map<string, 0 | 1 | 2>(); // 0=unseen,1=in-stack,2=done
|
|
89
|
+
const order: string[] = [];
|
|
90
|
+
let cycle: string[] = [];
|
|
91
|
+
const visit = (ref: string, stack: string[]): boolean => {
|
|
92
|
+
const s = state.get(ref) || 0;
|
|
93
|
+
if (s === 2) return true;
|
|
94
|
+
if (s === 1) { cycle = [...stack.slice(stack.indexOf(ref)), ref]; return false; }
|
|
95
|
+
state.set(ref, 1);
|
|
96
|
+
for (const dep of byRef.get(ref)?.dependsOn || []) {
|
|
97
|
+
if (!byRef.has(dep)) continue; // dangling dep reported separately
|
|
98
|
+
if (!visit(dep, [...stack, ref])) return false;
|
|
99
|
+
}
|
|
100
|
+
state.set(ref, 2);
|
|
101
|
+
order.push(ref);
|
|
102
|
+
return true;
|
|
103
|
+
};
|
|
104
|
+
for (const r of resources) if (!visit(r.ref, [])) break;
|
|
105
|
+
return { order, cycle };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Validate a state-recipe (structure only — no runtime). */
|
|
109
|
+
export function validateStateRecipe(recipe: StateRecipe): StateFinding[] {
|
|
110
|
+
const out: StateFinding[] = [];
|
|
111
|
+
const F = (severity: StateFinding['severity'], code: string, message: string) => out.push({ recipe: recipe.name, severity, code, message });
|
|
112
|
+
const resources = recipe.resources || [];
|
|
113
|
+
const refs = new Set(resources.map((r) => r.ref));
|
|
114
|
+
if (!resources.length && !recipe.shared) F('warn', 'EMPTY', 'recipe has neither resources nor a shared dataset');
|
|
115
|
+
for (const r of resources) {
|
|
116
|
+
if (!r.ref) F('error', 'NO_REF', 'a resource has no `ref`');
|
|
117
|
+
if (!r.entity) F('error', 'NO_ENTITY', `resource "${r.ref}" has no \`entity\``);
|
|
118
|
+
if (r.provenance && !PROVENANCES.includes(r.provenance)) F('error', 'BAD_PROVENANCE', `resource "${r.ref}" provenance "${r.provenance}" ∉ ${PROVENANCES.join('|')}`);
|
|
119
|
+
if (r.lifecycle && !LIFECYCLES.includes(r.lifecycle)) F('error', 'BAD_LIFECYCLE', `resource "${r.ref}" lifecycle "${r.lifecycle}" ∉ ${LIFECYCLES.join('|')}`);
|
|
120
|
+
for (const dep of r.dependsOn || []) if (!refs.has(dep)) F('error', 'DANGLING_DEP', `resource "${r.ref}" dependsOn "${dep}" which is not a resource`);
|
|
121
|
+
// api/db provenance needs a provider endpoint (Layer 1); others must NOT pretend to auto-provision.
|
|
122
|
+
if (RUNTIME_PROVENANCE.has(r.provenance as Provenance) && !r.provider) F('error', 'NO_PROVIDER', `resource "${r.ref}" provenance "${r.provenance}" needs a \`provider\` endpoint (@api:/@query:)`);
|
|
123
|
+
if (r.desiredState && !r.transition && RUNTIME_PROVENANCE.has(r.provenance as Provenance)) F('warn', 'NO_TRANSITION', `resource "${r.ref}" desiredState "${r.desiredState}" has no \`transition\` endpoint — Layer 1 cannot move it automatically`);
|
|
124
|
+
}
|
|
125
|
+
// Bindings must reference a real ${ref.field}.
|
|
126
|
+
for (const [k, v] of Object.entries(recipe.bindings || {})) {
|
|
127
|
+
const m = /^\$\{([^.}]+)\.[^}]+\}$/.exec(String(v));
|
|
128
|
+
if (!m) F('warn', 'BINDING_SHAPE', `binding "${k}: ${v}" is not of the form \${ref.field}`);
|
|
129
|
+
else if (!refs.has(m[1])) F('error', 'BINDING_REF', `binding "${k}" references unknown resource "${m[1]}"`);
|
|
130
|
+
}
|
|
131
|
+
const { cycle } = topoSort(resources);
|
|
132
|
+
if (cycle.length) F('error', 'CYCLE', `dependsOn cycle: ${cycle.join(' → ')}`);
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const article = (w: string) => (/^[aeiou]/i.test(w) ? 'an' : 'a');
|
|
137
|
+
|
|
138
|
+
/** Build the deterministic plan + the human-readable precondition block for a state-recipe. */
|
|
139
|
+
export function buildStatePlan(recipe: StateRecipe, opts: { datasource?: boolean } = {}): StatePlan {
|
|
140
|
+
const resources = (recipe.resources || []).map((r) => ({ provenance: 'manual' as Provenance, ...r }));
|
|
141
|
+
const { order } = topoSort(resources);
|
|
142
|
+
const ordered = order.length ? order.map((ref) => resources.find((r) => r.ref === ref)!) : resources;
|
|
143
|
+
// A resource is auto-provisionable only when its provenance is api/db AND a datasource is configured.
|
|
144
|
+
const autoable = (r: StateResource) => RUNTIME_PROVENANCE.has(r.provenance as Provenance) && !!opts.datasource;
|
|
145
|
+
const manual = ordered.length > 0 && !ordered.every(autoable);
|
|
146
|
+
|
|
147
|
+
const lines: string[] = [];
|
|
148
|
+
for (const r of ordered) {
|
|
149
|
+
const withStr = r.with && Object.keys(r.with).length ? ' ' + Object.entries(r.with).map(([k, v]) => `${k}=${v}`).join(', ') : '';
|
|
150
|
+
const owner = (r.dependsOn || []).length ? ` owned by ${r.dependsOn!.join('/')}` : '';
|
|
151
|
+
const stateStr = r.desiredState ? ` in state "${r.desiredState}"` : '';
|
|
152
|
+
const meta = `[provenance: ${r.provenance}${r.lifecycle ? `; lifecycle: ${r.lifecycle}` : ''}${autoable(r) ? `; auto: ${r.provider}` : ''}]`;
|
|
153
|
+
lines.push(`Given ${article(r.entity)} ${r.entity} "${r.ref}"${owner} exists${stateStr} ${meta}${withStr}`);
|
|
154
|
+
}
|
|
155
|
+
for (const [k, v] of Object.entries(recipe.bindings || {})) lines.push(`# binding: ${k} ← ${v}`);
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
recipe: recipe.name,
|
|
159
|
+
order: ordered.map((r) => r.ref),
|
|
160
|
+
resources: ordered,
|
|
161
|
+
bindings: recipe.bindings || {},
|
|
162
|
+
manual,
|
|
163
|
+
...(recipe.shared ? { sharedIdentity: recipe.shared } : {}),
|
|
164
|
+
preconditionLines: lines,
|
|
165
|
+
cleanup: typeof recipe.cleanup === 'string' ? recipe.cleanup : recipe.cleanup ? `keep — ${recipe.cleanup.reason}` : undefined,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Whether the project has a datasource (⇒ Layer 1 possible). */
|
|
170
|
+
export function hasDatasource(cwd: string): boolean {
|
|
171
|
+
return existsSync(join(cwd, 'datasources.yaml')) || existsSync(join(cwd, 'qa', 'datasources.yaml'));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Render the precondition block for weaving into a Gherkin Background (with the @manual tag if needed). */
|
|
175
|
+
export function renderPreconditionBlock(plan: StatePlan): string {
|
|
176
|
+
const tag = plan.manual ? '# @manual:data-setup — required state (auto-provisions when an api/db datasource is added)' : '# required state (Layer 1: auto-provisioned via the referenced endpoints)';
|
|
177
|
+
return [`${tag}`, `# recipe: ${plan.recipe}`, ...plan.preconditionLines].join('\n');
|
|
178
|
+
}
|