@sungen/driver-data-factory 3.2.10-beta.6 → 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 +10 -10
- package/common/field-types/email.yaml +19 -16
- package/common/field-types/jp/katakana.yaml +12 -12
- package/common/field-types/number-float.yaml +10 -10
- package/common/field-types/number-integer.yaml +12 -12
- package/common/field-types/password.yaml +22 -22
- package/common/field-types/phone.yaml +14 -14
- package/common/field-types/postcode.yaml +8 -8
- package/common/field-types/string.yaml +11 -11
- package/common/security/broken-auth.yaml +6 -6
- package/common/security/idor.yaml +5 -5
- package/common/security/input-validation.yaml +5 -5
- package/common/security/sqli.yaml +7 -7
- package/common/security/upload.yaml +7 -7
- package/common/security/xss.yaml +9 -9
- package/common/techniques/boundary.yaml +4 -4
- package/common/techniques/decision-table.yaml +4 -4
- package/common/techniques/equivalence.yaml +4 -4
- package/common/techniques/state-transition.yaml +4 -4
- package/package.json +3 -3
- package/src/applicability.ts +80 -0
- package/src/boundary.ts +319 -0
- package/src/catalog-loader.ts +104 -18
- package/src/cli.ts +233 -14
- package/src/contract.ts +93 -0
- package/src/crosscheck.ts +149 -0
- package/src/detect.ts +122 -0
- package/src/fixture.ts +111 -0
- package/src/generator.ts +231 -98
- package/src/index.ts +17 -2
- package/src/lint.ts +31 -16
- package/src/readme.ts +52 -0
- package/src/sensor.ts +32 -4
- package/src/state.ts +225 -0
- package/src/types.ts +82 -1
- package/src/validate.ts +77 -0
package/src/detect.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data Factory — auto-detection (Layer A of the auto-orchestration design).
|
|
3
|
+
*
|
|
4
|
+
* Makes Sungen NOTICE what test-data work a unit needs, without QA having to know to run a command:
|
|
5
|
+
* - a unit that HAS input fields but has NO field-map → its data was never standardized;
|
|
6
|
+
* - a spec that describes a STATEFUL PRECONDITION (a required entity/state) with no state-recipe and
|
|
7
|
+
* no Background/@manual precondition → the stateful setup is undeclared.
|
|
8
|
+
* Both are surfaced as advisory findings at `sungen generate`/`sungen audit` (the DF advisory sensor).
|
|
9
|
+
* Deterministic heuristics over spec.md + the feature; no runtime, no LLM. Advisory only — a nudge +
|
|
10
|
+
* an auto-draft prompt, never a hard fail (the fuzzy precondition scan must not block).
|
|
11
|
+
*/
|
|
12
|
+
import { join } from 'path';
|
|
13
|
+
import { existsSync, readFileSync, readdirSync } from 'fs';
|
|
14
|
+
import { loadStateRecipes } from './state';
|
|
15
|
+
|
|
16
|
+
export interface DetectFinding {
|
|
17
|
+
code: 'UNSTANDARDIZED' | 'PRECONDITION_UNDECLARED';
|
|
18
|
+
scenario: string;
|
|
19
|
+
message: string;
|
|
20
|
+
severity: 'warn' | 'info';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readIfExists(path: string): string {
|
|
24
|
+
try { return existsSync(path) ? readFileSync(path, 'utf8') : ''; } catch { return ''; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readSpec(dir: string): string {
|
|
28
|
+
return readIfExists(join(dir, 'requirements', 'spec.md'));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readFeature(dir: string): string {
|
|
32
|
+
const fdir = join(dir, 'features');
|
|
33
|
+
if (!existsSync(fdir)) return '';
|
|
34
|
+
try {
|
|
35
|
+
return readdirSync(fdir).filter((f) => f.endsWith('.feature')).map((f) => readIfExists(join(fdir, f))).join('\n');
|
|
36
|
+
} catch { return ''; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Does the unit take user input? Strongest signal: a Gherkin `fill [X] field` / `type` step; plus a
|
|
41
|
+
* spec field table row typed as input/select/textarea. A pure navigation / list / capture screen has
|
|
42
|
+
* none → we must NOT nag it ("no input fields → nothing to generate").
|
|
43
|
+
*/
|
|
44
|
+
export function hasInputFields(specText: string, featureText: string): boolean {
|
|
45
|
+
if (/\b(fill|type|enter|input|select|choose)\b\s+\[[^\]]+\]\s*(field|input|dropdown|textbox)?/i.test(featureText)) return true;
|
|
46
|
+
if (/\|\s*(input|select|textarea|checkbox|radio|combobox)\b/i.test(specText)) return true;
|
|
47
|
+
if (/\binput\s*\(|\bselect\s*\(|\btextarea\b/i.test(specText)) return true;
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A field-map exists for this unit. */
|
|
52
|
+
export function hasFieldMap(cwd: string, name: string): boolean {
|
|
53
|
+
return existsSync(join(cwd, 'qa', 'data-factory', `${name}.fields.yaml`));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Precondition signal phrases — a spec sentence hinting the test needs a pre-existing entity/state. */
|
|
57
|
+
const PRECONDITION_SIGNALS = [
|
|
58
|
+
'requires', 'required before', 'precondition', 'pre-condition', 'prerequisite',
|
|
59
|
+
'logged in', 'authenticated', 'signed in', 'must be logged',
|
|
60
|
+
'already exists', 'already registered', 'existing ', 'pre-existing', 'must exist',
|
|
61
|
+
'reset token', 'verification', 'provisional account', 'session-storage', 'session storage',
|
|
62
|
+
'buffer', 'seeded', 'must have', 'previously', 'valid token', 'auth token',
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
/** Extract spec sentences that look like a stateful precondition (deduped, capped). */
|
|
66
|
+
export function detectPreconditionSignals(specText: string): string[] {
|
|
67
|
+
if (!specText) return [];
|
|
68
|
+
const hits: string[] = [];
|
|
69
|
+
const seen = new Set<string>();
|
|
70
|
+
// Split into sentence-ish units (lines / bullet items / sentences).
|
|
71
|
+
const units = specText.split(/\n|(?<=[.。])\s+/).map((s) => s.replace(/\s+/g, ' ').trim()).filter(Boolean);
|
|
72
|
+
for (const u of units) {
|
|
73
|
+
const low = u.toLowerCase();
|
|
74
|
+
// Skip markdown headings/table separators, and spec-template meta lines (US labels, rationale)
|
|
75
|
+
// that mention auth/priority only descriptively — not an actual precondition.
|
|
76
|
+
if (/^#{1,6}\s|^\|?\s*-{3,}/.test(u)) continue;
|
|
77
|
+
if (/^\*\*(why|independent test|acceptance|business context|target users|priority)\b/i.test(u)) continue;
|
|
78
|
+
if (/\bpriority\b/i.test(low) && /\bp[0-9]\b/i.test(low)) continue;
|
|
79
|
+
const sig = PRECONDITION_SIGNALS.find((s) => low.includes(s));
|
|
80
|
+
if (sig) {
|
|
81
|
+
const key = low.slice(0, 60);
|
|
82
|
+
if (!seen.has(key)) { seen.add(key); hits.push(u.length > 160 ? u.slice(0, 157) + '…' : u); }
|
|
83
|
+
}
|
|
84
|
+
if (hits.length >= 4) break;
|
|
85
|
+
}
|
|
86
|
+
return hits;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Is a stateful precondition already declared — a state-recipe with resources, or a Background/@manual? */
|
|
90
|
+
export function hasStateDeclaration(cwd: string, featureText: string): boolean {
|
|
91
|
+
if (/^\s*Background:/m.test(featureText) || /@manual\b/.test(featureText)) return true;
|
|
92
|
+
return loadStateRecipes(cwd).some((r) => (r.resources && r.resources.length) || r.shared);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Run Layer-A detection for one unit dir. Returns advisory findings (possibly empty). */
|
|
96
|
+
export function detectUnit(cwd: string, dir: string, name: string): DetectFinding[] {
|
|
97
|
+
const spec = readSpec(dir);
|
|
98
|
+
const feature = readFeature(dir);
|
|
99
|
+
const out: DetectFinding[] = [];
|
|
100
|
+
|
|
101
|
+
// 1. Inputs exist but never standardized.
|
|
102
|
+
if (!hasFieldMap(cwd, name) && hasInputFields(spec, feature)) {
|
|
103
|
+
out.push({
|
|
104
|
+
code: 'UNSTANDARDIZED',
|
|
105
|
+
scenario: name,
|
|
106
|
+
severity: 'warn',
|
|
107
|
+
message: `DATA-FACTORY-UNSTANDARDIZED: ${name} has input fields but no standardized test-data (no qa/data-factory/${name}.fields.yaml) — run \`/sungen:create-data-test ${name}\` (or it is authored inline by \`/sungen:create-test\`) so values are standardized valid/boundary/invalid, not hand-invented.`,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 2. Stateful precondition described in the spec but not declared anywhere.
|
|
112
|
+
const signals = detectPreconditionSignals(spec);
|
|
113
|
+
if (signals.length && !hasStateDeclaration(cwd, feature)) {
|
|
114
|
+
out.push({
|
|
115
|
+
code: 'PRECONDITION_UNDECLARED',
|
|
116
|
+
scenario: name,
|
|
117
|
+
severity: 'info',
|
|
118
|
+
message: `DATA-FACTORY-PRECONDITION-UNDECLARED: ${name}'s spec describes a stateful precondition but the test declares none (no state-recipe, no Background/@manual). Declare it with a state-recipe + \`sungen data state\` so it renders a @manual:data-setup Background. Signals: ${signals.map((s) => `"${s}"`).join('; ')}`,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
package/src/fixture.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
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
|
+
* 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.
|
|
14
|
+
*
|
|
15
|
+
* Deterministic: same field-map + catalog → same CSV. Columns are the flattened leaf paths (nested
|
|
16
|
+
* groups → dotted headers, e.g. `address.city`). Reference the file from an `@api` upload (`files:`)
|
|
17
|
+
* or a UI file-input step.
|
|
18
|
+
*/
|
|
19
|
+
import type { Catalog } from './types';
|
|
20
|
+
import { expandFieldMap, type FieldMap, type ExpandedField } from './generator';
|
|
21
|
+
|
|
22
|
+
interface Leaf { path: string; field: ExpandedField }
|
|
23
|
+
|
|
24
|
+
/** Flatten nested groups to leaf fields with dotted-path headers. */
|
|
25
|
+
function leaves(expanded: ExpandedField[], prefix = ''): Leaf[] {
|
|
26
|
+
const out: Leaf[] = [];
|
|
27
|
+
for (const f of expanded) {
|
|
28
|
+
const path = prefix ? `${prefix}.${f.name}` : f.name;
|
|
29
|
+
if (f.children) out.push(...leaves(f.children, path));
|
|
30
|
+
else out.push({ path, field: f });
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const validOf = (f: ExpandedField): string => f.points.find((p) => p.group === 'valid' && p.value != null)?.value ?? '';
|
|
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);
|
|
41
|
+
|
|
42
|
+
/** RFC-4180-ish CSV cell quoting. */
|
|
43
|
+
function cell(v: string): string {
|
|
44
|
+
return /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
|
|
45
|
+
}
|
|
46
|
+
function row(cells: string[]): string {
|
|
47
|
+
return cells.map(cell).join(',');
|
|
48
|
+
}
|
|
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
|
+
|
|
63
|
+
export interface FixtureResult {
|
|
64
|
+
valid: string; // CSV text (header + N valid rows)
|
|
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)
|
|
67
|
+
headers: string[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Build the CSV fixture(s) from a field-map. */
|
|
71
|
+
export function generateFixture(map: FieldMap, catalog: Catalog, opts: { rows?: number; includeInvalid?: boolean } = {}): FixtureResult {
|
|
72
|
+
const rows = Math.max(1, opts.rows ?? 3);
|
|
73
|
+
const ls = leaves(expandFieldMap(map, catalog));
|
|
74
|
+
const headers = ls.map((l) => l.path);
|
|
75
|
+
|
|
76
|
+
// VALID rows — resolve the valid value per field; a `unique` field's `{{$timestamp}}` token is a
|
|
77
|
+
// runtime var, not valid in a static file, so replace it with the row index to keep rows distinct.
|
|
78
|
+
const validRows: string[] = [];
|
|
79
|
+
for (let i = 1; i <= rows; i++) {
|
|
80
|
+
validRows.push(row(ls.map((l) => validOf(l.field).replace(/\{\{\$timestamp\}\}/g, String(i)))));
|
|
81
|
+
}
|
|
82
|
+
const valid = [row(headers), ...validRows].join('\n') + '\n';
|
|
83
|
+
|
|
84
|
+
let invalid: string | undefined;
|
|
85
|
+
let expected: FixtureExpected | undefined;
|
|
86
|
+
if (opts.includeInvalid) {
|
|
87
|
+
const invRows: string[] = [];
|
|
88
|
+
const expectedRows: ExpectedRow[] = [];
|
|
89
|
+
for (const target of ls) {
|
|
90
|
+
const bad = firstInvalid(target.field);
|
|
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.
|
|
94
|
+
const cells = ls.map((l) => (l === target ? (bad.value as string) : validOf(l.field).replace(/\{\{\$timestamp\}\}/g, '1')));
|
|
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));
|
|
97
|
+
}
|
|
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
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
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;
|
|
111
|
+
}
|
package/src/generator.ts
CHANGED
|
@@ -9,17 +9,37 @@
|
|
|
9
9
|
* payloads, and resolving placeholder error codes through the field's `errorMap`. Same input →
|
|
10
10
|
* same output (determinism is an asset). Nothing here reads a live page or an LLM.
|
|
11
11
|
*/
|
|
12
|
-
import type { Catalog, FieldType, DataPoint } from './types';
|
|
12
|
+
import type { Catalog, FieldType, DataPoint, SelectionContext } from './types';
|
|
13
|
+
import { expandBoundaryHint, satisfiesConstraints, ensureBvaComplete, type Validity } from './boundary';
|
|
14
|
+
import { evaluateWhen, fieldFacts, inProfile } from './applicability';
|
|
13
15
|
|
|
14
16
|
export interface FieldDef {
|
|
15
17
|
name: string;
|
|
16
|
-
/** Catalog field-type id (email/password/…). A type absent from the catalog → blind spot. */
|
|
17
|
-
type
|
|
18
|
+
/** Catalog field-type id (email/password/…). Omit for a GROUP (a field with nested `fields`). A type absent from the catalog → blind spot. */
|
|
19
|
+
type?: string;
|
|
18
20
|
required?: boolean;
|
|
19
21
|
/** Project overrides merged over the catalog constraints (maxLength, min, max, …). */
|
|
20
22
|
constraints?: Record<string, any>;
|
|
21
23
|
/** Placeholder errorRef (Mxx) → real project message code. Unmapped refs are flagged open. */
|
|
22
24
|
errorMap?: Record<string, string>;
|
|
25
|
+
/**
|
|
26
|
+
* Field must be fresh/unique per test run (e.g. a register email/username that can't collide
|
|
27
|
+
* with a record left by a previous run). When true, the VALID value(s) get a runtime `{{$timestamp}}`
|
|
28
|
+
* suffix so `resolveDynamicVars` produces a new value every run — no cross-run cache, no flakiness.
|
|
29
|
+
*/
|
|
30
|
+
unique?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Nested / group field (complex form): sub-fields expanded recursively under this name
|
|
33
|
+
* (e.g. `address` → `city`, `postcode`). A field with `fields` is a group and needs no `type`.
|
|
34
|
+
*/
|
|
35
|
+
fields?: FieldDef[];
|
|
36
|
+
/**
|
|
37
|
+
* Inter-field dependency: this field's rule/format depends on another field's value
|
|
38
|
+
* (e.g. postcode format `dependsOn: { field: country, value: JP }`). Carried through as metadata
|
|
39
|
+
* so the scenario weave + QA honor the relationship — the generator does not resolve the runtime
|
|
40
|
+
* condition (that is scenario/@cases logic), it records it.
|
|
41
|
+
*/
|
|
42
|
+
dependsOn?: { field: string; value?: string };
|
|
23
43
|
}
|
|
24
44
|
|
|
25
45
|
export interface FieldMap {
|
|
@@ -39,85 +59,64 @@ export interface ExpandedPoint {
|
|
|
39
59
|
open?: boolean;
|
|
40
60
|
/** Value could not be synthesized deterministically (missing constraint) — needs input. */
|
|
41
61
|
todo?: string;
|
|
62
|
+
/**
|
|
63
|
+
* The generator's expectation for this value: does it satisfy the field's constraints (`valid`) or
|
|
64
|
+
* violate them (`invalid`)? Computed by the boundary oracle for synthesized points; `valid` for the
|
|
65
|
+
* happy-path group and `invalid` for declared-invalid values. `validateGenerated` re-checks it — a
|
|
66
|
+
* mismatch (a `valid`-expected value that fails the constraints, or vice versa) is a defect.
|
|
67
|
+
*/
|
|
68
|
+
expectedValidity?: Validity;
|
|
69
|
+
/**
|
|
70
|
+
* True when the VALUE was synthesized from a numeric/length boundary (so its whole validity claim is
|
|
71
|
+
* about range). The validator only second-guesses an `invalid` point when it is synthesized — a
|
|
72
|
+
* literal charset/format invalid (or a security payload) legitimately satisfies the length oracle.
|
|
73
|
+
*/
|
|
74
|
+
synthesized?: boolean;
|
|
75
|
+
/** Provenance: `overwrite` when the project's `.overwrite` replaced/added this point (else shipped). */
|
|
76
|
+
source?: 'shipped' | 'overwrite';
|
|
77
|
+
/** Bucket: `adversarial` = a security/injection payload (rendered separately from plain invalid). */
|
|
78
|
+
category?: 'adversarial';
|
|
42
79
|
}
|
|
43
80
|
|
|
44
81
|
export interface ExpandedField {
|
|
45
82
|
name: string;
|
|
46
|
-
type
|
|
83
|
+
type?: string;
|
|
47
84
|
blindSpot: boolean;
|
|
48
85
|
points: ExpandedPoint[];
|
|
86
|
+
/** Nested sub-fields (a group field). When present, `points` is empty and children hold the data. */
|
|
87
|
+
children?: ExpandedField[];
|
|
88
|
+
/** Recorded inter-field dependency (metadata only — not resolved deterministically). */
|
|
89
|
+
dependsOn?: { field: string; value?: string };
|
|
90
|
+
/** Cases NOT materialized for the selection context (applicability) — surfaced, never silent. */
|
|
91
|
+
excluded?: Array<{ id: string; reason: string }>;
|
|
49
92
|
}
|
|
50
93
|
|
|
51
|
-
/**
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (
|
|
64
|
-
|
|
65
|
-
|
|
94
|
+
/**
|
|
95
|
+
* Coerce an out-of-range numeric VALID literal into range, so a project's `valid` representative is
|
|
96
|
+
* actually valid for its constraints (a generic catalog example like `123` under `max: 100` becomes
|
|
97
|
+
* `100`). Returns null when in range already, or the type isn't numeric (non-numeric mismatches are
|
|
98
|
+
* left for the validator to flag — the field-map should supply a fitting value).
|
|
99
|
+
*/
|
|
100
|
+
function coerceValidNumeric(type: string, value: string, c: Record<string, any>): string | null {
|
|
101
|
+
if (!['number-integer', 'number-float', 'currency'].includes(type)) return null;
|
|
102
|
+
const n = Number(value);
|
|
103
|
+
if (!Number.isFinite(n)) return null;
|
|
104
|
+
let out = n;
|
|
105
|
+
if (Number.isFinite(c.min) && out < c.min) out = c.min;
|
|
106
|
+
if (Number.isFinite(c.max) && out > c.max) out = c.max;
|
|
107
|
+
if (out === n) return null; // already in range
|
|
108
|
+
if (type === 'number-float' || (type === 'currency' && c.decimalPlaces > 0)) {
|
|
109
|
+
const dp = Number.isFinite(c.decimalPlaces) ? c.decimalPlaces : 2;
|
|
110
|
+
return out.toFixed(dp);
|
|
66
111
|
}
|
|
67
|
-
return
|
|
112
|
+
return String(Math.round(out));
|
|
68
113
|
}
|
|
69
114
|
|
|
70
|
-
/**
|
|
71
|
-
function
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
// Email length boundaries. `>` / "vượt" → over-boundary (invalid, offset +1).
|
|
76
|
-
if (type === 'email') {
|
|
77
|
-
const over = hint.includes('>') || hint.includes('vượt') || hint.includes('over');
|
|
78
|
-
const offset = over ? 1 : hint.includes('-1') ? -1 : hint.includes('+1') ? 1 : 0;
|
|
79
|
-
if (hint.includes('maxlocal') || (hint.includes('local') && (hint.includes('max') || over))) {
|
|
80
|
-
const v = synthEmail(c, 'maxLocal', offset); return v ? { value: v } : { value: null, todo: 'maxLocal' };
|
|
81
|
-
}
|
|
82
|
-
if (hint.includes('maxtotal') || (hint.includes('tổng') && (hint.includes('max') || over)) || (hint.includes('total'))) {
|
|
83
|
-
const v = synthEmail(c, 'maxTotal', offset); return v ? { value: v } : { value: null, todo: 'maxTotal' };
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// String-length types: min / max length boundaries.
|
|
88
|
-
const strTypes = ['string', 'password', 'phone', 'postcode', 'katakana'];
|
|
89
|
-
if (strTypes.includes(type)) {
|
|
90
|
-
const min = c.minLength ?? c.length;
|
|
91
|
-
const max = c.maxLength ?? c.length;
|
|
92
|
-
if (hint.includes('min') && Number.isFinite(min)) {
|
|
93
|
-
const n = hint.includes('+1') ? min + 1 : min;
|
|
94
|
-
return { value: fillLen(n, type === 'password' ? 'Aa1' : 'a') };
|
|
95
|
-
}
|
|
96
|
-
if (hint.includes('max') && Number.isFinite(max)) {
|
|
97
|
-
const n = hint.includes('-1') ? max - 1 : max;
|
|
98
|
-
return { value: fillLen(n, type === 'password' ? 'Aa1' : 'a') };
|
|
99
|
-
}
|
|
100
|
-
return { value: null, todo: 'minLength/maxLength' };
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// Numeric types: min / max value boundaries.
|
|
104
|
-
const numTypes = ['number-integer', 'number-float', 'currency'];
|
|
105
|
-
if (numTypes.includes(type)) {
|
|
106
|
-
const min = c.min, max = c.max;
|
|
107
|
-
if (hint.includes('min') && Number.isFinite(min)) {
|
|
108
|
-
if (hint.includes('-1')) return { value: String(min - 1) };
|
|
109
|
-
if (hint.includes('+1')) return { value: String(min + 1) };
|
|
110
|
-
return { value: String(min) };
|
|
111
|
-
}
|
|
112
|
-
if (hint.includes('max') && Number.isFinite(max)) {
|
|
113
|
-
if (hint.includes('+1')) return { value: String(max + 1) };
|
|
114
|
-
if (hint.includes('-1')) return { value: String(max - 1) };
|
|
115
|
-
return { value: String(max) };
|
|
116
|
-
}
|
|
117
|
-
return { value: null, todo: 'min/max' };
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
return { value: null, todo: 'value/gen not resolvable' };
|
|
115
|
+
/** A gen hint that references a numeric/length boundary the generator can expand structurally. */
|
|
116
|
+
function isBoundaryHint(hint: string): boolean {
|
|
117
|
+
return /\bmin\b|\bmax\b|minimum|maximum|length|byte|exact|decimal|precision|local|total|over|below|under|exceed|\+\s*1|-\s*1|<|>/.test(
|
|
118
|
+
hint.toLowerCase(),
|
|
119
|
+
);
|
|
121
120
|
}
|
|
122
121
|
|
|
123
122
|
function resolveError(dp: DataPoint, errorMap?: Record<string, string>): { expectedError?: string; open?: boolean } {
|
|
@@ -126,27 +125,119 @@ function resolveError(dp: DataPoint, errorMap?: Record<string, string>): { expec
|
|
|
126
125
|
return { expectedError: dp.errorRef, open: true }; // placeholder survives; flagged open to confirm
|
|
127
126
|
}
|
|
128
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Make a valid value fresh-per-run by injecting a `{{$timestamp}}` token (resolved at runtime by the
|
|
130
|
+
* TestDataLoader's resolveDynamicVars). Email keeps validity via a `+tag` in the local part; other
|
|
131
|
+
* types append the token. Deterministic at compile time (same token string) — uniqueness is at run time.
|
|
132
|
+
*/
|
|
133
|
+
function makeUnique(value: string, type: string): string {
|
|
134
|
+
if (value.includes('{{$')) return value; // already dynamic
|
|
135
|
+
if (type === 'email' && value.includes('@')) {
|
|
136
|
+
const at = value.indexOf('@');
|
|
137
|
+
return `${value.slice(0, at)}+{{$timestamp}}${value.slice(at)}`;
|
|
138
|
+
}
|
|
139
|
+
return `${value}{{$timestamp}}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
129
142
|
/** Expand one field into valid/boundary/invalid data points using the effective catalog. */
|
|
130
|
-
export function expandField(field: FieldDef, catalog: Catalog): ExpandedField {
|
|
131
|
-
|
|
132
|
-
if (
|
|
143
|
+
export function expandField(field: FieldDef, catalog: Catalog, ctx: SelectionContext = {}): ExpandedField {
|
|
144
|
+
// Group / nested field: no own type — expand each sub-field recursively.
|
|
145
|
+
if (field.fields && field.fields.length) {
|
|
146
|
+
return {
|
|
147
|
+
name: field.name,
|
|
148
|
+
type: undefined,
|
|
149
|
+
blindSpot: false,
|
|
150
|
+
points: [],
|
|
151
|
+
children: field.fields.map((sub) => expandField(sub, catalog, ctx)),
|
|
152
|
+
...(field.dependsOn ? { dependsOn: field.dependsOn } : {}),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
const ft: FieldType | undefined = field.type ? catalog.fieldTypes[field.type] : undefined;
|
|
156
|
+
if (!ft) return { name: field.name, type: field.type, blindSpot: true, points: [], ...(field.dependsOn ? { dependsOn: field.dependsOn } : {}) };
|
|
133
157
|
|
|
134
158
|
const c = { ...(ft.constraints || {}), ...(field.constraints || {}) };
|
|
159
|
+
const type = field.type as string;
|
|
135
160
|
const points: ExpandedPoint[] = [];
|
|
161
|
+
const excluded: Array<{ id: string; reason: string }> = [];
|
|
162
|
+
const facts = fieldFacts(field, c);
|
|
136
163
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
164
|
+
// Gate a catalog case by applicability (`when`) then by the selection profile. A `when` miss is
|
|
165
|
+
// reported as excluded (not silently dropped); a profile miss is an explicit selection (not reported).
|
|
166
|
+
const admit = (dp: DataPoint, group: ExpandedPoint['group'], category?: 'adversarial'): boolean => {
|
|
167
|
+
const ap = evaluateWhen(dp.when, facts, ctx);
|
|
168
|
+
if (!ap.applicable) { excluded.push({ id: dp.id, reason: ap.reason || 'not applicable' }); return false; }
|
|
169
|
+
return inProfile(group, category, ctx.profile);
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// Default validity for a group: the happy path is valid; the invalid group is invalid; a boundary
|
|
173
|
+
// point's validity is decided per-point by the oracle (in/out of range).
|
|
174
|
+
const groupValidity = (group: ExpandedPoint['group']): Validity => (group === 'invalid' ? 'invalid' : 'valid');
|
|
175
|
+
|
|
176
|
+
const emit = (p: Omit<ExpandedPoint, 'expectedValidity'> & { expectedValidity: Validity }) => points.push(p);
|
|
177
|
+
|
|
178
|
+
const pushLiteral = (dp: DataPoint, group: ExpandedPoint['group'], value: string | null, opts: { validity?: Validity; label?: string; todo?: string } = {}) => {
|
|
179
|
+
// A valid representative must be valid for the project: clamp an out-of-range numeric literal.
|
|
180
|
+
let coerceNote: string | undefined;
|
|
181
|
+
if (group === 'valid' && value != null && satisfiesConstraints(type, value, c) === false) {
|
|
182
|
+
const fixed = coerceValidNumeric(type, value, c);
|
|
183
|
+
if (fixed != null) { value = fixed; coerceNote = 'adjusted to fit constraints'; }
|
|
184
|
+
}
|
|
185
|
+
const isUniqueValid = group === 'valid' && field.unique && value != null;
|
|
186
|
+
const v = isUniqueValid ? makeUnique(value!, type) : value;
|
|
187
|
+
// Expected validity reflects INTENT by group, so the validator (validateGenerated) can catch a
|
|
188
|
+
// mismatch against the oracle instead of the mismatch being silently absorbed here:
|
|
189
|
+
// - valid group → `valid` (a value that actually violates the constraints is a VALUE_NOT_VALID).
|
|
190
|
+
// - invalid group → `invalid` (declared: charset/format/blocklist/behavior — the length oracle
|
|
191
|
+
// cannot "prove" such a literal valid, so it must NOT flip it to valid).
|
|
192
|
+
// - boundary group → the oracle decides (that group is precisely about range).
|
|
193
|
+
let validity: Validity;
|
|
194
|
+
if (dp.validity) validity = dp.validity;
|
|
195
|
+
else if (group === 'boundary') {
|
|
196
|
+
const oracle = satisfiesConstraints(type, isUniqueValid ? value : v, c);
|
|
197
|
+
validity = oracle == null ? (opts.validity ?? 'valid') : oracle ? 'valid' : 'invalid';
|
|
198
|
+
} else validity = opts.validity ?? groupValidity(group);
|
|
199
|
+
emit({
|
|
140
200
|
id: dp.id,
|
|
141
201
|
group,
|
|
142
|
-
value:
|
|
143
|
-
todo:
|
|
202
|
+
value: v,
|
|
203
|
+
todo: opts.todo,
|
|
144
204
|
reason: dp.reason,
|
|
145
|
-
note: dp.note,
|
|
205
|
+
note: isUniqueValid ? `${dp.note ? dp.note + ' · ' : ''}fresh per run` : coerceNote ? `${dp.note ? dp.note + ' · ' : ''}${coerceNote}` : (opts.label ? `${dp.note ? dp.note + ' · ' : ''}${opts.label}` : dp.note),
|
|
206
|
+
expectedValidity: validity,
|
|
207
|
+
...(dp.source === 'overwrite' ? { source: 'overwrite' as const } : {}),
|
|
146
208
|
...resolveError(dp, field.errorMap),
|
|
147
209
|
});
|
|
148
210
|
};
|
|
149
211
|
|
|
212
|
+
const push = (dp: DataPoint, group: ExpandedPoint['group']) => {
|
|
213
|
+
if (!admit(dp, group)) return; // not applicable / out of profile
|
|
214
|
+
// Literal value in the catalog/overwrite → one point (oracle still judges its validity).
|
|
215
|
+
if (dp.value != null) return pushLiteral(dp, group, dp.value);
|
|
216
|
+
|
|
217
|
+
const hint = `${dp.gen || ''} ${dp.note || ''}`.trim();
|
|
218
|
+
// A boundary/length/numeric hint → expand to one case PER boundary point, each with its validity.
|
|
219
|
+
if (hint && isBoundaryHint(hint)) {
|
|
220
|
+
const pts = expandBoundaryHint(hint, type, c);
|
|
221
|
+
for (const bp of pts) {
|
|
222
|
+
emit({
|
|
223
|
+
id: dp.id + bp.suffix,
|
|
224
|
+
group,
|
|
225
|
+
value: bp.value,
|
|
226
|
+
todo: bp.todo,
|
|
227
|
+
reason: dp.reason,
|
|
228
|
+
note: bp.label && !bp.todo ? `${dp.note ? dp.note + ' · ' : ''}${bp.label}` : dp.note,
|
|
229
|
+
expectedValidity: dp.validity ?? bp.validity,
|
|
230
|
+
synthesized: bp.value != null,
|
|
231
|
+
...(dp.source === 'overwrite' ? { source: 'overwrite' as const } : {}),
|
|
232
|
+
...resolveError(dp, field.errorMap),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
// A non-boundary gen (charset/full-width/format) — can't synthesize deterministically; keep open.
|
|
238
|
+
pushLiteral(dp, group, null, { validity: groupValidity(group), todo: dp.gen ? `synthesize: ${dp.gen}` : 'value/gen not resolvable' });
|
|
239
|
+
};
|
|
240
|
+
|
|
150
241
|
for (const dp of ft.valid || []) push(dp, 'valid');
|
|
151
242
|
for (const dp of ft.boundary || []) push(dp, 'boundary');
|
|
152
243
|
for (const dp of ft.invalid || []) {
|
|
@@ -154,11 +245,12 @@ export function expandField(field: FieldDef, catalog: Catalog): ExpandedField {
|
|
|
154
245
|
push(dp, 'invalid');
|
|
155
246
|
}
|
|
156
247
|
|
|
157
|
-
// Append security payloads referenced by uses_security.
|
|
248
|
+
// Append security payloads referenced by uses_security — the `adversarial` category (own bucket).
|
|
158
249
|
for (const bank of ft.uses_security || []) {
|
|
159
250
|
const b = catalog.security[bank];
|
|
160
251
|
if (!b) continue;
|
|
161
252
|
for (const p of b.payloads || []) {
|
|
253
|
+
if (!admit(p as DataPoint, 'invalid', 'adversarial')) continue;
|
|
162
254
|
points.push({
|
|
163
255
|
id: p.id,
|
|
164
256
|
group: 'invalid',
|
|
@@ -166,16 +258,43 @@ export function expandField(field: FieldDef, catalog: Catalog): ExpandedField {
|
|
|
166
258
|
todo: p.value == null ? `security technique: ${p.technique || bank}` : undefined,
|
|
167
259
|
reason: `${bank}: ${(p.reason as string) || (p.kind as string) || (p.technique as string) || ''}`.trim(),
|
|
168
260
|
note: b.oracle,
|
|
261
|
+
// Adversarial payloads are treated as invalid input (must be rejected/escaped, never accepted).
|
|
262
|
+
expectedValidity: 'invalid',
|
|
263
|
+
category: 'adversarial',
|
|
169
264
|
});
|
|
170
265
|
}
|
|
171
266
|
}
|
|
172
267
|
|
|
173
|
-
|
|
268
|
+
// BVA completeness: for every bounded dimension in the constraints, ensure the full
|
|
269
|
+
// min-1/min/min+1/max-1/max/max+1 set exists — auto-adding only what the catalog left out (e.g. an
|
|
270
|
+
// email `minLength` the catalog didn't model). Only in-range points are `boundary`; out-of-range are
|
|
271
|
+
// `invalid`. Appended after the catalog cases so it never overrides an authored one.
|
|
272
|
+
const idCode = type.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
|
273
|
+
for (const ex of ensureBvaComplete(type, c, points.map((p) => p.value))) {
|
|
274
|
+
points.push({
|
|
275
|
+
id: `CHK-${idCode}-BVA-${ex.label}`,
|
|
276
|
+
group: ex.group,
|
|
277
|
+
value: ex.value,
|
|
278
|
+
expectedValidity: ex.validity,
|
|
279
|
+
synthesized: true,
|
|
280
|
+
note: `BVA (auto): ${ex.label}`,
|
|
281
|
+
...(ex.validity === 'invalid' ? { reason: `out of bounds (${ex.label})` } : {}),
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
name: field.name,
|
|
287
|
+
type: field.type,
|
|
288
|
+
blindSpot: false,
|
|
289
|
+
points,
|
|
290
|
+
...(field.dependsOn ? { dependsOn: field.dependsOn } : {}),
|
|
291
|
+
...(excluded.length ? { excluded } : {}),
|
|
292
|
+
};
|
|
174
293
|
}
|
|
175
294
|
|
|
176
295
|
/** Expand a whole field-map. */
|
|
177
|
-
export function expandFieldMap(map: FieldMap, catalog: Catalog): ExpandedField[] {
|
|
178
|
-
return (map.fields || []).map((f) => expandField(f, catalog));
|
|
296
|
+
export function expandFieldMap(map: FieldMap, catalog: Catalog, ctx: SelectionContext = {}): ExpandedField[] {
|
|
297
|
+
return (map.fields || []).map((f) => expandField(f, catalog, ctx));
|
|
179
298
|
}
|
|
180
299
|
|
|
181
300
|
/**
|
|
@@ -186,23 +305,37 @@ export function renderTestData(expanded: ExpandedField[]): Record<string, any> {
|
|
|
186
305
|
const out: Record<string, any> = {
|
|
187
306
|
_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.',
|
|
188
307
|
};
|
|
189
|
-
for (const f of expanded)
|
|
190
|
-
if (f.blindSpot) {
|
|
191
|
-
out[f.name] = { _blind_spot: `type "${f.type}" not in Data Factory catalog — provide qa/data-factory/project/${f.name}.yaml` };
|
|
192
|
-
continue;
|
|
193
|
-
}
|
|
194
|
-
const valid = f.points.filter((p) => p.group === 'valid');
|
|
195
|
-
const boundary = f.points.filter((p) => p.group === 'boundary');
|
|
196
|
-
const invalid = f.points.filter((p) => p.group === 'invalid');
|
|
197
|
-
out[f.name] = {
|
|
198
|
-
valid: valid[0]?.value ?? null,
|
|
199
|
-
boundary: boundary.map((p) => trim({ id: p.id, value: p.value, note: p.note, todo: p.todo })),
|
|
200
|
-
invalid: invalid.map((p) => trim({ id: p.id, value: p.value, expected_error: p.expectedError, open: p.open, reason: p.reason, todo: p.todo })),
|
|
201
|
-
};
|
|
202
|
-
}
|
|
308
|
+
for (const f of expanded) out[f.name] = renderOne(f);
|
|
203
309
|
return out;
|
|
204
310
|
}
|
|
205
311
|
|
|
312
|
+
/** Render one expanded field — recurses for nested groups; carries a dependsOn note. */
|
|
313
|
+
function renderOne(f: ExpandedField): Record<string, any> {
|
|
314
|
+
const dep = f.dependsOn ? { _depends_on: f.dependsOn.value != null ? `${f.dependsOn.field} = ${f.dependsOn.value}` : f.dependsOn.field } : {};
|
|
315
|
+
if (f.children) {
|
|
316
|
+
const group: Record<string, any> = { ...dep };
|
|
317
|
+
for (const c of f.children) group[c.name] = renderOne(c);
|
|
318
|
+
return group;
|
|
319
|
+
}
|
|
320
|
+
if (f.blindSpot) {
|
|
321
|
+
return { _blind_spot: `type "${f.type}" not in Data Factory catalog — provide qa/data-factory/project/${f.name}.yaml`, ...dep };
|
|
322
|
+
}
|
|
323
|
+
const valid = f.points.filter((p) => p.group === 'valid');
|
|
324
|
+
const boundary = f.points.filter((p) => p.group === 'boundary');
|
|
325
|
+
// Plain invalid excludes the adversarial (security) cases — those get their own bucket.
|
|
326
|
+
const invalid = f.points.filter((p) => p.group === 'invalid' && p.category !== 'adversarial');
|
|
327
|
+
const adversarial = f.points.filter((p) => p.category === 'adversarial');
|
|
328
|
+
const row: Record<string, any> = {
|
|
329
|
+
...dep,
|
|
330
|
+
valid: valid[0]?.value ?? null,
|
|
331
|
+
boundary: boundary.map((p) => trim({ id: p.id, value: p.value, expected: p.expectedValidity, via: p.source === 'overwrite' ? '.overwrite' : undefined, note: p.note, todo: p.todo })),
|
|
332
|
+
invalid: invalid.map((p) => trim({ id: p.id, value: p.value, expected: p.expectedValidity, via: p.source === 'overwrite' ? '.overwrite' : undefined, expected_error: p.expectedError, open: p.open, reason: p.reason, todo: p.todo })),
|
|
333
|
+
};
|
|
334
|
+
if (adversarial.length) row.adversarial = adversarial.map((p) => trim({ id: p.id, value: p.value, reason: p.reason, note: p.note, todo: p.todo }));
|
|
335
|
+
if (f.excluded?.length) row._not_applicable = f.excluded.map((e) => `${e.id}: ${e.reason}`);
|
|
336
|
+
return row;
|
|
337
|
+
}
|
|
338
|
+
|
|
206
339
|
function trim(o: Record<string, any>): Record<string, any> {
|
|
207
340
|
const r: Record<string, any> = {};
|
|
208
341
|
for (const [k, v] of Object.entries(o)) if (v != null) r[k] = v;
|