@sun-asterisk/sungen 3.2.14 → 3.2.16

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 (65) hide show
  1. package/dist/cli/commands/delivery.d.ts.map +1 -1
  2. package/dist/cli/commands/delivery.js +29 -50
  3. package/dist/cli/commands/delivery.js.map +1 -1
  4. package/dist/dashboard/snapshot-builder.d.ts.map +1 -1
  5. package/dist/dashboard/snapshot-builder.js +3 -46
  6. package/dist/dashboard/snapshot-builder.js.map +1 -1
  7. package/dist/exporters/locale-variants.d.ts +38 -0
  8. package/dist/exporters/locale-variants.d.ts.map +1 -0
  9. package/dist/exporters/locale-variants.js +159 -0
  10. package/dist/exporters/locale-variants.js.map +1 -0
  11. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/table-match-data.hbs +3 -11
  12. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/table-row-exists.hbs +11 -1
  13. package/dist/generators/test-generator/code-generator.js +1 -1
  14. package/dist/generators/test-generator/code-generator.js.map +1 -1
  15. package/dist/generators/test-generator/diagnostics.d.ts +4 -1
  16. package/dist/generators/test-generator/diagnostics.d.ts.map +1 -1
  17. package/dist/generators/test-generator/diagnostics.js +3 -0
  18. package/dist/generators/test-generator/diagnostics.js.map +1 -1
  19. package/dist/generators/test-generator/step-mapper.d.ts +6 -0
  20. package/dist/generators/test-generator/step-mapper.d.ts.map +1 -1
  21. package/dist/generators/test-generator/step-mapper.js +41 -2
  22. package/dist/generators/test-generator/step-mapper.js.map +1 -1
  23. package/dist/harness/annotation-overrides.d.ts +4 -1
  24. package/dist/harness/annotation-overrides.d.ts.map +1 -1
  25. package/dist/harness/annotation-overrides.js +5 -2
  26. package/dist/harness/annotation-overrides.js.map +1 -1
  27. package/dist/harness/audit.d.ts.map +1 -1
  28. package/dist/harness/audit.js +6 -1
  29. package/dist/harness/audit.js.map +1 -1
  30. package/dist/harness/capability.d.ts +25 -0
  31. package/dist/harness/capability.d.ts.map +1 -1
  32. package/dist/harness/capability.js +44 -2
  33. package/dist/harness/capability.js.map +1 -1
  34. package/dist/harness/quality-gates.d.ts.map +1 -1
  35. package/dist/harness/quality-gates.js +5 -0
  36. package/dist/harness/quality-gates.js.map +1 -1
  37. package/dist/orchestrator/templates/ai-src/commands/create-test.md +1 -1
  38. package/dist/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +7 -1
  39. package/dist/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +1 -0
  40. package/dist/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +8 -0
  41. package/dist/orchestrator/templates/ai-src/skills/sungen-harness-audit/SKILL.md +1 -1
  42. package/dist/orchestrator/templates/ai-src/skills/sungen-locale/SKILL.md +8 -0
  43. package/dist/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +1 -0
  44. package/dist/orchestrator/templates/specs-test-data.ts +13 -4
  45. package/package.json +4 -4
  46. package/src/cli/commands/delivery.ts +32 -70
  47. package/src/dashboard/snapshot-builder.ts +11 -61
  48. package/src/exporters/locale-variants.ts +157 -0
  49. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/table-match-data.hbs +3 -11
  50. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/table-row-exists.hbs +11 -1
  51. package/src/generators/test-generator/code-generator.ts +1 -1
  52. package/src/generators/test-generator/diagnostics.ts +4 -1
  53. package/src/generators/test-generator/step-mapper.ts +43 -2
  54. package/src/harness/annotation-overrides.ts +5 -2
  55. package/src/harness/audit.ts +7 -2
  56. package/src/harness/capability.ts +53 -3
  57. package/src/harness/quality-gates.ts +4 -0
  58. package/src/orchestrator/templates/ai-src/commands/create-test.md +1 -1
  59. package/src/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +7 -1
  60. package/src/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +1 -0
  61. package/src/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +8 -0
  62. package/src/orchestrator/templates/ai-src/skills/sungen-harness-audit/SKILL.md +1 -1
  63. package/src/orchestrator/templates/ai-src/skills/sungen-locale/SKILL.md +8 -0
  64. package/src/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +1 -0
  65. package/src/orchestrator/templates/specs-test-data.ts +13 -4
@@ -13,6 +13,7 @@ import { parseSpecFile } from '../../exporters/spec-parser';
13
13
  import { loadTestData } from '../../exporters/test-data-resolver';
14
14
  import { loadSelectorKeyMap } from '../../exporters/selector-key-resolver';
15
15
  import { loadPlaywrightReport, resolveResultsPath as resolveResultsPathShared } from '../../exporters/playwright-report-parser';
16
+ import { discoverLocaleVariants } from '../../exporters/locale-variants';
16
17
  import { mergeFeatureAndSpec } from '../../exporters/scenario-merger';
17
18
  import {
18
19
  buildTestCaseRows,
@@ -167,70 +168,11 @@ function resolveResultsPath(cwd: string, target: DeliveryTarget): string | null
167
168
  return resolveResultsPathShared(cwd, generatedDir(cwd, target), target.featureBaseName);
168
169
  }
169
170
 
170
- /**
171
- * A single locale variant of a delivery target.
172
- *
173
- * `locale === ''` denotes the base locale (file `<name>-test-result.json`,
174
- * test-data without overlay). Non-empty values come from
175
- * `<name>-test-result.<locale>.json` files found in the generated directory.
176
- *
177
- * `displayCode` is what we show in sheet names — `BASE_LOCALE.toUpperCase()`
178
- * for base, the locale code uppercased otherwise (e.g. `EN`, `JA`, `EN-US`).
179
- */
180
- interface LocaleVariant {
181
- locale: string; // '' for base, 'en' / 'ja' / 'staging-ja' for variants
182
- displayCode: string; // 'VI' / 'EN' / 'JA' — used in sheet name
183
- resultsPath: string | null;
184
- }
185
-
186
- /**
187
- * Base locale assumed when no `SUNGEN_ENV` and no explicit override.
188
- * Configurable later via `qa/.sungen-config.yaml` or similar — currently
189
- * hardcoded since every existing sungen project uses Vietnamese as the
190
- * source-of-truth `test-data/<feature>.yaml`.
191
- */
192
- const DEFAULT_BASE_LOCALE = 'vi';
193
-
194
- /**
195
- * Discover every locale variant available for a feature by scanning the
196
- * generated directory for `<featureBaseName>-test-result*.json` files.
197
- *
198
- * Result always includes the base (locale = '') as the first entry — even if
199
- * `<name>-test-result.json` doesn't exist, callers still need somewhere to
200
- * fall back to (Pending status). Variants follow alphabetically by locale
201
- * code, so the sheet order is deterministic across runs.
202
- */
203
- function discoverLocaleVariants(cwd: string, target: DeliveryTarget): LocaleVariant[] {
204
- const genDir = generatedDir(cwd, target);
205
- const prefix = `${target.featureBaseName}-test-result`;
206
- const variants: LocaleVariant[] = [];
207
-
208
- // Base variant: per-target result file if present, else fall back to the global
209
- // test-results/results.json (what playwright.config writes by default) via resolveResultsPath.
210
- variants.push({
211
- locale: '',
212
- displayCode: DEFAULT_BASE_LOCALE.toUpperCase(),
213
- resultsPath: resolveResultsPath(cwd, target),
214
- });
215
-
216
- if (fs.existsSync(genDir)) {
217
- const localeFiles = fs.readdirSync(genDir)
218
- .filter((f) => f.startsWith(`${prefix}.`) && f.endsWith('.json') && f !== `${prefix}.json`)
219
- .sort();
220
- for (const f of localeFiles) {
221
- // Strip prefix + '.' on the left, '.json' on the right → locale code.
222
- const locale = f.slice(prefix.length + 1, -'.json'.length);
223
- if (!locale) continue;
224
- variants.push({
225
- locale,
226
- displayCode: locale.toUpperCase(),
227
- resultsPath: path.join(genDir, f),
228
- });
229
- }
230
- }
231
-
232
- return variants;
233
- }
171
+ // Locale/env variant discovery is SHARED with the dashboard snapshot builder —
172
+ // see exporters/locale-variants.ts for the classification rules (declared
173
+ // locales via qa/capabilities.yaml, env pairing, mobile OS exemption). The two
174
+ // surfaces previously hand-mirrored this logic and drifted into the
175
+ // fake-locale bug (any env-suffixed result file became a "locale" sheet).
234
176
 
235
177
  function resolveTestDataPathForTarget(cwd: string, target: DeliveryTarget): string {
236
178
  return path.join(qaDir(cwd, target), 'test-data', `${target.featureBaseName}.yaml`);
@@ -522,7 +464,17 @@ async function exportTarget(
522
464
  return buildSummary(label, rows, path.relative(cwd, csvPath));
523
465
  }
524
466
 
525
- const variants = discoverLocaleVariants(cwd, target);
467
+ const { variants, ignored } = discoverLocaleVariants(
468
+ cwd, generatedDir(cwd, target), target.featureBaseName,
469
+ () => resolveResultsPath(cwd, target),
470
+ );
471
+ if (ignored.length > 0) {
472
+ const env = process.env.SUNGEN_ENV;
473
+ log(` ${COLOR.gray}Ignored result file(s) from other environments: ${ignored.join(', ')}${COLOR.reset}`);
474
+ for (const suffix of ignored) {
475
+ log(` ${COLOR.cyan}→ sungen delivery ${target.screen} --env ${suffix}${COLOR.reset}${env ? '' : ` ${COLOR.gray}(this run uses the base results only)${COLOR.reset}`}`);
476
+ }
477
+ }
526
478
 
527
479
  // Mobile: emit ONE deliverable per OS (csv + xlsx), each from its own
528
480
  // `<feature>-test-result.<os>.json`. Android/iOS are SEPARATE platform runs,
@@ -542,7 +494,11 @@ async function exportTarget(
542
494
  featureName: feature.featureName,
543
495
  featurePath: resolvePlatformAppId(featurePath, variant.locale), // OS-precise app id
544
496
  merged,
545
- testData: loadTestData(testDataFile, variant.locale || null),
497
+ // Overlay = the after-OS tail (what SUNGEN_ENV was during that run):
498
+ // `android.ja` merged <name>.ja.yaml at runtime — the old code passed the
499
+ // full `android.ja` suffix, looked up a nonexistent overlay, and silently
500
+ // showed base-language values in the Test Data column.
501
+ testData: loadTestData(testDataFile, variant.overlay ?? null),
546
502
  results: variant.resultsPath ? loadPlaywrightReport(variant.resultsPath) : null,
547
503
  env,
548
504
  selectorKeyMap,
@@ -579,9 +535,11 @@ async function exportTarget(
579
535
  const multiLocale = variants.length > 1;
580
536
 
581
537
  for (const variant of variants) {
582
- // For the base variant the overlay merge is skipped (`locale: null`);
583
- // for locale variants the matching `<feature>.<locale>.yaml` is merged.
584
- const variantTestData = loadTestData(testDataFile, variant.locale || null);
538
+ // Overlay parity with the RUNTIME: merge exactly what TestDataLoader merged
539
+ // during that run (the run's SUNGEN_ENV) the base variant under --env dev
540
+ // merges <feature>.dev.yaml, a ja variant merges <feature>.ja.yaml, plain
541
+ // base merges nothing.
542
+ const variantTestData = loadTestData(testDataFile, variant.overlay ?? null);
585
543
  const variantResults = variant.resultsPath ? loadPlaywrightReport(variant.resultsPath) : null;
586
544
  const variantRows = buildTestCaseRows({
587
545
  screen: label,
@@ -713,8 +671,12 @@ export function registerDeliveryCommand(program: Command): void {
713
671
  .argument('[names...]', 'Specific screen or flow names. Omit to process all.')
714
672
  .option('--skip-preflight', 'Skip pre-flight checks (not recommended)')
715
673
  .option('--continue-on-missing', 'Skip targets with blocking misses instead of aborting')
716
- .action(async (names: string[], options: { skipPreflight?: boolean; continueOnMissing?: boolean }) => {
674
+ .option('--env <env>', 'Environment/locale of the run to deliver (sets SUNGEN_ENV; e.g. dev, staging, ja, dev-ja)')
675
+ .action(async (names: string[], options: { skipPreflight?: boolean; continueOnMissing?: boolean; env?: string }) => {
717
676
  try {
677
+ // Same effect as running with SUNGEN_ENV=<env> — result-file resolution,
678
+ // locale pairing, and test-data overlays all key off this one string.
679
+ if (options.env) process.env.SUNGEN_ENV = options.env;
718
680
  const cwd = process.cwd();
719
681
 
720
682
  // 1. Scope detection — each positional name expands into one target
@@ -25,6 +25,7 @@ import { buildScreenSnapshot } from '../exporters/json-exporter';
25
25
  import { loadApiCatalog } from '../exporters/api-catalog-loader';
26
26
  import { buildApiDetailRows } from '../exporters/xlsx-exporter';
27
27
  import { resolvePlatformAppId } from '../exporters/mobile-app-id';
28
+ import { discoverLocaleVariants, LocaleVariant } from '../exporters/locale-variants';
28
29
  import { getPackageVersion } from '../exporters/package-info';
29
30
  import { ApiCatalogEntry, EnvironmentInfo } from '../exporters/types';
30
31
  import {
@@ -38,11 +39,10 @@ import {
38
39
  SNAPSHOT_VERSION,
39
40
  } from './types';
40
41
 
41
- /**
42
- * Locale code displayed when a screen has no overlay variants — the base
43
- * test-data file alone. Matches the delivery CLI's `DEFAULT_BASE_LOCALE`.
44
- */
45
- const DEFAULT_BASE_LOCALE = 'vi';
42
+ // Locale/env variant discovery is SHARED with the delivery CLI — see
43
+ // exporters/locale-variants.ts (declared locales, env pairing, mobile OS
44
+ // exemption). Previously a hand-mirrored copy that drifted into the
45
+ // fake-locale bug.
46
46
 
47
47
  export interface DashboardTarget {
48
48
  name: string;
@@ -217,13 +217,16 @@ function buildOneFeature(
217
217
  // Discover locale variants by scanning <feature>-test-result*.json files.
218
218
  // Always includes base ('') as the first entry, even if its results file
219
219
  // is missing — UI still needs to render the feature.
220
- const variants = discoverLocaleVariants(cwd, genBase, featureBaseName);
220
+ const { variants } = discoverLocaleVariants(
221
+ cwd, genBase, featureBaseName,
222
+ () => resolveResultsPath(cwd, genBase, featureBaseName),
223
+ );
221
224
 
222
225
  // Build one per-variant ScreenSnapshot (test-data overlay + results + OS-precise
223
226
  // env column). Reused by both the web/i18n (locale-axis) and mobile (platform-axis) paths.
224
- const buildVariant = (variant: FeatureLocaleVariant) => {
227
+ const buildVariant = (variant: LocaleVariant) => {
225
228
  const testData = fs.existsSync(testDataFile)
226
- ? loadTestData(testDataFile, variant.locale || null)
229
+ ? loadTestData(testDataFile, variant.overlay ?? null)
227
230
  : {};
228
231
  const results = variant.resultsPath ? loadPlaywrightReport(variant.resultsPath) : null;
229
232
  return buildScreenSnapshot({
@@ -341,59 +344,6 @@ function buildOneFeature(
341
344
  }];
342
345
  }
343
346
 
344
- /**
345
- * A single locale variant of a feature.
346
- * `locale === ''` denotes the base (file `<name>-test-result.json`,
347
- * test-data without overlay merge). Variants come from
348
- * `<name>-test-result.<locale>.json` filenames.
349
- */
350
- interface FeatureLocaleVariant {
351
- locale: string;
352
- displayCode: string;
353
- resultsPath: string | null;
354
- }
355
-
356
- /**
357
- * Scan the generated directory for `<basename>-test-result*.json` files
358
- * and infer locale codes. The base entry is always returned first; locale
359
- * variants follow alphabetically so sheet/tab order stays deterministic.
360
- *
361
- * Mirrors `discoverLocaleVariants` in `src/cli/commands/delivery.ts` so the
362
- * dashboard and the CSV/XLSX delivery agree on what locales exist for a
363
- * given feature.
364
- */
365
- function discoverLocaleVariants(cwd: string, genDir: string, featureBaseName: string): FeatureLocaleVariant[] {
366
- const prefix = `${featureBaseName}-test-result`;
367
- const variants: FeatureLocaleVariant[] = [];
368
-
369
- // Base variant resolves through the SHARED candidate chain (per-feature file
370
- // first, then the global test-results/results.json playwright writes by
371
- // default) — the same resolution delivery.ts uses. Without the fallback the
372
- // dashboard showed every scenario Pending on projects that only have the
373
- // global results file, while delivery correctly reported Passed.
374
- variants.push({
375
- locale: '',
376
- displayCode: DEFAULT_BASE_LOCALE.toUpperCase(),
377
- resultsPath: resolveResultsPath(cwd, genDir, featureBaseName),
378
- });
379
-
380
- if (fs.existsSync(genDir)) {
381
- const localeFiles = fs.readdirSync(genDir)
382
- .filter((f) => f.startsWith(`${prefix}.`) && f.endsWith('.json') && f !== `${prefix}.json`)
383
- .sort();
384
- for (const f of localeFiles) {
385
- const locale = f.slice(prefix.length + 1, -'.json'.length);
386
- if (!locale) continue;
387
- variants.push({
388
- locale,
389
- displayCode: locale.toUpperCase(),
390
- resultsPath: path.join(genDir, f),
391
- });
392
- }
393
- }
394
-
395
- return variants;
396
- }
397
347
 
398
348
  /**
399
349
  * Combine per-feature summaries into a single screen-level rollup.
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Locale/env classification for `<feature>-test-result.<suffix>.json` files —
3
+ * SHARED by the delivery CLI and the dashboard snapshot builder (previously two
4
+ * hand-mirrored copies that had already drifted into the fake-locale bug).
5
+ *
6
+ * Field report (3.2.14): every result-file suffix was treated as a LOCALE. A
7
+ * team running `SUNGEN_ENV=dev npx playwright test` (the standard .env.qa.dev
8
+ * convention) then `sungen delivery` without an env got a fabricated two-locale
9
+ * deliverable: a "VI Auto" sheet (hardcoded base label, all Pending, pointing
10
+ * at a result file that doesn't exist) next to a "DEV Auto" sheet.
11
+ *
12
+ * The file mechanism cannot distinguish an env overlay (`login.dev.yaml`,
13
+ * per-env credentials) from a locale overlay (`login.ja.yaml`, translated
14
+ * text) — they are deliberately the same mechanism keyed by SUNGEN_ENV. So
15
+ * locales are DECLARED, not guessed:
16
+ *
17
+ * # qa/capabilities.yaml
18
+ * locales: [ja, en] # non-base locales (written by /sungen:locale)
19
+ * base_locale: en # language of the base test-data (default: en)
20
+ *
21
+ * Suffix classification (first match wins), with E = the delivery env
22
+ * (SUNGEN_ENV / --env, possibly empty):
23
+ * 1. ^(android|ios)(.|$) → OS run (mobile per-OS branch consumes)
24
+ * 2. suffix === E → the base variant's own file (not a variant)
25
+ * 3. E === '' && suffix ∈ locales → locale variant on the base env
26
+ * 4. E !== '' && suffix === `${E}-<L>`, L ∈ locales → locale variant on env E
27
+ * 5. anything else → env run — EXCLUDED from variants and
28
+ * reported in `ignored` so the CLI can hint "run with --env <suffix>".
29
+ *
30
+ * Env pairing (rules 2–4) means a delivery only ever mixes results from ONE
31
+ * environment. Running with `--env ja` (an env named exactly like a declared
32
+ * locale) is interpreted as "deliver that locale's run" — the documented
33
+ * SUNGEN_ENV=<locale> convention, not a collision.
34
+ */
35
+ import * as fs from 'fs';
36
+ import * as path from 'path';
37
+ import { parse as parseYaml } from 'yaml';
38
+
39
+ export interface LocaleVariant {
40
+ /** Result-file suffix as found on disk ('' = base). */
41
+ locale: string;
42
+ /** Sheet-name label (used only in multi-locale mode). */
43
+ displayCode: string;
44
+ resultsPath: string | null;
45
+ /**
46
+ * Test-data overlay code to merge for this variant's Test Data column —
47
+ * mirrors what the RUNTIME merged (TestDataLoader uses SUNGEN_ENV): the full
48
+ * suffix on web, the after-OS tail on mobile (`android.ja` ran with
49
+ * SUNGEN_ENV=ja). `null` = base data only. loadTestData no-ops on a missing
50
+ * overlay file, so passing an env code whose overlay doesn't exist is safe.
51
+ */
52
+ overlay: string | null;
53
+ }
54
+
55
+ export interface LocaleDiscovery {
56
+ variants: LocaleVariant[];
57
+ /** Env-run suffixes with result files, excluded from this delivery (hint material). */
58
+ ignored: string[];
59
+ }
60
+
61
+ export interface LocaleConfig {
62
+ baseLocale: string;
63
+ locales: string[];
64
+ }
65
+
66
+ export const DEFAULT_BASE_LOCALE = 'en';
67
+
68
+ /** Read the i18n declaration from qa/capabilities.yaml (absent → no locales, base 'en'). */
69
+ export function readLocaleConfig(cwd: string): LocaleConfig {
70
+ try {
71
+ const p = path.join(cwd, 'qa', 'capabilities.yaml');
72
+ if (fs.existsSync(p)) {
73
+ const parsed = parseYaml(fs.readFileSync(p, 'utf-8')) as
74
+ { locales?: unknown; base_locale?: unknown } | null;
75
+ const locales = Array.isArray(parsed?.locales)
76
+ ? parsed!.locales!.filter((l): l is string => typeof l === 'string' && l.length > 0)
77
+ : [];
78
+ const baseLocale = typeof parsed?.base_locale === 'string' && parsed.base_locale
79
+ ? parsed.base_locale
80
+ : DEFAULT_BASE_LOCALE;
81
+ return { baseLocale, locales };
82
+ }
83
+ } catch { /* malformed yaml → behave as undeclared */ }
84
+ return { baseLocale: DEFAULT_BASE_LOCALE, locales: [] };
85
+ }
86
+
87
+ const OS_SUFFIX = /^(android|ios)(\.|$)/;
88
+
89
+ /** The after-OS tail of a mobile suffix: 'android.ja' → 'ja', 'ios' → ''. */
90
+ export function osSuffixTail(suffix: string): string {
91
+ return suffix.replace(OS_SUFFIX, '');
92
+ }
93
+
94
+ /**
95
+ * Discover the variants of a feature for ONE delivery run.
96
+ *
97
+ * `resolveBaseResultsPath` supplies the base variant's results (delivery and
98
+ * the dashboard share the same env-aware candidate chain via
99
+ * playwright-report-parser.resolveResultsPath — passed in to avoid a cycle).
100
+ */
101
+ export function discoverLocaleVariants(
102
+ cwd: string,
103
+ genDir: string,
104
+ featureBaseName: string,
105
+ resolveBaseResultsPath: () => string | null,
106
+ ): LocaleDiscovery {
107
+ const cfg = readLocaleConfig(cwd);
108
+ const env = process.env.SUNGEN_ENV || '';
109
+ const prefix = `${featureBaseName}-test-result`;
110
+
111
+ const variants: LocaleVariant[] = [{
112
+ locale: '',
113
+ // With --env <locale> (the SUNGEN_ENV=<locale> convention) the base IS that
114
+ // locale's run; otherwise the base carries the declared base language.
115
+ displayCode: (cfg.locales.includes(env) ? env : cfg.baseLocale).toUpperCase(),
116
+ resultsPath: resolveBaseResultsPath(),
117
+ overlay: env || null,
118
+ }];
119
+ const ignored: string[] = [];
120
+
121
+ if (fs.existsSync(genDir)) {
122
+ const suffixes = fs.readdirSync(genDir)
123
+ .filter((f) => f.startsWith(`${prefix}.`) && f.endsWith('.json') && f !== `${prefix}.json`)
124
+ .map((f) => f.slice(prefix.length + 1, -'.json'.length))
125
+ .filter((s) => s.length > 0)
126
+ .sort();
127
+ for (const suffix of suffixes) {
128
+ if (OS_SUFFIX.test(suffix)) {
129
+ // Mobile per-OS run — kept unconditionally; the mobile branch consumes these.
130
+ // Overlay = the tail (what SUNGEN_ENV was during that run), never the OS itself.
131
+ variants.push({
132
+ locale: suffix,
133
+ displayCode: suffix.toUpperCase(),
134
+ resultsPath: path.join(genDir, `${prefix}.${suffix}.json`),
135
+ overlay: osSuffixTail(suffix) || null,
136
+ });
137
+ continue;
138
+ }
139
+ if (suffix === env) continue; // the base variant's own file
140
+ const isLocaleOfThisEnv = env === ''
141
+ ? cfg.locales.includes(suffix)
142
+ : suffix.startsWith(`${env}-`) && cfg.locales.includes(suffix.slice(env.length + 1));
143
+ if (isLocaleOfThisEnv) {
144
+ variants.push({
145
+ locale: suffix,
146
+ displayCode: suffix.toUpperCase(),
147
+ resultsPath: path.join(genDir, `${prefix}.${suffix}.json`),
148
+ overlay: suffix,
149
+ });
150
+ } else {
151
+ ignored.push(suffix);
152
+ }
153
+ }
154
+ }
155
+
156
+ return { variants, ignored };
157
+ }
@@ -1,15 +1,7 @@
1
- {{~#if isGiven~}}
2
1
  {
3
- const rows = {{> locator}}.locator('tbody').getByRole('row', { includeHidden: true }).filter({ visible: true });
2
+ const table = {{> locator}};
3
+ const rows = table.locator('tbody').getByRole('row', { includeHidden: true }).filter({ visible: true });
4
4
  {{#each assertions}}
5
- {{this}}
5
+ {{{this}}}
6
6
  {{/each~}}
7
7
  }
8
- {{~else~}}
9
- {
10
- const rows = {{> locator}}.locator('tbody').getByRole('row', { includeHidden: true }).filter({ visible: true });
11
- {{#each assertions}}
12
- {{this}}
13
- {{/each~}}
14
- }
15
- {{~/if}}
@@ -1,7 +1,17 @@
1
+ {{~#if rowSelector}}
2
+ {{~#if isGiven}}
3
+ const tableRow = {{> locator}}.locator('{{rowSelector}}');
4
+ await tableRow.filter({ hasText: '{{escapeQuotes filterValue}}' }).waitFor();
5
+ {{~else}}
6
+ const tableRow = {{> locator}}.locator('{{rowSelector}}');
7
+ await expect(tableRow).toContainText('{{escapeQuotes filterValue}}');
8
+ {{~/if}}
9
+ {{~else}}
1
10
  {{~#if isGiven}}
2
11
  const tableRow = {{> locator}}.getByRole('row', { includeHidden: true }).filter({ hasText: '{{escapeQuotes filterValue}}' }).filter({ visible: true });
3
12
  await tableRow.waitFor();
4
13
  {{~else}}
5
14
  const tableRow = {{> locator}}.getByRole('row', { includeHidden: true }).filter({ hasText: '{{escapeQuotes filterValue}}' }).filter({ visible: true });
6
15
  await expect(tableRow).toBeVisible();
7
- {{~/if}}
16
+ {{~/if}}
17
+ {{~/if}}
@@ -908,7 +908,7 @@ export class CodeGenerator {
908
908
  // them. Rewrite those to the per-row `rowData` view — each row then fires its own `@api` call
909
909
  // with that row's input and asserts that row's expected status/body (the success/failure matrix).
910
910
  // `testData.cases()/withRow()` (the global loader, no `.get/.bind/.set`) is intentionally left alone.
911
- return transformToRuntimeData(rendered, 'rowData').replace(/\btestData\.(get|bind|set)\(/g, 'rowData.$1(');
911
+ return transformToRuntimeData(rendered, 'rowData').replace(/\btestData\.(get|bind|set|raw)\(/g, 'rowData.$1(');
912
912
  }
913
913
  return rendered;
914
914
  }
@@ -17,12 +17,15 @@
17
17
  * action line + ≥1 labeled Observable:/Oracle:/Expected: line
18
18
  * SG-W009 — test-data references a `${QA_*}` secret that is declared in neither .env.qa
19
19
  * nor .env.qa.example — the test will throw "not set" the moment the key is used
20
+ * SG-W010 — a row step's [Ref] looks POSITIONAL (First/Last/Top/…) but has no selectors.yaml
21
+ * entry: the label is display-only, so the step checks existence anywhere in the
22
+ * table — an ordering rule would pass even when the row moved
20
23
  *
21
24
  * Diagnostics never block generation by themselves; `sungen generate --strict`
22
25
  * turns any collected diagnostic into a non-zero exit.
23
26
  */
24
27
 
25
- export type DiagnosticCode = 'SG-W001' | 'SG-W002' | 'SG-W003' | 'SG-W005' | 'SG-W006' | 'SG-W007' | 'SG-W008' | 'SG-W009';
28
+ export type DiagnosticCode = 'SG-W001' | 'SG-W002' | 'SG-W003' | 'SG-W005' | 'SG-W006' | 'SG-W007' | 'SG-W008' | 'SG-W009' | 'SG-W010';
26
29
 
27
30
  export interface Diagnostic {
28
31
  code: DiagnosticCode;
@@ -37,6 +37,10 @@ export class StepMapper {
37
37
  /** Row scope: when active, column assertions are scoped to this row */
38
38
  private inRowScope: boolean = false;
39
39
  private rowScopeTable: string = '';
40
+ /** Count of row-scope-entering steps in the current scenario + whether the shared
41
+ * `tableRow` binding has been emitted (let-once + reassign when there are ≥2). */
42
+ private tableRowScopeSteps: number = 0;
43
+ private tableRowDeclared: boolean = false;
40
44
  /** SG-W007: an action step already ran in this scenario (alert handlers registered after it are too late) */
41
45
  private sawActionStep: boolean = false;
42
46
  /** Compile-time warning channel (SG-W001/W002); optional — absent means silent legacy behavior */
@@ -109,6 +113,12 @@ export class StepMapper {
109
113
  /**
110
114
  * Set scenario context for path variable resolution
111
115
  */
116
+ /** A `see [Ref] row in [Table] table with {{v}}` step — declares `tableRow` and enters row scope. */
117
+ private isRowScopeEntryStep(step: ParsedStep): boolean {
118
+ return step.elementType === 'row' && /\bin\b.*\btable\b/i.test(step.text) &&
119
+ /\bwith\b/i.test(step.text) && !/\bis\s+hidden\b/i.test(step.text);
120
+ }
121
+
112
122
  setScenarioContext(steps: ParsedStep[]): void {
113
123
  this.currentScenarioSteps = steps;
114
124
  // Reset dialog and frame scope at the start of each new scenario
@@ -116,6 +126,16 @@ export class StepMapper {
116
126
  this.inFrameScope = false;
117
127
  this.inRowScope = false;
118
128
  this.rowScopeTable = '';
129
+ // Row-scope steps declare `const tableRow` at FUNCTION scope on purpose — the
130
+ // following `[Col] column with {{v}}` steps reference it, so the declaration
131
+ // can't be block-wrapped like the self-contained row ACTIONS are. But two or
132
+ // more row steps in one scenario then redeclare the same const → the whole
133
+ // spec fails to parse ("Identifier 'tableRow' has already been declared").
134
+ // Count them up front: with ≥2 the first becomes `let` and the rest plain
135
+ // reassignments (which also correctly REBINDS the row scope); a single
136
+ // occurrence keeps `const` so existing single-row specs stay byte-identical.
137
+ this.tableRowScopeSteps = steps.filter((s) => this.isRowScopeEntryStep(s)).length;
138
+ this.tableRowDeclared = false;
119
139
  this.sawActionStep = false;
120
140
  this.lastPrimaryKeyword = 'Given';
121
141
  this.dataResolver.clearCaptured(); // captured vars are scenario-scoped
@@ -245,13 +265,34 @@ export class StepMapper {
245
265
 
246
266
  if (mappedStep) {
247
267
  // Enter row scope when a row-in-table step is matched
248
- if (step.elementType === 'row' && /\bin\b.*\btable\b/i.test(step.text) &&
249
- /\bwith\b/i.test(step.text) && !/\bis\s+hidden\b/i.test(step.text)) {
268
+ if (this.isRowScopeEntryStep(step)) {
250
269
  const brackets = step.text.match(/\[([^\]]+)\]/g) || [];
251
270
  this.inRowScope = true;
252
271
  this.rowScopeTable = brackets.length >= 2
253
272
  ? brackets[1].replace(/[\[\]]/g, '')
254
273
  : '';
274
+ // ≥2 row steps in this scenario: turn the duplicate `const tableRow`
275
+ // declarations into one `let` + reassignments (see setScenarioContext).
276
+ if (this.tableRowScopeSteps > 1 && mappedStep.code && mappedStep.code.includes('const tableRow')) {
277
+ if (this.tableRowDeclared) {
278
+ mappedStep.code = mappedStep.code.replace('const tableRow', 'tableRow');
279
+ } else {
280
+ mappedStep.code = mappedStep.code.replace('const tableRow', 'let tableRow');
281
+ this.tableRowDeclared = true;
282
+ }
283
+ }
284
+ // SG-W010 — a POSITIONAL-looking row label ([First X Row], [Last …]) without a
285
+ // selectors.yaml entry is display-only: the step degrades to an anywhere-in-the-
286
+ // table existence check and an ordering rule silently never fails. A resolved
287
+ // entry compiles to a positioned toContainText — its absence is detectable here.
288
+ const rowLabel = (brackets[0] || '').replace(/[\[\]]/g, '');
289
+ if (this.diagnostics && rowLabel &&
290
+ /\b(first|last|top|bottom|nth|\d+(st|nd|rd|th)?)\b/i.test(rowLabel) &&
291
+ mappedStep.code && !mappedStep.code.includes('toContainText')) {
292
+ this.diagnostics.add('SG-W010',
293
+ `row label "[${rowLabel}]" looks positional but has no selectors.yaml entry — it is a display label only, so this step checks existence ANYWHERE in the table (an ordering rule would pass even when the row moved). Declare it to position the row, e.g. "${rowLabel.toLowerCase()}:\\n type: locator\\n value: 'tbody tr:first-child'"`,
294
+ { feature: this.featureName, step: step.text });
295
+ }
255
296
  }
256
297
 
257
298
  if (this.verbose) {
@@ -2,10 +2,13 @@
2
2
  * Shared annotation-override grammar for precondition annotations (`@query`/`@api`).
3
3
  *
4
4
  * Parses `name(a={{x}},b="lit",c=3)` overrides into a map of JS expressions, e.g.
5
- * `{ a: "testData.get('x')", b: "\"lit\"", c: "3" }`. Used by the DB and API capability drivers'
5
+ * `{ a: "testData.raw('x')", b: "\"lit\"", c: "3" }`. Used by the DB and API capability drivers'
6
6
  * precondition codegen; lives in core so both drivers can share it. Gherkin tags carry no whitespace,
7
7
  * so values are single tokens — flows thread a prior response via a whole-value ref, e.g.
8
8
  * `@api:get_profile(token={{login.body.token}})`, with the auth scheme declared in the catalog header.
9
+ * A `{{ref}}` override binds into a typed sink (DB query param, JSON request body), so it resolves
10
+ * via `testData.raw()` (native type, still interpolates embedded refs) rather than `get()`
11
+ * (Gherkin-text stringify) — a numeric override otherwise reaches the DB/API as a JSON string.
9
12
  */
10
13
  export function parseQueryOverrides(raw?: string): Record<string, string> {
11
14
  const out: Record<string, string> = {};
@@ -18,7 +21,7 @@ export function parseQueryOverrides(raw?: string): Record<string, string> {
18
21
  if (!key) continue;
19
22
  const v = val.match(/^\{\{\s*([^}]+?)\s*\}\}$/);
20
23
  const q = val.match(/^["'](.*)["']$/);
21
- if (v) out[key] = `testData.get(${JSON.stringify(v[1])})`;
24
+ if (v) out[key] = `testData.raw(${JSON.stringify(v[1])})`;
22
25
  else if (q) out[key] = JSON.stringify(q[1]);
23
26
  else if (/^-?\d+(?:\.\d+)?$/.test(val)) out[key] = val;
24
27
  else out[key] = JSON.stringify(val);
@@ -17,7 +17,7 @@ import {
17
17
  } from './sensors';
18
18
  import { loadFlowScenarios } from './flow-check';
19
19
  import { manualReasonMismatches, MANUAL_REASONS, buildPlan } from './capability-plan';
20
- import { readCapabilities } from './capability';
20
+ import { readCapabilities, verificationScopeFindings } from './capability';
21
21
  import { readIntent, projectRootFromScreenDir, IntentProfile } from './intent';
22
22
  import { getProvenance, Provenance } from './provenance';
23
23
  import { specCoverage, SpecCoverageResult, parseSpecClauses } from './spec-coverage';
@@ -129,7 +129,8 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
129
129
  discoverAndRegisterCapabilities();
130
130
  // The active platform (web | mobile | …) activates its own capability for scoring + sensor routing.
131
131
  // `web` has no capability of its own → scoringCap stays the default `ui` (byte-identical).
132
- const platform = readCapabilities(projectRootFromScreenDir(screenDir)).platform;
132
+ const capProfile = readCapabilities(projectRootFromScreenDir(screenDir));
133
+ const platform = capProfile.platform;
133
134
  const defaultCap = capabilityRegistry.defaultCapabilityId();
134
135
  const scoringCapId = scoringCapabilityFor(catalogScreenName, defaultCap, platform);
135
136
  const scoringCap = scoringCapId ? capabilityRegistry.get(scoringCapId) : undefined;
@@ -223,6 +224,10 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
223
224
  for (const c of flowCredits) {
224
225
  findings.push(`COVERED-VIA-FLOW: critical theme "${c.theme}" is not covered on this screen but is deeply covered by flow "${c.flow}" — cross-screen depth correctly owned by the flow, so it is credited (not a screen gap). Verify with \`sungen flow-check\`.`);
225
226
  }
227
+ // Verification-scope (advisory): API/DB verification the project's recorded test type never asked for.
228
+ for (const v of verificationScopeFindings(featureText, fs.existsSync(viewpointPath) ? fs.readFileSync(viewpointPath, 'utf-8') : '', capProfile)) {
229
+ findings.push(`VERIFICATION-OUT-OF-SCOPE (${v.layer}): this unit ${v.message}`);
230
+ }
226
231
  for (const g of gate.gaps) {
227
232
  if (g.status === 'shallow') {
228
233
  findings.push(`GATE: critical theme "${g.theme}" is covered only by SHALLOW scenarios (no data assertion) → deepen with \`... with {{value}}\` / \`table ... with {{value}}\` (count @manual cross-screen too).`);