@sungen/driver-data-factory 3.2.12-beta.1 → 3.2.12-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/state.ts ADDED
@@ -0,0 +1,225 @@
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
+ /** Guess a provenance from a precondition sentence — best-effort; QA confirms. */
175
+ export function guessProvenance(signal: string): Provenance {
176
+ const s = signal.toLowerCase();
177
+ if (/session-storage|session storage|local ?storage|cookie|buffer/.test(s)) return 'client-storage';
178
+ if (/token|reset|verification|otp|invitation|seeded/.test(s)) return 'fixture';
179
+ if (/logged in|authenticated|signed in|auth token|session/.test(s)) return 'server-context';
180
+ if (/already|existing|registered|must exist|previously|prior/.test(s)) return 'prior-flow';
181
+ return 'manual';
182
+ }
183
+
184
+ /**
185
+ * Auto-scaffold a state-recipe SKELETON from a spec's precondition sentences (Layer-C auto-draft).
186
+ * Deterministic: one resource per detected signal, provenance guessed, entity left as a TODO for the
187
+ * AI/QA to fill (never fabricates a domain entity). Returns null when no precondition signal is found.
188
+ */
189
+ export function scaffoldRecipe(unit: string, signals: string[]): StateRecipe | null {
190
+ if (!signals.length) return null;
191
+ const resources: StateResource[] = signals.slice(0, 4).map((sig, i) => ({
192
+ ref: `r${i + 1}`,
193
+ entity: 'TODO', // ← name the real entity (from the signal); do not invent domain values
194
+ provenance: guessProvenance(sig),
195
+ lifecycle: 'reusable',
196
+ note: sig,
197
+ }));
198
+ return {
199
+ name: `${unit}_precondition`,
200
+ description: `stateful precondition for ${unit} — AUTO-SCAFFOLDED from spec; REVIEW: name each entity, set desiredState/bindings, confirm with QA`,
201
+ resources,
202
+ };
203
+ }
204
+
205
+ /** Serialize a scaffolded recipe as a YAML block to splice under `recipes:` (2-space indented). */
206
+ export function renderRecipeYaml(recipe: StateRecipe): string {
207
+ const lines: string[] = [` ${recipe.name}:`];
208
+ if (recipe.description) lines.push(` description: "${recipe.description}"`);
209
+ lines.push(' resources:');
210
+ for (const r of recipe.resources || []) {
211
+ lines.push(` - ref: ${r.ref}`);
212
+ lines.push(` entity: ${r.entity} # ← name the real entity`);
213
+ lines.push(` provenance: ${r.provenance} # guessed — confirm (prior-flow|fixture|client-storage|server-context|manual|api|db)`);
214
+ if (r.lifecycle) lines.push(` lifecycle: ${r.lifecycle}`);
215
+ if (r.note) lines.push(` note: ${JSON.stringify(r.note)}`);
216
+ }
217
+ lines.push(' # bindings: { someId: "${r1.id}" } # ← wire ids the test needs');
218
+ return lines.join('\n') + '\n';
219
+ }
220
+
221
+ /** Render the precondition block for weaving into a Gherkin Background (with the @manual tag if needed). */
222
+ export function renderPreconditionBlock(plan: StatePlan): string {
223
+ 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)';
224
+ return [`${tag}`, `# recipe: ${plan.recipe}`, ...plan.preconditionLines].join('\n');
225
+ }
package/src/types.ts CHANGED
@@ -18,6 +18,33 @@ export interface DataPoint {
18
18
  /** Pull values from a security bank (e.g. `security/sqli`) into this group. */
19
19
  from?: string;
20
20
  note?: string;
21
+ /**
22
+ * Optional explicit validity for this point (`valid` | `invalid`). Normally the generator's oracle
23
+ * computes it from the constraints; set this only to pin an intent the constraints can't express
24
+ * (e.g. a format-invalid value whose length happens to sit inside the range).
25
+ */
26
+ validity?: 'valid' | 'invalid';
27
+ /**
28
+ * Where this point ended up coming from after the `.overwrite` merge — `shipped` (company common) or
29
+ * `overwrite` (the project replaced/added it). Set by the loader; carried through to the generated
30
+ * value so a reader can answer "why is this value here?". Not authored by hand.
31
+ */
32
+ source?: 'shipped' | 'overwrite';
33
+ /**
34
+ * Applicability conditions (M2b). The point is materialized only when ALL conditions hold for the
35
+ * field + selection context. Field-derived keys are always enforced; environment keys only filter
36
+ * when the context supplies them (a missing context value = "no filter", so the point is kept):
37
+ * - `required: true` — applies only to a REQUIRED field (dropped when the field is explicitly optional).
38
+ * - `negativesAllowed: true` — applies only when the numeric field allows negatives (min < 0 / no min).
39
+ * - `locale: <prefix>` — applies only when ctx.locale starts with the prefix (e.g. `ja`).
40
+ * - `channel: api|ui` / `sink: html|text|…` — applies only when ctx.channel/ctx.sink matches.
41
+ */
42
+ when?: Record<string, unknown>;
43
+ /**
44
+ * Bucket for output + profile selection. `adversarial` = a security/injection payload (kept out of
45
+ * the plain `invalid` bucket so a QA sees attack cases separately). Default (unset) = functional.
46
+ */
47
+ category?: 'adversarial';
21
48
  }
22
49
 
23
50
  export interface FieldType {
@@ -48,10 +75,40 @@ export interface Technique {
48
75
  [k: string]: unknown;
49
76
  }
50
77
 
78
+ /**
79
+ * A domain-EXPERIENCE checklist — cases that come from real-world testing experience, not from a
80
+ * field's format (e.g. the credential/account states a login screen must cover: soft-deleted, locked,
81
+ * unverified…). Each case is a SCENARIO that usually needs a domain-state precondition, so it is
82
+ * knowledge for the login/auth viewpoint + a state-recipe — not a field-level value.
83
+ */
84
+ export interface Experience {
85
+ experience: string;
86
+ description?: string;
87
+ applies_when?: string;
88
+ cases: Array<Record<string, unknown> & { id: string; expect?: string }>;
89
+ oracle?: string;
90
+ }
91
+
51
92
  export interface Catalog {
52
93
  fieldTypes: Record<string, FieldType>;
53
94
  security: Record<string, SecurityBank>;
54
95
  techniques: Record<string, Technique>;
96
+ experience: Record<string, Experience>;
97
+ }
98
+
99
+ /** Named case-selection profiles (which groups/categories a run wants). */
100
+ export type Profile = 'regression' | 'functional' | 'smoke' | 'security-min';
101
+
102
+ /**
103
+ * The selection context Core (or a CLI flag) passes to the generator to pick applicable cases.
104
+ * All fields optional — an absent field means "no filter on that axis" (permissive default).
105
+ */
106
+ export interface SelectionContext {
107
+ locale?: string;
108
+ channel?: 'api' | 'ui';
109
+ sink?: string;
110
+ /** `regression` (default) = every case; see Profile for the reduced sets. */
111
+ profile?: Profile;
55
112
  }
56
113
 
57
114
  /** A warning that a project's common.overwrite.yaml entry looks stale against the shipped common. */
@@ -60,3 +117,27 @@ export interface OverwriteWarning {
60
117
  detail: string;
61
118
  suggestion: string;
62
119
  }
120
+
121
+ /** What the `.overwrite` layer changed for one field-type (provenance — "no silent overwrite"). */
122
+ export interface FieldTypeProvenance {
123
+ type: string;
124
+ /** Ids removed via `disable:` (an id that was not in the shipped common is reported as a warning). */
125
+ disabled: string[];
126
+ /** Ids that existed in shipped AND the overlay — the overlay value won. */
127
+ replaced: string[];
128
+ /** Ids new from the overlay (a project-specific case). */
129
+ added: string[];
130
+ /** Constraint keys the overlay changed. */
131
+ constraintsOverridden: string[];
132
+ /** True when the overlay replaced `uses_security`. */
133
+ securityChanged: boolean;
134
+ }
135
+
136
+ /** Catalog-wide provenance of the shipped-common → project-overwrite merge. */
137
+ export interface CatalogProvenance {
138
+ fieldTypes: Record<string, FieldTypeProvenance>;
139
+ /** Wholly new field-types added by the project (not in shipped common). */
140
+ newFieldTypes: string[];
141
+ security: { added: string[]; replaced: string[] };
142
+ techniques: { added: string[]; replaced: string[] };
143
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Data Factory — the deterministic data-quality validator (the Driver's contribution to the harness).
3
+ *
4
+ * `lint.ts` answers "is the coverage enough?" (≥1 valid, boundary present, error codes mapped).
5
+ * This module answers the harder question both reviews flagged: "is each generated value SEMANTICALLY
6
+ * correct?" — i.e. does a value labeled `valid` actually satisfy the constraints, and does a value
7
+ * labeled `invalid` actually violate them? It is the machine oracle a future Master "Data Gate"
8
+ * (see docs/spec/data-factory-quality-roadmap.md) calls; today it powers the golden tests and the
9
+ * `sungen data validate` CLI. Pure + deterministic — no AI, no runtime.
10
+ */
11
+ import type { Catalog, SelectionContext } from './types';
12
+ import { expandFieldMap, type FieldMap, type FieldDef, type ExpandedField, type ExpandedPoint } from './generator';
13
+ import { satisfiesConstraints } from './boundary';
14
+
15
+ export interface ValidationFinding {
16
+ field: string;
17
+ pointId: string;
18
+ severity: 'error' | 'warn';
19
+ code: 'VALUE_NOT_VALID' | 'INVALID_LOOKS_VALID';
20
+ message: string;
21
+ }
22
+
23
+ /** Validate the values a set of expanded points, given the effective constraints for that field. */
24
+ export function validatePoints(field: string, type: string | undefined, points: ExpandedPoint[], c: Record<string, any>): ValidationFinding[] {
25
+ const out: ValidationFinding[] = [];
26
+ if (!type) return out;
27
+ for (const p of points) {
28
+ if (p.value == null) continue; // open/todo — lint handles these
29
+ const oracle = satisfiesConstraints(type, p.value, c);
30
+ if (oracle == null) continue; // no length/range constraint to judge against
31
+ if (p.expectedValidity === 'valid' && oracle === false) {
32
+ out.push({
33
+ field, pointId: p.id, severity: 'error', code: 'VALUE_NOT_VALID',
34
+ message: `${p.id}: value ${JSON.stringify(p.value)} is expected VALID but violates the field constraints (${describe(type, c)})`,
35
+ });
36
+ } else if (p.expectedValidity === 'invalid' && oracle === true && p.synthesized) {
37
+ // Only second-guess a SYNTHESIZED (boundary/range) invalid: its whole claim is about range, so
38
+ // satisfying the range means the boundary was mis-synthesized. Literal charset/format invalids and
39
+ // security payloads legitimately pass the length oracle — not a finding.
40
+ out.push({
41
+ field, pointId: p.id, severity: 'warn', code: 'INVALID_LOOKS_VALID',
42
+ message: `${p.id}: value ${JSON.stringify(p.value)} is expected INVALID (out of range) but satisfies the constraints (${describe(type, c)}) — the boundary was mis-synthesized`,
43
+ });
44
+ }
45
+ }
46
+ return out;
47
+ }
48
+
49
+ function describe(type: string, c: Record<string, any>): string {
50
+ const parts: string[] = [];
51
+ if (Number.isFinite(c.minLength ?? c.length)) parts.push(`minLen=${c.minLength ?? c.length}`);
52
+ if (Number.isFinite(c.maxLength ?? c.length)) parts.push(`maxLen=${c.maxLength ?? c.length}`);
53
+ if (Number.isFinite(c.min)) parts.push(`min=${c.min}`);
54
+ if (Number.isFinite(c.max)) parts.push(`max=${c.max}`);
55
+ if (Number.isFinite(c.maxLocal)) parts.push(`maxLocal=${c.maxLocal}`);
56
+ if (Number.isFinite(c.maxTotal)) parts.push(`maxTotal=${c.maxTotal}`);
57
+ if (Number.isFinite(c.decimalPlaces)) parts.push(`decimalPlaces=${c.decimalPlaces}`);
58
+ return parts.join(', ') || type;
59
+ }
60
+
61
+ /** Validate a whole field-map: expand it, then check every value against its expected validity. */
62
+ export function validateGenerated(map: FieldMap, catalog: Catalog, ctx: SelectionContext = {}): ValidationFinding[] {
63
+ const expanded = expandFieldMap(map, catalog, ctx);
64
+ const out: ValidationFinding[] = [];
65
+ const walk = (efs: ExpandedField[], defs: FieldDef[], prefix: string) => {
66
+ for (const ef of efs) {
67
+ const def = defs.find((d) => d.name === ef.name);
68
+ const path = prefix ? `${prefix}.${ef.name}` : ef.name;
69
+ if (ef.children) { walk(ef.children, def?.fields || [], path); continue; }
70
+ const ft = ef.type ? catalog.fieldTypes[ef.type] : undefined;
71
+ const c = { ...(ft?.constraints || {}), ...(def?.constraints || {}) };
72
+ out.push(...validatePoints(path, ef.type, ef.points, c));
73
+ }
74
+ };
75
+ walk(expanded, map.fields, '');
76
+ return out;
77
+ }