@sun-asterisk/sungen 3.2.18 → 3.2.19

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 (249) hide show
  1. package/dist/capabilities/discover.d.ts.map +1 -1
  2. package/dist/capabilities/discover.js +4 -1
  3. package/dist/capabilities/discover.js.map +1 -1
  4. package/dist/cli/commands/add.d.ts.map +1 -1
  5. package/dist/cli/commands/add.js +4 -2
  6. package/dist/cli/commands/add.js.map +1 -1
  7. package/dist/cli/commands/dashboard.d.ts.map +1 -1
  8. package/dist/cli/commands/dashboard.js +10 -1
  9. package/dist/cli/commands/dashboard.js.map +1 -1
  10. package/dist/cli/commands/delivery.d.ts.map +1 -1
  11. package/dist/cli/commands/delivery.js +19 -1
  12. package/dist/cli/commands/delivery.js.map +1 -1
  13. package/dist/cli/commands/generate.d.ts.map +1 -1
  14. package/dist/cli/commands/generate.js +4 -1
  15. package/dist/cli/commands/generate.js.map +1 -1
  16. package/dist/dashboard/html-renderer.d.ts.map +1 -1
  17. package/dist/dashboard/html-renderer.js +7 -2
  18. package/dist/dashboard/html-renderer.js.map +1 -1
  19. package/dist/dashboard/snapshot-builder.d.ts.map +1 -1
  20. package/dist/dashboard/snapshot-builder.js +3 -0
  21. package/dist/dashboard/snapshot-builder.js.map +1 -1
  22. package/dist/dashboard/types.d.ts +4 -0
  23. package/dist/dashboard/types.d.ts.map +1 -1
  24. package/dist/exporters/api-environment.d.ts.map +1 -1
  25. package/dist/exporters/api-environment.js +4 -24
  26. package/dist/exporters/api-environment.js.map +1 -1
  27. package/dist/exporters/api-testcase-formatter.d.ts +6 -0
  28. package/dist/exporters/api-testcase-formatter.d.ts.map +1 -1
  29. package/dist/exporters/api-testcase-formatter.js +11 -4
  30. package/dist/exporters/api-testcase-formatter.js.map +1 -1
  31. package/dist/exporters/csv-exporter.d.ts.map +1 -1
  32. package/dist/exporters/csv-exporter.js +40 -9
  33. package/dist/exporters/csv-exporter.js.map +1 -1
  34. package/dist/exporters/env-qa.d.ts +13 -0
  35. package/dist/exporters/env-qa.d.ts.map +1 -0
  36. package/dist/exporters/env-qa.js +82 -0
  37. package/dist/exporters/env-qa.js.map +1 -0
  38. package/dist/exporters/feature-parser.d.ts.map +1 -1
  39. package/dist/exporters/feature-parser.js +16 -33
  40. package/dist/exporters/feature-parser.js.map +1 -1
  41. package/dist/exporters/json-exporter.d.ts.map +1 -1
  42. package/dist/exporters/json-exporter.js +57 -20
  43. package/dist/exporters/json-exporter.js.map +1 -1
  44. package/dist/exporters/matrix/build.d.ts.map +1 -1
  45. package/dist/exporters/matrix/build.js +6 -5
  46. package/dist/exporters/matrix/build.js.map +1 -1
  47. package/dist/exporters/matrix/gates.d.ts.map +1 -1
  48. package/dist/exporters/matrix/gates.js +3 -2
  49. package/dist/exporters/matrix/gates.js.map +1 -1
  50. package/dist/exporters/matrix/step-buckets.d.ts +13 -0
  51. package/dist/exporters/matrix/step-buckets.d.ts.map +1 -0
  52. package/dist/exporters/matrix/step-buckets.js +7 -0
  53. package/dist/exporters/matrix/step-buckets.js.map +1 -0
  54. package/dist/exporters/qa-env-secret-denylist.d.ts +43 -0
  55. package/dist/exporters/qa-env-secret-denylist.d.ts.map +1 -0
  56. package/dist/exporters/qa-env-secret-denylist.js +143 -0
  57. package/dist/exporters/qa-env-secret-denylist.js.map +1 -0
  58. package/dist/exporters/scenario-merger.d.ts +20 -13
  59. package/dist/exporters/scenario-merger.d.ts.map +1 -1
  60. package/dist/exporters/scenario-merger.js +55 -30
  61. package/dist/exporters/scenario-merger.js.map +1 -1
  62. package/dist/exporters/step-formatter.d.ts +42 -33
  63. package/dist/exporters/step-formatter.d.ts.map +1 -1
  64. package/dist/exporters/step-formatter.js +68 -68
  65. package/dist/exporters/step-formatter.js.map +1 -1
  66. package/dist/exporters/types.d.ts +9 -8
  67. package/dist/exporters/types.d.ts.map +1 -1
  68. package/dist/generators/test-generator/adapters/adapter-interface.d.ts +1 -0
  69. package/dist/generators/test-generator/adapters/adapter-interface.d.ts.map +1 -1
  70. package/dist/generators/test-generator/adapters/appium/appium-adapter.d.ts +1 -0
  71. package/dist/generators/test-generator/adapters/appium/appium-adapter.d.ts.map +1 -1
  72. package/dist/generators/test-generator/adapters/appium/appium-adapter.js.map +1 -1
  73. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/keyboard-global-action.hbs +3 -1
  74. package/dist/generators/test-generator/adapters/playwright/playwright-adapter.d.ts +1 -0
  75. package/dist/generators/test-generator/adapters/playwright/playwright-adapter.d.ts.map +1 -1
  76. package/dist/generators/test-generator/adapters/playwright/playwright-adapter.js.map +1 -1
  77. package/dist/generators/test-generator/adapters/playwright/templates/imports.hbs +3 -0
  78. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/keyboard-global-action.hbs +3 -1
  79. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/page-assertion.hbs +1 -1
  80. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/storage-key-assertion.hbs +4 -0
  81. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/tab-order-assertion.hbs +30 -0
  82. package/dist/generators/test-generator/adapters/playwright/templates/steps/navigation/route-assertion.hbs +1 -4
  83. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/dialog-root.hbs +1 -1
  84. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/url-assertion.hbs +10 -0
  85. package/dist/generators/test-generator/code-generator.d.ts.map +1 -1
  86. package/dist/generators/test-generator/code-generator.js +70 -7
  87. package/dist/generators/test-generator/code-generator.js.map +1 -1
  88. package/dist/generators/test-generator/diagnostics.d.ts +7 -1
  89. package/dist/generators/test-generator/diagnostics.d.ts.map +1 -1
  90. package/dist/generators/test-generator/diagnostics.js +6 -0
  91. package/dist/generators/test-generator/diagnostics.js.map +1 -1
  92. package/dist/generators/test-generator/recipe-expander.d.ts +1 -1
  93. package/dist/generators/test-generator/recipe-expander.d.ts.map +1 -1
  94. package/dist/generators/test-generator/recipe-expander.js +1 -1
  95. package/dist/generators/test-generator/recipe-expander.js.map +1 -1
  96. package/dist/generators/test-generator/step-mapper.d.ts +17 -0
  97. package/dist/generators/test-generator/step-mapper.d.ts.map +1 -1
  98. package/dist/generators/test-generator/step-mapper.js +108 -17
  99. package/dist/generators/test-generator/step-mapper.js.map +1 -1
  100. package/dist/generators/test-generator/template-engine.d.ts +1 -0
  101. package/dist/generators/test-generator/template-engine.d.ts.map +1 -1
  102. package/dist/generators/test-generator/template-engine.js +1 -1
  103. package/dist/generators/test-generator/template-engine.js.map +1 -1
  104. package/dist/generators/test-generator/utils/data-resolver.d.ts +7 -1
  105. package/dist/generators/test-generator/utils/data-resolver.d.ts.map +1 -1
  106. package/dist/generators/test-generator/utils/data-resolver.js +8 -4
  107. package/dist/generators/test-generator/utils/data-resolver.js.map +1 -1
  108. package/dist/generators/test-generator/utils/runtime-data-transformer.d.ts +2 -2
  109. package/dist/generators/test-generator/utils/runtime-data-transformer.d.ts.map +1 -1
  110. package/dist/generators/test-generator/utils/runtime-data-transformer.js +56 -11
  111. package/dist/generators/test-generator/utils/runtime-data-transformer.js.map +1 -1
  112. package/dist/generators/test-generator/utils/selector-resolver.d.ts +18 -2
  113. package/dist/generators/test-generator/utils/selector-resolver.d.ts.map +1 -1
  114. package/dist/generators/test-generator/utils/selector-resolver.js +29 -6
  115. package/dist/generators/test-generator/utils/selector-resolver.js.map +1 -1
  116. package/dist/harness/audit.d.ts.map +1 -1
  117. package/dist/harness/audit.js +8 -7
  118. package/dist/harness/audit.js.map +1 -1
  119. package/dist/harness/capability-plan.d.ts.map +1 -1
  120. package/dist/harness/capability-plan.js +2 -3
  121. package/dist/harness/capability-plan.js.map +1 -1
  122. package/dist/harness/catalog/drivers.yaml +1 -1
  123. package/dist/harness/challenge.d.ts.map +1 -1
  124. package/dist/harness/challenge.js +1 -55
  125. package/dist/harness/challenge.js.map +1 -1
  126. package/dist/harness/data-driven-lint.d.ts.map +1 -1
  127. package/dist/harness/data-driven-lint.js +14 -9
  128. package/dist/harness/data-driven-lint.js.map +1 -1
  129. package/dist/harness/depth-lint.d.ts.map +1 -1
  130. package/dist/harness/depth-lint.js +1 -3
  131. package/dist/harness/depth-lint.js.map +1 -1
  132. package/dist/harness/flow-check.d.ts.map +1 -1
  133. package/dist/harness/flow-check.js +9 -7
  134. package/dist/harness/flow-check.js.map +1 -1
  135. package/dist/harness/flow-plan.d.ts.map +1 -1
  136. package/dist/harness/flow-plan.js +4 -3
  137. package/dist/harness/flow-plan.js.map +1 -1
  138. package/dist/harness/manifest.js +2 -2
  139. package/dist/harness/manifest.js.map +1 -1
  140. package/dist/harness/parse.d.ts +12 -0
  141. package/dist/harness/parse.d.ts.map +1 -1
  142. package/dist/harness/parse.js +28 -1
  143. package/dist/harness/parse.js.map +1 -1
  144. package/dist/harness/script-check.d.ts.map +1 -1
  145. package/dist/harness/script-check.js +52 -30
  146. package/dist/harness/script-check.js.map +1 -1
  147. package/dist/harness/secret-scan.d.ts +3 -1
  148. package/dist/harness/secret-scan.d.ts.map +1 -1
  149. package/dist/harness/secret-scan.js +14 -10
  150. package/dist/harness/secret-scan.js.map +1 -1
  151. package/dist/harness/sensors.d.ts.map +1 -1
  152. package/dist/harness/sensors.js +15 -4
  153. package/dist/harness/sensors.js.map +1 -1
  154. package/dist/harness/trace.js +1 -1
  155. package/dist/harness/trace.js.map +1 -1
  156. package/dist/harness/unit-paths.d.ts +16 -0
  157. package/dist/harness/unit-paths.d.ts.map +1 -1
  158. package/dist/harness/unit-paths.js +39 -2
  159. package/dist/harness/unit-paths.js.map +1 -1
  160. package/dist/index.d.ts +1 -0
  161. package/dist/index.d.ts.map +1 -1
  162. package/dist/index.js +3 -1
  163. package/dist/index.js.map +1 -1
  164. package/dist/orchestrator/screen-manager.d.ts.map +1 -1
  165. package/dist/orchestrator/screen-manager.js +8 -2
  166. package/dist/orchestrator/screen-manager.js.map +1 -1
  167. package/dist/orchestrator/templates/ai-src/commands/dashboard.md +2 -1
  168. package/dist/orchestrator/templates/ai-src/commands/run-test.md +1 -1
  169. package/dist/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +2 -0
  170. package/dist/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +51 -2
  171. package/dist/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +6 -0
  172. package/dist/orchestrator/templates/ai-src/skills/sungen-selector-keys/SKILL.md +6 -0
  173. package/dist/orchestrator/templates/playwright.config.ts +33 -2
  174. package/dist/orchestrator/templates/specs-mock.d.ts +66 -0
  175. package/dist/orchestrator/templates/specs-mock.d.ts.map +1 -0
  176. package/dist/orchestrator/templates/specs-mock.js +137 -0
  177. package/dist/orchestrator/templates/specs-mock.js.map +1 -0
  178. package/dist/orchestrator/templates/specs-mock.ts +179 -0
  179. package/dist/orchestrator/test-data-guide.d.ts.map +1 -1
  180. package/dist/orchestrator/test-data-guide.js +4 -0
  181. package/dist/orchestrator/test-data-guide.js.map +1 -1
  182. package/package.json +3 -3
  183. package/src/capabilities/discover.ts +4 -1
  184. package/src/cli/commands/add.ts +4 -2
  185. package/src/cli/commands/dashboard.ts +10 -1
  186. package/src/cli/commands/delivery.ts +19 -1
  187. package/src/cli/commands/generate.ts +4 -1
  188. package/src/dashboard/html-renderer.ts +8 -2
  189. package/src/dashboard/snapshot-builder.ts +3 -0
  190. package/src/dashboard/types.ts +4 -0
  191. package/src/exporters/api-environment.ts +3 -20
  192. package/src/exporters/api-testcase-formatter.ts +8 -2
  193. package/src/exporters/csv-exporter.ts +47 -8
  194. package/src/exporters/env-qa.ts +41 -0
  195. package/src/exporters/feature-parser.ts +17 -38
  196. package/src/exporters/json-exporter.ts +71 -24
  197. package/src/exporters/matrix/build.ts +6 -5
  198. package/src/exporters/matrix/gates.ts +3 -2
  199. package/src/exporters/matrix/step-buckets.ts +15 -0
  200. package/src/exporters/qa-env-secret-denylist.ts +118 -0
  201. package/src/exporters/scenario-merger.ts +80 -42
  202. package/src/exporters/step-formatter.ts +67 -66
  203. package/src/exporters/types.ts +9 -8
  204. package/src/generators/test-generator/adapters/adapter-interface.ts +1 -1
  205. package/src/generators/test-generator/adapters/appium/appium-adapter.ts +1 -1
  206. package/src/generators/test-generator/adapters/appium/templates/steps/actions/keyboard-global-action.hbs +3 -1
  207. package/src/generators/test-generator/adapters/playwright/playwright-adapter.ts +1 -1
  208. package/src/generators/test-generator/adapters/playwright/templates/imports.hbs +3 -0
  209. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/keyboard-global-action.hbs +3 -1
  210. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/page-assertion.hbs +1 -1
  211. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/storage-key-assertion.hbs +4 -0
  212. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/tab-order-assertion.hbs +30 -0
  213. package/src/generators/test-generator/adapters/playwright/templates/steps/navigation/route-assertion.hbs +1 -4
  214. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/dialog-root.hbs +1 -1
  215. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/url-assertion.hbs +10 -0
  216. package/src/generators/test-generator/code-generator.ts +82 -7
  217. package/src/generators/test-generator/diagnostics.ts +7 -1
  218. package/src/generators/test-generator/recipe-expander.ts +2 -2
  219. package/src/generators/test-generator/step-mapper.ts +106 -15
  220. package/src/generators/test-generator/template-engine.ts +2 -2
  221. package/src/generators/test-generator/utils/data-resolver.ts +8 -5
  222. package/src/generators/test-generator/utils/runtime-data-transformer.ts +57 -11
  223. package/src/generators/test-generator/utils/selector-resolver.ts +29 -5
  224. package/src/harness/audit.ts +10 -9
  225. package/src/harness/capability-plan.ts +2 -2
  226. package/src/harness/catalog/drivers.yaml +1 -1
  227. package/src/harness/challenge.ts +2 -3
  228. package/src/harness/data-driven-lint.ts +13 -9
  229. package/src/harness/depth-lint.ts +2 -4
  230. package/src/harness/flow-check.ts +9 -7
  231. package/src/harness/flow-plan.ts +4 -3
  232. package/src/harness/manifest.ts +3 -3
  233. package/src/harness/parse.ts +28 -1
  234. package/src/harness/script-check.ts +52 -32
  235. package/src/harness/secret-scan.ts +11 -7
  236. package/src/harness/sensors.ts +15 -4
  237. package/src/harness/trace.ts +2 -2
  238. package/src/harness/unit-paths.ts +39 -2
  239. package/src/index.ts +1 -0
  240. package/src/orchestrator/screen-manager.ts +7 -2
  241. package/src/orchestrator/templates/ai-src/commands/dashboard.md +2 -1
  242. package/src/orchestrator/templates/ai-src/commands/run-test.md +1 -1
  243. package/src/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +2 -0
  244. package/src/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +51 -2
  245. package/src/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +6 -0
  246. package/src/orchestrator/templates/ai-src/skills/sungen-selector-keys/SKILL.md +6 -0
  247. package/src/orchestrator/templates/playwright.config.ts +33 -2
  248. package/src/orchestrator/templates/specs-mock.ts +179 -0
  249. package/src/orchestrator/test-data-guide.ts +4 -0
@@ -20,12 +20,18 @@
20
20
  * SG-W010 — a row step's [Ref] looks POSITIONAL (First/Last/Top/…) but has no selectors.yaml
21
21
  * entry: the label is display-only, so the step checks existence anywhere in the
22
22
  * table — an ordering rule would pass even when the row moved
23
+ * SG-W011 — a storage assertion written in `expect … in local/session storage` form:
24
+ * `expect` steps only read `{{response}}` refs, so this compiles to a silent
25
+ * no-op — the supported grammar is `User see [KEY] in local storage exists/…`
26
+ * SG-W012 — a mock-install step AFTER a navigation step in the same block: page.route()
27
+ * registered after goto() misses every request fired during page load — move
28
+ * the install before the navigation (or into Background)
23
29
  *
24
30
  * Diagnostics never block generation by themselves; `sungen generate --strict`
25
31
  * turns any collected diagnostic into a non-zero exit.
26
32
  */
27
33
 
28
- export type DiagnosticCode = 'SG-W001' | 'SG-W002' | 'SG-W003' | 'SG-W005' | 'SG-W006' | 'SG-W007' | 'SG-W008' | 'SG-W009' | 'SG-W010';
34
+ export type DiagnosticCode = 'SG-W001' | 'SG-W002' | 'SG-W003' | 'SG-W005' | 'SG-W006' | 'SG-W007' | 'SG-W008' | 'SG-W009' | 'SG-W010' | 'SG-W011' | 'SG-W012';
29
35
 
30
36
  export interface Diagnostic {
31
37
  code: DiagnosticCode;
@@ -21,7 +21,7 @@
21
21
  export interface RecipeExpandContext {
22
22
  /** Pre-rendered locator code for the widget element (dialog/frame scoping included). */
23
23
  triggerCode: string;
24
- /** The step's value — a literal or a __SUNGEN_TD__ runtime marker. */
24
+ /** The step's value — a literal or a __SUNGEN_TD$…$__ runtime marker. */
25
25
  value: string;
26
26
  }
27
27
 
@@ -53,7 +53,7 @@ function expandPrimitive(prim: unknown, ctx: RecipeExpandContext, itemVar: strin
53
53
  const targetExpr = (v: unknown): string =>
54
54
  v === '$trigger' ? ctx.triggerCode : `page.locator('${escape(String(v))}')`;
55
55
  // Resolve a `$value`/`$item`/literal into a JS value EXPRESSION (quoted unless $item;
56
- // a quoted __SUNGEN_TD__ marker is rewritten to testData.get(...) by the runtime transform).
56
+ // a quoted __SUNGEN_TD$…$__ marker is rewritten to testData.get(...) by the runtime transform).
57
57
  const valueExpr = (v: unknown): string => {
58
58
  if (v === '$item') {
59
59
  if (!itemVar) throw new Error('$item is only valid inside forEach');
@@ -43,6 +43,8 @@ export class StepMapper {
43
43
  private tableRowDeclared: boolean = false;
44
44
  /** SG-W007: an action step already ran in this scenario (alert handlers registered after it are too late) */
45
45
  private sawActionStep: boolean = false;
46
+ /** SG-W012: a navigation already ran in this scenario (mocks installed after it miss page-load requests) */
47
+ private sawNavigationStep: boolean = false;
46
48
  /** Compile-time warning channel (SG-W001/W002); optional — absent means silent legacy behavior */
47
49
  private diagnostics?: DiagnosticsCollector;
48
50
  /** App profile (qa/app.yaml merged over presets/defaults) — the per-project BEHAVIOR layer */
@@ -91,6 +93,14 @@ export class StepMapper {
91
93
  this.featureName = featureName;
92
94
  this.featurePath = featurePath;
93
95
  this.selectorResolver.setFeatureContext(featureName);
96
+ // One StepMapper serves every feature in a run, so the step counter that names the
97
+ // generated temporaries (`__t_<n>`) used to carry over between files: a screen's spec
98
+ // then depended on how many steps the PRECEDING files happened to contain — i.e. on
99
+ // readdir order and on whether the run was `--all` or `--screen`. Same input, different
100
+ // output, which is exactly what a deterministic compiler must not do (and what made a
101
+ // multi-feature screen report permanent false DRIFT in script-check). The counter only
102
+ // needs to be unique WITHIN one spec file, so it restarts with each feature.
103
+ this.stepCounter = 0;
94
104
  }
95
105
 
96
106
  /**
@@ -137,6 +147,7 @@ export class StepMapper {
137
147
  this.tableRowScopeSteps = steps.filter((s) => this.isRowScopeEntryStep(s)).length;
138
148
  this.tableRowDeclared = false;
139
149
  this.sawActionStep = false;
150
+ this.sawNavigationStep = false;
140
151
  this.lastPrimaryKeyword = 'Given';
141
152
  this.dataResolver.clearCaptured(); // captured vars are scenario-scoped
142
153
  this.templateEngine.resetBaseContext();
@@ -181,6 +192,20 @@ export class StepMapper {
181
192
  this.sawActionStep = true;
182
193
  }
183
194
 
195
+ // A mock must be routed BEFORE the navigation whose requests it should intercept —
196
+ // page.route() registered after goto() misses everything fired during page load
197
+ // (mirror of the late-alert-listener check above). Background installs compile
198
+ // into beforeEach, before any scenario step, so they never trip this.
199
+ const isMockInstallStep = /\[[^\]]+\]\s+mock\s+is\s+active\b/i.test(step.text);
200
+ if (isMockInstallStep && this.sawNavigationStep) {
201
+ this.diagnostics?.add('SG-W012',
202
+ 'mock installed AFTER a navigation step — requests fired during page load were not intercepted. Move the mock step before the navigation, or declare it in Background',
203
+ { feature: this.featureName, step: step.text });
204
+ } else if (!isMockInstallStep && step.elementType === 'page' &&
205
+ /\b(?:is on|navigates?\s+to|opens?)\b/i.test(step.text)) {
206
+ this.sawNavigationStep = true;
207
+ }
208
+
184
209
  // Frame scope directives — intercept before pattern matching
185
210
  if (/\bswitch(?:es)?\s+to\b/i.test(step.text) &&
186
211
  (step.elementType === 'frame' || step.elementType === 'iframe')) {
@@ -221,7 +246,14 @@ export class StepMapper {
221
246
  // Given User is on [...] dialog — set dialog scope (3 cases)
222
247
  if (step.text.includes('is on') && step.elementType === 'dialog') {
223
248
  this.inDialogScope = true;
224
- const contextVars: Record<string, any> = { inDialog: true };
249
+ // setBaseContext MERGES clear every root key so a previous dialog scope in the
250
+ // same scenario cannot leak its root into this one.
251
+ const contextVars: Record<string, any> = {
252
+ inDialog: true,
253
+ dialogRootExpr: undefined,
254
+ dialogRoleName: undefined,
255
+ dialogFilterText: undefined,
256
+ };
225
257
 
226
258
  if (step.selectorRef && step.dataRef) {
227
259
  // Case 3: [panel] dialog with {{value}}
@@ -234,16 +266,18 @@ export class StepMapper {
234
266
  filterText = `\${${step.dataRef}}`;
235
267
  }
236
268
  contextVars.dialogFilterText = filterText;
269
+ // The declared entry still owns the ROOT the filter applies to — but only when it
270
+ // is a non-role strategy (locator/testid/css). A role entry keeps the bare
271
+ // getByRole('dialog') base on purpose here: the name is dynamic, hence the filter.
272
+ const rootExpr = this.renderDialogRoot(step.selectorRef, { nonRoleOnly: true });
273
+ if (rootExpr) contextVars.dialogRootExpr = rootExpr;
237
274
  } else if (step.selectorRef) {
238
- // Case 2: [Title] dialog → named dialog role
239
- let roleName = step.selectorRef;
240
- try {
241
- const resolved = this.selectorResolver.resolveSelector(step.selectorRef, this.featureName, 'dialog', 0);
242
- roleName = resolved.name || step.selectorRef;
243
- } catch {
244
- roleName = step.selectorRef;
245
- }
246
- contextVars.dialogRoleName = roleName;
275
+ // Case 2: [Title] dialog → the root the DECLARED selectors.yaml entry asks for
276
+ // (issue: an explicit `type: locator` / `value: '[role="dialog"]'` was being
277
+ // discarded in favour of getByRole('dialog', { name: <reference text> })).
278
+ const rootExpr = this.renderDialogRoot(step.selectorRef);
279
+ if (rootExpr) contextVars.dialogRootExpr = rootExpr;
280
+ else contextVars.dialogRoleName = step.selectorRef;
247
281
  }
248
282
  // Case 1: no selectorRef → plain page.getByRole('dialog')
249
283
 
@@ -303,16 +337,69 @@ export class StepMapper {
303
337
  }
304
338
 
305
339
  // No pattern matched - return TODO comment
306
- this.diagnostics?.add('SG-W001', 'step matched no pattern — compiled to a no-op // TODO comment', {
307
- feature: this.featureName,
308
- step: step.text,
309
- });
340
+ if (/\bexpect\b.*\b(?:local|session)\s+storage\b/i.test(step.text)) {
341
+ // The natural first guess for a storage check — but `expect` steps only read
342
+ // {{response}} refs, so this shape silently compiles to nothing. Point at the
343
+ // supported grammar instead of emitting the generic no-pattern warning.
344
+ this.diagnostics?.add('SG-W011',
345
+ 'storage assertions use `User see [KEY] in local storage exists` (also: does not exist / is not empty / has length {{n}} / key matching "PATTERN") — the `expect …` form compiles to a no-op', {
346
+ feature: this.featureName,
347
+ step: step.text,
348
+ });
349
+ } else {
350
+ this.diagnostics?.add('SG-W001', 'step matched no pattern — compiled to a no-op // TODO comment', {
351
+ feature: this.featureName,
352
+ step: step.text,
353
+ });
354
+ }
310
355
  return {
311
356
  code: `// TODO: Implement step: ${step.text}`,
312
357
  comment: `Unrecognized step pattern: ${step.text}`,
313
358
  };
314
359
  }
315
360
 
361
+ /** Strategies the `locator` partial renders into a usable ROOT expression. Anything else
362
+ * (mobile native strategies) has no meaning as a Playwright dialog root. */
363
+ private static readonly DIALOG_ROOT_STRATEGIES = ['locator', 'testid', 'role', 'id', 'label', 'text', 'placeholder'];
364
+
365
+ /**
366
+ * Render the Playwright root expression for `Given User is on [X] dialog` from the entry
367
+ * DECLARED in selectors.yaml, through the same `locator` partial every other step renders
368
+ * with — so `type: locator`, `type: testid`, a non-`dialog` role, `name: null`, `exact:`
369
+ * and `nth:` mean here exactly what they mean everywhere else. Before this, the entry was
370
+ * read for its `name` only and the root was hardcoded to getByRole('dialog', { name }),
371
+ * silently discarding an explicit `type: locator` / `value: '[role="dialog"]'`.
372
+ *
373
+ * Returns undefined when the reference does not resolve to a usable root — the caller then
374
+ * falls back to the legacy named-dialog root, so behavior is unchanged for those entries.
375
+ */
376
+ private renderDialogRoot(selectorRef: string, opts: { nonRoleOnly?: boolean } = {}): string | undefined {
377
+ let resolved: any;
378
+ try {
379
+ resolved = this.selectorResolver.resolveSelector(selectorRef, this.featureName, 'dialog', 0);
380
+ } catch {
381
+ return undefined;
382
+ }
383
+ if (!resolved) return undefined;
384
+
385
+ // `type: role` + a `locator:` field collapses to strategy 'css', which the locator partial
386
+ // has no case for — normalize it to the raw-locator strategy it actually means.
387
+ if (resolved.strategy === 'css' && resolved.locator) {
388
+ resolved = { ...resolved, strategy: 'locator', value: resolved.locator };
389
+ }
390
+ if (!StepMapper.DIALOG_ROOT_STRATEGIES.includes(resolved.strategy)) return undefined;
391
+ if (opts.nonRoleOnly && resolved.strategy === 'role') return undefined;
392
+
393
+ // Render the root PAGE-rooted: leaving inDialog set (or `scope: dialog` on the entry
394
+ // itself) would send the partial back through dialog-root and nest the dialog in itself.
395
+ const expr = this.templateEngine.renderStep('locator', {
396
+ ...resolved,
397
+ scope: resolved.scope === 'dialog' ? undefined : resolved.scope,
398
+ inDialog: false,
399
+ }).trim();
400
+ return expr || undefined;
401
+ }
402
+
316
403
  /**
317
404
  * SG-W002 — a step carried a parsed {{dataRef}} but the matched pattern's generated
318
405
  * code never uses it (neither the runtime marker, the ${ref} fallback, nor the
@@ -323,7 +410,11 @@ export class StepMapper {
323
410
  private checkDataRefConsumed(step: ParsedStep, mapped: MappedStep): void {
324
411
  if (!step.dataRef || !this.diagnostics) return;
325
412
  const code = mapped.code || '';
326
- if (code.includes(DataResolver.encodeMarker(step.dataRef))) return; // runtime marker
413
+ // Runtime marker in both spellings: templates that embed the value into a regex
414
+ // literal run it through the escapeRegex helper, which escapes the marker's `$`
415
+ // delimiters, so the code may carry `__SUNGEN_TD\$key\$__` instead of the plain form.
416
+ const marker = DataResolver.encodeMarker(step.dataRef);
417
+ if (code.includes(marker) || code.includes(marker.replace(/\$/g, '\\$'))) return;
327
418
  if (code.includes(`\${${step.dataRef}}`)) return; // unresolved fallback
328
419
  if (code.includes(step.dataRef)) return; // testData.set('ref'), comments
329
420
  try {
@@ -277,8 +277,8 @@ export class TemplateEngine {
277
277
  this.baseContext = { ...this.persistentContext };
278
278
  }
279
279
 
280
- renderImports(options?: { runtimeData?: boolean; basePath?: string; isParallel?: boolean; needsCleanupImport?: boolean ; needsDb?: boolean; needsApi?: boolean }): string {
281
- return this.render('imports', { runtimeData: options?.runtimeData, basePath: options?.basePath || '..', isParallel: options?.isParallel, needsCleanupImport: options?.needsCleanupImport, needsDb: options?.needsDb, needsApi: options?.needsApi });
280
+ renderImports(options?: { runtimeData?: boolean; basePath?: string; isParallel?: boolean; needsCleanupImport?: boolean ; needsDb?: boolean; needsApi?: boolean; needsMock?: boolean }): string {
281
+ return this.render('imports', { runtimeData: options?.runtimeData, basePath: options?.basePath || '..', isParallel: options?.isParallel, needsCleanupImport: options?.needsCleanupImport, needsDb: options?.needsDb, needsApi: options?.needsApi, needsMock: options?.needsMock });
282
282
  }
283
283
 
284
284
  renderTestFile(data: {
@@ -112,12 +112,15 @@ export class DataResolver {
112
112
  return { fileName: parts[0], valuePath: parts.slice(1) };
113
113
  }
114
114
 
115
+ /**
116
+ * Encode a test-data ref as an opaque wire marker: `__SUNGEN_TD$<key>$__`,
117
+ * dots escaped to `_D_`. `$` delimiters keep the key boundary unambiguous
118
+ * when the surrounding text continues with underscores (`{{code}}_SUFFIX`)
119
+ * while the marker stays a single JS-identifier-safe token. This is the ONLY
120
+ * producer; the only decoder lives in runtime-data-transformer.ts.
121
+ */
115
122
  static encodeMarker(ref: string): string {
116
- return `__SUNGEN_TD_${ref.replace(/\./g, '_D_')}__`;
117
- }
118
-
119
- static decodeMarker(encoded: string): string {
120
- return encoded.replace(/^__SUNGEN_TD_/, '').replace(/__$/, '').replace(/_D_/g, '.');
123
+ return `__SUNGEN_TD$${ref.replace(/\./g, '_D_')}$__`;
121
124
  }
122
125
 
123
126
  /**
@@ -1,8 +1,54 @@
1
- const MARKER_PATTERN = /__SUNGEN_TD_([A-Za-z0-9_]+)__/;
1
+ /**
2
+ * Wire format of a runtime test-data marker: `__SUNGEN_TD$<key>$__`.
3
+ *
4
+ * `$` delimits the encoded key on both sides. The key alphabet is [A-Za-z0-9_]
5
+ * (dots in refs are escaped to `_D_` by DataResolver.encodeMarker), so a `$`
6
+ * can never occur INSIDE a key — the `$__` terminator stays unambiguous even
7
+ * when the surrounding literal continues with underscore text. The previous
8
+ * `__SUNGEN_TD_<key>__` format could not tell its `__` terminator apart from a
9
+ * `_`-leading suffix: in `'{{code}}_ACCESS_TOKEN'` the greedy key class
10
+ * swallowed one `_` of the `___` run and decoding produced `get('code_')` plus
11
+ * a detached `ACCESS_TOKEN`. `$` is also identifier-safe — the whole marker
12
+ * still parses as ONE bare JS token, so it survives the bare-identifier
13
+ * (numeric) positions Pass 3 handles.
14
+ *
15
+ * Single source of truth: every pass and every whole-literal probe below is
16
+ * built from these constants. Invariant: every marker producer goes through
17
+ * DataResolver.encodeMarker and every consumer through this module — a
18
+ * drifting hand-copied char class is what let the suffix bug hide.
19
+ */
20
+ const MARKER_KEY = '[A-Za-z0-9_]+';
21
+ // Templates that embed a value into a regex literal run it through the escapeRegex
22
+ // Handlebars helper first (template-engine.ts), which escapes `$` — so a marker can
23
+ // arrive here spelled `__SUNGEN_TD\$key\$__`. `\\\\?` accepts that optional backslash
24
+ // before each delimiter; the replacement consumes it, so no escape leaks to output.
25
+ const DELIM = '\\\\?\\$';
26
+ /** Marker with no capture group — for "contains a marker" positions inside bigger regexes. */
27
+ const MARKER = `__SUNGEN_TD${DELIM}${MARKER_KEY}${DELIM}__`;
28
+ /** Marker capturing the encoded key. */
29
+ const MARKER_CAP = `__SUNGEN_TD${DELIM}(${MARKER_KEY})${DELIM}__`;
30
+
31
+ const MARKER_PATTERN = new RegExp(MARKER);
32
+ // Pass 0: full-line // comments carrying a marker (anchored — see pass comment).
33
+ const RE_COMMENT_LINE = new RegExp(`^(\\s*)\\/\\/(.*)${MARKER_CAP}(.*)$`, 'gm');
34
+ // Pass 1: a quoted string literal containing at least one marker. Covers ', " and
35
+ // backtick containers; `\\.` lets an ESCAPED quote (`'it\'s {{x}}'`) sit inside the
36
+ // body instead of terminating the literal early. The single-char branch excludes `\`
37
+ // so every backslash is consumed by exactly ONE branch — an ambiguous `(?!\1).`
38
+ // alternative enumerates 2^n segmentations when a marker-less escape-heavy literal
39
+ // (escapeQuotes output: multi-line Flutter content-desc) fails to match, hanging
40
+ // the generate run. `\n` exclusion just makes the dot's no-newline behavior explicit.
41
+ const RE_STRING_LITERAL = new RegExp('([\'"`])((?:\\\\.|(?!\\1)[^\\\\\\n])*?' + MARKER + '(?:\\\\.|(?!\\1)[^\\\\\\n])*)\\1', 'g');
42
+ // Pass 2: a single-line regex literal containing at least one marker.
43
+ const RE_REGEX_LITERAL = new RegExp(`\\/((?:[^/\\\\\\n]|\\\\.)*?${MARKER}(?:[^/\\\\\\n]|\\\\.)*)\\/([gimsuy]*)`, 'g');
44
+ // Whole-literal probe: the body is EXACTLY one marker (bare accessor call, no template).
45
+ const RE_WHOLE_MARKER = new RegExp(`^${MARKER_CAP}$`);
46
+ // Every marker occurrence, capturing the key (Pass 3 + interpolation).
47
+ const RE_MARKER_G = new RegExp(MARKER_CAP, 'g');
2
48
 
3
49
  /**
4
- * Replace __SUNGEN_TD_ markers with testData.get() calls in generated code.
5
- * Three passes: comments, string literals, then regex literals.
50
+ * Replace __SUNGEN_TD$…$__ markers with testData.get() calls in generated code.
51
+ * Four passes: comments, string literals, regex literals, then bare identifiers.
6
52
  */
7
53
  export function transformToRuntimeData(code: string, accessor: string = 'testData'): string {
8
54
  // Pass 0: Comments — replace markers in // comments with decoded key name.
@@ -14,7 +60,7 @@ export function transformToRuntimeData(code: string, accessor: string = 'testDat
14
60
  // testData.get() (silent: `toHaveAttribute('href', 'official_site_url')`). Generated
15
61
  // comments are always full-line, so anchoring loses nothing.
16
62
  code = code.replace(
17
- /^(\s*)\/\/(.*)__SUNGEN_TD_([A-Za-z0-9_]+)__(.*)$/gm,
63
+ RE_COMMENT_LINE,
18
64
  (_, indent, before, enc, after) => `${indent}//${before}${decodeKey(enc)}${after}`
19
65
  );
20
66
 
@@ -30,22 +76,22 @@ export function transformToRuntimeData(code: string, accessor: string = 'testDat
30
76
  // 'marker' → testData.get('key')
31
77
  // 'a__m1__b__m2__c' → `a${testData.get('k1')}b${testData.get('k2')}c`
32
78
  code = code.replace(
33
- /(['"])((?:(?!\1).)*?__SUNGEN_TD_[A-Za-z0-9_]+__(?:(?!\1).)*)\1/g,
79
+ RE_STRING_LITERAL,
34
80
  (_, _quote, body) => {
35
- const whole = /^__SUNGEN_TD_([A-Za-z0-9_]+)__$/.exec(body);
81
+ const whole = RE_WHOLE_MARKER.exec(body);
36
82
  if (whole) return `${accessor}.get('${decodeKey(whole[1])}')`;
37
83
  return `\`${interpolateMarkers(body, accessor)}\``;
38
84
  }
39
85
  );
40
86
 
41
- // Pass 2: Regex literal context — /…__marker__…/ → new RegExp(`…`). Same
87
+ // Pass 2: Regex literal context — /…$marker$…/ → new RegExp(`…`). Same
42
88
  // several-markers-per-literal rule as Pass 1 (/{{min}}-{{max}} items/).
43
89
  // Only matches within a single line (no newlines in the body).
44
90
  code = code.replace(
45
- /\/((?:[^/\\\n]|\\.)*?__SUNGEN_TD_[A-Za-z0-9_]+__(?:[^/\\\n]|\\.)*)\/([gimsuy]*)/g,
91
+ RE_REGEX_LITERAL,
46
92
  (_, body, flags) => {
47
93
  const flagStr = flags ? `, '${flags}'` : '';
48
- const whole = /^__SUNGEN_TD_([A-Za-z0-9_]+)__$/.exec(body);
94
+ const whole = RE_WHOLE_MARKER.exec(body);
49
95
  if (whole) return `new RegExp(${accessor}.get('${decodeKey(whole[1])}')${flagStr})`;
50
96
  return `new RegExp(\`${interpolateMarkers(body, accessor)}\`${flagStr})`;
51
97
  }
@@ -55,7 +101,7 @@ export function transformToRuntimeData(code: string, accessor: string = 'testDat
55
101
  // sits as a bare token in a numeric position (e.g. `toHaveCount(__marker__)` from the
56
102
  // table/list count templates). testData.get() returns a string, so coerce with Number().
57
103
  code = code.replace(
58
- /__SUNGEN_TD_([A-Za-z0-9_]+)__/g,
104
+ RE_MARKER_G,
59
105
  (_, enc) => `Number(${accessor}.get('${decodeKey(enc)}'))`
60
106
  );
61
107
 
@@ -72,5 +118,5 @@ function decodeKey(encoded: string): string {
72
118
 
73
119
  /** Replace every marker in a literal's body with a `${accessor.get('key')}` interpolation. */
74
120
  function interpolateMarkers(body: string, accessor: string): string {
75
- return body.replace(/__SUNGEN_TD_([A-Za-z0-9_]+)__/g, (_, enc) => `\${${accessor}.get('${decodeKey(enc)}')}`);
121
+ return body.replace(RE_MARKER_G, (_, enc) => `\${${accessor}.get('${decodeKey(enc)}')}`);
76
122
  }
@@ -56,6 +56,23 @@ interface SelectorEntry {
56
56
  };
57
57
  }
58
58
 
59
+ /**
60
+ * Roles whose accessible name comes from their own CONTENT, not from a label — so a name the
61
+ * compiler invented from the `[Reference]` text can never match a real element.
62
+ *
63
+ * The `name:` key is defaulted to the Gherkin reference when an entry omits it, which is right for
64
+ * label-named roles (button/link/textbox) and wrong for these. Left unchecked it compiles to
65
+ * `getByRole('option', { name: 'Shop Item' })` — zero elements — and `toBeHidden()` PASSES on an
66
+ * empty locator, so the scenario goes green without ever testing anything.
67
+ *
68
+ * The rule lives HERE, at the point the name is invented, rather than in each consumer: it used to
69
+ * be enforced in exactly one of the branches that read `resolved.name`, and every other branch
70
+ * (`is hidden`, `is disabled`) silently kept the bogus filter.
71
+ *
72
+ * An EXPLICIT `name:` in selectors.yaml is always honoured — the user knows their DOM.
73
+ */
74
+ export const LIST_MEMBER_ROLES = new Set(['listitem', 'option', 'cell']);
75
+
59
76
  // New selector file structure: flat key-value pairs
60
77
  type SelectorFile = Record<string, SelectorEntry>;
61
78
 
@@ -275,7 +292,9 @@ export class SelectorResolver {
275
292
  'list': () => ({ strategy: 'role', role: 'list', name: label, value: 'list' }),
276
293
  'listitem': () => ({ strategy: 'role', role: 'listitem', value: 'listitem' }),
277
294
  'searchbox': () => ({ strategy: 'role', role: 'searchbox', name: label, value: 'searchbox' }),
278
- 'option': () => ({ strategy: 'role', role: 'option', name: label, value: 'option' }),
295
+ // no `name` a list-member role names itself from its content (see LIST_MEMBER_ROLES);
296
+ // `listitem` already omitted it, `option`/`cell` used to disagree.
297
+ 'option': () => ({ strategy: 'role', role: 'option', value: 'option' }),
279
298
  'slider': () => ({ strategy: 'role', role: 'slider', name: label, value: 'slider' }),
280
299
  'switch': () => ({ strategy: 'role', role: 'switch', name: label, value: 'switch' }),
281
300
  'tab': () => ({ strategy: 'role', role: 'tab', name: label, value: 'tab' }),
@@ -286,7 +305,7 @@ export class SelectorResolver {
286
305
  'progressbar': () => ({ strategy: 'role', role: 'progressbar', name: label, value: 'progressbar' }),
287
306
  'region': () => ({ strategy: 'role', role: 'region', name: label, value: 'region' }),
288
307
  'article': () => ({ strategy: 'role', role: 'article', name: label, value: 'article' }),
289
- 'cell': () => ({ strategy: 'role', role: 'cell', name: label, value: 'cell' }),
308
+ 'cell': () => ({ strategy: 'role', role: 'cell', value: 'cell' }),
290
309
  'status': () => ({ strategy: 'role', role: 'status', name: label, value: 'status' }),
291
310
  'navigation': () => ({ strategy: 'role', role: 'navigation', name: label, value: 'navigation' }),
292
311
  };
@@ -491,8 +510,8 @@ export class SelectorResolver {
491
510
  * Replace {{variable}} references in selector values with runtime data markers.
492
511
  * This enables i18n: selectors can reference locale-dependent text from test-data.
493
512
  *
494
- * "{{lbl_submit}}" → "__SUNGEN_TD_lbl_submit__"
495
- * "Hello {{name}}" → "Hello __SUNGEN_TD_name__"
513
+ * "{{lbl_submit}}" → "__SUNGEN_TD$lbl_submit$__"
514
+ * "Hello {{name}}" → "Hello __SUNGEN_TD$name$__"
496
515
  * "No variables" → "No variables" (unchanged)
497
516
  */
498
517
  private static resolveTemplateVars(value: string): string {
@@ -517,7 +536,12 @@ export class SelectorResolver {
517
536
  // Explicit `name: null` in YAML is a deliberate opt-out (e.g. a role selector that must NOT
518
537
  // filter by accessible name); it must not be conflated with "unset". The role template omits
519
538
  // `{ name }` when this resolves to a falsy value.
520
- const rawName = entry.name === undefined ? originalLabel : (entry.name ?? '');
539
+ // A list-member role gets NO defaulted name see LIST_MEMBER_ROLES. `type` is the entry's
540
+ // declared kind and `rawValue` the role, so the check reads the entry, not a resolved shape.
541
+ const defaultsNameFromLabel = !(type === 'role' && LIST_MEMBER_ROLES.has(String(rawValue)));
542
+ const rawName = entry.name === undefined
543
+ ? (defaultsNameFromLabel ? originalLabel : '')
544
+ : (entry.name ?? '');
521
545
  const value = SelectorResolver.resolveTemplateVars(rawValue);
522
546
  const name = SelectorResolver.resolveTemplateVars(rawName);
523
547
  const nth = entry.nth !== undefined && entry.nth !== null ? entry.nth : undefined;
@@ -8,9 +8,9 @@
8
8
  */
9
9
  import * as path from 'path';
10
10
  import * as fs from 'fs';
11
- import { loadScenarios, parseViewpointOverview, ScenarioInfo, ViewpointEntry } from './parse';
11
+ import { loadUnitScenarios, readUnitFeatureText, parseViewpointOverview, ScenarioInfo, ViewpointEntry } from './parse';
12
12
  import { checkViewpointBaseline, ViewpointBaseline } from './viewpoint-baseline';
13
- import { featureBasename } from './unit-paths';
13
+ import { featureFilesFor } from './unit-paths';
14
14
  import {
15
15
  loadCatalog, viewpointGate, assertionDepth, dataThemesFor, depthThresholdFor, coverageBalance, duplicateClusters, traceability, claimProof, taxonomyLint,
16
16
  automatableManual, flowCoveredThemes, flowRegressionDepth, oracleStrength,
@@ -117,9 +117,10 @@ export function seniorBandedOverall(
117
117
  }
118
118
 
119
119
  export function runAudit(screenDir: string, screenName: string): AuditReport {
120
- // The feature filename is the unit's LAST segmentan api flow (`flows/<flow>`) lives at
121
- // `<dir>/features/<flow>.feature`, not `features/flows/<flow>.feature` (which found 0 scenarios).
122
- const featurePath = path.join(screenDir, 'features', `${featureBasename(screenName)}.feature`);
120
+ // A unit is a DIRECTORY of features`add --feature` puts sub-features beside the canonical
121
+ // one, and they all count. `featureFiles[0]` is that canonical `<basename>.feature` (an api
122
+ // flow `flows/<flow>` lives at `<dir>/features/<flow>.feature`, not `features/flows/…`).
123
+ const featureFiles = featureFilesFor(screenDir, screenName);
123
124
  const viewpointPath = path.join(screenDir, 'requirements', 'test-viewpoint.md');
124
125
  // Catalog-resolution id (for the @api/@query gate sensors): the unit's path relative to qa/ —
125
126
  // `flows/<flow>`, `api/<area>`, `api/flows/<flow>`, else the bare screen. A bare screen matches
@@ -127,9 +128,9 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
127
128
  const catalogScreenName = catalogIdFromScreenDir(screenDir);
128
129
 
129
130
  const specPath = path.join(screenDir, 'requirements', 'spec.md');
130
- const featureText = fs.existsSync(featurePath) ? readTextFile(featurePath) : '';
131
+ const featureText = readUnitFeatureText(screenDir, screenName);
131
132
 
132
- const scenarios: ScenarioInfo[] = loadScenarios(featurePath);
133
+ const scenarios: ScenarioInfo[] = loadUnitScenarios(screenDir, screenName);
133
134
  const viewpoints: ViewpointEntry[] = parseViewpointOverview(viewpointPath);
134
135
  // AO-1 — capability-routed scoring: the viewpoint catalog + score-bearing gate are owned by the
135
136
  // unit's capability, resolved from the unit id (`api/<area>` → `api`; screen/flow → the default
@@ -390,14 +391,14 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
390
391
  }
391
392
  // TQ-9 — manual reason-code mismatch: an explicit @manual:Mx that disagrees with the reason
392
393
  // text makes the capability planner recommend the wrong driver. Surface it so the code is fixed.
393
- for (const mm of manualReasonMismatches(featurePath).slice(0, 8)) {
394
+ for (const mm of featureFiles.flatMap(manualReasonMismatches).slice(0, 8)) {
394
395
  const label = MANUAL_REASONS[mm.inferred]?.label ?? (mm.inferred === 'XS' ? 'Cross-screen (→ flow)' : mm.inferred);
395
396
  findings.push(`MANUAL-REASON-MISMATCH: "${mm.scenario}" is tagged @manual:${mm.explicit} but its reason reads as ${mm.inferred} (${label}) → fix the code so \`sungen capability plan\` recommends the right driver to automate it.`);
396
397
  }
397
398
  // TQ-10 — surface the Capability Planner recommendation (recommend-only; never installs). Silenced
398
399
  // by `capability_suggestions: off` in qa/context.md. Reuses the planner (trustworthy after TQ-9).
399
400
  if (intent.capabilitySuggestions) {
400
- const plan = buildPlan(screenDir, featureBasename(screenName));
401
+ const plan = buildPlan(screenDir, screenName);
401
402
  if (plan.recommendations.length) {
402
403
  const recs = plan.recommendations.map((r) => `\`sungen capability add ${r.driver}\` (automates ${r.count})`).join(' · ');
403
404
  findings.push(`CAPABILITY-SUGGESTION: ${plan.capabilityManual} @manual scenario(s) are capability-manual (a driver could automate them) — ${recs}. Recommend-only: nothing is installed automatically; the ${plan.judgmentManual} judgment-manual (M6/M8/M9) correctly stay manual.`);
@@ -10,6 +10,7 @@ import * as fs from 'fs';
10
10
  import * as path from 'path';
11
11
  import { loadDriverCatalog } from './capability';
12
12
  import { readTextFile } from './read-text';
13
+ import { featureFilesFor } from './unit-paths';
13
14
 
14
15
  export type ReasonClass = 'capability' | 'keep' | 'flow';
15
16
  export interface ReasonDef { code: string; label: string; cls: ReasonClass; drivers: string[] }
@@ -193,8 +194,7 @@ export interface CapabilityPlan {
193
194
  }
194
195
 
195
196
  export function buildPlan(screenDir: string, screenName: string): CapabilityPlan {
196
- const featurePath = path.join(screenDir, 'features', `${screenName}.feature`);
197
- const scenarios = parseScenarios(featurePath);
197
+ const scenarios = featureFilesFor(screenDir, screenName).flatMap(parseScenarios);
198
198
  const catalog = loadDriverCatalog();
199
199
 
200
200
  const modes: Record<string, number> = {};
@@ -58,7 +58,7 @@ drivers:
58
58
  mock:
59
59
  kind: capability
60
60
  package: "@sungen/driver-mock"
61
- status: planned
61
+ status: shipped
62
62
  capabilities: ["@mock", "@network"]
63
63
  unblocks: [M3]
64
64
  mail-file:
@@ -18,7 +18,7 @@
18
18
  * The AI agent layer adds the semantic + novelty judgement on top of this spine.
19
19
  */
20
20
  import * as path from 'path';
21
- import { loadScenarios, ScenarioInfo } from './parse';
21
+ import { loadUnitScenarios, ScenarioInfo } from './parse';
22
22
  import { runAudit } from './audit';
23
23
 
24
24
  export interface ChallengeFinding {
@@ -63,8 +63,7 @@ const NOVELTY_LENSES = [
63
63
  ];
64
64
 
65
65
  export function buildChallenge(screenDir: string, screenName: string): ChallengeReport {
66
- const featurePath = path.join(screenDir, 'features', `${screenName}.feature`);
67
- const scenarios: ScenarioInfo[] = loadScenarios(featurePath);
66
+ const scenarios: ScenarioInfo[] = loadUnitScenarios(screenDir, screenName);
68
67
  const audit = runAudit(screenDir, screenName);
69
68
 
70
69
  // 1. Coverage critic — over-covered buckets (secondary >> business-core) + shallow themes.
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import * as fs from 'fs';
10
10
  import * as path from 'path';
11
+ import { featureFilesFor } from './unit-paths';
11
12
  import { parse as parseYaml } from 'yaml';
12
13
  import { GherkinParser, ParsedScenario } from '../generators/gherkin-parser';
13
14
  import { resolveQuery, lintCatalog } from './query-catalog';
@@ -44,15 +45,18 @@ export function lintDataDriven(screenDir: string, cwd: string = process.cwd()):
44
45
  const base = path.basename(screenDir);
45
46
  const isFlow = path.basename(path.dirname(screenDir)) === 'flows';
46
47
  const screenName = isFlow ? `flows/${base}` : base;
47
- const featurePath = path.join(screenDir, 'features', `${base}.feature`);
48
- if (!fs.existsSync(featurePath)) return [];
49
-
50
- let scenarios: ParsedScenario[];
51
- try {
52
- scenarios = new GherkinParser().parseFeatureFile(featurePath).scenarios || [];
53
- } catch {
54
- return [];
55
- }
48
+ // Every `.feature` of the unit — `add --feature` sub-features lint too. A file that
49
+ // fails to parse contributes nothing rather than voiding the whole unit's lint.
50
+ const scenarios: ParsedScenario[] = featureFilesFor(screenDir, base)
51
+ .filter((p) => fs.existsSync(p))
52
+ .flatMap((p) => {
53
+ try {
54
+ return new GherkinParser().parseFeatureFile(p).scenarios || [];
55
+ } catch {
56
+ return [];
57
+ }
58
+ });
59
+ if (scenarios.length === 0) return [];
56
60
 
57
61
  const tdPath = path.join(screenDir, 'test-data', `${base}.yaml`);
58
62
  const td: Record<string, any> = tdPath && fs.existsSync(tdPath) ? parseYaml(fs.readFileSync(tdPath, 'utf8')) || {} : {};
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import * as fs from 'fs';
15
15
  import * as path from 'path';
16
- import { loadScenarios, parseViewpointOverview, ScenarioInfo, ViewpointEntry } from './parse';
16
+ import { loadUnitScenarios, parseViewpointOverview, ScenarioInfo, ViewpointEntry } from './parse';
17
17
  import { loadCatalog, viewpointGate, assertionDepth, dataThemesFor, CatalogTheme } from './sensors';
18
18
 
19
19
  export type DepthAction = 'deepen' | 'defer';
@@ -50,11 +50,9 @@ function matchTheme(s: ScenarioInfo, dataThemes: CatalogTheme[]): CatalogTheme |
50
50
  }
51
51
 
52
52
  export function runDepthLint(screenDir: string, screenName: string, focus = 'functional'): DepthLintReport {
53
- const last = screenName.split('/').pop() || screenName;
54
- const featurePath = path.join(screenDir, 'features', `${last}.feature`);
55
53
  const viewpointPath = path.join(screenDir, 'requirements', 'test-viewpoint.md');
56
54
 
57
- const scenarios: ScenarioInfo[] = loadScenarios(featurePath);
55
+ const scenarios: ScenarioInfo[] = loadUnitScenarios(screenDir, screenName);
58
56
  const viewpoints: ViewpointEntry[] = parseViewpointOverview(viewpointPath);
59
57
  const catalog = loadCatalog();
60
58
  const gate = viewpointGate(scenarios, viewpoints, catalog);