@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.
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Data Factory — Cross-artifact Gate (M3-B).
3
+ *
4
+ * `data validate` checks the DATA in isolation (values are semantically correct). This checks that the
5
+ * TESTCASE and its test-data actually AGREE — the "genuinely new piece" from the harness-merge review:
6
+ * - every `{{var}}` a scenario references resolves to a key in the unit's test-data (no dangling ref);
7
+ * - a cross-screen SHARED dataset (M3-A `shared:`) is present and identical across its member screens
8
+ * (the registration setup→confirmation→register lineage: confirmation must show what setup buffered);
9
+ * - a unit that declares a manual state-precondition (a state-recipe) surfaces it in the feature
10
+ * (a `@manual` tag or a Background) rather than silently dropping the required state.
11
+ *
12
+ * Deterministic; no runtime, no LLM. Invoked from the create-test / create-data-test AI loop (and by
13
+ * `sungen data crosscheck`), exit-1 on an error finding.
14
+ */
15
+ import { readFileSync, existsSync, readdirSync } from 'fs';
16
+ import { join } from 'path';
17
+ import { parse as parseYaml } from 'yaml';
18
+ import { loadStateRecipes, buildStatePlan, hasDatasource } from './state';
19
+
20
+ export interface CrossFinding {
21
+ unit: string;
22
+ scenario?: string;
23
+ severity: 'error' | 'warn' | 'info';
24
+ code: 'DANGLING_VAR' | 'SHARED_MISSING' | 'SHARED_MISMATCH' | 'PRECONDITION_MISSING';
25
+ message: string;
26
+ }
27
+
28
+ /** Resolve a unit's feature dir + test-data path (screen or flow). */
29
+ function unitPaths(cwd: string, name: string): { featureDir: string; testData: string } | null {
30
+ for (const kind of ['screens', 'flows']) {
31
+ const base = join(cwd, 'qa', kind, name);
32
+ if (existsSync(base)) return { featureDir: join(base, 'features'), testData: join(base, 'test-data', `${name}.yaml`) };
33
+ }
34
+ return null;
35
+ }
36
+
37
+ function readFeatureText(featureDir: string): string {
38
+ if (!existsSync(featureDir)) return '';
39
+ return readdirSync(featureDir)
40
+ .filter((f) => f.endsWith('.feature'))
41
+ .map((f) => readFileSync(join(featureDir, f), 'utf8'))
42
+ .join('\n');
43
+ }
44
+
45
+ /** Extract `{{var}}` roots referenced in a feature, excluding dynamic `{{$...}}` runtime vars. */
46
+ export function referencedVars(featureText: string): string[] {
47
+ const out = new Set<string>();
48
+ const re = /\{\{\s*([^}]+?)\s*\}\}/g;
49
+ let m: RegExpExecArray | null;
50
+ while ((m = re.exec(featureText))) {
51
+ const tok = m[1].trim();
52
+ if (tok.startsWith('$')) continue; // {{$timestamp}} etc. — runtime dynamic, not a test-data key
53
+ const root = tok.split(/[.|[]/)[0].trim(); // login.email → login ; name|upper → name
54
+ if (root) out.add(root);
55
+ }
56
+ return [...out];
57
+ }
58
+
59
+ function loadYaml(path: string): Record<string, any> {
60
+ if (!existsSync(path)) return {};
61
+ try { return (parseYaml(readFileSync(path, 'utf8')) as Record<string, any>) || {}; } catch { return {}; }
62
+ }
63
+
64
+ /**
65
+ * Column names supplied per-row by every `@cases:<dataset>` referenced in the feature (where the
66
+ * dataset is a top-level LIST in test-data). A `{{col}}` inside a data-driven scenario resolves to
67
+ * the current row, not a top-level key — so these are valid roots, not dangling. Mirrors the
68
+ * compiler's data-driven lint, which is @cases-aware; without this, any @cases scenario that reads
69
+ * a row column (`{{input}}`, `{{code}}`, …) would be a false-positive DANGLING_VAR.
70
+ */
71
+ export function casesColumns(featureText: string, data: Record<string, any>): Set<string> {
72
+ const cols = new Set<string>();
73
+ const re = /@cases:([A-Za-z0-9_.-]+)/g;
74
+ let m: RegExpExecArray | null;
75
+ while ((m = re.exec(featureText))) {
76
+ const ds = data[m[1]];
77
+ if (!Array.isArray(ds)) continue;
78
+ for (const row of ds) {
79
+ if (row && typeof row === 'object' && !Array.isArray(row)) {
80
+ for (const k of Object.keys(row)) cols.add(k);
81
+ }
82
+ }
83
+ }
84
+ return cols;
85
+ }
86
+
87
+ /** Cross-check one unit's feature ↔ test-data. */
88
+ export function crosscheckUnit(cwd: string, name: string): CrossFinding[] {
89
+ const out: CrossFinding[] = [];
90
+ const paths = unitPaths(cwd, name);
91
+ if (!paths) return out;
92
+ const feature = readFeatureText(paths.featureDir);
93
+ if (!feature) return out;
94
+ const data = loadYaml(paths.testData);
95
+ const topKeys = new Set(Object.keys(data).filter((k) => !k.startsWith('_')));
96
+ const caseCols = casesColumns(feature, data); // @cases row columns resolve per-row, not as top-level keys
97
+
98
+ // 1. Dangling vars — a {{var}} whose root key is absent from test-data (and not a @cases column).
99
+ for (const v of referencedVars(feature)) {
100
+ if (!topKeys.has(v) && !caseCols.has(v)) {
101
+ out.push({ unit: name, severity: 'error', code: 'DANGLING_VAR', message: `scenario references {{${v}}} but no "${v}" key exists in test-data/${name}.yaml` });
102
+ }
103
+ }
104
+
105
+ // 3. Manual state-precondition declared but not surfaced in the feature.
106
+ const ds = hasDatasource(cwd);
107
+ for (const r of loadStateRecipes(cwd)) {
108
+ const plan = buildStatePlan(r, { datasource: ds });
109
+ if (plan.manual && new RegExp(`@dataFactory:${r.name}\\b`).test(feature) === false) {
110
+ const surfaced = /@manual/.test(feature) || /Background:/.test(feature);
111
+ if (!surfaced) out.push({ unit: name, severity: 'warn', code: 'PRECONDITION_MISSING', message: `state-recipe "${r.name}" needs a manual precondition, but the feature has no Background / @manual tag — weave its precondition block (\`sungen data state --recipe ${r.name}\`)` });
112
+ }
113
+ }
114
+ return out;
115
+ }
116
+
117
+ /** Cross-check a cross-screen SHARED dataset: present + identical across all member screens. */
118
+ export function crosscheckShared(cwd: string): CrossFinding[] {
119
+ const out: CrossFinding[] = [];
120
+ for (const r of loadStateRecipes(cwd)) {
121
+ if (!r.shared) continue;
122
+ const { entity, screens } = r.shared;
123
+ const datasets = screens.map((s) => {
124
+ const paths = unitPaths(cwd, s);
125
+ const data = paths ? loadYaml(paths.testData) : {};
126
+ return { screen: s, present: paths != null && entity in data, value: data[entity] };
127
+ });
128
+ const present = datasets.filter((d) => d.present);
129
+ for (const d of datasets) if (!d.present) out.push({ unit: d.screen, severity: 'warn', code: 'SHARED_MISSING', message: `shared dataset "${entity}" (recipe "${r.name}") is not present in ${d.screen}'s test-data` });
130
+ // Identity: every present copy must equal the first.
131
+ if (present.length >= 2) {
132
+ const ref = JSON.stringify(present[0].value);
133
+ for (const d of present.slice(1)) {
134
+ if (JSON.stringify(d.value) !== ref) {
135
+ out.push({ unit: d.screen, severity: 'error', code: 'SHARED_MISMATCH', message: `shared dataset "${entity}" differs between ${present[0].screen} and ${d.screen} — the same values must flow across the ${r.name} journey (e.g. confirmation must show what setup entered)` });
136
+ }
137
+ }
138
+ }
139
+ }
140
+ return out;
141
+ }
142
+
143
+ /** Cross-check the whole project (all units + shared datasets). */
144
+ export function crosscheckAll(cwd: string, units: string[]): CrossFinding[] {
145
+ const out: CrossFinding[] = [];
146
+ for (const u of units) out.push(...crosscheckUnit(cwd, u));
147
+ out.push(...crosscheckShared(cwd));
148
+ return out;
149
+ }
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 CHANGED
@@ -6,9 +6,11 @@
6
6
  * Export` checklist area. Two products:
7
7
  * • the VALID fixture — N rows of valid values (should import cleanly). Unique fields get a
8
8
  * per-row suffix so rows don't collide.
9
- * • the INVALID fixture — one row per field's first invalid case (all-valid except that one field),
10
- * 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.
9
+ * • the INVALID fixture — one row per field's first invalid case (all-valid except that one field).
10
+ * It carries the SAME headers as the valid file (no extra column) so a schema-strict importer
11
+ * fails on the DATA, not on an unexpected column. The "which row is bad and why" lives in a
12
+ * SIDECAR (`<name>.invalid.expected.yaml`), keyed by 0-based data-row index — the expected result
13
+ * never alters the payload under test.
12
14
  *
13
15
  * Deterministic: same field-map + catalog → same CSV. Columns are the flattened leaf paths (nested
14
16
  * groups → dotted headers, e.g. `address.city`). Reference the file from an `@api` upload (`files:`)
@@ -31,7 +33,11 @@ function leaves(expanded: ExpandedField[], prefix = ''): Leaf[] {
31
33
  }
32
34
 
33
35
  const validOf = (f: ExpandedField): string => f.points.find((p) => p.group === 'valid' && p.value != null)?.value ?? '';
34
- const firstInvalid = (f: ExpandedField) => f.points.find((p) => p.group === 'invalid' && p.value != null);
36
+ // First concrete invalid, preferring a plain (non-adversarial) format/boundary invalid over a
37
+ // security payload — a bulk-import row-rejection test wants a data violation, not an injection string.
38
+ const firstInvalid = (f: ExpandedField) =>
39
+ f.points.find((p) => p.group === 'invalid' && p.category !== 'adversarial' && p.value != null) ??
40
+ f.points.find((p) => p.group === 'invalid' && p.value != null);
35
41
 
36
42
  /** RFC-4180-ish CSV cell quoting. */
37
43
  function cell(v: string): string {
@@ -41,9 +47,23 @@ function row(cells: string[]): string {
41
47
  return cells.map(cell).join(',');
42
48
  }
43
49
 
50
+ /** One expected-rejection entry in the sidecar, keyed to a 0-based data row of the invalid CSV. */
51
+ export interface ExpectedRow {
52
+ row: number;
53
+ field: string;
54
+ chk: string;
55
+ reason: string;
56
+ expected_error?: string;
57
+ }
58
+ export interface FixtureExpected {
59
+ _note: string;
60
+ rows: ExpectedRow[];
61
+ }
62
+
44
63
  export interface FixtureResult {
45
64
  valid: string; // CSV text (header + N valid rows)
46
- invalid?: string; // CSV text (header + _violation + one bad row per field)
65
+ invalid?: string; // CSV text (SAME headers as valid + one bad row per field — no extra column)
66
+ expected?: FixtureExpected; // sidecar: per-row expected rejection (0-based data-row index)
47
67
  headers: string[];
48
68
  }
49
69
 
@@ -62,18 +82,30 @@ export function generateFixture(map: FieldMap, catalog: Catalog, opts: { rows?:
62
82
  const valid = [row(headers), ...validRows].join('\n') + '\n';
63
83
 
64
84
  let invalid: string | undefined;
85
+ let expected: FixtureExpected | undefined;
65
86
  if (opts.includeInvalid) {
66
- const invHeaders = [...headers, '_violation'];
67
87
  const invRows: string[] = [];
88
+ const expectedRows: ExpectedRow[] = [];
68
89
  for (const target of ls) {
69
90
  const bad = firstInvalid(target.field);
70
91
  if (!bad) continue; // no concrete invalid value to seed
92
+ // Same columns as the valid file — only the target cell is bad. No `_violation` column: a
93
+ // schema-strict importer must fail on the data, not reject the file for an unexpected column.
71
94
  const cells = ls.map((l) => (l === target ? (bad.value as string) : validOf(l.field).replace(/\{\{\$timestamp\}\}/g, '1')));
72
- const why = `${target.path}: ${bad.reason || 'invalid'}${bad.expectedError ? ` (${bad.expectedError})` : ''}`;
73
- invRows.push(row([...cells, why]));
95
+ expectedRows.push(trimExpected({ row: invRows.length, field: target.path, chk: bad.id, reason: bad.reason || 'invalid', expected_error: bad.expectedError }));
96
+ invRows.push(row(cells));
74
97
  }
75
- invalid = [row(invHeaders), ...invRows].join('\n') + '\n';
98
+ invalid = [row(headers), ...invRows].join('\n') + '\n';
99
+ expected = {
100
+ _note: 'Each row of the .invalid.csv should be REJECTED. `row` is the 0-based data-row index (header excluded). The CSV carries no extra columns so a strict importer fails on the data, not the schema.',
101
+ rows: expectedRows,
102
+ };
76
103
  }
77
104
 
78
- return { valid, invalid, headers };
105
+ return { valid, invalid, expected, headers };
106
+ }
107
+
108
+ function trimExpected(e: ExpectedRow): ExpectedRow {
109
+ if (e.expected_error == null) { const { expected_error, ...rest } = e; return rest as ExpectedRow; }
110
+ return e;
79
111
  }