@sungen/driver-data-factory 3.2.32 → 3.2.34-beta.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sungen/driver-data-factory",
3
- "version": "3.2.32",
3
+ "version": "3.2.34-beta.1",
4
4
  "description": "Sungen Data Factory capability — standardized test-data generation from a field-type + security knowledge catalog (valid/boundary/invalid + traceability), the .overwrite merge layer, and (opt-in) @dataFactory runtime seeding. Design-time knowledge is runtime-free. Plugs into @sun-asterisk/sungen via the capability SPI.",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -14,7 +14,7 @@
14
14
  "author": "eqe team (engineer & quality) — Sun Asterisk",
15
15
  "license": "MIT",
16
16
  "dependencies": {
17
- "@sun-asterisk/sungen": "3.2.32",
17
+ "@sun-asterisk/sungen": "3.2.34-beta.1",
18
18
  "yaml": "^2.4.0"
19
19
  },
20
20
  "files": [
package/src/cli.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * then generate the standardized `test-data/<name>.yaml` or lint its coverage. The slash command
7
7
  * `/sungen:create-data-test` wraps these after the AI has authored the field-map from spec.md.
8
8
  */
9
- import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from 'fs';
9
+ import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, renameSync, rmSync } from 'fs';
10
10
  import { join, dirname } from 'path';
11
11
  import { createInterface } from 'readline';
12
12
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
@@ -69,7 +69,23 @@ function loadFieldMap(cwd: string, name: string): FieldMap | null {
69
69
  return parseYaml(readFileSync(p, 'utf8')) as FieldMap;
70
70
  }
71
71
 
72
- async function genOne(cwd: string, name: string, opts: { force?: boolean; ctx?: SelectionContext }): Promise<'written' | 'skipped' | 'missing'> {
72
+ /**
73
+ * Write via a sibling tmp + rename, so an interrupted run can never leave a truncated test-data
74
+ * file. Local rather than core's `writeFileAtomic`: every import from `@sun-asterisk/sungen` in
75
+ * this package is `import type`, deliberately, so loading the driver never forces a core resolve.
76
+ */
77
+ function writeTestDataAtomic(file: string, data: string): void {
78
+ const tmp = `${file}.tmp-${process.pid}`;
79
+ try {
80
+ writeFileSync(tmp, data, 'utf8');
81
+ renameSync(tmp, file);
82
+ } catch (err) {
83
+ try { rmSync(tmp, { force: true }); } catch { /* best-effort cleanup — the real error is below */ }
84
+ throw err;
85
+ }
86
+ }
87
+
88
+ async function genOne(cwd: string, name: string, opts: { force?: boolean; ctx?: SelectionContext }): Promise<'written' | 'skipped' | 'missing' | 'failed'> {
73
89
  const map = loadFieldMap(cwd, name);
74
90
  if (!map) { console.error(` ✗ ${name}: no field-map at qa/data-factory/${name}.fields.yaml (author it first, e.g. via /sungen:create-data-test)`); return 'missing'; }
75
91
  const { catalog, warnings, provenance } = loadEffectiveCatalog(cwd);
@@ -96,17 +112,34 @@ async function genOne(cwd: string, name: string, opts: { force?: boolean; ctx?:
96
112
  let final = dfData;
97
113
  let mergedInto = false;
98
114
  if (exists) {
115
+ // Read the existing file BEFORE asking. The prompt promises to keep the other keys; asking for
116
+ // consent to a merge we have already failed to read is asking the user to approve their own
117
+ // data loss — and answering "y" to that promise is exactly what used to destroy the file.
118
+ let existing: Record<string, any>;
119
+ try {
120
+ existing = (parseYaml(readFileSync(out, 'utf8')) as Record<string, any>) || {};
121
+ } catch (e) {
122
+ // Refuse, the way `delivery.ts` refuses to overwrite a workbook it could not read. Swallowing
123
+ // this meant `existing = {}`, so the merge wrote ONLY the Data Factory keys over a file of
124
+ // credentials, URLs and @cases datasets — and then printed "other keys preserved".
125
+ // `--force` does not change this: it means "do not ask", not "destroy without reading".
126
+ const rel = out.replace(cwd + '/', '');
127
+ console.error(` ✗ ${name}: not written — ${rel} could not be parsed (${(e as Error).message.split('\n')[0]})`);
128
+ console.error(` Merging would keep none of its existing keys, so nothing was written.`);
129
+ console.error(` Fix the YAML and re-run, or move the file aside to generate a fresh one.`);
130
+ return 'failed';
131
+ }
99
132
  if (!opts.force) {
100
133
  const a = (await ask(` ? ${name}: test-data exists — merge standardized field data in (keeps other keys)? [y/N] `)).toLowerCase();
101
134
  if (a !== 'y' && a !== 'yes') { console.log(` · ${name}: skipped`); return 'skipped'; }
102
135
  }
103
- let existing: Record<string, any> = {};
104
- try { existing = (parseYaml(readFileSync(out, 'utf8')) as Record<string, any>) || {}; } catch { existing = {}; }
105
136
  final = { ...existing, ...dfData }; // DF-owned keys win; all other existing keys preserved
106
137
  mergedInto = true;
107
138
  }
108
139
  mkdirSync(dirname(out), { recursive: true });
109
- writeFileSync(out, stringifyYaml(final), 'utf8');
140
+ // Atomic: this file is user-authored test-data that we have just promised to preserve. A crash
141
+ // between truncate and write would leave it half-written — the same loss by a different route.
142
+ writeTestDataAtomic(out, stringifyYaml(final));
110
143
  const blind = expanded.filter((f) => f.blindSpot).map((f) => f.name);
111
144
  const verb = mergedInto ? 'merged into' : 'wrote';
112
145
  const fieldKeys = expanded.map((f) => f.name).length;
@@ -133,7 +166,14 @@ export function registerDataFactoryCommand(program: any): void {
133
166
  ensureDataFactoryReadme(cwd);
134
167
  let ctx: SelectionContext;
135
168
  try { ctx = ctxFromOpts(opts); } catch (e: any) { console.error(e.message); process.exitCode = 1; return; }
136
- if (opts.screen) { await genOne(cwd, opts.screen, { ...opts, ctx }); return; }
169
+ if (opts.screen) {
170
+ // A run that wrote nothing must not report success to the shell. `skipped` is the user's own
171
+ // answer to the prompt, so it stays 0; `failed` (unreadable target) and `missing` (no
172
+ // field-map) are errors that scripts and CI need to see.
173
+ const outcome = await genOne(cwd, opts.screen, { ...opts, ctx });
174
+ if (outcome === 'failed' || outcome === 'missing') process.exitCode = 1;
175
+ return;
176
+ }
137
177
  if (!opts.all) {
138
178
  console.error('Specify --screen <name> for one unit, or --all for every unit (with confirmation).');
139
179
  process.exitCode = 1;
@@ -150,8 +190,16 @@ export function registerDataFactoryCommand(program: any): void {
150
190
  if (a !== 'y' && a !== 'yes') { console.log('Cancelled.'); return; }
151
191
  }
152
192
  let written = 0;
153
- for (const n of names) if ((await genOne(cwd, n, { force: opts.force, ctx })) === 'written') written++;
154
- console.log(`Done: ${written}/${names.length} written.`);
193
+ let failed = 0;
194
+ for (const n of names) {
195
+ const outcome = await genOne(cwd, n, { force: opts.force, ctx });
196
+ if (outcome === 'written') written++;
197
+ else if (outcome === 'failed' || outcome === 'missing') failed++;
198
+ }
199
+ console.log(`Done: ${written}/${names.length} written${failed ? ` · ${failed} failed` : ''}.`);
200
+ // One refused unit must not be buried by N successful ones — `--all` over a repo where a
201
+ // single test-data file is unparseable would otherwise exit 0.
202
+ if (failed) process.exitCode = 1;
155
203
  });
156
204
 
157
205
  data
package/src/crosscheck.ts CHANGED
@@ -25,15 +25,42 @@ export interface CrossFinding {
25
25
  message: string;
26
26
  }
27
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 {
28
+ /** Resolve a unit's feature dir + test-data dir and the unit-named data file (screen or flow). */
29
+ function unitPaths(cwd: string, name: string): { featureDir: string; testData: string; testDataDir: string } | null {
30
30
  for (const kind of ['screens', 'flows']) {
31
31
  const base = join(cwd, 'qa', kind, name);
32
- if (existsSync(base)) return { featureDir: join(base, 'features'), testData: join(base, 'test-data', `${name}.yaml`) };
32
+ if (existsSync(base)) {
33
+ return {
34
+ featureDir: join(base, 'features'),
35
+ testDataDir: join(base, 'test-data'),
36
+ testData: join(base, 'test-data', `${name}.yaml`),
37
+ };
38
+ }
33
39
  }
34
40
  return null;
35
41
  }
36
42
 
43
+ /**
44
+ * Each `.feature` in the unit paired with the test-data file the COMPILER resolves its `{{vars}}`
45
+ * against — `<feature>.yaml`, named after the feature, not after the unit.
46
+ *
47
+ * This gate used to concatenate every feature in the directory and check the lot against the single
48
+ * unit-named file, so it disagreed with the loader in both directions: a key living only in
49
+ * `<feature>.yaml` was reported DANGLING_VAR though it resolved fine, and a feature with no data
50
+ * file of its own was reported CLEAN while every one of its variables was about to be emitted as a
51
+ * literal (#781). The second is the one that matters — the gate whose whole job is catching a
52
+ * dangling `{{var}}` returned `0 error · 0 warn` on exactly the file that had them.
53
+ */
54
+ function featureDataPairs(featureDir: string, testDataDir: string): Array<{ file: string; text: string; dataFile: string; dataName: string }> {
55
+ if (!existsSync(featureDir)) return [];
56
+ return readdirSync(featureDir)
57
+ .filter((f) => f.endsWith('.feature'))
58
+ .map((f) => {
59
+ const dataName = `${f.replace(/\.feature$/, '')}.yaml`;
60
+ return { file: f, text: readFileSync(join(featureDir, f), 'utf8'), dataFile: join(testDataDir, dataName), dataName };
61
+ });
62
+ }
63
+
37
64
  function readFeatureText(featureDir: string): string {
38
65
  if (!existsSync(featureDir)) return '';
39
66
  return readdirSync(featureDir)
@@ -114,16 +141,25 @@ export function crosscheckUnit(cwd: string, name: string): CrossFinding[] {
114
141
  if (!paths) return out;
115
142
  const feature = readFeatureText(paths.featureDir);
116
143
  if (!feature) return out;
117
- const data = loadYaml(paths.testData);
118
- const topKeys = new Set(Object.keys(data).filter((k) => !k.startsWith('_')));
119
- const caseCols = casesColumns(feature, data); // @cases row columns resolve per-row, not as top-level keys
120
- const captured = rememberedVars(feature); // runtime-bound vars — no static key can exist
121
-
122
144
  // 1. Dangling vars — a {{var}} whose root key is absent from test-data (and not a @cases column
123
- // or a runtime-captured binding).
124
- for (const v of referencedVars(feature)) {
125
- if (!topKeys.has(v) && !caseCols.has(v) && !captured.has(v)) {
126
- out.push({ unit: name, severity: 'error', code: 'DANGLING_VAR', message: `scenario references {{${v}}} but no "${v}" key exists in test-data/${name}.yaml` });
145
+ // or a runtime-captured binding). Checked PER FEATURE against the file the loader reads for
146
+ // that feature, so this gate and the compiler cannot disagree about which file is authoritative.
147
+ for (const fx of featureDataPairs(paths.featureDir, paths.testDataDir)) {
148
+ const data = loadYaml(fx.dataFile);
149
+ const topKeys = new Set(Object.keys(data).filter((k) => !k.startsWith('_')));
150
+ const caseCols = casesColumns(fx.text, data); // @cases row columns resolve per-row, not as top-level keys
151
+ const captured = rememberedVars(fx.text); // runtime-bound vars — no static key can exist
152
+ for (const v of referencedVars(fx.text)) {
153
+ if (!topKeys.has(v) && !caseCols.has(v) && !captured.has(v)) {
154
+ out.push({
155
+ unit: name,
156
+ severity: 'error',
157
+ code: 'DANGLING_VAR',
158
+ // Names the file actually consulted. The old message always said `<unit>.yaml`, which sent
159
+ // readers to edit a file the compiler would never read for that feature.
160
+ message: `${fx.file} references {{${v}}} but no "${v}" key exists in test-data/${fx.dataName}`,
161
+ });
162
+ }
127
163
  }
128
164
  }
129
165
 
package/src/sensor.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  import { basename, join } from 'path';
18
18
  import { existsSync, readFileSync } from 'fs';
19
19
  import { parse as parseYaml } from 'yaml';
20
- import type { Sensor } from '@sun-asterisk/sungen';
20
+ import type { Sensor, SensorFinding } from '@sun-asterisk/sungen';
21
21
  import { loadEffectiveCatalog } from './catalog-loader';
22
22
  import { lintFieldMap, summarize } from './lint';
23
23
  import { validateGenerated } from './validate';
@@ -32,7 +32,7 @@ export const dataFactoryCoverageSensor: Sensor<{ dir: string; cwd: string }> = {
32
32
  const name = basename(dir);
33
33
  // Layer A auto-detection — runs regardless of whether a field-map exists, so a unit that needs
34
34
  // standardization or a declared precondition is NOTICED, not silently skipped.
35
- const detected = detectUnit(cwd, dir, name).map((d) => ({
35
+ const detected: SensorFinding[] = detectUnit(cwd, dir, name).map((d) => ({
36
36
  sensorId: 'data-factory-coverage',
37
37
  capability: 'data-factory',
38
38
  scenario: d.scenario,
@@ -43,12 +43,31 @@ export const dataFactoryCoverageSensor: Sensor<{ dir: string; cwd: string }> = {
43
43
  const fieldMapPath = join(cwd, 'qa', 'data-factory', `${name}.fields.yaml`);
44
44
  if (!existsSync(fieldMapPath)) return detected; // no field-map: report only what detection noticed
45
45
  let map: FieldMap;
46
- try { map = parseYaml(readFileSync(fieldMapPath, 'utf8')) as FieldMap; } catch { return detected; }
46
+ try {
47
+ map = parseYaml(readFileSync(fieldMapPath, 'utf8')) as FieldMap;
48
+ } catch (e) {
49
+ // `existsSync` above already answered absence, so this only fires for a field-map that EXISTS
50
+ // and is broken — and returning `detected` would report it exactly as if none had been
51
+ // written. That silently drops three families of findings (lintFieldMap issues, the catalog's
52
+ // OVERWRITE-STALE warnings, validateGenerated), leaving an author who broke their YAML with
53
+ // the same output as one who never wrote a field map. This sensor has a findings channel;
54
+ // use it.
55
+ detected.push({
56
+ sensorId: 'data-factory-coverage',
57
+ capability: 'data-factory',
58
+ scenario: name,
59
+ message: `DATA-FACTORY-FIELDMAP-UNREADABLE: qa/data-factory/${name}.fields.yaml could not be parsed `
60
+ + `(${e instanceof Error ? e.message.split('\n')[0] : String(e)}) — coverage lint, stale-catalog `
61
+ + `warnings and semantic validation are all skipped for this unit until it parses.`,
62
+ severity: 'error',
63
+ });
64
+ return detected;
65
+ }
47
66
  if (!map || !Array.isArray(map.fields)) return detected;
48
67
 
49
68
  const { catalog, warnings } = loadEffectiveCatalog(cwd);
50
69
  const { issues } = lintFieldMap(map, catalog);
51
- const findings = issues.map((i) => ({
70
+ const findings: SensorFinding[] = issues.map((i) => ({
52
71
  sensorId: 'data-factory-coverage',
53
72
  capability: 'data-factory',
54
73
  scenario: i.field,