@sungen/driver-data-factory 3.2.10-beta.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/common/field-types/currency.yaml +20 -0
- package/common/field-types/email.yaml +30 -0
- package/common/field-types/jp/katakana.yaml +20 -0
- package/common/field-types/number-float.yaml +20 -0
- package/common/field-types/number-integer.yaml +21 -0
- package/common/field-types/password.yaml +30 -0
- package/common/field-types/phone.yaml +23 -0
- package/common/field-types/postcode.yaml +16 -0
- package/common/field-types/string.yaml +20 -0
- package/common/security/broken-auth.yaml +9 -0
- package/common/security/idor.yaml +8 -0
- package/common/security/input-validation.yaml +8 -0
- package/common/security/sqli.yaml +11 -0
- package/common/security/upload.yaml +10 -0
- package/common/security/xss.yaml +12 -0
- package/common/techniques/boundary.yaml +6 -0
- package/common/techniques/decision-table.yaml +6 -0
- package/common/techniques/equivalence.yaml +6 -0
- package/common/techniques/state-transition.yaml +6 -0
- package/package.json +25 -0
- package/src/catalog-loader.ts +123 -0
- package/src/cli.ts +172 -0
- package/src/fixture.ts +79 -0
- package/src/generator.ts +271 -0
- package/src/index.ts +44 -0
- package/src/lint.ts +78 -0
- package/src/precondition.ts +70 -0
- package/src/sensor.ts +64 -0
- package/src/types.ts +62 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data Factory CLI — `sungen data gen` and `sungen data lint`.
|
|
3
|
+
*
|
|
4
|
+
* Deterministic authoring commands (design-time; no runtime). They read the field-map
|
|
5
|
+
* (`qa/data-factory/<name>.fields.yaml`) + the effective catalog (shipped common + `.overwrite`),
|
|
6
|
+
* then generate the standardized `test-data/<name>.yaml` or lint its coverage. The slash command
|
|
7
|
+
* `/sungen:create-data-test` wraps these after the AI has authored the field-map from spec.md.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from 'fs';
|
|
10
|
+
import { join, dirname } from 'path';
|
|
11
|
+
import { createInterface } from 'readline';
|
|
12
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
13
|
+
import { loadEffectiveCatalog } from './catalog-loader';
|
|
14
|
+
import { expandFieldMap, renderTestData, type FieldMap } from './generator';
|
|
15
|
+
import { lintFieldMap, summarize } from './lint';
|
|
16
|
+
import { generateFixture } from './fixture';
|
|
17
|
+
|
|
18
|
+
const FIELDMAP_DIR = (cwd: string) => join(cwd, 'qa', 'data-factory');
|
|
19
|
+
const fieldMapPath = (cwd: string, name: string) => join(FIELDMAP_DIR(cwd), `${name}.fields.yaml`);
|
|
20
|
+
|
|
21
|
+
/** Resolve where a unit's test-data lives: screen or flow (mirrors sungen auto-detect). */
|
|
22
|
+
function testDataPath(cwd: string, name: string): string | null {
|
|
23
|
+
const screen = join(cwd, 'qa', 'screens', name);
|
|
24
|
+
const flow = join(cwd, 'qa', 'flows', name);
|
|
25
|
+
if (existsSync(screen)) return join(screen, 'test-data', `${name}.yaml`);
|
|
26
|
+
if (existsSync(flow)) return join(flow, 'test-data', `${name}.yaml`);
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function listFieldMaps(cwd: string): string[] {
|
|
31
|
+
const dir = FIELDMAP_DIR(cwd);
|
|
32
|
+
if (!existsSync(dir)) return [];
|
|
33
|
+
return readdirSync(dir)
|
|
34
|
+
.filter((f) => f.endsWith('.fields.yaml'))
|
|
35
|
+
.map((f) => f.replace(/\.fields\.yaml$/, ''));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function ask(question: string): Promise<string> {
|
|
39
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
40
|
+
return new Promise((res) => rl.question(question, (a) => { rl.close(); res(a.trim()); }));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function loadFieldMap(cwd: string, name: string): FieldMap | null {
|
|
44
|
+
const p = fieldMapPath(cwd, name);
|
|
45
|
+
if (!existsSync(p)) return null;
|
|
46
|
+
return parseYaml(readFileSync(p, 'utf8')) as FieldMap;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function genOne(cwd: string, name: string, opts: { force?: boolean }): Promise<'written' | 'skipped' | 'missing'> {
|
|
50
|
+
const map = loadFieldMap(cwd, name);
|
|
51
|
+
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'; }
|
|
52
|
+
const { catalog, warnings } = loadEffectiveCatalog(cwd);
|
|
53
|
+
for (const w of warnings) console.warn(` ⚠ overwrite: ${w.detail} → ${w.suggestion}`);
|
|
54
|
+
|
|
55
|
+
const expanded = expandFieldMap(map, catalog);
|
|
56
|
+
const out = testDataPath(cwd, name);
|
|
57
|
+
if (!out) { console.error(` ✗ ${name}: no qa/screens/${name} or qa/flows/${name}`); return 'missing'; }
|
|
58
|
+
|
|
59
|
+
const dfData = renderTestData(expanded) as Record<string, any>;
|
|
60
|
+
const exists = existsSync(out);
|
|
61
|
+
|
|
62
|
+
// MERGE, don't clobber. Data Factory owns only the keys it generates (`_generated_by` + each
|
|
63
|
+
// field name); every OTHER key in the existing test-data (create-test's data, flow-namespaced
|
|
64
|
+
// keys like `login.email`, @cases datasets, overrides) is preserved. A regenerated field key is
|
|
65
|
+
// replaced by its standardized version (that's the point). Fixes the whole-file clobber.
|
|
66
|
+
let final = dfData;
|
|
67
|
+
let mergedInto = false;
|
|
68
|
+
if (exists) {
|
|
69
|
+
if (!opts.force) {
|
|
70
|
+
const a = (await ask(` ? ${name}: test-data exists — merge standardized field data in (keeps other keys)? [y/N] `)).toLowerCase();
|
|
71
|
+
if (a !== 'y' && a !== 'yes') { console.log(` · ${name}: skipped`); return 'skipped'; }
|
|
72
|
+
}
|
|
73
|
+
let existing: Record<string, any> = {};
|
|
74
|
+
try { existing = (parseYaml(readFileSync(out, 'utf8')) as Record<string, any>) || {}; } catch { existing = {}; }
|
|
75
|
+
final = { ...existing, ...dfData }; // DF-owned keys win; all other existing keys preserved
|
|
76
|
+
mergedInto = true;
|
|
77
|
+
}
|
|
78
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
79
|
+
writeFileSync(out, stringifyYaml(final), 'utf8');
|
|
80
|
+
const blind = expanded.filter((f) => f.blindSpot).map((f) => f.name);
|
|
81
|
+
const verb = mergedInto ? 'merged into' : 'wrote';
|
|
82
|
+
const fieldKeys = expanded.map((f) => f.name).length;
|
|
83
|
+
console.log(` ✓ ${name}: ${verb} ${out.replace(cwd + '/', '')} (${fieldKeys} field key(s)${mergedInto ? ', other keys preserved' : ''})${blind.length ? ` — blind spots: ${blind.join(', ')}` : ''}`);
|
|
84
|
+
return 'written';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function registerDataFactoryCommand(program: any): void {
|
|
88
|
+
const data = program.command('data').description('Data Factory — standardized test-data generation');
|
|
89
|
+
|
|
90
|
+
data
|
|
91
|
+
.command('gen')
|
|
92
|
+
.description('Generate standardized test-data from qa/data-factory/<name>.fields.yaml + the catalog')
|
|
93
|
+
.option('--screen <name>', 'Generate for one screen or flow')
|
|
94
|
+
.option('--all', 'Generate for every field-map under qa/data-factory/ (requires confirm)')
|
|
95
|
+
.option('--yes', 'Assume yes for the --all confirmation')
|
|
96
|
+
.option('--force', 'Overwrite existing test-data without asking (implies --yes)')
|
|
97
|
+
.action(async (opts: { screen?: string; all?: boolean; yes?: boolean; force?: boolean }) => {
|
|
98
|
+
const cwd = process.cwd();
|
|
99
|
+
if (opts.screen) { await genOne(cwd, opts.screen, opts); return; }
|
|
100
|
+
if (!opts.all) {
|
|
101
|
+
console.error('Specify --screen <name> for one unit, or --all for every unit (with confirmation).');
|
|
102
|
+
process.exitCode = 1;
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
// ALL mode — guard against a mistyped/omitted name.
|
|
106
|
+
const names = listFieldMaps(cwd);
|
|
107
|
+
if (names.length === 0) { console.error('No field-maps under qa/data-factory/*.fields.yaml.'); process.exitCode = 1; return; }
|
|
108
|
+
const existing = names.filter((n) => { const p = testDataPath(cwd, n); return p && existsSync(p); });
|
|
109
|
+
console.log(`Data Factory — generate for ALL ${names.length} unit(s): ${names.join(', ')}`);
|
|
110
|
+
console.log(` ${names.length - existing.length} new · ${existing.length} would overwrite existing test-data`);
|
|
111
|
+
if (!opts.yes && !opts.force) {
|
|
112
|
+
const a = (await ask(`Proceed for ALL ${names.length} unit(s)? [y/N] `)).toLowerCase();
|
|
113
|
+
if (a !== 'y' && a !== 'yes') { console.log('Cancelled.'); return; }
|
|
114
|
+
}
|
|
115
|
+
let written = 0;
|
|
116
|
+
for (const n of names) if ((await genOne(cwd, n, { force: opts.force })) === 'written') written++;
|
|
117
|
+
console.log(`Done: ${written}/${names.length} written.`);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
data
|
|
121
|
+
.command('lint')
|
|
122
|
+
.description('Check test-data coverage (valid/boundary/invalid + traceability) per phuong-phap §6')
|
|
123
|
+
.option('--screen <name>', 'Lint one unit (default: all field-maps)')
|
|
124
|
+
.action((opts: { screen?: string }) => {
|
|
125
|
+
const cwd = process.cwd();
|
|
126
|
+
const names = opts.screen ? [opts.screen] : listFieldMaps(cwd);
|
|
127
|
+
if (names.length === 0) { console.error('No field-maps under qa/data-factory/*.fields.yaml.'); process.exitCode = 1; return; }
|
|
128
|
+
const { catalog } = loadEffectiveCatalog(cwd);
|
|
129
|
+
let totalErrors = 0;
|
|
130
|
+
for (const name of names) {
|
|
131
|
+
const map = loadFieldMap(cwd, name);
|
|
132
|
+
if (!map) { console.error(`✗ ${name}: no field-map`); totalErrors++; continue; }
|
|
133
|
+
const { issues } = lintFieldMap(map, catalog);
|
|
134
|
+
const s = summarize(issues);
|
|
135
|
+
totalErrors += s.errors;
|
|
136
|
+
const badge = s.errors ? '✗' : s.warns ? '⚠' : '✓';
|
|
137
|
+
console.log(`${badge} ${name}: ${s.errors} error · ${s.warns} warn · ${s.infos} info`);
|
|
138
|
+
for (const i of issues) {
|
|
139
|
+
const mark = i.severity === 'error' ? ' ✗' : i.severity === 'warn' ? ' ⚠' : ' ·';
|
|
140
|
+
console.log(`${mark} [${i.field}] ${i.code}: ${i.message}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (totalErrors) process.exitCode = 1;
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
data
|
|
147
|
+
.command('fixture')
|
|
148
|
+
.description('Generate a CSV import fixture from the field-map (bulk-import / file-upload testing)')
|
|
149
|
+
.requiredOption('--screen <name>', 'Unit whose field-map to build a fixture from')
|
|
150
|
+
.option('--rows <n>', 'Number of valid rows', '3')
|
|
151
|
+
.option('--invalid', 'Also write <name>.invalid.csv (one seeded-bad row per field + _violation column)')
|
|
152
|
+
.option('--force', 'Overwrite existing fixture file(s) without asking')
|
|
153
|
+
.action(async (opts: { screen: string; rows?: string; invalid?: boolean; force?: boolean }) => {
|
|
154
|
+
const cwd = process.cwd();
|
|
155
|
+
const map = loadFieldMap(cwd, opts.screen);
|
|
156
|
+
if (!map) { console.error(`✗ ${opts.screen}: no field-map at qa/data-factory/${opts.screen}.fields.yaml`); process.exitCode = 1; return; }
|
|
157
|
+
const { catalog } = loadEffectiveCatalog(cwd);
|
|
158
|
+
const res = generateFixture(map, catalog, { rows: parseInt(opts.rows || '3', 10), includeInvalid: !!opts.invalid });
|
|
159
|
+
const dir = join(cwd, 'qa', 'fixtures');
|
|
160
|
+
mkdirSync(dir, { recursive: true });
|
|
161
|
+
const validPath = join(dir, `${opts.screen}.csv`);
|
|
162
|
+
const writes: Array<[string, string]> = [[validPath, res.valid]];
|
|
163
|
+
if (res.invalid) writes.push([join(dir, `${opts.screen}.invalid.csv`), res.invalid]);
|
|
164
|
+
for (const [p] of writes) {
|
|
165
|
+
if (existsSync(p) && !opts.force) {
|
|
166
|
+
const a = (await ask(` ? ${p.replace(cwd + '/', '')} exists — overwrite? [y/N] `)).toLowerCase();
|
|
167
|
+
if (a !== 'y' && a !== 'yes') { console.log(' · skipped'); return; }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
for (const [p, content] of writes) { writeFileSync(p, content, 'utf8'); console.log(` ✓ wrote ${p.replace(cwd + '/', '')} (${res.headers.length} columns)`); }
|
|
171
|
+
});
|
|
172
|
+
}
|
package/src/fixture.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data Factory — CSV/file fixture generator (5b).
|
|
3
|
+
*
|
|
4
|
+
* Turns a field-map into a **bulk-import fixture file** (a CSV whose columns are the fields and whose
|
|
5
|
+
* rows are standardized data), for testing CSV/Excel import, bulk upload, etc. — the `CSV / Import /
|
|
6
|
+
* Export` checklist area. Two products:
|
|
7
|
+
* • the VALID fixture — N rows of valid values (should import cleanly). Unique fields get a
|
|
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
|
+
* plus a trailing `_violation` column naming the field + reason + expected error — so a row-level
|
|
11
|
+
* import-rejection test knows which row should fail and why.
|
|
12
|
+
*
|
|
13
|
+
* Deterministic: same field-map + catalog → same CSV. Columns are the flattened leaf paths (nested
|
|
14
|
+
* groups → dotted headers, e.g. `address.city`). Reference the file from an `@api` upload (`files:`)
|
|
15
|
+
* or a UI file-input step.
|
|
16
|
+
*/
|
|
17
|
+
import type { Catalog } from './types';
|
|
18
|
+
import { expandFieldMap, type FieldMap, type ExpandedField } from './generator';
|
|
19
|
+
|
|
20
|
+
interface Leaf { path: string; field: ExpandedField }
|
|
21
|
+
|
|
22
|
+
/** Flatten nested groups to leaf fields with dotted-path headers. */
|
|
23
|
+
function leaves(expanded: ExpandedField[], prefix = ''): Leaf[] {
|
|
24
|
+
const out: Leaf[] = [];
|
|
25
|
+
for (const f of expanded) {
|
|
26
|
+
const path = prefix ? `${prefix}.${f.name}` : f.name;
|
|
27
|
+
if (f.children) out.push(...leaves(f.children, path));
|
|
28
|
+
else out.push({ path, field: f });
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const validOf = (f: ExpandedField): string => f.points.find((p) => p.group === 'valid' && p.value != null)?.value ?? '';
|
|
34
|
+
const firstInvalid = (f: ExpandedField) => f.points.find((p) => p.group === 'invalid' && p.value != null);
|
|
35
|
+
|
|
36
|
+
/** RFC-4180-ish CSV cell quoting. */
|
|
37
|
+
function cell(v: string): string {
|
|
38
|
+
return /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
|
|
39
|
+
}
|
|
40
|
+
function row(cells: string[]): string {
|
|
41
|
+
return cells.map(cell).join(',');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface FixtureResult {
|
|
45
|
+
valid: string; // CSV text (header + N valid rows)
|
|
46
|
+
invalid?: string; // CSV text (header + _violation + one bad row per field)
|
|
47
|
+
headers: string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Build the CSV fixture(s) from a field-map. */
|
|
51
|
+
export function generateFixture(map: FieldMap, catalog: Catalog, opts: { rows?: number; includeInvalid?: boolean } = {}): FixtureResult {
|
|
52
|
+
const rows = Math.max(1, opts.rows ?? 3);
|
|
53
|
+
const ls = leaves(expandFieldMap(map, catalog));
|
|
54
|
+
const headers = ls.map((l) => l.path);
|
|
55
|
+
|
|
56
|
+
// VALID rows — resolve the valid value per field; a `unique` field's `{{$timestamp}}` token is a
|
|
57
|
+
// runtime var, not valid in a static file, so replace it with the row index to keep rows distinct.
|
|
58
|
+
const validRows: string[] = [];
|
|
59
|
+
for (let i = 1; i <= rows; i++) {
|
|
60
|
+
validRows.push(row(ls.map((l) => validOf(l.field).replace(/\{\{\$timestamp\}\}/g, String(i)))));
|
|
61
|
+
}
|
|
62
|
+
const valid = [row(headers), ...validRows].join('\n') + '\n';
|
|
63
|
+
|
|
64
|
+
let invalid: string | undefined;
|
|
65
|
+
if (opts.includeInvalid) {
|
|
66
|
+
const invHeaders = [...headers, '_violation'];
|
|
67
|
+
const invRows: string[] = [];
|
|
68
|
+
for (const target of ls) {
|
|
69
|
+
const bad = firstInvalid(target.field);
|
|
70
|
+
if (!bad) continue; // no concrete invalid value to seed
|
|
71
|
+
const cells = ls.map((l) => (l === target ? (bad.value as string) : validOf(l.field).replace(/\{\{\$timestamp\}\}/g, '1')));
|
|
72
|
+
const why = `${target.path}: ${bad.reason || 'invalid'}${bad.expectedError ? ` (${bad.expectedError})` : ''}`;
|
|
73
|
+
invRows.push(row([...cells, why]));
|
|
74
|
+
}
|
|
75
|
+
invalid = [row(invHeaders), ...invRows].join('\n') + '\n';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { valid, invalid, headers };
|
|
79
|
+
}
|
package/src/generator.ts
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data Factory generator — deterministic expansion of a field-map into standardized test-data.
|
|
3
|
+
*
|
|
4
|
+
* The AI skill (`/sungen:create-data-test`) reads spec.md/viewpoint and writes the field-MAP
|
|
5
|
+
* (`qa/data-factory/<name>.fields.yaml`): each field's `type` + project `constraints` + `errorMap`.
|
|
6
|
+
* This module is the deterministic half: given that field-map + the effective catalog, it expands
|
|
7
|
+
* every field into valid / boundary / invalid data points — pulling literal values from the catalog,
|
|
8
|
+
* synthesizing length/number boundaries from the constraints, appending the referenced security
|
|
9
|
+
* payloads, and resolving placeholder error codes through the field's `errorMap`. Same input →
|
|
10
|
+
* same output (determinism is an asset). Nothing here reads a live page or an LLM.
|
|
11
|
+
*/
|
|
12
|
+
import type { Catalog, FieldType, DataPoint } from './types';
|
|
13
|
+
|
|
14
|
+
export interface FieldDef {
|
|
15
|
+
name: string;
|
|
16
|
+
/** Catalog field-type id (email/password/…). Omit for a GROUP (a field with nested `fields`). A type absent from the catalog → blind spot. */
|
|
17
|
+
type?: string;
|
|
18
|
+
required?: boolean;
|
|
19
|
+
/** Project overrides merged over the catalog constraints (maxLength, min, max, …). */
|
|
20
|
+
constraints?: Record<string, any>;
|
|
21
|
+
/** Placeholder errorRef (Mxx) → real project message code. Unmapped refs are flagged open. */
|
|
22
|
+
errorMap?: Record<string, string>;
|
|
23
|
+
/**
|
|
24
|
+
* Field must be fresh/unique per test run (e.g. a register email/username that can't collide
|
|
25
|
+
* with a record left by a previous run). When true, the VALID value(s) get a runtime `{{$timestamp}}`
|
|
26
|
+
* suffix so `resolveDynamicVars` produces a new value every run — no cross-run cache, no flakiness.
|
|
27
|
+
*/
|
|
28
|
+
unique?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Nested / group field (complex form): sub-fields expanded recursively under this name
|
|
31
|
+
* (e.g. `address` → `city`, `postcode`). A field with `fields` is a group and needs no `type`.
|
|
32
|
+
*/
|
|
33
|
+
fields?: FieldDef[];
|
|
34
|
+
/**
|
|
35
|
+
* Inter-field dependency: this field's rule/format depends on another field's value
|
|
36
|
+
* (e.g. postcode format `dependsOn: { field: country, value: JP }`). Carried through as metadata
|
|
37
|
+
* so the scenario weave + QA honor the relationship — the generator does not resolve the runtime
|
|
38
|
+
* condition (that is scenario/@cases logic), it records it.
|
|
39
|
+
*/
|
|
40
|
+
dependsOn?: { field: string; value?: string };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface FieldMap {
|
|
44
|
+
screen?: string;
|
|
45
|
+
flow?: string;
|
|
46
|
+
fields: FieldDef[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ExpandedPoint {
|
|
50
|
+
id: string;
|
|
51
|
+
group: 'valid' | 'boundary' | 'invalid';
|
|
52
|
+
value: string | null;
|
|
53
|
+
reason?: string;
|
|
54
|
+
note?: string;
|
|
55
|
+
/** Resolved expected error (via errorMap) — or the raw placeholder + `open: true` if unmapped. */
|
|
56
|
+
expectedError?: string;
|
|
57
|
+
open?: boolean;
|
|
58
|
+
/** Value could not be synthesized deterministically (missing constraint) — needs input. */
|
|
59
|
+
todo?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ExpandedField {
|
|
63
|
+
name: string;
|
|
64
|
+
type?: string;
|
|
65
|
+
blindSpot: boolean;
|
|
66
|
+
points: ExpandedPoint[];
|
|
67
|
+
/** Nested sub-fields (a group field). When present, `points` is empty and children hold the data. */
|
|
68
|
+
children?: ExpandedField[];
|
|
69
|
+
/** Recorded inter-field dependency (metadata only — not resolved deterministically). */
|
|
70
|
+
dependsOn?: { field: string; value?: string };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Deterministic filler: a string of exactly `n` chars from a charset seed. */
|
|
74
|
+
function fillLen(n: number, base = 'a'): string {
|
|
75
|
+
if (!Number.isFinite(n) || n <= 0) return '';
|
|
76
|
+
return base.repeat(n).slice(0, n);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Build an email of a given total or local length (half-width), with a boundary offset (-1/0/+1). */
|
|
80
|
+
function synthEmail(constraints: Record<string, any>, which: 'maxLocal' | 'maxTotal', offset = 0): string | null {
|
|
81
|
+
const domain = '@example.com';
|
|
82
|
+
if (which === 'maxLocal' && Number.isFinite(constraints.maxLocal)) {
|
|
83
|
+
return fillLen(constraints.maxLocal + offset) + domain;
|
|
84
|
+
}
|
|
85
|
+
if (which === 'maxTotal' && Number.isFinite(constraints.maxTotal)) {
|
|
86
|
+
const localLen = Math.max(1, constraints.maxTotal + offset - domain.length);
|
|
87
|
+
return fillLen(localLen) + domain;
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Try to synthesize a boundary/gen value from constraints. Returns null when data is insufficient. */
|
|
93
|
+
function synthesize(dp: DataPoint, type: string, c: Record<string, any>): { value: string | null; todo?: string } {
|
|
94
|
+
if (dp.value != null) return { value: dp.value };
|
|
95
|
+
const hint = `${dp.gen || ''} ${dp.note || ''}`.toLowerCase();
|
|
96
|
+
|
|
97
|
+
// Email length boundaries. `>` / "vượt" → over-boundary (invalid, offset +1).
|
|
98
|
+
if (type === 'email') {
|
|
99
|
+
const over = hint.includes('>') || hint.includes('vượt') || hint.includes('over');
|
|
100
|
+
const offset = over ? 1 : hint.includes('-1') ? -1 : hint.includes('+1') ? 1 : 0;
|
|
101
|
+
if (hint.includes('maxlocal') || (hint.includes('local') && (hint.includes('max') || over))) {
|
|
102
|
+
const v = synthEmail(c, 'maxLocal', offset); return v ? { value: v } : { value: null, todo: 'maxLocal' };
|
|
103
|
+
}
|
|
104
|
+
if (hint.includes('maxtotal') || (hint.includes('tổng') && (hint.includes('max') || over)) || (hint.includes('total'))) {
|
|
105
|
+
const v = synthEmail(c, 'maxTotal', offset); return v ? { value: v } : { value: null, todo: 'maxTotal' };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// String-length types: min / max length boundaries.
|
|
110
|
+
const strTypes = ['string', 'password', 'phone', 'postcode', 'katakana'];
|
|
111
|
+
if (strTypes.includes(type)) {
|
|
112
|
+
const min = c.minLength ?? c.length;
|
|
113
|
+
const max = c.maxLength ?? c.length;
|
|
114
|
+
if (hint.includes('min') && Number.isFinite(min)) {
|
|
115
|
+
const n = hint.includes('+1') ? min + 1 : min;
|
|
116
|
+
return { value: fillLen(n, type === 'password' ? 'Aa1' : 'a') };
|
|
117
|
+
}
|
|
118
|
+
if (hint.includes('max') && Number.isFinite(max)) {
|
|
119
|
+
const n = hint.includes('-1') ? max - 1 : max;
|
|
120
|
+
return { value: fillLen(n, type === 'password' ? 'Aa1' : 'a') };
|
|
121
|
+
}
|
|
122
|
+
return { value: null, todo: 'minLength/maxLength' };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Numeric types: min / max value boundaries.
|
|
126
|
+
const numTypes = ['number-integer', 'number-float', 'currency'];
|
|
127
|
+
if (numTypes.includes(type)) {
|
|
128
|
+
const min = c.min, max = c.max;
|
|
129
|
+
if (hint.includes('min') && Number.isFinite(min)) {
|
|
130
|
+
if (hint.includes('-1')) return { value: String(min - 1) };
|
|
131
|
+
if (hint.includes('+1')) return { value: String(min + 1) };
|
|
132
|
+
return { value: String(min) };
|
|
133
|
+
}
|
|
134
|
+
if (hint.includes('max') && Number.isFinite(max)) {
|
|
135
|
+
if (hint.includes('+1')) return { value: String(max + 1) };
|
|
136
|
+
if (hint.includes('-1')) return { value: String(max - 1) };
|
|
137
|
+
return { value: String(max) };
|
|
138
|
+
}
|
|
139
|
+
return { value: null, todo: 'min/max' };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return { value: null, todo: 'value/gen not resolvable' };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function resolveError(dp: DataPoint, errorMap?: Record<string, string>): { expectedError?: string; open?: boolean } {
|
|
146
|
+
if (!dp.errorRef) return {};
|
|
147
|
+
if (errorMap && errorMap[dp.errorRef]) return { expectedError: errorMap[dp.errorRef] };
|
|
148
|
+
return { expectedError: dp.errorRef, open: true }; // placeholder survives; flagged open to confirm
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Make a valid value fresh-per-run by injecting a `{{$timestamp}}` token (resolved at runtime by the
|
|
153
|
+
* TestDataLoader's resolveDynamicVars). Email keeps validity via a `+tag` in the local part; other
|
|
154
|
+
* types append the token. Deterministic at compile time (same token string) — uniqueness is at run time.
|
|
155
|
+
*/
|
|
156
|
+
function makeUnique(value: string, type: string): string {
|
|
157
|
+
if (value.includes('{{$')) return value; // already dynamic
|
|
158
|
+
if (type === 'email' && value.includes('@')) {
|
|
159
|
+
const at = value.indexOf('@');
|
|
160
|
+
return `${value.slice(0, at)}+{{$timestamp}}${value.slice(at)}`;
|
|
161
|
+
}
|
|
162
|
+
return `${value}{{$timestamp}}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Expand one field into valid/boundary/invalid data points using the effective catalog. */
|
|
166
|
+
export function expandField(field: FieldDef, catalog: Catalog): ExpandedField {
|
|
167
|
+
// Group / nested field: no own type — expand each sub-field recursively.
|
|
168
|
+
if (field.fields && field.fields.length) {
|
|
169
|
+
return {
|
|
170
|
+
name: field.name,
|
|
171
|
+
type: undefined,
|
|
172
|
+
blindSpot: false,
|
|
173
|
+
points: [],
|
|
174
|
+
children: field.fields.map((sub) => expandField(sub, catalog)),
|
|
175
|
+
...(field.dependsOn ? { dependsOn: field.dependsOn } : {}),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const ft: FieldType | undefined = field.type ? catalog.fieldTypes[field.type] : undefined;
|
|
179
|
+
if (!ft) return { name: field.name, type: field.type, blindSpot: true, points: [], ...(field.dependsOn ? { dependsOn: field.dependsOn } : {}) };
|
|
180
|
+
|
|
181
|
+
const c = { ...(ft.constraints || {}), ...(field.constraints || {}) };
|
|
182
|
+
const points: ExpandedPoint[] = [];
|
|
183
|
+
|
|
184
|
+
const push = (dp: DataPoint, group: ExpandedPoint['group']) => {
|
|
185
|
+
const s = synthesize(dp, field.type, c);
|
|
186
|
+
// Uniqueness: only the VALID (happy-path create) value gets a fresh-per-run suffix. Boundary/invalid
|
|
187
|
+
// stay literal — they test rules, not record creation.
|
|
188
|
+
const value = group === 'valid' && field.unique && s.value != null
|
|
189
|
+
? makeUnique(s.value, field.type)
|
|
190
|
+
: s.value;
|
|
191
|
+
points.push({
|
|
192
|
+
id: dp.id,
|
|
193
|
+
group,
|
|
194
|
+
value,
|
|
195
|
+
todo: s.todo,
|
|
196
|
+
reason: dp.reason,
|
|
197
|
+
note: field.unique && group === 'valid' && s.value != null ? `${dp.note ? dp.note + ' · ' : ''}fresh per run` : dp.note,
|
|
198
|
+
...resolveError(dp, field.errorMap),
|
|
199
|
+
});
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
for (const dp of ft.valid || []) push(dp, 'valid');
|
|
203
|
+
for (const dp of ft.boundary || []) push(dp, 'boundary');
|
|
204
|
+
for (const dp of ft.invalid || []) {
|
|
205
|
+
if (dp.from) continue; // security refs are expanded below
|
|
206
|
+
push(dp, 'invalid');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Append security payloads referenced by uses_security.
|
|
210
|
+
for (const bank of ft.uses_security || []) {
|
|
211
|
+
const b = catalog.security[bank];
|
|
212
|
+
if (!b) continue;
|
|
213
|
+
for (const p of b.payloads || []) {
|
|
214
|
+
points.push({
|
|
215
|
+
id: p.id,
|
|
216
|
+
group: 'invalid',
|
|
217
|
+
value: (p.value as string) ?? null,
|
|
218
|
+
todo: p.value == null ? `security technique: ${p.technique || bank}` : undefined,
|
|
219
|
+
reason: `${bank}: ${(p.reason as string) || (p.kind as string) || (p.technique as string) || ''}`.trim(),
|
|
220
|
+
note: b.oracle,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return { name: field.name, type: field.type, blindSpot: false, points, ...(field.dependsOn ? { dependsOn: field.dependsOn } : {}) };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Expand a whole field-map. */
|
|
229
|
+
export function expandFieldMap(map: FieldMap, catalog: Catalog): ExpandedField[] {
|
|
230
|
+
return (map.fields || []).map((f) => expandField(f, catalog));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Render expanded fields into a standardized `test-data/<name>.yaml` object (serialize with `yaml`).
|
|
235
|
+
* Reviewable + traceable: each value carries its CHK id and resolved expected error.
|
|
236
|
+
*/
|
|
237
|
+
export function renderTestData(expanded: ExpandedField[]): Record<string, any> {
|
|
238
|
+
const out: Record<string, any> = {
|
|
239
|
+
_generated_by: 'sungen data gen — standardized test-data (valid/boundary/invalid + CHK trace). Do not hand-edit values; edit the .fields.yaml or common.overwrite.yaml and re-run.',
|
|
240
|
+
};
|
|
241
|
+
for (const f of expanded) out[f.name] = renderOne(f);
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Render one expanded field — recurses for nested groups; carries a dependsOn note. */
|
|
246
|
+
function renderOne(f: ExpandedField): Record<string, any> {
|
|
247
|
+
const dep = f.dependsOn ? { _depends_on: f.dependsOn.value != null ? `${f.dependsOn.field} = ${f.dependsOn.value}` : f.dependsOn.field } : {};
|
|
248
|
+
if (f.children) {
|
|
249
|
+
const group: Record<string, any> = { ...dep };
|
|
250
|
+
for (const c of f.children) group[c.name] = renderOne(c);
|
|
251
|
+
return group;
|
|
252
|
+
}
|
|
253
|
+
if (f.blindSpot) {
|
|
254
|
+
return { _blind_spot: `type "${f.type}" not in Data Factory catalog — provide qa/data-factory/project/${f.name}.yaml`, ...dep };
|
|
255
|
+
}
|
|
256
|
+
const valid = f.points.filter((p) => p.group === 'valid');
|
|
257
|
+
const boundary = f.points.filter((p) => p.group === 'boundary');
|
|
258
|
+
const invalid = f.points.filter((p) => p.group === 'invalid');
|
|
259
|
+
return {
|
|
260
|
+
...dep,
|
|
261
|
+
valid: valid[0]?.value ?? null,
|
|
262
|
+
boundary: boundary.map((p) => trim({ id: p.id, value: p.value, note: p.note, todo: p.todo })),
|
|
263
|
+
invalid: invalid.map((p) => trim({ id: p.id, value: p.value, expected_error: p.expectedError, open: p.open, reason: p.reason, todo: p.todo })),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function trim(o: Record<string, any>): Record<string, any> {
|
|
268
|
+
const r: Record<string, any> = {};
|
|
269
|
+
for (const [k, v] of Object.entries(o)) if (v != null) r[k] = v;
|
|
270
|
+
return r;
|
|
271
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sungen/driver-data-factory — Sungen Data Factory capability.
|
|
3
|
+
*
|
|
4
|
+
* Two layers (see docs/spec/data-factory-driver-spec.md):
|
|
5
|
+
* - DF-KNOWLEDGE (design-time, runtime-free): the field-type + security + technique catalog under
|
|
6
|
+
* `common/`, the `.overwrite` merge layer (catalog-loader), and — in later phases — the generator
|
|
7
|
+
* that writes standardized `test-data/<screen>.yaml` (valid/boundary/invalid + CHK traceability)
|
|
8
|
+
* and the `sungen data gen|lint` CLI. Because it embeds no runtime, this layer is safe to bundle
|
|
9
|
+
* by default (like core), which is why the driver is registered as a bundled capability.
|
|
10
|
+
* - DF-SEEDING (runtime, opt-in): the `@dataFactory:<recipe>` precondition codegen that seeds a
|
|
11
|
+
* record and binds its id into `{{var}}` (M1 "missing data setup"). It delegates to the datasource
|
|
12
|
+
* of driver-db/api and is only active when a datasource exists — added in a later phase.
|
|
13
|
+
*
|
|
14
|
+
* Plugs into core via the capability SPI; core discovers and calls `register()` at runtime.
|
|
15
|
+
*/
|
|
16
|
+
import type { CapabilityRegistry } from '@sun-asterisk/sungen';
|
|
17
|
+
import { registerDataFactoryCommand } from './cli';
|
|
18
|
+
import { dataFactoryCoverageSensor } from './sensor';
|
|
19
|
+
import { dfPreconditionCodegen } from './precondition';
|
|
20
|
+
|
|
21
|
+
export { loadShippedCatalog, loadEffectiveCatalog, shippedCommonHash, commonDir } from './catalog-loader';
|
|
22
|
+
export { expandField, expandFieldMap, renderTestData } from './generator';
|
|
23
|
+
export type { FieldDef, FieldMap, ExpandedField, ExpandedPoint } from './generator';
|
|
24
|
+
export { lintFieldMap, summarize } from './lint';
|
|
25
|
+
export type { LintIssue } from './lint';
|
|
26
|
+
export { registerDataFactoryCommand } from './cli';
|
|
27
|
+
export { generateFixture } from './fixture';
|
|
28
|
+
export type { FixtureResult } from './fixture';
|
|
29
|
+
export { dataFactoryCoverageSensor } from './sensor';
|
|
30
|
+
export { dfPreconditionCodegen } from './precondition';
|
|
31
|
+
export type { Catalog, FieldType, SecurityBank, Technique, DataPoint, OverwriteWarning } from './types';
|
|
32
|
+
|
|
33
|
+
/** Register the Data Factory capability. */
|
|
34
|
+
export function register(registry: CapabilityRegistry): void {
|
|
35
|
+
registry.register({
|
|
36
|
+
id: 'data-factory',
|
|
37
|
+
annotations: ['@dataFactory'],
|
|
38
|
+
cliCommands: [registerDataFactoryCommand as (program: unknown) => void], // `sungen data gen|lint`
|
|
39
|
+
sensors: [dataFactoryCoverageSensor], // advisory: test-data coverage surfaced at `sungen generate`
|
|
40
|
+
preconditionCodegen: dfPreconditionCodegen, // @dataFactory:<recipe> → bind standardized seed values (beta)
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const sungenDriver = { capability: 'data-factory', register } as const;
|
package/src/lint.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data Factory lint — the deterministic coverage oracle from phuong-phap-tao-test-data.md §6.
|
|
3
|
+
*
|
|
4
|
+
* For each field it confirms the "đủ" checklist: ≥1 valid, boundary present when the field has a
|
|
5
|
+
* length/number bound, ≥1 invalid per rule, no unresolved values, error codes mapped (not left as
|
|
6
|
+
* placeholders). It also surfaces blind spots (type not in catalog) and — via the catalog loader —
|
|
7
|
+
* stale `.overwrite` warnings. Pure functions; no side effects.
|
|
8
|
+
*/
|
|
9
|
+
import type { Catalog } from './types';
|
|
10
|
+
import type { FieldMap, ExpandedField, FieldDef } from './generator';
|
|
11
|
+
import { expandFieldMap } from './generator';
|
|
12
|
+
|
|
13
|
+
export interface LintIssue {
|
|
14
|
+
field: string;
|
|
15
|
+
severity: 'error' | 'warn' | 'info';
|
|
16
|
+
code: string;
|
|
17
|
+
message: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const BOUNDED = new Set(['email', 'string', 'password', 'phone', 'postcode', 'katakana', 'number-integer', 'number-float', 'currency']);
|
|
21
|
+
|
|
22
|
+
export function lintFieldMap(map: FieldMap, catalog: Catalog): { issues: LintIssue[]; expanded: ExpandedField[] } {
|
|
23
|
+
const expanded = expandFieldMap(map, catalog);
|
|
24
|
+
const issues: LintIssue[] = [];
|
|
25
|
+
|
|
26
|
+
// Flatten nested groups to leaf fields (dotted path), pairing each with its FieldDef (for `required`).
|
|
27
|
+
const leaves: Array<{ f: ExpandedField; def?: FieldDef; path: string }> = [];
|
|
28
|
+
const walk = (efs: ExpandedField[], defs: FieldDef[], prefix: string) => {
|
|
29
|
+
for (const ef of efs) {
|
|
30
|
+
const def = defs.find((d) => d.name === ef.name);
|
|
31
|
+
const path = prefix ? `${prefix}.${ef.name}` : ef.name;
|
|
32
|
+
if (ef.children) walk(ef.children, def?.fields || [], path);
|
|
33
|
+
else leaves.push({ f: ef, def, path });
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
walk(expanded, map.fields, '');
|
|
37
|
+
|
|
38
|
+
for (const { f, def, path } of leaves) {
|
|
39
|
+
if (f.blindSpot) {
|
|
40
|
+
issues.push({ field: path, severity: 'error', code: 'BLIND_SPOT',
|
|
41
|
+
message: `type "${f.type}" is not in the Data Factory catalog — supply qa/data-factory/project/${f.name}.yaml (valid set, invalid/nonexistent values, error code, dependencies)` });
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const valid = f.points.filter((p) => p.group === 'valid');
|
|
45
|
+
const boundary = f.points.filter((p) => p.group === 'boundary');
|
|
46
|
+
const invalid = f.points.filter((p) => p.group === 'invalid');
|
|
47
|
+
|
|
48
|
+
if (valid.length === 0) issues.push({ field: path, severity: 'error', code: 'NO_VALID', message: 'no valid representative value' });
|
|
49
|
+
if (invalid.length === 0) issues.push({ field: path, severity: 'error', code: 'NO_INVALID', message: 'no invalid value — every rule needs at least one' });
|
|
50
|
+
if (f.type && BOUNDED.has(f.type) && boundary.length === 0) {
|
|
51
|
+
issues.push({ field: path, severity: 'warn', code: 'NO_BOUNDARY', message: `type "${f.type}" usually has min/max — add boundary values or state why not (phuong-phap §6)` });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Unresolved (todo) values — a boundary/gen the constraints could not synthesize.
|
|
55
|
+
for (const p of f.points) {
|
|
56
|
+
if (p.todo) issues.push({ field: path, severity: 'warn', code: 'UNRESOLVED',
|
|
57
|
+
message: `${p.id} (${p.group}) needs input: ${p.todo}${p.note ? ` — ${p.note}` : ''}` });
|
|
58
|
+
}
|
|
59
|
+
// Placeholder error codes still unmapped.
|
|
60
|
+
const openErrs = invalid.filter((p) => p.open);
|
|
61
|
+
if (openErrs.length) {
|
|
62
|
+
issues.push({ field: path, severity: 'info', code: 'OPEN_ERRORREF',
|
|
63
|
+
message: `${openErrs.length} invalid case(s) use placeholder error codes (${[...new Set(openErrs.map((p) => p.expectedError))].join(', ')}) — map to real codes via errorMap in the field-map, or confirm with BA/Dev` });
|
|
64
|
+
}
|
|
65
|
+
if (def?.required && valid.length === 0) {
|
|
66
|
+
issues.push({ field: path, severity: 'error', code: 'REQUIRED_NO_VALID', message: 'required field with no valid value' });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { issues, expanded };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function summarize(issues: LintIssue[]): { errors: number; warns: number; infos: number } {
|
|
73
|
+
return {
|
|
74
|
+
errors: issues.filter((i) => i.severity === 'error').length,
|
|
75
|
+
warns: issues.filter((i) => i.severity === 'warn').length,
|
|
76
|
+
infos: issues.filter((i) => i.severity === 'info').length,
|
|
77
|
+
};
|
|
78
|
+
}
|