@sungen/driver-data-factory 3.2.31 → 3.2.33
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 +2 -2
- package/src/cli.ts +56 -8
- package/src/crosscheck.ts +48 -12
- package/src/sensor.ts +23 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sungen/driver-data-factory",
|
|
3
|
-
"version": "3.2.
|
|
3
|
+
"version": "3.2.33",
|
|
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.
|
|
17
|
+
"@sun-asterisk/sungen": "3.2.33",
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
|
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
|
-
|
|
154
|
-
|
|
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
|
|
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))
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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 {
|
|
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,
|