@sun-asterisk/sungen 3.2.34 → 3.2.35

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 (218) hide show
  1. package/dist/cli/commands/audit.d.ts.map +1 -1
  2. package/dist/cli/commands/audit.js +9 -4
  3. package/dist/cli/commands/audit.js.map +1 -1
  4. package/dist/cli/commands/capability.d.ts.map +1 -1
  5. package/dist/cli/commands/capability.js +5 -1
  6. package/dist/cli/commands/capability.js.map +1 -1
  7. package/dist/cli/commands/defect.d.ts +7 -0
  8. package/dist/cli/commands/defect.d.ts.map +1 -0
  9. package/dist/cli/commands/defect.js +68 -0
  10. package/dist/cli/commands/defect.js.map +1 -0
  11. package/dist/cli/commands/delivery.d.ts.map +1 -1
  12. package/dist/cli/commands/delivery.js +35 -7
  13. package/dist/cli/commands/delivery.js.map +1 -1
  14. package/dist/cli/commands/triage.d.ts +7 -0
  15. package/dist/cli/commands/triage.d.ts.map +1 -0
  16. package/dist/cli/commands/triage.js +98 -0
  17. package/dist/cli/commands/triage.js.map +1 -0
  18. package/dist/cli/index.js +4 -0
  19. package/dist/cli/index.js.map +1 -1
  20. package/dist/exporters/csv-exporter.d.ts +4 -0
  21. package/dist/exporters/csv-exporter.d.ts.map +1 -1
  22. package/dist/exporters/csv-exporter.js +1 -1
  23. package/dist/exporters/csv-exporter.js.map +1 -1
  24. package/dist/exporters/feature-parser.d.ts +8 -0
  25. package/dist/exporters/feature-parser.d.ts.map +1 -1
  26. package/dist/exporters/feature-parser.js +12 -0
  27. package/dist/exporters/feature-parser.js.map +1 -1
  28. package/dist/exporters/matrix/build.d.ts.map +1 -1
  29. package/dist/exporters/matrix/build.js +49 -10
  30. package/dist/exporters/matrix/build.js.map +1 -1
  31. package/dist/exporters/matrix/carry-manual-results.d.ts +19 -0
  32. package/dist/exporters/matrix/carry-manual-results.d.ts.map +1 -1
  33. package/dist/exporters/matrix/carry-manual-results.js +76 -6
  34. package/dist/exporters/matrix/carry-manual-results.js.map +1 -1
  35. package/dist/exporters/matrix/export.d.ts +5 -2
  36. package/dist/exporters/matrix/export.d.ts.map +1 -1
  37. package/dist/exporters/matrix/export.js +14 -3
  38. package/dist/exporters/matrix/export.js.map +1 -1
  39. package/dist/exporters/matrix/render-csv.js +1 -1
  40. package/dist/exporters/matrix/render-csv.js.map +1 -1
  41. package/dist/exporters/matrix/render-xlsx.d.ts.map +1 -1
  42. package/dist/exporters/matrix/render-xlsx.js +3 -0
  43. package/dist/exporters/matrix/render-xlsx.js.map +1 -1
  44. package/dist/exporters/matrix/types.d.ts +5 -0
  45. package/dist/exporters/matrix/types.d.ts.map +1 -1
  46. package/dist/exporters/matrix/types.js.map +1 -1
  47. package/dist/exporters/playwright-report-parser.d.ts.map +1 -1
  48. package/dist/exporters/playwright-report-parser.js +1 -0
  49. package/dist/exporters/playwright-report-parser.js.map +1 -1
  50. package/dist/exporters/scenario-merger.d.ts.map +1 -1
  51. package/dist/exporters/scenario-merger.js +2 -0
  52. package/dist/exporters/scenario-merger.js.map +1 -1
  53. package/dist/exporters/test-data-resolver.d.ts.map +1 -1
  54. package/dist/exporters/test-data-resolver.js +12 -2
  55. package/dist/exporters/test-data-resolver.js.map +1 -1
  56. package/dist/exporters/types.d.ts +5 -0
  57. package/dist/exporters/types.d.ts.map +1 -1
  58. package/dist/generators/gherkin-parser/index.d.ts.map +1 -1
  59. package/dist/generators/gherkin-parser/index.js +1 -0
  60. package/dist/generators/gherkin-parser/index.js.map +1 -1
  61. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/contain-text-assertion.hbs +2 -1
  62. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/expanded-assertion.hbs +2 -0
  63. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/date-picker-unsupported.hbs +5 -0
  64. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/fill-action.hbs +13 -1
  65. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/contain-text-assertion.hbs +9 -1
  66. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/expanded-assertion.hbs +12 -0
  67. package/dist/generators/test-generator/code-generator.js +4 -5
  68. package/dist/generators/test-generator/code-generator.js.map +1 -1
  69. package/dist/generators/test-generator/diagnostics.d.ts +9 -3
  70. package/dist/generators/test-generator/diagnostics.d.ts.map +1 -1
  71. package/dist/generators/test-generator/diagnostics.js +9 -3
  72. package/dist/generators/test-generator/diagnostics.js.map +1 -1
  73. package/dist/generators/test-generator/patterns/types.d.ts +1 -1
  74. package/dist/generators/test-generator/patterns/types.d.ts.map +1 -1
  75. package/dist/generators/test-generator/step-mapper.d.ts +8 -0
  76. package/dist/generators/test-generator/step-mapper.d.ts.map +1 -1
  77. package/dist/generators/test-generator/step-mapper.js +52 -8
  78. package/dist/generators/test-generator/step-mapper.js.map +1 -1
  79. package/dist/generators/test-generator/utils/data-resolver.d.ts +16 -0
  80. package/dist/generators/test-generator/utils/data-resolver.d.ts.map +1 -1
  81. package/dist/generators/test-generator/utils/data-resolver.js +59 -0
  82. package/dist/generators/test-generator/utils/data-resolver.js.map +1 -1
  83. package/dist/generators/test-generator/utils/selector-resolver.d.ts.map +1 -1
  84. package/dist/generators/test-generator/utils/selector-resolver.js +10 -2
  85. package/dist/generators/test-generator/utils/selector-resolver.js.map +1 -1
  86. package/dist/harness/audit.d.ts.map +1 -1
  87. package/dist/harness/audit.js +18 -5
  88. package/dist/harness/audit.js.map +1 -1
  89. package/dist/harness/counted-assertions.d.ts +2 -0
  90. package/dist/harness/counted-assertions.d.ts.map +1 -0
  91. package/dist/harness/counted-assertions.js +38 -0
  92. package/dist/harness/counted-assertions.js.map +1 -0
  93. package/dist/harness/defect-draft.d.ts +14 -0
  94. package/dist/harness/defect-draft.d.ts.map +1 -0
  95. package/dist/harness/defect-draft.js +143 -0
  96. package/dist/harness/defect-draft.js.map +1 -0
  97. package/dist/harness/defects.d.ts +24 -0
  98. package/dist/harness/defects.d.ts.map +1 -0
  99. package/dist/harness/defects.js +118 -0
  100. package/dist/harness/defects.js.map +1 -0
  101. package/dist/harness/manifest.d.ts.map +1 -1
  102. package/dist/harness/manifest.js +2 -2
  103. package/dist/harness/manifest.js.map +1 -1
  104. package/dist/harness/next-step.d.ts +0 -4
  105. package/dist/harness/next-step.d.ts.map +1 -1
  106. package/dist/harness/next-step.js +34 -4
  107. package/dist/harness/next-step.js.map +1 -1
  108. package/dist/harness/parse.d.ts +18 -0
  109. package/dist/harness/parse.d.ts.map +1 -1
  110. package/dist/harness/parse.js +20 -11
  111. package/dist/harness/parse.js.map +1 -1
  112. package/dist/harness/script-check.d.ts.map +1 -1
  113. package/dist/harness/script-check.js +7 -1
  114. package/dist/harness/script-check.js.map +1 -1
  115. package/dist/harness/sensors.d.ts.map +1 -1
  116. package/dist/harness/sensors.js +23 -6
  117. package/dist/harness/sensors.js.map +1 -1
  118. package/dist/harness/triage-signals.d.ts +20 -0
  119. package/dist/harness/triage-signals.d.ts.map +1 -0
  120. package/dist/harness/triage-signals.js +30 -0
  121. package/dist/harness/triage-signals.js.map +1 -0
  122. package/dist/harness/triage-unit.d.ts +23 -0
  123. package/dist/harness/triage-unit.d.ts.map +1 -0
  124. package/dist/harness/triage-unit.js +142 -0
  125. package/dist/harness/triage-unit.js.map +1 -0
  126. package/dist/harness/triage.d.ts +28 -0
  127. package/dist/harness/triage.d.ts.map +1 -0
  128. package/dist/harness/triage.js +99 -0
  129. package/dist/harness/triage.js.map +1 -0
  130. package/dist/harness/viewpoint-ledger.d.ts.map +1 -1
  131. package/dist/harness/viewpoint-ledger.js +2 -1
  132. package/dist/harness/viewpoint-ledger.js.map +1 -1
  133. package/dist/index.d.ts.map +1 -1
  134. package/dist/index.js +2 -1
  135. package/dist/index.js.map +1 -1
  136. package/dist/orchestrator/ai-skills/commands.d.ts.map +1 -1
  137. package/dist/orchestrator/ai-skills/commands.js +5 -3
  138. package/dist/orchestrator/ai-skills/commands.js.map +1 -1
  139. package/dist/orchestrator/ai-skills/golden-skills.test.js +29 -0
  140. package/dist/orchestrator/ai-skills/golden-skills.test.js.map +1 -1
  141. package/dist/orchestrator/screen-manager.d.ts.map +1 -1
  142. package/dist/orchestrator/screen-manager.js +4 -2
  143. package/dist/orchestrator/screen-manager.js.map +1 -1
  144. package/dist/orchestrator/templates/ai-src/commands/add-flow.md +26 -4
  145. package/dist/orchestrator/templates/ai-src/commands/run-test.md +8 -2
  146. package/dist/orchestrator/templates/ai-src/config/claude.md +20 -7
  147. package/dist/orchestrator/templates/ai-src/config/copilot.md +20 -7
  148. package/dist/orchestrator/templates/ai-src/skills/sungen-api-coverage-model/SKILL.md +1 -1
  149. package/dist/orchestrator/templates/ai-src/skills/sungen-capture-mobile/SKILL.md +3 -3
  150. package/dist/orchestrator/templates/ai-src/skills/sungen-data-factory/SKILL.md +9 -0
  151. package/dist/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +2 -1
  152. package/dist/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +7 -4
  153. package/dist/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +25 -9
  154. package/dist/orchestrator/templates/ai-src/skills/sungen-harness-audit/SKILL.md +2 -0
  155. package/dist/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +4 -3
  156. package/dist/orchestrator/templates/specs-test-data.ts +22 -4
  157. package/package.json +3 -3
  158. package/src/cli/commands/audit.ts +9 -4
  159. package/src/cli/commands/capability.ts +3 -1
  160. package/src/cli/commands/defect.ts +49 -0
  161. package/src/cli/commands/delivery.ts +33 -10
  162. package/src/cli/commands/triage.ts +57 -0
  163. package/src/cli/index.ts +4 -0
  164. package/src/exporters/csv-exporter.ts +5 -1
  165. package/src/exporters/feature-parser.ts +12 -0
  166. package/src/exporters/matrix/build.ts +51 -9
  167. package/src/exporters/matrix/carry-manual-results.ts +70 -7
  168. package/src/exporters/matrix/export.ts +12 -2
  169. package/src/exporters/matrix/render-csv.ts +1 -1
  170. package/src/exporters/matrix/render-xlsx.ts +3 -0
  171. package/src/exporters/matrix/types.ts +5 -0
  172. package/src/exporters/playwright-report-parser.ts +1 -0
  173. package/src/exporters/scenario-merger.ts +2 -1
  174. package/src/exporters/test-data-resolver.ts +10 -2
  175. package/src/exporters/types.ts +5 -0
  176. package/src/generators/gherkin-parser/index.ts +1 -0
  177. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/contain-text-assertion.hbs +2 -1
  178. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/expanded-assertion.hbs +2 -0
  179. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/date-picker-unsupported.hbs +5 -0
  180. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/fill-action.hbs +13 -1
  181. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/contain-text-assertion.hbs +9 -1
  182. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/expanded-assertion.hbs +12 -0
  183. package/src/generators/test-generator/code-generator.ts +4 -4
  184. package/src/generators/test-generator/diagnostics.ts +9 -3
  185. package/src/generators/test-generator/patterns/types.ts +2 -0
  186. package/src/generators/test-generator/step-mapper.ts +56 -9
  187. package/src/generators/test-generator/utils/data-resolver.ts +53 -0
  188. package/src/generators/test-generator/utils/selector-resolver.ts +10 -2
  189. package/src/harness/audit.ts +19 -6
  190. package/src/harness/counted-assertions.ts +33 -0
  191. package/src/harness/defect-draft.ts +115 -0
  192. package/src/harness/defects.ts +94 -0
  193. package/src/harness/manifest.ts +2 -2
  194. package/src/harness/next-step.ts +29 -4
  195. package/src/harness/parse.ts +20 -11
  196. package/src/harness/script-check.ts +7 -1
  197. package/src/harness/sensors.ts +26 -7
  198. package/src/harness/triage-signals.ts +46 -0
  199. package/src/harness/triage-unit.ts +113 -0
  200. package/src/harness/triage.ts +127 -0
  201. package/src/harness/viewpoint-ledger.ts +2 -2
  202. package/src/index.ts +2 -1
  203. package/src/orchestrator/ai-skills/commands.ts +5 -3
  204. package/src/orchestrator/ai-skills/golden-skills.test.ts +23 -0
  205. package/src/orchestrator/screen-manager.ts +3 -1
  206. package/src/orchestrator/templates/ai-src/commands/add-flow.md +26 -4
  207. package/src/orchestrator/templates/ai-src/commands/run-test.md +8 -2
  208. package/src/orchestrator/templates/ai-src/config/claude.md +20 -7
  209. package/src/orchestrator/templates/ai-src/config/copilot.md +20 -7
  210. package/src/orchestrator/templates/ai-src/skills/sungen-api-coverage-model/SKILL.md +1 -1
  211. package/src/orchestrator/templates/ai-src/skills/sungen-capture-mobile/SKILL.md +3 -3
  212. package/src/orchestrator/templates/ai-src/skills/sungen-data-factory/SKILL.md +9 -0
  213. package/src/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +2 -1
  214. package/src/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +7 -4
  215. package/src/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +25 -9
  216. package/src/orchestrator/templates/ai-src/skills/sungen-harness-audit/SKILL.md +2 -0
  217. package/src/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +4 -3
  218. package/src/orchestrator/templates/specs-test-data.ts +22 -4
@@ -161,6 +161,7 @@ export function loadPlaywrightReport(reportPath: string): Map<string, Playwright
161
161
  errorMessages: distinct.length > 0 ? distinct : undefined,
162
162
  tracePath: trace,
163
163
  projectName: test?.projectName,
164
+ attempts: results.length || undefined,
164
165
  });
165
166
  }
166
167
  return result;
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { FeatureMetadata, OrderedStep, ScenarioMetadata, SpecFileData, SpecTest } from './types';
12
- import { isStepsBaseScenario, isSampleScaffoldScenario, extractTestcaseType } from './feature-parser';
12
+ import { isStepsBaseScenario, isSampleScaffoldScenario, isHookScenario, extractTestcaseType } from './feature-parser';
13
13
  import { SCENARIO_ID } from './feature-parser';
14
14
  import { extractUnlabeledOracle, onlyPresenceAssertions } from './manual-hybrid-oracle';
15
15
 
@@ -139,6 +139,7 @@ export function mergeFeatureAndSpec(
139
139
  for (const scenario of feature.scenarios) {
140
140
  if (isStepsBaseScenario(scenario)) continue; // skip @steps base scenarios
141
141
  if (isSampleScaffoldScenario(scenario)) continue; // skip default sample
142
+ if (isHookScenario(scenario)) continue; // skip @beforeAll/@afterEach/@afterAll hooks
142
143
 
143
144
  const specTest = findMatchingSpecTest(scenario, spec.tests, feature.scenarios);
144
145
  const base = scenario.extendsName ? stepsBaseByName.get(scenario.extendsName) : undefined;
@@ -183,8 +183,16 @@ export function renderDynamicTokens(text: string): string {
183
183
  const [min, max] = (args || '1:9999').split(':');
184
184
  return `<random ${min}-${max}>`;
185
185
  }
186
- case 'date': return "<today's date>";
187
- case 'datetime': return '<current date-time>';
186
+ case 'date': {
187
+ if (!args) return "<today's date>";
188
+ // Same argument grammar as the runtime resolver (specs-test-data.ts), which leaves anything
189
+ // else raw — so a raw token here is what the tester would see too, and Gate D flags it.
190
+ const off = /^([+-]\d{1,5})d$/.exec(args.trim());
191
+ if (!off) return m;
192
+ const n = Number(off[1]);
193
+ return `<today's date ${n < 0 ? '-' : '+'} ${Math.abs(n)} day${Math.abs(n) === 1 ? '' : 's'}>`;
194
+ }
195
+ case 'datetime': return args ? m : '<current date-time>';
188
196
  default: return m;
189
197
  }
190
198
  });
@@ -95,6 +95,9 @@ export interface ScenarioMetadata {
95
95
  * as Gherkin steps — the merger parses these into Steps/Expected for the report.
96
96
  */
97
97
  comments?: string[];
98
+ /** Set for a `@beforeAll` / `@afterEach` / `@afterAll` scenario — a hook the compiler emits as
99
+ * `test.beforeAll()` etc., never a test case (#880). */
100
+ hookType?: 'beforeAll' | 'afterEach' | 'afterAll';
98
101
  }
99
102
 
100
103
  /**
@@ -134,6 +137,8 @@ export interface PlaywrightResult {
134
137
  errorMessages?: string[];
135
138
  tracePath?: string;
136
139
  projectName?: string;
140
+ /** How many attempts the test took (retries + 1). A pass with attempts > 1 is flaky. */
141
+ attempts?: number;
137
142
  }
138
143
 
139
144
  /**
@@ -18,6 +18,7 @@ export const STATE_CLAUSES: ReadonlyArray<StateClause> = [
18
18
  'sorted ascending', 'sorted descending',
19
19
  'unchecked', 'checked',
20
20
  'hidden', 'visible', 'disabled', 'enabled', 'focused', 'empty', 'loading', 'selected',
21
+ 'expanded', 'collapsed',
21
22
  ];
22
23
 
23
24
  /**
@@ -8,7 +8,8 @@
8
8
  ? [await __el.getAttribute('label'), await __el.getAttribute('value')]
9
9
  : [await __el.getText(), await __el.getAttribute('content-desc')];
10
10
  const __actual = String(__sources.find(s => s && String(s).trim() !== '') ?? '').trim();
11
- if (!__actual.includes(__expected)) {
11
+ {{!-- An empty expected value means "shows no text": includes('') holds for any text (#910). --}}
12
+ if ({{#if emptyMeansEmpty}}__expected === '' ? __actual !== '' : {{/if}}!__actual.includes(__expected)) {
12
13
  throw new Error(`[sungen] Expected text containing "${__expected}" but element reads "${__actual}"`);
13
14
  }
14
15
  }
@@ -0,0 +1,2 @@
1
+ {{!-- aria-expanded has no native-mobile equivalent; the appium expand action is a bare tap. --}}
2
+ throw new Error("[sungen] 'is expanded' / 'is collapsed' (aria-expanded) is web-only and has no native-mobile equivalent. Assert what the open panel shows instead (e.g. see [X] section), or tag this scenario @manual.");
@@ -0,0 +1,5 @@
1
+ {{!-- A date-picker whose trigger is a <button> (a calendar popover, #907) has no value to type:
2
+ fill() throws "Element is not an <input>". With no recipe there is no honest compilation, so
3
+ fail loud and name the way forward — the appium twin of this template does the same for a
4
+ native picker dialog. --}}
5
+ throw new Error('[sungen] {{escapeQuotes reason}}');
@@ -1 +1,13 @@
1
- await {{> locator}}.fill('{{escapeQuotes fillValue}}');
1
+ {{#if guardFillable~}}
2
+ {{!-- A recipe-less date-picker / button / link: Playwright's own "Element is not an <input>" names
3
+ no fix when the target is a trigger that opens a calendar popup. Keep Playwright's semantics
4
+ (it also fills a <label>'s control or a contenteditable child) and only re-word that one
5
+ failure with the way forward (#907). --}}
6
+ try {
7
+ await {{> locator}}.fill('{{escapeQuotes fillValue}}');
8
+ } catch (e) {
9
+ if (!/is not an <input>/.test(String(e))) throw e;
10
+ throw new Error('[sungen] [{{escapeQuotes selectorRef}}] is not a fillable element (a button or popup trigger opens a picker). Declare its choreography as a recipe with a fill intent (and an assertValue intent) in qa/app.yaml and point the selector at it with variant: <recipe>, or widgets.datepicker for every date-picker.');
11
+ }
12
+ {{else~}}
13
+ await {{> locator}}.fill('{{escapeQuotes fillValue}}');{{/if}}
@@ -1 +1,9 @@
1
- await expect({{> locator}}).toContainText('{{escapeQuotes expectedText}}');
1
+ {{#if emptyMeansEmpty~}}
2
+ {{!-- toContainText('') passes for ANY text — the empty string is a substring of everything — so an
3
+ "is empty" check would assert nothing. An empty expected value means the element shows no text (#910). --}}
4
+ { const __expected = '{{escapeQuotes expectedText}}';
5
+ if (__expected === '') await expect({{> locator}}).toHaveText('');
6
+ else await expect({{> locator}}).toContainText(__expected);
7
+ }
8
+ {{else~}}
9
+ await expect({{> locator}}).toContainText('{{escapeQuotes expectedText}}');{{/if}}
@@ -0,0 +1,12 @@
1
+ {{!-- `see [X] is expanded | is collapsed` (#911). Reads the signal expand-action.hbs drives — a class
2
+ on a descendant, a declared boolean attribute, else aria-expanded — but with no "only if the
3
+ element exposes it" escape: an assertion that can skip itself asserts nothing. --}}
4
+ {{#if expanded.class}}
5
+ {{#if (eq direction 'expand')}}
6
+ await expect({{> locator}}.locator('.{{escapeQuotes expanded.class}}')).toBeVisible();
7
+ {{~else~}}
8
+ await expect({{> locator}}.locator('.{{escapeQuotes expanded.class}}')).toHaveCount(0);
9
+ {{~/if}}
10
+ {{~else~}}
11
+ await expect({{> locator}}).toHaveAttribute('{{#if expanded.attribute}}{{escapeQuotes expanded.attribute}}{{else}}aria-expanded{{/if}}', '{{#if (eq direction 'expand')}}true{{else}}false{{/if}}');
12
+ {{~/if}}
@@ -1187,9 +1187,9 @@ export class CodeGenerator {
1187
1187
  ): { preconditions: CapabilityPrecondition[]; casesDataset?: string } {
1188
1188
  this.stepMapper.setScenarioContext(stepsToMap);
1189
1189
 
1190
- // Data-driven (@cases): the scenario's {{col}} refs are dataset *row columns* that
1191
- // exist only at runtime — register them as captured so they resolve to a runtime
1192
- // get() (→ rowData.get) instead of compile-time YAML lookup.
1190
+ // Data-driven (@cases): the scenario's {{col}} refs that are dataset *row columns* exist
1191
+ // only at runtime — they resolve to a runtime get() (→ rowData.get) instead of a compile-time
1192
+ // YAML lookup. A plain test-data key stays readable at compile time (#950).
1193
1193
  const casesTag = scenario.tags.find((t) => t.startsWith('@cases:'));
1194
1194
  const casesDataset = casesTag ? casesTag.slice('@cases:'.length).trim() : undefined;
1195
1195
  if (casesDataset) {
@@ -1197,7 +1197,7 @@ export class CodeGenerator {
1197
1197
  for (const st of stepsToMap) {
1198
1198
  for (const m of (st.text || '').matchAll(/\{\{\s*([\w.]+)\s*\}\}/g)) refs.add(m[1]);
1199
1199
  }
1200
- for (const r of refs) this.stepMapper.registerCaptured(r);
1200
+ this.stepMapper.bindCasesDataset(casesDataset, refs);
1201
1201
  }
1202
1202
 
1203
1203
  const preconditions = this.capabilityPreconditions(scenario);
@@ -49,7 +49,8 @@
49
49
  * nothing for the query half to assert, so the step checks the PATH only. Not an
50
50
  * error (the value may legitimately vary per environment), but the step asserts
51
51
  * strictly less than it appears to
52
- * SG-W015 — `see [X] card/item with {{n}}` where the value is a bare number: this compiles
52
+ * SG-W015 — `see [X] card/item with {{n}}` where the value is a bare number (in @cases: on
53
+ * any row), or `see [X] row with {{n}}` whose @cases rows mix numbers and text: this compiles
53
54
  * to a TEXT assertion (toHaveText('3')), NOT a count — an author reaching for
54
55
  * "3 cards are shown" gets an assertion that can only pass if the card's text
55
56
  * IS the digit. Count semantics exist on row/table ("see [T] table with {{n}}",
@@ -67,13 +68,18 @@
67
68
  * SG-E023 — the step carries an assertion clause (`matches "…"`, `contains`, `has text`, a state)
68
69
  * but the generated code is a BARE VISIBILITY check: the clause was swallowed by the
69
70
  * catch-all and the test would pass whatever the element says. Four separate defects
70
- * have arrived this way, so the step compiles to a runtime throw instead.
71
+ * have arrived this way, so the step compiles to a runtime throw instead. Also a
72
+ * trailing `is <word>` / `is not <word>` that is no state (`is on`, `is open`,
73
+ * `is not visible`), other than visibility itself (#911).
71
74
  * SG-W017 — a step needs a selector-entry field the entry does not declare, so there is nothing
72
75
  * honest to compile. Today: a table paginate step whose table declares no
73
- * `pagination.next`/`.prev` (#712). The mirror of SG-W004 — that one reports a field
76
+ * `pagination.next`/`.prev` (#712); `is expanded`/`is collapsed` on an entry declaring
77
+ * `expanded.state: none` (#911). The mirror of SG-W004 — that one reports a field
74
78
  * written that nothing reads, this one a field a consumer needs that nobody wrote.
75
79
  * Compiles the step to a loud runtime throw rather than guessing a locator, which is
76
80
  * the #690 failure (a locator no page has, failing far from the cause).
81
+ * Also a web `fill [X] date-picker` whose entry locates a `<button>` (a calendar
82
+ * trigger) and names no `fill` recipe — `.fill()` cannot type into a button (#907).
77
83
  * SG-W018 — a selectors.yaml entry declares an `nth:` the compiler cannot use (#720). `nth:` was
78
84
  * the one entry field whose VALUE nothing checked: the key is known (SG-W004 quiet) and
79
85
  * the `type:` is valid (SG-E027 quiet), so `nth: -1`, `nth: 'last'`, `nth: 1.5` and
@@ -38,6 +38,8 @@ export type StateClause =
38
38
  | 'empty'
39
39
  | 'loading'
40
40
  | 'selected'
41
+ | 'expanded'
42
+ | 'collapsed'
41
43
  | 'sorted ascending'
42
44
  | 'sorted descending';
43
45
 
@@ -13,6 +13,9 @@ import { isKnownSelectorType } from './utils/selector-type-validation';
13
13
  import { loadAppProfile, ResolvedAppProfile } from './app-profile';
14
14
  import { capabilityRegistry } from '../../capabilities/registry';
15
15
 
16
+ /** Trailing `is <word>` that a visibility check honestly asserts (SG-E023 lets these through). */
17
+ const VISIBILITY_WORDS = new Set(['visible', 'displayed', 'shown', 'present']);
18
+
16
19
  /**
17
20
  * The correction SG-W001 owes a project that gated a platform driver away.
18
21
  *
@@ -143,6 +146,10 @@ export class StepMapper {
143
146
  this.featurePlatform = featurePlatform;
144
147
  this.featurePath = featurePath;
145
148
  this.selectorResolver.setFeatureContext(featureName);
149
+ // Scenario-scoped data state (captured vars, the bound @cases dataset) of the previous feature's
150
+ // last scenario must not reach this feature's Background, which is mapped before any scenario
151
+ // resets it: a column `{{k}}` there made a plain `{{k}}` here unreadable (#950).
152
+ this.dataResolver.clearCaptured();
146
153
  // One StepMapper serves every feature in a run, so the step counter that names the
147
154
  // generated temporaries (`__t_<n>`) used to carry over between files: a screen's spec
148
155
  // then depended on how many steps the PRECEDING files happened to contain — i.e. on
@@ -212,6 +219,23 @@ export class StepMapper {
212
219
  this.dataResolver.registerCaptured(name);
213
220
  }
214
221
 
222
+ /**
223
+ * `@cases:<dataset>` — bind the scenario's dataset and register as runtime-only the refs that are
224
+ * its COLUMNS (each row supplies them). A plain key the base test-data defines stays a normal ref,
225
+ * so the compiler can still read its value (#950: registering every ref turned
226
+ * `row with {{rows_per_page}}` into a text assertion). A ref that is neither stays runtime-only,
227
+ * as before.
228
+ */
229
+ bindCasesDataset(dataset: string, refs: Iterable<string>): void {
230
+ const columns = new Set(this.dataResolver.setCasesDataset(dataset, this.featureName));
231
+ for (const ref of refs) {
232
+ const isColumn = columns.has(ref.split(/[.[]/)[0]);
233
+ if (isColumn || this.dataResolver.peekBaseValue(ref, this.featureName) === undefined) {
234
+ this.dataResolver.registerCaptured(ref);
235
+ }
236
+ }
237
+ }
238
+
215
239
  /**
216
240
  * Register the head of a variable bound by the feature's `@beforeAll` hook. Feature-scoped: it
217
241
  * survives setScenarioContext() so every scenario can read `{{head.*}}` as a runtime get().
@@ -291,8 +315,11 @@ export class StepMapper {
291
315
  /\bsees?\s+\[/.test(step.text) && /\bwith\b/.test(step.text)) {
292
316
  // peekBaseValue reads the literal from test-data even in runtime-data mode,
293
317
  // where resolveData returns an opaque marker (never numeric-looking).
318
+ // Every value the ref takes — a @cases column has one per row (#950).
294
319
  let resolvedValue: string | undefined;
295
- try { resolvedValue = this.dataResolver.peekBaseValue(step.dataRef, this.featureName); } catch { /* unresolved at compile time → cannot judge */ }
320
+ try {
321
+ resolvedValue = this.dataResolver.peekValues(step.dataRef, this.featureName)?.find((v) => /^\d+$/.test(v.trim()));
322
+ } catch { /* unresolved at compile time → cannot judge */ }
296
323
  if (resolvedValue !== undefined && /^\d+$/.test(resolvedValue.trim())) {
297
324
  this.diagnostics.add('SG-W015',
298
325
  `\`see [${step.selectorRef}] ${step.elementType} with {{${step.dataRef}}}\` compiles to a TEXT assertion toHaveText('${resolvedValue.trim()}'), NOT a count of ${step.elementType}s. For a count use \`see [Ref] row with {{n}}\` / \`see [T] table with {{n}}\` (or a list "should have count"); if '${resolvedValue.trim()}' really is the ${step.elementType}'s text, assert it on a text/label element instead`,
@@ -655,7 +682,8 @@ export class StepMapper {
655
682
  if (!/\b(?:urlMatches|expectPage)\(/.test(mapped.code || '')) return; // both URL-assertion shapes (#662)
656
683
  // The base value, never resolveData(): in runtime mode that returns the marker, which carries
657
684
  // no query by construction — the diagnostic would then fire on every single step.
658
- const value = this.dataResolver.peekBaseValue(step.dataRef, this.featureName);
685
+ // Any value the ref takes — a @cases column has one per row (#950); the first without a query.
686
+ const value = this.dataResolver.peekValues(step.dataRef, this.featureName)?.find((v) => !/[?#]/.test(v));
659
687
  if (value == null || /[?#]/.test(value)) return;
660
688
  this.diagnostics.add('SG-W014',
661
689
  `{{${step.dataRef}}} = "${value}" carries no query and no fragment, so this page assertion checks the PATH only. Pass a value like "?q=…" (or drop the "with" and let the step assert a bare page).`,
@@ -856,13 +884,32 @@ export class StepMapper {
856
884
  [/\b(?:should\s+have|has)\s+text\b/i, 'has text'],
857
885
  ];
858
886
  const hit = CLAUSES.find(([re]) => re.test(step.text));
859
- if (!hit) return null;
860
-
861
- const reason = `step "${step.text}" carries the clause \`${hit[1]}\` but compiled to a bare `
862
- + `visibility check — the clause was dropped, so the assertion would pass whatever the element `
863
- + `says. Either the phrasing is not the supported one for that clause (see the `
864
- + `sungen-gherkin-syntax reference), or this sungen predates the step: check you are running `
865
- + `the project-local compiler (\`npx sungen\`), not an older global one.`;
887
+ // A trailing `is <word>` that is no state reached a bare visibility check the same way:
888
+ // `see [Start Date] button is expanded` asserted only that the button was shown, and passed with
889
+ // the calendar closed (#911); `is not visible` even asserted the OPPOSITE. Known states are
890
+ // SG-E021's (`step.state` is set). Only what a visibility check honestly asserts passes:
891
+ // `is visible` and its synonyms, and `is not hidden`. Storage's `is not empty` never gets here —
892
+ // this guard sees bare visibility code only.
893
+ let state: string | undefined;
894
+ if (!hit && !step.state) {
895
+ const m = maskRefsForMatching(step.text).replace(/\s+/g, ' ').replace(/ ?\.$/, '').toLowerCase()
896
+ .match(/ (?:is|are)( not| no longer)? ([a-z][\w-]*)$/);
897
+ const honest = m && (m[1] ? m[2] === 'hidden' : VISIBILITY_WORDS.has(m[2]));
898
+ if (m && !honest) state = `is${m[1] ?? ''} ${m[2]}`;
899
+ }
900
+ if (!hit && !state) return null;
901
+
902
+ const reason = state
903
+ ? `step "${step.text}" ends in \`${state}\`, which is not a sungen state, but compiled to a bare `
904
+ + `visibility check — the words were dropped, so the step would pass whenever the element is `
905
+ + `shown. Use a state the compiler asserts (is visible / is hidden / is disabled / is enabled / `
906
+ + `is checked / is unchecked / is selected / is focused / is empty / is expanded / is collapsed …, `
907
+ + `see the sungen-gherkin-syntax States list), or tag the scenario @manual.`
908
+ : `step "${step.text}" carries the clause \`${hit![1]}\` but compiled to a bare `
909
+ + `visibility check — the clause was dropped, so the assertion would pass whatever the element `
910
+ + `says. Either the phrasing is not the supported one for that clause (see the `
911
+ + `sungen-gherkin-syntax reference), or this sungen predates the step: check you are running `
912
+ + `the project-local compiler (\`npx sungen\`), not an older global one.`;
866
913
  this.diagnostics?.add('SG-E023', reason, { feature: this.featureName, step: step.text });
867
914
  return {
868
915
  code: unsupportedStepCode(reason),
@@ -54,6 +54,7 @@ export class DataResolver {
54
54
  /** Clear captured vars — call at the start of each scenario. */
55
55
  clearCaptured(): void {
56
56
  this.capturedVars.clear();
57
+ this.casesRows = undefined;
57
58
  }
58
59
 
59
60
  // Vars bound by a `@beforeAll` hook's `@api:`/`@query:` preconditions. The hook runs before every
@@ -131,6 +132,58 @@ export class DataResolver {
131
132
  }
132
133
  }
133
134
 
135
+ /** The `@cases:<name>` dataset rows of the scenario being compiled (scenario-scoped). */
136
+ private casesRows?: Array<Record<string, unknown>>;
137
+
138
+ /**
139
+ * Bind the scenario's `@cases` dataset so its values can be judged at compile time, and return its
140
+ * column names (empty when the dataset cannot be read — the caller then treats every ref as
141
+ * runtime-only, as before #950).
142
+ */
143
+ setCasesDataset(dataset: string | undefined, featureName?: string): string[] {
144
+ this.casesRows = undefined;
145
+ if (!dataset || !featureName) return [];
146
+ try {
147
+ const rows = this.loadDataFile(featureName, dataset)?.[dataset];
148
+ if (Array.isArray(rows) && rows.length) {
149
+ // A scalar row is the `value` column — the runtime cases() wraps it the same way.
150
+ this.casesRows = rows.map((r) => (r && typeof r === 'object' && !Array.isArray(r) ? r : { value: r }));
151
+ }
152
+ } catch {
153
+ // unreadable dataset → no columns known
154
+ }
155
+ return this.casesRows ? [...new Set(this.casesRows.flatMap((r) => Object.keys(r)))] : [];
156
+ }
157
+
158
+ /**
159
+ * Every value a ref can take at run time, for compile-time judgements: the base value of a plain
160
+ * key, or — for a column of the scenario's `@cases` dataset — the value on EACH row (a row that
161
+ * lacks the column falls back to the base key, as `rowData.get()` does). A dataset column used to
162
+ * be opaque here, so `row with {{n}}` lost its count reading in @cases and the SG-W014 / SG-W015
163
+ * checks went silent (#950). Undefined when any value cannot be read.
164
+ */
165
+ peekValues(dataRef: string, featureName?: string): string[] | undefined {
166
+ const head = dataRef.split(/[.[]/)[0];
167
+ if (!this.casesRows?.some((row) => head in row)) {
168
+ const base = this.peekBaseValue(dataRef, featureName);
169
+ return base === undefined ? undefined : [base];
170
+ }
171
+ const out: string[] = [];
172
+ for (const row of this.casesRows) {
173
+ let value: unknown;
174
+ if (head in row) {
175
+ value = row;
176
+ for (const key of dataRef.split('.')) value = value && typeof value === 'object' ? (value as Record<string, unknown>)[key] : undefined;
177
+ } else {
178
+ try { value = this.resolveValue(dataRef, featureName); } catch { return undefined; }
179
+ }
180
+ if (typeof value === 'number' || typeof value === 'boolean') value = String(value);
181
+ if (typeof value !== 'string') return undefined;
182
+ out.push(value);
183
+ }
184
+ return out;
185
+ }
186
+
134
187
  private resolveValue(dataRef: string, featureName?: string): string {
135
188
  const { fileName, valuePath } = this.parseDataRef(dataRef, featureName);
136
189
  const data = this.loadDataFile(fileName, dataRef);
@@ -1024,8 +1024,8 @@ export class SelectorResolver {
1024
1024
  // `type: locator` had to be used instead, which no page of the reference says (issue #690).
1025
1025
  //
1026
1026
  // Handled HERE, above the generic `locator:`-field branch below, because that branch returns
1027
- // strategy 'css' and drops `exact` — putting `upload` in the switch left the two spellings
1028
- // diverging for any entry that sets `locator:`. `withExtras` still stamps the declared
1027
+ // strategy 'css' — putting `upload` in the switch left the two spellings diverging for any
1028
+ // entry that sets `locator:`. `withExtras` still stamps the declared
1029
1029
  // `selectorType`, so a consumer that needs to tell an upload from a plain locator still can.
1030
1030
  //
1031
1031
  // `exact` is inert for the upload action itself (`setInputFiles` takes no text filter), but it
@@ -1049,6 +1049,7 @@ export class SelectorResolver {
1049
1049
  value: locator,
1050
1050
  locator: locator,
1051
1051
  nth,
1052
+ exact,
1052
1053
  });
1053
1054
  }
1054
1055
 
@@ -1113,11 +1114,18 @@ export class SelectorResolver {
1113
1114
  return resolvedRole;
1114
1115
  }
1115
1116
 
1117
+ // `exact` carries no meaning for getByTestId itself, but text assertions on the entry read it
1118
+ // (`label with {{v}}` → toHaveText vs toContainText). Dropping it here made `exact: true`
1119
+ // inert on a testid entry while working on every other identity strategy (#910).
1116
1120
  case 'testid':
1121
+ // `exact` travels with a testid entry too: the getByTestId locator ignores it, but a step
1122
+ // that compares text (`label with {{v}}`) reads it to pick toHaveText over toContainText.
1123
+ // It used to be dropped here alone, so `exact: true` on a testid entry did nothing (#910).
1117
1124
  return withExtras({
1118
1125
  strategy: 'testid',
1119
1126
  value,
1120
1127
  nth,
1128
+ exact,
1121
1129
  });
1122
1130
 
1123
1131
  case 'label':
@@ -34,6 +34,7 @@ import { discoverAndRegisterCapabilities } from '../capabilities/discover';
34
34
  import { contextRouter } from '../capabilities/context-router';
35
35
  import { readTextFile, readViewpointText } from './read-text';
36
36
  import { uiInventoryCoverage, uiInventoryFindings, UiInventoryResult } from './ui-inventory';
37
+ import { defectLinkFindings } from './defects';
37
38
 
38
39
  export interface AuditReport {
39
40
  screen: string;
@@ -215,9 +216,13 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
215
216
  ? { ...catalog, page_types: {} } as Catalog
216
217
  : catalog;
217
218
  const capGate = scoringCap?.gateProvider as
218
- ((i: { scenarios: ScenarioInfo[]; viewpoints: ViewpointEntry[]; catalog: Catalog; focus: typeof intent.focus; cwd: string; screenName: string; threshold: number; businessCriticalMethods?: string[] }) => { gate: GateResult; depth: DepthResult }) | undefined;
219
- const provided = capGate?.({ scenarios, viewpoints, catalog: gateCatalog, focus: intent.focus, cwd: projectRootFromScreenDir(screenDir), screenName: catalogScreenName, threshold: depthThresholdFor(intent.focus), businessCriticalMethods: intent.businessCriticalMethods });
220
- const gate = provided?.gate ?? viewpointGate(scenarios, viewpoints, gateCatalog, platform === 'mobile', viewpointText);
219
+ ((i: { scenarios: ScenarioInfo[]; viewpoints: ViewpointEntry[]; catalog: Catalog; focus: typeof intent.focus; cwd: string; screenName: string; threshold: number; businessCriticalMethods?: string[]; isMobile: boolean; viewpointText: string }) => { gate: GateResult; depth: DepthResult }) | undefined;
220
+ // The capability gate gets the SAME isMobile + viewpoint text as the in-core fallback, or a declared
221
+ // `page-type:` is ignored wherever driver-ui scores — every web project, and a mobile project whose
222
+ // CLI cannot resolve driver-mobile (#864).
223
+ const isMobile = platform === 'mobile';
224
+ const provided = capGate?.({ scenarios, viewpoints, catalog: gateCatalog, focus: intent.focus, cwd: projectRootFromScreenDir(screenDir), screenName: catalogScreenName, threshold: depthThresholdFor(intent.focus), businessCriticalMethods: intent.businessCriticalMethods, isMobile, viewpointText });
225
+ const gate = provided?.gate ?? viewpointGate(scenarios, viewpoints, gateCatalog, isMobile, viewpointText);
221
226
  const depth = provided?.depth ?? assertionDepth(scenarios, dataThemesFor(gateCatalog, gate.pageType), intent.focus);
222
227
 
223
228
  // TQ-4 — deferral-aware coverage credit: an inherently cross-screen theme (cart / detail /
@@ -618,9 +623,15 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
618
623
  // #414 — serial-cascade masking: an @serial suite of independent (re-navigating) scenarios hides
619
624
  // failures, because Playwright's describe.serial SKIPS everything after the first red. Isolation is
620
625
  // the default, so this only fires when @serial was added to a suite that didn't need it.
621
- const cascadeN = serialCascadeRisk(featureText, scenarios.length);
622
- if (cascadeN) {
623
- findings.push(`SERIAL-CASCADE-RISK: this \`@serial\` suite has ${cascadeN} INDEPENDENT scenarios (each re-navigates via Background) that don't need a shared-page sequence. \`describe.serial\` SKIPS every test after the first failure → one early red MASKS the rest as never-run. **Drop \`@serial\`** — the default per-scenario isolation runs every scenario and isolates failures. Keep \`@serial\` only for a genuinely stateful ordered sequence (where scenario N depends on N-1's state).`);
626
+ // `@serial` is a property of ONE feature file — only that file compiles to `describe.serial` — so
627
+ // each file is judged on its own text and its own scenarios. The unit-wide text paired one
628
+ // feature's @serial with another's navigating Background and counted every scenario of the unit,
629
+ // telling QA to drop @serial from a genuinely ordered 2-scenario feature (#887).
630
+ for (const file of featureFiles.filter((f) => fs.existsSync(f))) {
631
+ const cascadeN = serialCascadeRisk(readTextFile(file), scenarios.filter((s) => s.sourceFile === file).length);
632
+ if (cascadeN) {
633
+ findings.push(`SERIAL-CASCADE-RISK: the \`@serial\` suite ${path.basename(file)} has ${cascadeN} INDEPENDENT scenarios (each re-navigates via Background) that don't need a shared-page sequence. \`describe.serial\` SKIPS every test after the first failure → one early red MASKS the rest as never-run. **Drop \`@serial\`** — the default per-scenario isolation runs every scenario and isolates failures. Keep \`@serial\` only for a genuinely stateful ordered sequence (where scenario N depends on N-1's state).`);
634
+ }
624
635
  }
625
636
  if (flowDepth.stateful && flowDepth.missing.length) {
626
637
  // The advice names the collection the CONTRACT declared. It used to say "the cart" whatever
@@ -647,6 +658,8 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
647
658
  for (const m of taxonomy.mislabeled) {
648
659
  findings.push(`VP-MISLABEL: "${m.name}" is coded VP-${m.current} but reads as ${m.suggested} (signal: "${m.signal}") → re-tag VP-${m.suggested}-NNN so the coverage matrix isn't skewed.`);
649
660
  }
661
+ // Scenario ↔ defect record links (qa/defects/) — advisory, never scored.
662
+ findings.push(...defectLinkFindings(projectRootFromScreenDir(screenDir), catalogScreenName, featureText, scenarios.map((sc) => sc.name)));
650
663
  if (balance.unclassifiedRatio > 0.4) {
651
664
  findings.push(`TAXONOMY-UNCLASSIFIED: ${balance.note} → align the VP-<CATEGORY> codes with the catalog (or extend the bucket keywords) so coverage-balance is meaningful.`);
652
665
  } else if (balance.imbalanced && !flowScored) {
@@ -0,0 +1,33 @@
1
+ /**
2
+ * How many items a scenario's assertions demonstrate — the denominator of CLAIM-PARTIAL (#622).
3
+ *
4
+ * One reading for both places that check a counted claim (the viewpoint ledger and the title-level
5
+ * `numericClaimGaps`), which used to carry their own copy of the regex.
6
+ *
7
+ * Counted, once per distinct assertion:
8
+ * - a value: `see [X] … with|contains {{v}}`;
9
+ * - a positive state: `see [X] … is checked|selected|enabled|disabled`;
10
+ * - an indicator's presence: `see [X] icon|checkbox|toggle|radio|badge|image` (#866) — on mobile a
11
+ * ticked item is often observable ONLY as its tick icon, so one per item is the proof.
12
+ * Not counted: plain visibility of text/headers (it shows a label, not one of N values) and absence
13
+ * (`is hidden`, `not …`), which proves nothing is there.
14
+ */
15
+ // The tail stops before the NEXT `see [`, or consecutive assertions would swallow each other.
16
+ const SEE_STEP = /\bsee \[([^\]]+)\]((?:(?!\bsee \[)[^[])*)/g;
17
+ const VALUE = /^[^|]*?\b(?:with|contains) (\{\{[^}]*\}\})/;
18
+ const ABSENT = /^\s*\S*\s*(?:is hidden|is not\b|not\b|does not\b)/;
19
+ const STATE = /^\s*\S*\s*is (checked|selected|enabled|disabled)\b/;
20
+ const INDICATOR = /^\s*(icon|checkbox|toggle|radio|badge|image)\b/;
21
+
22
+ export function countedAssertions(stepsText: string): number {
23
+ const seen = new Set<string>();
24
+ for (const m of stepsText.matchAll(SEE_STEP)) {
25
+ const [, target, rest] = m;
26
+ if (ABSENT.test(rest)) continue;
27
+ // Keyed on the target and WHAT it proves — never on the tail, which runs into the next step, so
28
+ // the same element asserted before and after an action is still one assertion.
29
+ const proof = VALUE.exec(rest)?.[1] ?? STATE.exec(rest)?.[1] ?? INDICATOR.exec(rest)?.[1];
30
+ if (proof) seen.add(`${target}|${proof}`);
31
+ }
32
+ return seen.size;
33
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * `sungen defect draft` — turn each `product-candidate` row of a unit's last triage into a record
3
+ * `qa/defects/DEF-NNN-<slug>.md` (see defects.ts for the link checks). Deterministic: Steps come
4
+ * from the scenario's Gherkin with test-data resolved, Expected/Actual from the assertion, and the
5
+ * severity is PROPOSED by rule until QA confirms it. Never overwrites a file, and never drafts a
6
+ * scenario an existing record already names.
7
+ */
8
+ import * as fs from 'fs';
9
+ import * as path from 'path';
10
+ import { stringify as toYaml } from 'yaml';
11
+ import type { ResolvedUnit } from '../cli/resolve-unit';
12
+ import { redactSecrets } from '../exporters/api-testcase-formatter';
13
+ import { getEnvironment } from '../exporters/run-environment';
14
+ import { parseFeatureMetadata } from '../exporters/feature-parser';
15
+ import { mergeFeatureAndSpec, MergedScenario } from '../exporters/scenario-merger';
16
+ import { maskCredentialValue } from '../exporters/sensitive-key';
17
+ import { parseSpecFile } from '../exporters/spec-parser';
18
+ import { loadTestData, substituteTestDataVars } from '../exporters/test-data-resolver';
19
+ import { readCapabilities } from './capability';
20
+ import { defectsDir, nextDefectId, readDefects } from './defects';
21
+ import type { TriageRow } from './triage';
22
+ import { readTriageReport, unitSecrets } from './triage-unit';
23
+
24
+ export interface DraftResult {
25
+ /** `sungen triage` has not run for this unit. */
26
+ missingTriage?: boolean;
27
+ written: string[];
28
+ skipped: Array<{ scenario: string; id: string }>;
29
+ }
30
+
31
+ /** Blocking actions: a @high scenario that fails on one of these is `major`, otherwise `minor`. */
32
+ const BLOCKING = /\b(?:submit|save|apply|log ?in|sign ?in|sign ?up|register|checkout|pay|confirm|send|navigate|redirect)\b/i;
33
+
34
+ const slug = (s: string): string =>
35
+ s.replace(/^\s*(?:VP|FL)-[A-Z0-9-]+\s+/i, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 50).replace(/-$/, '');
36
+
37
+ interface UnitScenario { merged: MergedScenario; testData: Record<string, string>; secrets: string[] }
38
+
39
+ /** Scenario title → its merged Gherkin, test-data (credentials masked) and secret values. */
40
+ function unitScenarios(cwd: string, unit: ResolvedUnit): Map<string, UnitScenario> {
41
+ const out = new Map<string, UnitScenario>();
42
+ const featuresDir = path.join(unit.dir, 'features');
43
+ if (!fs.existsSync(featuresDir)) return out;
44
+ for (const f of fs.readdirSync(featuresDir).filter((x) => x.endsWith('.feature'))) {
45
+ const base = f.replace(/\.feature$/, '');
46
+ const spec = parseSpecFile(path.join(cwd, 'specs', 'generated', unit.unitId, `${base}.spec.ts`));
47
+ const dataFile = path.join(unit.dir, 'test-data', `${base}.yaml`);
48
+ const raw = fs.existsSync(dataFile) ? loadTestData(dataFile) : {};
49
+ const testData = Object.fromEntries(Object.entries(raw).map(([k, v]) => [k, maskCredentialValue(k, String(v))]));
50
+ const secrets = unitSecrets(raw, cwd);
51
+ for (const m of mergeFeatureAndSpec(parseFeatureMetadata(path.join(featuresDir, f)), spec)) {
52
+ out.set(m.feature.name, { merged: m, testData, secrets });
53
+ }
54
+ }
55
+ return out;
56
+ }
57
+
58
+ function render(cwd: string, unit: ResolvedUnit, id: string, row: TriageRow, sc: UnitScenario | undefined): string {
59
+ const tags = sc?.merged.feature.tags ?? [];
60
+ const resolve = (t: string): string => (sc ? substituteTestDataVars(t, sc.testData) : t);
61
+ const steps = (sc?.merged.resolvedSteps ?? []).map((s) => resolve(s.text));
62
+ const thens = (sc?.merged.resolvedExpected ?? []).map((s) => resolve(s.text));
63
+ const high = tags.some((t) => t === '@high' || t === '@critical');
64
+ const severity = high && BLOCKING.test([row.scenario, ...steps].join(' ')) ? 'major' : 'minor';
65
+ const specFile = path.join(unit.dir, 'requirements', 'spec.md');
66
+ const env = getEnvironment(cwd);
67
+ const front = {
68
+ id, title: row.scenario, severity, severity_source: 'proposed', status: 'open',
69
+ units: [unit.unitId], scenarios: [row.scenario], claims: row.ids, legacy: row.legacy,
70
+ spec: fs.existsSync(specFile) ? path.relative(cwd, specFile) : '',
71
+ found: {
72
+ date: new Date().toISOString().slice(0, 10),
73
+ env: process.env.SUNGEN_ENV || 'default',
74
+ base_url: env.baseURL,
75
+ platform: row.variant || readCapabilities(cwd).platform || 'web',
76
+ },
77
+ tracker: '',
78
+ };
79
+ const body = [
80
+ '---', toYaml(front).trimEnd(), '---', '',
81
+ `# ${id}: ${row.scenario}`, '',
82
+ '## Summary', '', '(to fill: what is wrong, where, and who it affects)', '',
83
+ '## Steps to reproduce', '', ...(steps.length ? steps.map((s, i) => `${i + 1}. ${s}`) : ['(scenario steps not found — see the feature)']), '',
84
+ '## Expected', '', ...thens.map((t) => `- ${t}`), ...(row.expected ? [`- Asserted value: ${row.expected}`] : []), '',
85
+ '## Actual', '', `- ${row.received ? `Received: ${row.received}` : row.evidence}`, '',
86
+ '## Evidence', '', `- Error: ${row.evidence}`, ...(row.tracePath ? [`- Trace: ${row.tracePath}`] : []), '',
87
+ '## Notes', '', `- Triage: ${row.class} (${row.ruleId})${row.variant ? ` on ${row.variant}` : ''}`, '',
88
+ ].join('\n');
89
+ return redactSecrets(body, sc?.secrets ?? []);
90
+ }
91
+
92
+ export function draftDefects(cwd: string, unit: ResolvedUnit, opts: { scenario?: string } = {}): DraftResult {
93
+ const triage = readTriageReport(cwd, unit.unitId);
94
+ if (!triage) return { missingTriage: true, written: [], skipped: [] };
95
+ const recorded = new Map<string, string>();
96
+ for (const r of readDefects(cwd)) if (r.units.includes(unit.unitId)) for (const s of r.scenarios) recorded.set(s, r.id);
97
+
98
+ const wanted = triage.rows.filter((r) => r.class === 'product-candidate'
99
+ && (!opts.scenario || r.scenario.includes(opts.scenario) || r.ids.includes(opts.scenario)));
100
+ const scenarios = unitScenarios(cwd, unit);
101
+ const result: DraftResult = { written: [], skipped: [] };
102
+ const seen = new Set<string>();
103
+ for (const row of wanted) {
104
+ if (seen.has(row.scenario)) continue; // one record per scenario, even across OS variants
105
+ seen.add(row.scenario);
106
+ const existing = recorded.get(row.scenario);
107
+ if (existing) { result.skipped.push({ scenario: row.scenario, id: existing }); continue; }
108
+ const id = nextDefectId(cwd);
109
+ const file = path.join(defectsDir(cwd), `${id}-${slug(row.scenario) || 'defect'}.md`);
110
+ fs.mkdirSync(path.dirname(file), { recursive: true });
111
+ fs.writeFileSync(file, render(cwd, unit, id, row, scenarios.get(row.scenario)), { encoding: 'utf-8', flag: 'wx' });
112
+ result.written.push(path.relative(cwd, file));
113
+ }
114
+ return result;
115
+ }