@sungen/driver-data-factory 3.2.12-beta.1 → 3.2.12-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.
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Data Factory — coverage check (does the AUTHORED test-data actually cover the standardized set?).
3
+ *
4
+ * `data validate` checks the DF-generated field block is internally correct; `data crosscheck` checks
5
+ * testcase ↔ data agree. But `/sungen:create-test` often AUTHORS login/flow test-data by hand (seeded
6
+ * accounts + `@cases`) instead of merging `data gen`'s output — so the deterministic BVA the generator
7
+ * would produce (e.g. email length 4/5/6 for `minLength: 5`) and the experience checklist (soft-deleted
8
+ * login…) can be silently missed. This check reconciles the authored values against BOTH:
9
+ * - the BVA the field-map's constraints REQUIRE (per bounded dimension), and
10
+ * - the auth-account-states experience checklist (for a login/auth unit).
11
+ * It matches fuzzily (authored keys are free-form): length for length dims, numeric value for numeric
12
+ * dims, keyword for experience. Advisory (warn) — a visible nudge the create-test loop must address or
13
+ * the QA waives; never blocks. Pure + deterministic.
14
+ */
15
+ import { readFileSync, existsSync, readdirSync } from 'fs';
16
+ import { join } from 'path';
17
+ import { parse as parseYaml } from 'yaml';
18
+ import type { Catalog } from './types';
19
+ import { expandField, type FieldMap, type FieldDef } from './generator';
20
+
21
+ export interface CoverageFinding {
22
+ unit: string;
23
+ severity: 'warn' | 'info';
24
+ code: 'BVA_UNCOVERED' | 'EXPERIENCE_UNCOVERED';
25
+ field?: string;
26
+ message: string;
27
+ }
28
+
29
+ const STR_LEN_TYPES = new Set(['email', 'string', 'password', 'phone', 'postcode', 'katakana']);
30
+ const NUM_TYPES = new Set(['number-integer', 'number-float', 'currency']);
31
+
32
+ function unitTestDataPath(cwd: string, name: string): string | null {
33
+ for (const kind of ['screens', 'flows']) {
34
+ const p = join(cwd, 'qa', kind, name, 'test-data', `${name}.yaml`);
35
+ if (existsSync(p)) return p;
36
+ }
37
+ return null;
38
+ }
39
+ function unitFeatureText(cwd: string, name: string): string {
40
+ for (const kind of ['screens', 'flows']) {
41
+ const dir = join(cwd, 'qa', kind, name, 'features');
42
+ if (!existsSync(dir)) continue;
43
+ try { return readdirSync(dir).filter((f) => f.endsWith('.feature')).map((f) => readFileSync(join(dir, f), 'utf8')).join('\n'); } catch { return ''; }
44
+ }
45
+ return '';
46
+ }
47
+ function loadYaml(p: string): Record<string, any> {
48
+ if (!existsSync(p)) return {};
49
+ try { return (parseYaml(readFileSync(p, 'utf8')) as Record<string, any>) || {}; } catch { return {}; }
50
+ }
51
+
52
+ /** Every string value anywhere in the test-data (top-level scalars + nested + `@cases` rows). */
53
+ function collectStrings(data: Record<string, any>): string[] {
54
+ const out: string[] = [];
55
+ const walk = (v: unknown) => {
56
+ if (typeof v === 'string') out.push(v);
57
+ else if (Array.isArray(v)) v.forEach(walk);
58
+ else if (v && typeof v === 'object') Object.values(v as Record<string, unknown>).forEach(walk);
59
+ };
60
+ for (const [k, v] of Object.entries(data)) { if (k.startsWith('_')) continue; walk(v); }
61
+ return out;
62
+ }
63
+
64
+ /** Short human label for a boundary point (from its note / id). */
65
+ function pointLabel(p: { id: string; note?: string }): string {
66
+ if (p.note) { const tail = p.note.split(/·|:/).pop()?.trim(); if (tail) return tail; }
67
+ return p.id.replace(/^CHK-[A-Z0-9]+-/, '');
68
+ }
69
+
70
+ /** BVA coverage for one field: which required boundary points are not represented among authored values. */
71
+ function bvaGaps(field: FieldDef, catalog: Catalog, authored: string[]): string[] {
72
+ if (!field.type) return [];
73
+ const ef = expandField(field, catalog);
74
+ const synth = ef.points.filter((p) => p.synthesized && p.value != null);
75
+ if (!synth.length) return [];
76
+ const gaps: string[] = [];
77
+ const seen = new Set<string>();
78
+ if (STR_LEN_TYPES.has(field.type)) {
79
+ // Type-aware filter so an unrelated string of the same LENGTH doesn't falsely "cover" the boundary
80
+ // (e.g. a 5-char URL must not count as covering an email `minLength`-5 case). Email → values with
81
+ // an `@`; katakana → values with kana; others (generic string) stay loose.
82
+ const plausible =
83
+ field.type === 'email' ? authored.filter((s) => s.includes('@'))
84
+ : field.type === 'katakana' ? authored.filter((s) => /[゠-ヿヲ-゚]/.test(s))
85
+ : authored;
86
+ const authoredLens = new Set(plausible.map((s) => s.length));
87
+ for (const p of synth) {
88
+ const len = (p.value as string).length;
89
+ const key = `len:${len}`;
90
+ if (seen.has(key) || authoredLens.has(len)) { seen.add(key); continue; }
91
+ seen.add(key);
92
+ gaps.push(`${pointLabel(p)} (length ${len}, ${p.expectedValidity})`);
93
+ }
94
+ } else if (NUM_TYPES.has(field.type)) {
95
+ const authoredNums = new Set(authored.map((s) => Number(s)).filter((n) => Number.isFinite(n)));
96
+ for (const p of synth) {
97
+ const n = Number(p.value);
98
+ const key = `num:${n}`;
99
+ if (seen.has(key) || authoredNums.has(n)) { seen.add(key); continue; }
100
+ seen.add(key);
101
+ gaps.push(`${pointLabel(p)} (value ${p.value}, ${p.expectedValidity})`);
102
+ }
103
+ }
104
+ return gaps;
105
+ }
106
+
107
+ /** Keyword patterns proving an auth-account-state is covered somewhere in the data/feature. */
108
+ const STATE_COVERED: Record<string, RegExp> = {
109
+ active: /valid|active|success|happy/i,
110
+ 'wrong-password': /wrong.?pass|password.?mismatch|incorrect.?pass|bad.?pass/i,
111
+ 'non-existent-account': /unregister|non.?exist|not.*(exist|found|in nj)|no account/i,
112
+ 'soft-deleted': /soft.?delet|deleted.?account|marked.?deleted|deleted_at/i,
113
+ 'locked-or-suspended': /lock|suspend|inactive|disabled account/i,
114
+ 'unverified-email': /unverif|not.?verified|pending.?verif|email.?verification/i,
115
+ 'password-expired': /expir/i,
116
+ // valid-with-surrounding-whitespace is handled by a VALUE-level check (an actual email with
117
+ // leading/trailing space), not this blob regex — the word "trim" alone (from the empty-trim case)
118
+ // must NOT count as covering the valid-after-trim case.
119
+ };
120
+
121
+ /** Coverage check for one unit — BVA gaps per field + auth-experience gaps for a login/auth unit. */
122
+ export function coverageUnit(cwd: string, name: string, catalog: Catalog): CoverageFinding[] {
123
+ const fmPath = join(cwd, 'qa', 'data-factory', `${name}.fields.yaml`);
124
+ if (!existsSync(fmPath)) return [];
125
+ const map = loadYaml(fmPath) as FieldMap;
126
+ if (!map || !Array.isArray(map.fields)) return [];
127
+ const tdPath = unitTestDataPath(cwd, name);
128
+ const data = tdPath ? loadYaml(tdPath) : {};
129
+ const authored = collectStrings(data);
130
+ const feature = unitFeatureText(cwd, name);
131
+ const blob = (authored.join('\n') + '\n' + JSON.stringify(data) + '\n' + feature).toLowerCase();
132
+ const out: CoverageFinding[] = [];
133
+
134
+ // 1. BVA coverage per field (flatten nested groups to leaves).
135
+ const leaves: FieldDef[] = [];
136
+ const walk = (fs: FieldDef[]) => { for (const f of fs) { if (f.fields) walk(f.fields); else leaves.push(f); } };
137
+ walk(map.fields);
138
+ for (const f of leaves) {
139
+ const gaps = bvaGaps(f, catalog, authored);
140
+ if (gaps.length) {
141
+ out.push({ unit: name, field: f.name, severity: 'warn', code: 'BVA_UNCOVERED',
142
+ message: `field "${f.name}" (${f.type}): ${gaps.length} boundary case(s) not represented in test-data — ${gaps.join('; ')}. The field-map's constraints require them (e.g. minLength → a below-min & at-min value); author them or waive with a reason.` });
143
+ }
144
+ }
145
+
146
+ // 2. Auth-experience coverage — only for a login/auth unit (has an email + a password field).
147
+ const isLogin = leaves.some((f) => f.type === 'email') && leaves.some((f) => f.type === 'password');
148
+ const exp = catalog.experience?.['auth-account-states'];
149
+ if (isLogin && exp) {
150
+ // An actual email value carrying leading/trailing whitespace = the valid-after-trim case covered.
151
+ const hasWhitespaceEmail = authored.some((s) => s.includes('@') && s !== s.trim());
152
+ const missing: string[] = [];
153
+ for (const c of exp.cases || []) {
154
+ const state = String((c as any).state || c.id);
155
+ if (state === 'valid-with-surrounding-whitespace') { if (!hasWhitespaceEmail) missing.push(state); continue; }
156
+ const re = STATE_COVERED[state];
157
+ if (re && !re.test(blob)) missing.push(state);
158
+ }
159
+ if (missing.length) {
160
+ out.push({ unit: name, severity: 'warn', code: 'EXPERIENCE_UNCOVERED',
161
+ message: `login unit: auth-account-states not covered — ${missing.join(', ')}. Each needs a credential-state scenario (a seeded account in that state → the correct rejection/route). Add them or waive per spec (some may be N/A).` });
162
+ }
163
+ }
164
+ return out;
165
+ }
@@ -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
  }