@sun-asterisk/sungen 3.0.1 → 3.1.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.
Files changed (132) hide show
  1. package/dist/cli/commands/challenge.d.ts.map +1 -1
  2. package/dist/cli/commands/challenge.js +9 -2
  3. package/dist/cli/commands/challenge.js.map +1 -1
  4. package/dist/cli/commands/delivery.d.ts.map +1 -1
  5. package/dist/cli/commands/delivery.js +3 -2
  6. package/dist/cli/commands/delivery.js.map +1 -1
  7. package/dist/cli/commands/generate.d.ts.map +1 -1
  8. package/dist/cli/commands/generate.js +8 -0
  9. package/dist/cli/commands/generate.js.map +1 -1
  10. package/dist/exporters/csv-exporter.d.ts.map +1 -1
  11. package/dist/exporters/csv-exporter.js +92 -76
  12. package/dist/exporters/csv-exporter.js.map +1 -1
  13. package/dist/exporters/spec-parser.d.ts.map +1 -1
  14. package/dist/exporters/spec-parser.js +3 -1
  15. package/dist/exporters/spec-parser.js.map +1 -1
  16. package/dist/generators/test-generator/adapters/adapter-interface.d.ts +2 -0
  17. package/dist/generators/test-generator/adapters/adapter-interface.d.ts.map +1 -1
  18. package/dist/generators/test-generator/adapters/playwright/playwright-adapter.d.ts +1 -0
  19. package/dist/generators/test-generator/adapters/playwright/playwright-adapter.d.ts.map +1 -1
  20. package/dist/generators/test-generator/adapters/playwright/playwright-adapter.js.map +1 -1
  21. package/dist/generators/test-generator/adapters/playwright/templates/imports.hbs +3 -0
  22. package/dist/generators/test-generator/adapters/playwright/templates/scenario.hbs +19 -1
  23. package/dist/generators/test-generator/code-generator.d.ts +12 -0
  24. package/dist/generators/test-generator/code-generator.d.ts.map +1 -1
  25. package/dist/generators/test-generator/code-generator.js +137 -4
  26. package/dist/generators/test-generator/code-generator.js.map +1 -1
  27. package/dist/generators/test-generator/patterns/database-patterns.d.ts +6 -0
  28. package/dist/generators/test-generator/patterns/database-patterns.d.ts.map +1 -0
  29. package/dist/generators/test-generator/patterns/database-patterns.js +95 -0
  30. package/dist/generators/test-generator/patterns/database-patterns.js.map +1 -0
  31. package/dist/generators/test-generator/patterns/expect-patterns.d.ts +3 -0
  32. package/dist/generators/test-generator/patterns/expect-patterns.d.ts.map +1 -0
  33. package/dist/generators/test-generator/patterns/expect-patterns.js +54 -0
  34. package/dist/generators/test-generator/patterns/expect-patterns.js.map +1 -0
  35. package/dist/generators/test-generator/patterns/index.d.ts +1 -0
  36. package/dist/generators/test-generator/patterns/index.d.ts.map +1 -1
  37. package/dist/generators/test-generator/patterns/index.js +8 -1
  38. package/dist/generators/test-generator/patterns/index.js.map +1 -1
  39. package/dist/generators/test-generator/step-mapper.d.ts +6 -0
  40. package/dist/generators/test-generator/step-mapper.d.ts.map +1 -1
  41. package/dist/generators/test-generator/step-mapper.js +8 -0
  42. package/dist/generators/test-generator/step-mapper.js.map +1 -1
  43. package/dist/generators/test-generator/template-engine.d.ts +4 -0
  44. package/dist/generators/test-generator/template-engine.d.ts.map +1 -1
  45. package/dist/generators/test-generator/template-engine.js +1 -1
  46. package/dist/generators/test-generator/template-engine.js.map +1 -1
  47. package/dist/generators/test-generator/utils/runtime-data-transformer.d.ts +1 -1
  48. package/dist/generators/test-generator/utils/runtime-data-transformer.d.ts.map +1 -1
  49. package/dist/generators/test-generator/utils/runtime-data-transformer.js +5 -5
  50. package/dist/generators/test-generator/utils/runtime-data-transformer.js.map +1 -1
  51. package/dist/harness/audit.js +1 -1
  52. package/dist/harness/capability-plan.js +1 -1
  53. package/dist/harness/catalog/drivers.yaml +1 -1
  54. package/dist/harness/catalog/universal-viewpoints.yaml +1 -1
  55. package/dist/harness/challenge.d.ts +1 -0
  56. package/dist/harness/challenge.d.ts.map +1 -1
  57. package/dist/harness/challenge.js +49 -2
  58. package/dist/harness/challenge.js.map +1 -1
  59. package/dist/harness/data-driven-lint.d.ts +7 -0
  60. package/dist/harness/data-driven-lint.d.ts.map +1 -0
  61. package/dist/harness/data-driven-lint.js +153 -0
  62. package/dist/harness/data-driven-lint.js.map +1 -0
  63. package/dist/harness/flow-plan.js +1 -1
  64. package/dist/harness/parse.d.ts +2 -0
  65. package/dist/harness/parse.d.ts.map +1 -1
  66. package/dist/harness/parse.js +16 -0
  67. package/dist/harness/parse.js.map +1 -1
  68. package/dist/harness/query-catalog.d.ts +48 -0
  69. package/dist/harness/query-catalog.d.ts.map +1 -0
  70. package/dist/harness/query-catalog.js +0 -0
  71. package/dist/harness/query-catalog.js.map +1 -0
  72. package/dist/harness/script-check.d.ts.map +1 -1
  73. package/dist/harness/script-check.js +11 -5
  74. package/dist/harness/script-check.js.map +1 -1
  75. package/dist/orchestrator/templates/ai-instructions/claude-agent-challenge.md +3 -2
  76. package/dist/orchestrator/templates/ai-instructions/claude-skill-gherkin-syntax.md +40 -0
  77. package/dist/orchestrator/templates/ai-instructions/claude-skill-harness-audit.md +1 -1
  78. package/dist/orchestrator/templates/ai-instructions/claude-skill-tc-generation.md +19 -0
  79. package/dist/orchestrator/templates/ai-instructions/claude-skill-tc-review.md +1 -0
  80. package/dist/orchestrator/templates/ai-instructions/claude-skill-test-design-techniques.md +6 -0
  81. package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-gherkin-syntax.md +40 -0
  82. package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-harness-audit.md +1 -1
  83. package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-tc-generation.md +19 -0
  84. package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-tc-review.md +1 -0
  85. package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-test-design-techniques.md +6 -0
  86. package/dist/orchestrator/templates/specs-db.d.ts +26 -0
  87. package/dist/orchestrator/templates/specs-db.d.ts.map +1 -0
  88. package/dist/orchestrator/templates/specs-db.js +193 -0
  89. package/dist/orchestrator/templates/specs-db.js.map +1 -0
  90. package/dist/orchestrator/templates/specs-db.ts +169 -0
  91. package/dist/orchestrator/templates/specs-test-data.ts +76 -15
  92. package/docs/orchestration-spec.md +3 -3
  93. package/package.json +2 -2
  94. package/src/cli/commands/challenge.ts +6 -2
  95. package/src/cli/commands/delivery.ts +3 -2
  96. package/src/cli/commands/generate.ts +8 -0
  97. package/src/exporters/csv-exporter.ts +22 -6
  98. package/src/exporters/spec-parser.ts +3 -1
  99. package/src/generators/test-generator/adapters/adapter-interface.ts +2 -1
  100. package/src/generators/test-generator/adapters/playwright/playwright-adapter.ts +1 -1
  101. package/src/generators/test-generator/adapters/playwright/templates/imports.hbs +3 -0
  102. package/src/generators/test-generator/adapters/playwright/templates/scenario.hbs +19 -1
  103. package/src/generators/test-generator/code-generator.ts +133 -4
  104. package/src/generators/test-generator/patterns/database-patterns.ts +96 -0
  105. package/src/generators/test-generator/patterns/expect-patterns.ts +49 -0
  106. package/src/generators/test-generator/patterns/index.ts +5 -0
  107. package/src/generators/test-generator/step-mapper.ts +9 -0
  108. package/src/generators/test-generator/template-engine.ts +5 -2
  109. package/src/generators/test-generator/utils/runtime-data-transformer.ts +5 -5
  110. package/src/harness/audit.ts +1 -1
  111. package/src/harness/capability-plan.ts +1 -1
  112. package/src/harness/catalog/drivers.yaml +1 -1
  113. package/src/harness/catalog/universal-viewpoints.yaml +1 -1
  114. package/src/harness/challenge.ts +47 -2
  115. package/src/harness/data-driven-lint.ts +119 -0
  116. package/src/harness/flow-plan.ts +1 -1
  117. package/src/harness/parse.ts +12 -0
  118. package/src/harness/query-catalog.ts +0 -0
  119. package/src/harness/script-check.ts +12 -6
  120. package/src/orchestrator/templates/ai-instructions/claude-agent-challenge.md +3 -2
  121. package/src/orchestrator/templates/ai-instructions/claude-skill-gherkin-syntax.md +40 -0
  122. package/src/orchestrator/templates/ai-instructions/claude-skill-harness-audit.md +1 -1
  123. package/src/orchestrator/templates/ai-instructions/claude-skill-tc-generation.md +19 -0
  124. package/src/orchestrator/templates/ai-instructions/claude-skill-tc-review.md +1 -0
  125. package/src/orchestrator/templates/ai-instructions/claude-skill-test-design-techniques.md +6 -0
  126. package/src/orchestrator/templates/ai-instructions/github-skill-sungen-gherkin-syntax.md +40 -0
  127. package/src/orchestrator/templates/ai-instructions/github-skill-sungen-harness-audit.md +1 -1
  128. package/src/orchestrator/templates/ai-instructions/github-skill-sungen-tc-generation.md +19 -0
  129. package/src/orchestrator/templates/ai-instructions/github-skill-sungen-tc-review.md +1 -0
  130. package/src/orchestrator/templates/ai-instructions/github-skill-sungen-test-design-techniques.md +6 -0
  131. package/src/orchestrator/templates/specs-db.ts +169 -0
  132. package/src/orchestrator/templates/specs-test-data.ts +76 -15
@@ -54,6 +54,25 @@ user-invocable: false
54
54
  OR condition: generate 1 scenario per branch where that branch alone triggers the outcome.
55
55
  → Happy-path only = missing the most common multi-condition implementation bug.
56
56
 
57
+ - **Many inputs, same steps → ONE data-driven scenario (`@cases`), not N copies:**
58
+ When a rule needs lots of inputs with the *same* step shape (email/format validation,
59
+ BVA boundary triples, EP classes, decision-table rows), tag one scenario `@cases:<dataset>`,
60
+ reference each row's columns as `{{col}}`, and put the rows as a LIST in test-data:
61
+ ```gherkin
62
+ @high @cases:email_validation
63
+ Scenario: VP-VAL-001 The email field rejects invalid formats
64
+ When User fill [Email] field with {{email}}
65
+ Then User see [Error] message with {{expected_error}}
66
+ ```
67
+ ```yaml
68
+ email_validation:
69
+ - { case: "no @", email: "plainaddress", expected_error: "Invalid email" }
70
+ - { case: "valid", email: "ok@x.com", expected_error: "" }
71
+ ```
72
+ → one `test()` per row, each labelled by `case`. Adding inputs = editing test-data (no recompile),
73
+ and env overlays apply. Prefer this over duplicating a scenario per value. (Gherkin
74
+ `Scenario Outline`/`Examples` is NOT supported — use `@cases`.)
75
+
57
76
  ---
58
77
 
59
78
  ## Tier System
@@ -120,6 +120,7 @@ Build a mapping table: for each applicable group, does the feature have a matchi
120
120
  - **EP**: keep only **one representative** per invalid class; same-class duplicates → flag as redundant.
121
121
  - **BVA**: spec defines min/max → cover `min-1`, `min`, `max`, `max+1` (Maxlength, counts…).
122
122
  - Error messages must match the spec **word-for-word**, not generic.
123
+ - **Data-driven (`@cases`)**: a `@cases:<dataset>` scenario legitimately covers many inputs in ONE scenario (one row per EP class / boundary / rule). Do **not** flag it as "too few negative cases" or as duplication — instead review the **dataset rows**: are all EP classes / boundary triples present, each labelled, expected values exact? N near-identical scenarios that differ only by input value → flag and recommend collapsing to `@cases`.
123
124
 
124
125
  ---
125
126
 
@@ -17,6 +17,12 @@ Apply selectively — not every screen needs all four techniques. Use the techni
17
17
 
18
18
  **Rule:** These techniques determine **how many** and **which** scenarios to generate. `sungen-viewpoint` determines **which viewpoints** to cover.
19
19
 
20
+ **Implementing the data table → `@cases` (data-driven):** when EP classes / BVA boundary triples /
21
+ decision-table rows share the *same step shape* and differ only by input/expected values, encode
22
+ them as ONE `@cases:<dataset>` scenario (each class/boundary/rule = one row in the test-data list,
23
+ labelled by a `case` column) instead of N near-duplicate scenarios. The technique still decides the
24
+ rows; `@cases` is how you write them compactly. See `sungen-gherkin-syntax` → Data-driven.
25
+
20
26
  ---
21
27
 
22
28
  ## 1. Equivalence Partitioning (EP)
@@ -0,0 +1,26 @@
1
+ declare class DataSource {
2
+ private configs;
3
+ private engines;
4
+ private cfg;
5
+ private engine;
6
+ private build;
7
+ /** A row matching `filter` must exist; if `expected` given, assert those columns on the first match. */
8
+ assertRow(table: string, filter: Record<string, any>, expected?: Record<string, any>, datasource?: string): Promise<void>;
9
+ /** No row matching `filter` may exist. */
10
+ assertNoRow(table: string, filter: Record<string, any>, datasource?: string): Promise<void>;
11
+ /** Exactly `count` rows must match `filter`. */
12
+ assertCount(table: string, filter: Record<string, any>, count: number, datasource?: string): Promise<void>;
13
+ /** Rewrites $1/$2 placeholders to `?` for SQLite. */
14
+ private sqlFor;
15
+ /** Read-only guard (second layer): a named query must be a single SELECT/WITH statement. */
16
+ private assertSelectOnly;
17
+ /**
18
+ * Run a catalog query (read-only) and return its rows. The result is bound to a `{{name}}`
19
+ * variable via `testData.bind(...)`, so the scenario asserts on it with `expect …` steps and
20
+ * path access (`{{name.count}}`, `{{name.first.col}}`, `{{name[2].col}}`).
21
+ */
22
+ fetchQuery(label: string, sql: string, params: any[], datasource?: string): Promise<any[]>;
23
+ }
24
+ export declare const db: DataSource;
25
+ export {};
26
+ //# sourceMappingURL=specs-db.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"specs-db.d.ts","sourceRoot":"","sources":["../../../src/orchestrator/templates/specs-db.ts"],"names":[],"mappings":"AA+DA,cAAM,UAAU;IACd,OAAO,CAAC,OAAO,CAAiD;IAChE,OAAO,CAAC,OAAO,CAA6B;IAE5C,OAAO,CAAC,GAAG;YAQG,MAAM;IAoBpB,OAAO,CAAC,KAAK;IAOb,wGAAwG;IAClG,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAc/H,0CAA0C;IACpC,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAOjG,gDAAgD;IAC1C,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAUhH,qDAAqD;IACrD,OAAO,CAAC,MAAM;IAKd,4FAA4F;IAC5F,OAAO,CAAC,gBAAgB;IASxB;;;;OAIG;IACG,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,EAAE,CAAC;CAKjG;AAMD,eAAO,MAAM,EAAE,YAAmB,CAAC"}
@@ -0,0 +1,193 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.db = void 0;
37
+ /* eslint-disable */
38
+ /**
39
+ * Sungen Data Driver — runtime DB-verification helper (auto-generated into specs/db.ts).
40
+ *
41
+ * Read-only by construction: it only ever issues a single parameterized SELECT, and
42
+ * table/column identifiers are validated against a strict allowlist pattern (identifiers
43
+ * can't be bound as parameters). Secrets come from .env.qa / process.env, never inline.
44
+ *
45
+ * Engines: PostgreSQL (`pg`) and SQLite (`better-sqlite3`), lazy-loaded on first use.
46
+ * Config: a `datasources.yaml` at the project root (or qa/), with ${VAR} resolved from env.
47
+ *
48
+ * DO NOT EDIT — regenerated by `sungen generate`.
49
+ */
50
+ const test_1 = require("@playwright/test");
51
+ const fs = __importStar(require("fs"));
52
+ const path = __importStar(require("path"));
53
+ const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
54
+ const ident = (s) => {
55
+ if (!IDENT.test(s))
56
+ throw new Error(`Unsafe identifier: ${JSON.stringify(s)} (allowed: [A-Za-z_][A-Za-z0-9_]*)`);
57
+ return s;
58
+ };
59
+ function loadEnvQa() {
60
+ for (const name of ['.env.qa', `.env.qa.${process.env.SUNGEN_ENV || ''}`]) {
61
+ const p = path.join(process.cwd(), name);
62
+ if (!name.endsWith('.') && fs.existsSync(p)) {
63
+ for (const line of fs.readFileSync(p, 'utf8').split('\n')) {
64
+ const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
65
+ if (m && process.env[m[1]] === undefined) {
66
+ process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
67
+ }
68
+ }
69
+ }
70
+ }
71
+ }
72
+ function loadConfig() {
73
+ loadEnvQa();
74
+ const file = [
75
+ path.join(process.cwd(), 'datasources.yaml'),
76
+ path.join(process.cwd(), 'qa', 'datasources.yaml'),
77
+ ].find((f) => fs.existsSync(f));
78
+ if (!file)
79
+ throw new Error('Data Driver: no datasources.yaml found (project root or qa/).');
80
+ let raw = fs.readFileSync(file, 'utf8').replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, k) => process.env[k] ?? '');
81
+ const { parse } = require('yaml');
82
+ const doc = parse(raw) || {};
83
+ if (!doc.datasources || typeof doc.datasources !== 'object') {
84
+ throw new Error('Data Driver: datasources.yaml must define a top-level `datasources:` map.');
85
+ }
86
+ return doc.datasources;
87
+ }
88
+ class DataSource {
89
+ constructor() {
90
+ this.configs = null;
91
+ this.engines = new Map();
92
+ }
93
+ cfg(name) {
94
+ if (!this.configs)
95
+ this.configs = loadConfig();
96
+ const key = name || Object.keys(this.configs)[0];
97
+ const conf = this.configs[key];
98
+ if (!conf)
99
+ throw new Error(`Data Driver: datasource "${key}" not found in datasources.yaml`);
100
+ return { key, conf };
101
+ }
102
+ async engine(name) {
103
+ const { key, conf } = this.cfg(name);
104
+ if (this.engines.has(key))
105
+ return { engine: this.engines.get(key), conf };
106
+ if (!conf.url)
107
+ throw new Error(`Data Driver: datasource "${key}" has no url (set it in .env.qa).`);
108
+ let engine;
109
+ if (conf.engine === 'postgres') {
110
+ const { Pool } = require('pg');
111
+ const pool = new Pool({ connectionString: conf.url, max: 2, statement_timeout: conf.statement_timeout_ms ?? 4000 });
112
+ engine = { query: async (sql, params) => (await pool.query(sql, params)).rows };
113
+ }
114
+ else if (conf.engine === 'sqlite') {
115
+ const Database = require('better-sqlite3');
116
+ const db = new Database(conf.url.replace(/^sqlite:/, ''), { readonly: conf.readonly !== false });
117
+ engine = { query: async (sql, params) => db.prepare(sql).all(...params) };
118
+ }
119
+ else {
120
+ throw new Error(`Data Driver: engine "${conf.engine}" not supported yet (postgres | sqlite).`);
121
+ }
122
+ this.engines.set(key, engine);
123
+ return { engine, conf };
124
+ }
125
+ build(table, filter) {
126
+ const cols = Object.keys(filter);
127
+ const where = cols.map((c, i) => `${ident(c)} = $${i + 1}`).join(' AND ');
128
+ const sql = `SELECT * FROM ${ident(table)}${where ? ' WHERE ' + where : ''} LIMIT 50`;
129
+ return { sql, params: cols.map((c) => filter[c]) };
130
+ }
131
+ /** A row matching `filter` must exist; if `expected` given, assert those columns on the first match. */
132
+ async assertRow(table, filter, expected, datasource) {
133
+ const { engine, conf } = await this.engine(datasource);
134
+ const { sql, params } = this.build(table, filter);
135
+ const rows = await engine.query(this.sqlFor(conf, sql), params);
136
+ (0, test_1.expect)(rows.length, `Expected a row in "${table}" where ${desc(filter)} — found ${rows.length}`).toBeGreaterThanOrEqual(1);
137
+ if (expected) {
138
+ const row = rows[0];
139
+ for (const [col, val] of Object.entries(expected)) {
140
+ ident(col);
141
+ (0, test_1.expect)(String(row[col]), `Column "${col}" of "${table}" where ${desc(filter)}`).toBe(String(val));
142
+ }
143
+ }
144
+ }
145
+ /** No row matching `filter` may exist. */
146
+ async assertNoRow(table, filter, datasource) {
147
+ const { engine, conf } = await this.engine(datasource);
148
+ const { sql, params } = this.build(table, filter);
149
+ const rows = await engine.query(this.sqlFor(conf, sql), params);
150
+ (0, test_1.expect)(rows.length, `Expected NO row in "${table}" where ${desc(filter)} — found ${rows.length}`).toBe(0);
151
+ }
152
+ /** Exactly `count` rows must match `filter`. */
153
+ async assertCount(table, filter, count, datasource) {
154
+ const { engine, conf } = await this.engine(datasource);
155
+ const cols = Object.keys(filter);
156
+ const where = cols.map((c, i) => `${ident(c)} = $${i + 1}`).join(' AND ');
157
+ const sql = `SELECT count(*) AS n FROM ${ident(table)}${where ? ' WHERE ' + where : ''}`;
158
+ const rows = await engine.query(this.sqlFor(conf, sql), cols.map((c) => filter[c]));
159
+ const n = Number(rows[0]?.n ?? rows[0]?.['count(*)'] ?? 0);
160
+ (0, test_1.expect)(n, `Expected ${count} row(s) in "${table}"${cols.length ? ' where ' + desc(filter) : ''} — found ${n}`).toBe(Number(count));
161
+ }
162
+ /** Rewrites $1/$2 placeholders to `?` for SQLite. */
163
+ sqlFor(conf, sql) {
164
+ return conf.engine === 'sqlite' ? sql.replace(/\$\d+/g, '?') : sql;
165
+ }
166
+ // --- Named queries (catalog-backed; SQL is resolved + embedded at compile time) -----------
167
+ /** Read-only guard (second layer): a named query must be a single SELECT/WITH statement. */
168
+ assertSelectOnly(label, sql) {
169
+ const s = sql.trim().replace(/;\s*$/, '');
170
+ if (!/^(SELECT|WITH)\b/i.test(s))
171
+ throw new Error(`Data Driver: ${label} is not a read-only SELECT — refused.`);
172
+ if (s.includes(';'))
173
+ throw new Error(`Data Driver: ${label} contains multiple statements — refused.`);
174
+ if (/\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE|GRANT|REVOKE|MERGE|REPLACE|CALL|EXEC|EXECUTE|ATTACH|PRAGMA|VACUUM)\b/i.test(s)) {
175
+ throw new Error(`Data Driver: ${label} contains a write/DDL keyword — refused.`);
176
+ }
177
+ }
178
+ /**
179
+ * Run a catalog query (read-only) and return its rows. The result is bound to a `{{name}}`
180
+ * variable via `testData.bind(...)`, so the scenario asserts on it with `expect …` steps and
181
+ * path access (`{{name.count}}`, `{{name.first.col}}`, `{{name[2].col}}`).
182
+ */
183
+ async fetchQuery(label, sql, params, datasource) {
184
+ this.assertSelectOnly(label, sql);
185
+ const { engine, conf } = await this.engine(datasource);
186
+ return engine.query(this.sqlFor(conf, sql), params);
187
+ }
188
+ }
189
+ function desc(filter) {
190
+ return Object.entries(filter).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(', ');
191
+ }
192
+ exports.db = new DataSource();
193
+ //# sourceMappingURL=specs-db.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"specs-db.js","sourceRoot":"","sources":["../../../src/orchestrator/templates/specs-db.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,169 @@
1
+ /* eslint-disable */
2
+ /**
3
+ * Sungen Data Driver — runtime DB-verification helper (auto-generated into specs/db.ts).
4
+ *
5
+ * Read-only by construction: it only ever issues a single parameterized SELECT, and
6
+ * table/column identifiers are validated against a strict allowlist pattern (identifiers
7
+ * can't be bound as parameters). Secrets come from .env.qa / process.env, never inline.
8
+ *
9
+ * Engines: PostgreSQL (`pg`) and SQLite (`better-sqlite3`), lazy-loaded on first use.
10
+ * Config: a `datasources.yaml` at the project root (or qa/), with ${VAR} resolved from env.
11
+ *
12
+ * DO NOT EDIT — regenerated by `sungen generate`.
13
+ */
14
+ import { expect } from '@playwright/test';
15
+ import * as fs from 'fs';
16
+ import * as path from 'path';
17
+
18
+ const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
19
+ const ident = (s: string): string => {
20
+ if (!IDENT.test(s)) throw new Error(`Unsafe identifier: ${JSON.stringify(s)} (allowed: [A-Za-z_][A-Za-z0-9_]*)`);
21
+ return s;
22
+ };
23
+
24
+ interface DataSourceConfig {
25
+ engine: 'postgres' | 'mysql' | 'sqlite';
26
+ url: string;
27
+ readonly?: boolean;
28
+ statement_timeout_ms?: number;
29
+ max_rows?: number;
30
+ }
31
+
32
+ function loadEnvQa(): void {
33
+ for (const name of ['.env.qa', `.env.qa.${process.env.SUNGEN_ENV || ''}`]) {
34
+ const p = path.join(process.cwd(), name);
35
+ if (!name.endsWith('.') && fs.existsSync(p)) {
36
+ for (const line of fs.readFileSync(p, 'utf8').split('\n')) {
37
+ const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
38
+ if (m && process.env[m[1]] === undefined) {
39
+ process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
40
+ }
41
+ }
42
+ }
43
+ }
44
+ }
45
+
46
+ function loadConfig(): Record<string, DataSourceConfig> {
47
+ loadEnvQa();
48
+ const file = [
49
+ path.join(process.cwd(), 'datasources.yaml'),
50
+ path.join(process.cwd(), 'qa', 'datasources.yaml'),
51
+ ].find((f) => fs.existsSync(f));
52
+ if (!file) throw new Error('Data Driver: no datasources.yaml found (project root or qa/).');
53
+ let raw = fs.readFileSync(file, 'utf8').replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, k) => process.env[k] ?? '');
54
+ const { parse } = require('yaml');
55
+ const doc = parse(raw) || {};
56
+ if (!doc.datasources || typeof doc.datasources !== 'object') {
57
+ throw new Error('Data Driver: datasources.yaml must define a top-level `datasources:` map.');
58
+ }
59
+ return doc.datasources;
60
+ }
61
+
62
+ type Engine = { query(sql: string, params: any[]): Promise<any[]>; };
63
+
64
+ class DataSource {
65
+ private configs: Record<string, DataSourceConfig> | null = null;
66
+ private engines = new Map<string, Engine>();
67
+
68
+ private cfg(name?: string): { key: string; conf: DataSourceConfig } {
69
+ if (!this.configs) this.configs = loadConfig();
70
+ const key = name || Object.keys(this.configs)[0];
71
+ const conf = this.configs[key];
72
+ if (!conf) throw new Error(`Data Driver: datasource "${key}" not found in datasources.yaml`);
73
+ return { key, conf };
74
+ }
75
+
76
+ private async engine(name?: string): Promise<{ engine: Engine; conf: DataSourceConfig }> {
77
+ const { key, conf } = this.cfg(name);
78
+ if (this.engines.has(key)) return { engine: this.engines.get(key)!, conf };
79
+ if (!conf.url) throw new Error(`Data Driver: datasource "${key}" has no url (set it in .env.qa).`);
80
+ let engine: Engine;
81
+ if (conf.engine === 'postgres') {
82
+ const { Pool } = require('pg');
83
+ const pool = new Pool({ connectionString: conf.url, max: 2, statement_timeout: conf.statement_timeout_ms ?? 4000 });
84
+ engine = { query: async (sql, params) => (await pool.query(sql, params)).rows };
85
+ } else if (conf.engine === 'sqlite') {
86
+ const Database = require('better-sqlite3');
87
+ const db = new Database(conf.url.replace(/^sqlite:/, ''), { readonly: conf.readonly !== false });
88
+ engine = { query: async (sql, params) => db.prepare(sql).all(...params) };
89
+ } else {
90
+ throw new Error(`Data Driver: engine "${conf.engine}" not supported yet (postgres | sqlite).`);
91
+ }
92
+ this.engines.set(key, engine);
93
+ return { engine, conf };
94
+ }
95
+
96
+ private build(table: string, filter: Record<string, any>): { sql: string; params: any[] } {
97
+ const cols = Object.keys(filter);
98
+ const where = cols.map((c, i) => `${ident(c)} = $${i + 1}`).join(' AND ');
99
+ const sql = `SELECT * FROM ${ident(table)}${where ? ' WHERE ' + where : ''} LIMIT 50`;
100
+ return { sql, params: cols.map((c) => filter[c]) };
101
+ }
102
+
103
+ /** A row matching `filter` must exist; if `expected` given, assert those columns on the first match. */
104
+ async assertRow(table: string, filter: Record<string, any>, expected?: Record<string, any>, datasource?: string): Promise<void> {
105
+ const { engine, conf } = await this.engine(datasource);
106
+ const { sql, params } = this.build(table, filter);
107
+ const rows = await engine.query(this.sqlFor(conf, sql), params);
108
+ expect(rows.length, `Expected a row in "${table}" where ${desc(filter)} — found ${rows.length}`).toBeGreaterThanOrEqual(1);
109
+ if (expected) {
110
+ const row = rows[0];
111
+ for (const [col, val] of Object.entries(expected)) {
112
+ ident(col);
113
+ expect(String(row[col]), `Column "${col}" of "${table}" where ${desc(filter)}`).toBe(String(val));
114
+ }
115
+ }
116
+ }
117
+
118
+ /** No row matching `filter` may exist. */
119
+ async assertNoRow(table: string, filter: Record<string, any>, datasource?: string): Promise<void> {
120
+ const { engine, conf } = await this.engine(datasource);
121
+ const { sql, params } = this.build(table, filter);
122
+ const rows = await engine.query(this.sqlFor(conf, sql), params);
123
+ expect(rows.length, `Expected NO row in "${table}" where ${desc(filter)} — found ${rows.length}`).toBe(0);
124
+ }
125
+
126
+ /** Exactly `count` rows must match `filter`. */
127
+ async assertCount(table: string, filter: Record<string, any>, count: number, datasource?: string): Promise<void> {
128
+ const { engine, conf } = await this.engine(datasource);
129
+ const cols = Object.keys(filter);
130
+ const where = cols.map((c, i) => `${ident(c)} = $${i + 1}`).join(' AND ');
131
+ const sql = `SELECT count(*) AS n FROM ${ident(table)}${where ? ' WHERE ' + where : ''}`;
132
+ const rows = await engine.query(this.sqlFor(conf, sql), cols.map((c) => filter[c]));
133
+ const n = Number(rows[0]?.n ?? rows[0]?.['count(*)'] ?? 0);
134
+ expect(n, `Expected ${count} row(s) in "${table}"${cols.length ? ' where ' + desc(filter) : ''} — found ${n}`).toBe(Number(count));
135
+ }
136
+
137
+ /** Rewrites $1/$2 placeholders to `?` for SQLite. */
138
+ private sqlFor(conf: DataSourceConfig, sql: string): string {
139
+ return conf.engine === 'sqlite' ? sql.replace(/\$\d+/g, '?') : sql;
140
+ }
141
+
142
+ // --- Named queries (catalog-backed; SQL is resolved + embedded at compile time) -----------
143
+ /** Read-only guard (second layer): a named query must be a single SELECT/WITH statement. */
144
+ private assertSelectOnly(label: string, sql: string): void {
145
+ const s = sql.trim().replace(/;\s*$/, '');
146
+ if (!/^(SELECT|WITH)\b/i.test(s)) throw new Error(`Data Driver: ${label} is not a read-only SELECT — refused.`);
147
+ if (s.includes(';')) throw new Error(`Data Driver: ${label} contains multiple statements — refused.`);
148
+ if (/\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE|GRANT|REVOKE|MERGE|REPLACE|CALL|EXEC|EXECUTE|ATTACH|PRAGMA|VACUUM)\b/i.test(s)) {
149
+ throw new Error(`Data Driver: ${label} contains a write/DDL keyword — refused.`);
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Run a catalog query (read-only) and return its rows. The result is bound to a `{{name}}`
155
+ * variable via `testData.bind(...)`, so the scenario asserts on it with `expect …` steps and
156
+ * path access (`{{name.count}}`, `{{name.first.col}}`, `{{name[2].col}}`).
157
+ */
158
+ async fetchQuery(label: string, sql: string, params: any[], datasource?: string): Promise<any[]> {
159
+ this.assertSelectOnly(label, sql);
160
+ const { engine, conf } = await this.engine(datasource);
161
+ return engine.query(this.sqlFor(conf, sql), params);
162
+ }
163
+ }
164
+
165
+ function desc(filter: Record<string, any>): string {
166
+ return Object.entries(filter).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(', ');
167
+ }
168
+
169
+ export const db = new DataSource();
@@ -5,6 +5,8 @@ import yaml from 'yaml';
5
5
 
6
6
  export class TestDataLoader {
7
7
  private data: Record<string, any>;
8
+ // Data-driven (@cases): when set (via withRow), get() prefers this row's columns.
9
+ private row?: Record<string, any>;
8
10
 
9
11
  private constructor(data: Record<string, any>) {
10
12
  this.data = data;
@@ -41,23 +43,56 @@ export class TestDataLoader {
41
43
  }
42
44
 
43
45
  get(key: string): string {
44
- // Captured/runtime vars (set() below) are stored under their literal — possibly
45
- // dotted — key (e.g. "cart.product_name"), so check the flat key first.
46
- let current: any = this.data[key];
47
- if (current === undefined || current === null) {
48
- // Fall back to nested navigation for YAML-structured keys (e.g. "cart.qty_two").
49
- current = this.data;
50
- for (const part of key.split('.')) {
51
- if (current == null || typeof current !== 'object') {
52
- throw new Error(`Test data key not found: ${key} (failed at '${part}')`);
53
- }
54
- current = current[part];
55
- }
56
- }
57
- if (current === undefined || current === null) {
46
+ const value = this.resolve(key);
47
+ if (value === undefined || value === null) {
58
48
  throw new Error(`Test data key not found: ${key}`);
59
49
  }
60
- return String(current);
50
+ return String(value);
51
+ }
52
+
53
+ /**
54
+ * Resolve a `{{...}}` reference to its raw value. Supports:
55
+ * - flat keys (incl. captured runtime vars stored under a literal dotted key);
56
+ * - `@cases` row columns (the current row wins);
57
+ * - structured paths over nested data AND `@query`-bound result arrays:
58
+ * `q.count` / `q.length` → number of rows
59
+ * `q.first.col` / `q.last.col` / `q[2].col` → a specific row's column
60
+ * `q.col` → shorthand for the first row's column
61
+ */
62
+ private resolve(key: string): any {
63
+ // 1. Exact flat key — captured vars (set()) live under a literal, possibly dotted, key.
64
+ if (this.row && key in this.row && this.row[key] !== undefined && this.row[key] !== null) {
65
+ return this.row[key];
66
+ }
67
+ if (this.data[key] !== undefined && this.data[key] !== null) {
68
+ return this.data[key];
69
+ }
70
+ // 2. Structured path: head from the row (cases) or shared data, then walk segments.
71
+ const tokens = String(key).replace(/\[(\d+)\]/g, '.$1').split('.');
72
+ let cur: any = (this.row && tokens[0] in this.row) ? this.row[tokens[0]] : this.data[tokens[0]];
73
+ for (let i = 1; i < tokens.length && cur != null; i++) cur = TestDataLoader.step(cur, tokens[i]);
74
+ return cur;
75
+ }
76
+
77
+ /** One navigation step over an array (with count/first/last/index/field-shorthand) or object. */
78
+ private static step(cur: any, token: string): any {
79
+ if (Array.isArray(cur)) {
80
+ if (token === 'count' || token === 'length') return cur.length;
81
+ if (token === 'first') return cur[0];
82
+ if (token === 'last') return cur[cur.length - 1];
83
+ if (/^\d+$/.test(token)) return cur[Number(token)];
84
+ return cur[0] == null ? undefined : cur[0][token]; // shorthand: first row's field
85
+ }
86
+ if (cur && typeof cur === 'object') return cur[token];
87
+ return undefined;
88
+ }
89
+
90
+ /**
91
+ * Bind a raw value (e.g. an `@query` result array) under `key` so `{{key.…}}` paths resolve.
92
+ * Unlike set(), the value is stored as-is (array/object), not coerced to a string.
93
+ */
94
+ bind(key: string, value: any): void {
95
+ this.data[key] = value;
61
96
  }
62
97
 
63
98
  /**
@@ -68,6 +103,32 @@ export class TestDataLoader {
68
103
  set(key: string, value: string): void {
69
104
  this.data[key] = value;
70
105
  }
106
+
107
+ /**
108
+ * Data-driven (@cases): return the list of rows at `key` (after env-overlay merge),
109
+ * each stamped with `__label` for the test title / report. Throws if missing or not a list.
110
+ */
111
+ cases(key: string): Array<Record<string, any>> {
112
+ const list = this.data[key];
113
+ if (!Array.isArray(list)) {
114
+ throw new Error(`@cases dataset "${key}" not found or not a list in test-data (got ${typeof list}).`);
115
+ }
116
+ return list.map((row: any, i: number) => {
117
+ const r: Record<string, any> = (row && typeof row === 'object' && !Array.isArray(row)) ? { ...row } : { value: row };
118
+ r.__label = String(r.case ?? r.name ?? r.label ?? `row ${i + 1}`);
119
+ return r;
120
+ });
121
+ }
122
+
123
+ /**
124
+ * Data-driven (@cases): a view whose get() prefers the given row's columns and falls
125
+ * back to the shared data. Used inside the per-row test() loop.
126
+ */
127
+ withRow(row: Record<string, any>): TestDataLoader {
128
+ const view = new TestDataLoader({ ...this.data }); // clone → per-row set() stays isolated
129
+ view.row = row;
130
+ return view;
131
+ }
71
132
  }
72
133
 
73
134
  function loadYamlSync(filePath: string): Record<string, any> | null {
@@ -2,7 +2,7 @@
2
2
 
3
3
  > Spec triển khai cho lần refactor lớn: chuyển từ **"Context Engine + Skill" (workflow tuyến tính)** sang **Orchestration + Harness Engine**.
4
4
  > Mục tiêu chính: **nâng chất lượng testcase** (đo & gate thay vì nhờ AI). Bất biến: `generate`, auto-fix selector, `delivery`, reports.
5
- > Tài liệu nền: `reports/sungen_direction_solution.md`, `reports/sungen_refactor_spec.md`, `reports/sungen_home_gherkin_viewpoint_coverage_review.md`.
5
+ > Tài liệu nền: `docs/spec/sungen_direction_solution.md`, `docs/spec/sungen_refactor_spec.md`.
6
6
 
7
7
  ---
8
8
 
@@ -153,7 +153,7 @@ Hết ngân sách repair (N vòng) mà vẫn FAIL:
153
153
  └─► KHÔNG lặp vô hạn. Ghi gap vào audit-report + đưa QA checkpoint (human-in-the-loop).
154
154
  ```
155
155
 
156
- **Quy tắc "khám phá thêm khi nông":** nếu assertion-depth sensor báo một viewpoint critical chỉ assert "see page/section" (vụ home: Cart/Detail/Filter), Orchestrator phải nhận diện đây là **cross-screen** → khuyến nghị chuyển sang **flow** (`add-flow`) và/hoặc cần **năng lực DSL capture biến** (xem `reports/sungen_refactor_spec.md` §5.4) — thay vì giả vờ pass bằng assertion nông.
156
+ **Quy tắc "khám phá thêm khi nông":** nếu assertion-depth sensor báo một viewpoint critical chỉ assert "see page/section" (vụ home: Cart/Detail/Filter), Orchestrator phải nhận diện đây là **cross-screen** → khuyến nghị chuyển sang **flow** (`add-flow`) và/hoặc cần **năng lực DSL capture biến** (xem `docs/spec/sungen_refactor_spec.md` §5.4) — thay vì giả vờ pass bằng assertion nông.
157
157
 
158
158
  ---
159
159
 
@@ -180,7 +180,7 @@ Bốn sensor + một gate. Input: `.feature` (qua GherkinParser) + `test-viewpoi
180
180
  - Orchestrator đưa phản hồi này lại bước sinh. **Ngân sách N vòng** (mặc định 2–3, như vòng auto-fix selector). Hết ngân sách → báo gap, không lặp vô hạn.
181
181
 
182
182
  ### 5.4 QA Checkpoint
183
- - QA accept/reject/edit/add viewpoint. Mọi quyết định ghi `feedback.jsonl` (xem `reports/sungen_refactor_spec.md` §8).
183
+ - QA accept/reject/edit/add viewpoint. Mọi quyết định ghi `feedback.jsonl` (xem `docs/spec/sungen_refactor_spec.md` §8).
184
184
 
185
185
  ---
186
186
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sun-asterisk/sungen",
3
- "version": "3.0.1",
3
+ "version": "3.1.1",
4
4
  "description": "Deterministic E2E Test Compiler - Gherkin + Selectors → Playwright tests",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -82,7 +82,7 @@
82
82
  "dist",
83
83
  "bin",
84
84
  "src",
85
- "docs",
85
+ "docs/orchestration-spec.md",
86
86
  "README.md",
87
87
  "LICENSE"
88
88
  ]
@@ -38,11 +38,15 @@ export function registerChallengeCommand(program: Command): void {
38
38
  if (report.collectionClaimSingular.length) {
39
39
  for (const f of report.collectionClaimSingular) L(` ⚠ ${f.scenario}\n → ${f.suggestion}`);
40
40
  } else L(' ✓ none');
41
- L(' ② Coverageover-covered / shallow');
41
+ L(' ② Data-drivenscenarios that should be one `@cases` (collapse data-variants / cover EP classes)');
42
+ if (report.dataDriven.length) {
43
+ for (const f of report.dataDriven) L(` ⚠ ${f.scenario}\n → ${f.suggestion}`);
44
+ } else L(' ✓ none');
45
+ L(' ③ Coverage — over-covered / shallow');
42
46
  if (report.overCovered.length) for (const o2 of report.overCovered) L(` • ${o2.bucket}: ${o2.note}`);
43
47
  if (report.shallowThemes.length) L(` • shallow themes: ${report.shallowThemes.join(', ')}`);
44
48
  if (!report.overCovered.length && !report.shallowThemes.length) L(' ✓ balanced');
45
- L(' Novelty — prompts for the `sungen-challenge` agent (≤20% of official, no auto-merge)');
49
+ L(' Novelty — prompts for the `sungen-challenge` agent (≤20% of official, no auto-merge)');
46
50
  for (const p of report.noveltyPrompts) L(` • ${p}`);
47
51
  L(' ── Exploration readiness ──');
48
52
  for (const e of report.explorationReadiness) L(` • ${e}`);
@@ -200,11 +200,12 @@ function discoverLocaleVariants(cwd: string, target: DeliveryTarget): LocaleVari
200
200
  const prefix = `${target.featureBaseName}-test-result`;
201
201
  const variants: LocaleVariant[] = [];
202
202
 
203
- const basePath = path.join(genDir, `${prefix}.json`);
203
+ // Base variant: per-target result file if present, else fall back to the global
204
+ // test-results/results.json (what playwright.config writes by default) via resolveResultsPath.
204
205
  variants.push({
205
206
  locale: '',
206
207
  displayCode: DEFAULT_BASE_LOCALE.toUpperCase(),
207
- resultsPath: fs.existsSync(basePath) ? basePath : null,
208
+ resultsPath: resolveResultsPath(cwd, target),
208
209
  });
209
210
 
210
211
  if (fs.existsSync(genDir)) {