@sun-asterisk/sungen 3.2.28 → 3.2.29

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 (217) hide show
  1. package/dist/capabilities/discover.d.ts +29 -0
  2. package/dist/capabilities/discover.d.ts.map +1 -1
  3. package/dist/capabilities/discover.js +144 -10
  4. package/dist/capabilities/discover.js.map +1 -1
  5. package/dist/capabilities/registry.d.ts +18 -0
  6. package/dist/capabilities/registry.d.ts.map +1 -1
  7. package/dist/capabilities/registry.js +25 -0
  8. package/dist/capabilities/registry.js.map +1 -1
  9. package/dist/cli/commands/audit.d.ts.map +1 -1
  10. package/dist/cli/commands/audit.js +9 -0
  11. package/dist/cli/commands/audit.js.map +1 -1
  12. package/dist/cli/commands/capability.js +1 -1
  13. package/dist/cli/commands/capability.js.map +1 -1
  14. package/dist/dashboard/templates/index.html +1 -1
  15. package/dist/exporters/feature-parser.d.ts +1 -1
  16. package/dist/exporters/feature-parser.js +1 -1
  17. package/dist/exporters/matrix/build.d.ts.map +1 -1
  18. package/dist/exporters/matrix/build.js +14 -1
  19. package/dist/exporters/matrix/build.js.map +1 -1
  20. package/dist/exporters/matrix/item-labels.d.ts +35 -0
  21. package/dist/exporters/matrix/item-labels.d.ts.map +1 -0
  22. package/dist/exporters/matrix/item-labels.js +90 -0
  23. package/dist/exporters/matrix/item-labels.js.map +1 -0
  24. package/dist/exporters/matrix/render-coverage-sheet.d.ts +14 -0
  25. package/dist/exporters/matrix/render-coverage-sheet.d.ts.map +1 -0
  26. package/dist/exporters/matrix/render-coverage-sheet.js +160 -0
  27. package/dist/exporters/matrix/render-coverage-sheet.js.map +1 -0
  28. package/dist/exporters/matrix/render-overview-sheet.d.ts +20 -0
  29. package/dist/exporters/matrix/render-overview-sheet.d.ts.map +1 -0
  30. package/dist/exporters/matrix/render-overview-sheet.js +36 -0
  31. package/dist/exporters/matrix/render-overview-sheet.js.map +1 -0
  32. package/dist/exporters/matrix/render-testcase-sheet.d.ts +30 -0
  33. package/dist/exporters/matrix/render-testcase-sheet.d.ts.map +1 -0
  34. package/dist/exporters/matrix/render-testcase-sheet.js +156 -0
  35. package/dist/exporters/matrix/render-testcase-sheet.js.map +1 -0
  36. package/dist/exporters/matrix/render-xlsx.d.ts +41 -31
  37. package/dist/exporters/matrix/render-xlsx.d.ts.map +1 -1
  38. package/dist/exporters/matrix/render-xlsx.js +72 -425
  39. package/dist/exporters/matrix/render-xlsx.js.map +1 -1
  40. package/dist/exporters/matrix/testcase-summary-band.d.ts +30 -0
  41. package/dist/exporters/matrix/testcase-summary-band.d.ts.map +1 -0
  42. package/dist/exporters/matrix/testcase-summary-band.js +75 -0
  43. package/dist/exporters/matrix/testcase-summary-band.js.map +1 -0
  44. package/dist/exporters/matrix/wording.d.ts.map +1 -1
  45. package/dist/exporters/matrix/wording.js +13 -0
  46. package/dist/exporters/matrix/wording.js.map +1 -1
  47. package/dist/exporters/matrix/xlsx-cells.d.ts +38 -0
  48. package/dist/exporters/matrix/xlsx-cells.d.ts.map +1 -0
  49. package/dist/exporters/matrix/xlsx-cells.js +52 -0
  50. package/dist/exporters/matrix/xlsx-cells.js.map +1 -0
  51. package/dist/exporters/xlsx-report-builder.d.ts +12 -1
  52. package/dist/exporters/xlsx-report-builder.d.ts.map +1 -1
  53. package/dist/exporters/xlsx-report-builder.js +10 -2
  54. package/dist/exporters/xlsx-report-builder.js.map +1 -1
  55. package/dist/generators/gherkin-parser/index.d.ts +7 -0
  56. package/dist/generators/gherkin-parser/index.d.ts.map +1 -1
  57. package/dist/generators/gherkin-parser/index.js +3 -3
  58. package/dist/generators/gherkin-parser/index.js.map +1 -1
  59. package/dist/generators/test-generator/adapters/appium/os-support.d.ts +53 -0
  60. package/dist/generators/test-generator/adapters/appium/os-support.d.ts.map +1 -0
  61. package/dist/generators/test-generator/adapters/appium/os-support.js +200 -0
  62. package/dist/generators/test-generator/adapters/appium/os-support.js.map +1 -0
  63. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/alert-fill-action.hbs +9 -2
  64. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/capture-variable.hbs +24 -1
  65. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/hover-action.hbs +6 -2
  66. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/hover-element-with-text.hbs +4 -3
  67. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/unknown-element-action.hbs +9 -0
  68. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/have-value-assertion.hbs +34 -1
  69. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/label-value-assertion.hbs +14 -6
  70. package/dist/generators/test-generator/adapters/appium/templates/steps/gestures/go-back-action.hbs +21 -0
  71. package/dist/generators/test-generator/adapters/appium/templates/steps/gestures/set-geolocation-action.hbs +16 -0
  72. package/dist/generators/test-generator/adapters/appium/templates/test-file.hbs +8 -0
  73. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/table-action-in-row-nth.hbs +4 -1
  74. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/table-action-in-row.hbs +4 -1
  75. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/selected-assertion.hbs +7 -0
  76. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/state-with-filter-assertion.hbs +7 -0
  77. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/table-empty.hbs +4 -1
  78. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/table-match-data.hbs +4 -1
  79. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/table-row-count.hbs +4 -1
  80. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/table-row-exists.hbs +12 -4
  81. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/table-row-not-exists.hbs +14 -2
  82. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/locator-base.hbs +8 -8
  83. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/id.hbs +1 -1
  84. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/testid.hbs +1 -1
  85. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/locator.hbs +8 -8
  86. package/dist/generators/test-generator/code-generator.d.ts.map +1 -1
  87. package/dist/generators/test-generator/code-generator.js +5 -1
  88. package/dist/generators/test-generator/code-generator.js.map +1 -1
  89. package/dist/generators/test-generator/diagnostics.d.ts +38 -2
  90. package/dist/generators/test-generator/diagnostics.d.ts.map +1 -1
  91. package/dist/generators/test-generator/diagnostics.js +37 -1
  92. package/dist/generators/test-generator/diagnostics.js.map +1 -1
  93. package/dist/generators/test-generator/patterns/index.d.ts +33 -6
  94. package/dist/generators/test-generator/patterns/index.d.ts.map +1 -1
  95. package/dist/generators/test-generator/patterns/index.js +71 -7
  96. package/dist/generators/test-generator/patterns/index.js.map +1 -1
  97. package/dist/generators/test-generator/patterns/types.d.ts +8 -0
  98. package/dist/generators/test-generator/patterns/types.d.ts.map +1 -1
  99. package/dist/generators/test-generator/step-mapper.d.ts +40 -1
  100. package/dist/generators/test-generator/step-mapper.d.ts.map +1 -1
  101. package/dist/generators/test-generator/step-mapper.js +100 -17
  102. package/dist/generators/test-generator/step-mapper.js.map +1 -1
  103. package/dist/generators/test-generator/utils/selector-resolver.d.ts +23 -1
  104. package/dist/generators/test-generator/utils/selector-resolver.d.ts.map +1 -1
  105. package/dist/generators/test-generator/utils/selector-resolver.js +78 -12
  106. package/dist/generators/test-generator/utils/selector-resolver.js.map +1 -1
  107. package/dist/harness/audit.d.ts +3 -1
  108. package/dist/harness/audit.d.ts.map +1 -1
  109. package/dist/harness/audit.js +51 -28
  110. package/dist/harness/audit.js.map +1 -1
  111. package/dist/harness/capability-plan.d.ts +4 -0
  112. package/dist/harness/capability-plan.d.ts.map +1 -1
  113. package/dist/harness/capability-plan.js +32 -2
  114. package/dist/harness/capability-plan.js.map +1 -1
  115. package/dist/harness/catalog/drivers.yaml +1 -1
  116. package/dist/harness/catalog/universal-viewpoints.yaml +145 -0
  117. package/dist/harness/parse.d.ts +6 -0
  118. package/dist/harness/parse.d.ts.map +1 -1
  119. package/dist/harness/parse.js +1 -1
  120. package/dist/harness/parse.js.map +1 -1
  121. package/dist/harness/repair.d.ts.map +1 -1
  122. package/dist/harness/repair.js +8 -1
  123. package/dist/harness/repair.js.map +1 -1
  124. package/dist/harness/sensors.d.ts +48 -0
  125. package/dist/harness/sensors.d.ts.map +1 -1
  126. package/dist/harness/sensors.js +26 -1
  127. package/dist/harness/sensors.js.map +1 -1
  128. package/dist/harness/viewpoint-ledger.d.ts +10 -0
  129. package/dist/harness/viewpoint-ledger.d.ts.map +1 -1
  130. package/dist/harness/viewpoint-ledger.js +23 -2
  131. package/dist/harness/viewpoint-ledger.js.map +1 -1
  132. package/dist/orchestrator/templates/ai-src/commands/delivery.md +4 -3
  133. package/dist/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +14 -5
  134. package/dist/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +48 -1
  135. package/dist/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +18 -3
  136. package/dist/orchestrator/templates/ai-src/skills/sungen-mobile-gestures/SKILL.md +16 -1
  137. package/dist/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +11 -0
  138. package/dist/orchestrator/templates/ai-src/skills/sungen-selector-keys/SKILL.md +10 -3
  139. package/dist/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +88 -2
  140. package/dist/orchestrator/templates/ai-src/skills/sungen-tc-review/SKILL.md +58 -1
  141. package/dist/orchestrator/templates/ai-src/skills/sungen-test-design-techniques/SKILL.md +13 -0
  142. package/dist/orchestrator/templates/ai-src/skills/sungen-viewpoint/SKILL.md +59 -10
  143. package/dist/orchestrator/templates/ai-src/skills/sungen-viewpoint/group-f-mobile-native.md +419 -0
  144. package/dist/orchestrator/templates/readme.md +1 -1
  145. package/dist/orchestrator/templates/specs-pw-shape-reporter.ts +0 -0
  146. package/dist/orchestrator/templates/wdio.conf.ts +105 -1
  147. package/package.json +3 -3
  148. package/src/capabilities/discover.ts +146 -10
  149. package/src/capabilities/registry.ts +30 -0
  150. package/src/cli/commands/audit.ts +9 -0
  151. package/src/cli/commands/capability.ts +1 -1
  152. package/src/dashboard/templates/index.html +1 -1
  153. package/src/exporters/feature-parser.ts +1 -1
  154. package/src/exporters/matrix/build.ts +13 -1
  155. package/src/exporters/matrix/item-labels.ts +78 -0
  156. package/src/exporters/matrix/render-coverage-sheet.ts +168 -0
  157. package/src/exporters/matrix/render-overview-sheet.ts +56 -0
  158. package/src/exporters/matrix/render-testcase-sheet.ts +178 -0
  159. package/src/exporters/matrix/render-xlsx.ts +67 -448
  160. package/src/exporters/matrix/testcase-summary-band.ts +79 -0
  161. package/src/exporters/matrix/wording.ts +13 -0
  162. package/src/exporters/matrix/xlsx-cells.ts +56 -0
  163. package/src/exporters/xlsx-report-builder.ts +16 -2
  164. package/src/generators/gherkin-parser/index.ts +1 -1
  165. package/src/generators/test-generator/adapters/appium/os-support.ts +176 -0
  166. package/src/generators/test-generator/adapters/appium/templates/steps/actions/alert-fill-action.hbs +9 -2
  167. package/src/generators/test-generator/adapters/appium/templates/steps/actions/capture-variable.hbs +24 -1
  168. package/src/generators/test-generator/adapters/appium/templates/steps/actions/hover-action.hbs +6 -2
  169. package/src/generators/test-generator/adapters/appium/templates/steps/actions/hover-element-with-text.hbs +4 -3
  170. package/src/generators/test-generator/adapters/appium/templates/steps/actions/unknown-element-action.hbs +9 -0
  171. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/have-value-assertion.hbs +34 -1
  172. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/label-value-assertion.hbs +14 -6
  173. package/src/generators/test-generator/adapters/appium/templates/steps/gestures/go-back-action.hbs +21 -0
  174. package/src/generators/test-generator/adapters/appium/templates/steps/gestures/set-geolocation-action.hbs +16 -0
  175. package/src/generators/test-generator/adapters/appium/templates/test-file.hbs +8 -0
  176. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/table-action-in-row-nth.hbs +4 -1
  177. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/table-action-in-row.hbs +4 -1
  178. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/selected-assertion.hbs +7 -0
  179. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/state-with-filter-assertion.hbs +7 -0
  180. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/table-empty.hbs +4 -1
  181. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/table-match-data.hbs +4 -1
  182. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/table-row-count.hbs +4 -1
  183. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/table-row-exists.hbs +12 -4
  184. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/table-row-not-exists.hbs +14 -2
  185. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/locator-base.hbs +8 -8
  186. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/id.hbs +1 -1
  187. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/testid.hbs +1 -1
  188. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/locator.hbs +8 -8
  189. package/src/generators/test-generator/code-generator.ts +5 -1
  190. package/src/generators/test-generator/diagnostics.ts +39 -2
  191. package/src/generators/test-generator/patterns/index.ts +78 -8
  192. package/src/generators/test-generator/patterns/types.ts +8 -0
  193. package/src/generators/test-generator/step-mapper.ts +107 -17
  194. package/src/generators/test-generator/utils/selector-resolver.ts +88 -12
  195. package/src/harness/audit.ts +55 -30
  196. package/src/harness/capability-plan.ts +33 -1
  197. package/src/harness/catalog/drivers.yaml +1 -1
  198. package/src/harness/catalog/universal-viewpoints.yaml +145 -0
  199. package/src/harness/parse.ts +7 -1
  200. package/src/harness/repair.ts +8 -1
  201. package/src/harness/sensors.ts +75 -1
  202. package/src/harness/viewpoint-ledger.ts +26 -5
  203. package/src/orchestrator/templates/ai-src/commands/delivery.md +4 -3
  204. package/src/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +14 -5
  205. package/src/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +48 -1
  206. package/src/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +18 -3
  207. package/src/orchestrator/templates/ai-src/skills/sungen-mobile-gestures/SKILL.md +16 -1
  208. package/src/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +11 -0
  209. package/src/orchestrator/templates/ai-src/skills/sungen-selector-keys/SKILL.md +10 -3
  210. package/src/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +88 -2
  211. package/src/orchestrator/templates/ai-src/skills/sungen-tc-review/SKILL.md +58 -1
  212. package/src/orchestrator/templates/ai-src/skills/sungen-test-design-techniques/SKILL.md +13 -0
  213. package/src/orchestrator/templates/ai-src/skills/sungen-viewpoint/SKILL.md +59 -10
  214. package/src/orchestrator/templates/ai-src/skills/sungen-viewpoint/group-f-mobile-native.md +419 -0
  215. package/src/orchestrator/templates/readme.md +1 -1
  216. package/src/orchestrator/templates/specs-pw-shape-reporter.ts +0 -0
  217. package/src/orchestrator/templates/wdio.conf.ts +105 -1
@@ -46,6 +46,12 @@ interface SelectorEntry {
46
46
  // === v2: Table ===
47
47
  columns?: Record<string, { index: number; header: string; elements?: Record<string, SelectorEntry> }>;
48
48
  pagination?: Record<string, string>;
49
+ // What EVERY row of this table matches, relative to the table locator (issue #688). The table
50
+ // templates assume an ARIA table — `getByRole('row')`, `tbody` — and on a list built from plain
51
+ // <div>s that universe is 0 elements, so `is hidden` / `is empty` pass without checking anything.
52
+ // `rows: 'div.job-row'` replaces the whole row chain. Distinct from a ROW LABEL's own `type: locator`
53
+ // entry, which positions ONE row (`tbody tr:first-child`) — a shape is a property of the table.
54
+ rows?: string;
49
55
 
50
56
  // === v2: Assertion Helpers ===
51
57
  attribute?: string; // Attribute to check (for "has" assertions)
@@ -130,6 +136,7 @@ export interface ResolvedSelector {
130
136
  contenteditable?: boolean; // Rich text editor
131
137
  columns?: Record<string, { index: number; header: string; elements?: Record<string, any> }>;
132
138
  pagination?: Record<string, string>;
139
+ rows?: string; // Row shape, relative to the table (issue #688) — see SelectorEntry.rows
133
140
  attribute?: string; // Attribute to check
134
141
  pattern?: string; // Regex for attribute value
135
142
  expanded?: { class?: string; attribute?: string; state?: 'none' }; // Expand/collapse state signal
@@ -150,10 +157,35 @@ export class SelectorResolver {
150
157
  private diagnostics?: DiagnosticsCollector;
151
158
  private contentNamedRoles: string[] = DEFAULT_CONTENT_NAMED_ROLES;
152
159
  private warnedContentNamed = new Set<string>();
160
+ /** The adapter's target platform — gates the list-member auto-infer (#665). Defaults to web. */
161
+ private platform: 'web' | 'mobile' = 'web';
153
162
 
154
- constructor(selectorsDir?: string, screenName?: string) {
163
+ constructor(selectorsDir?: string, screenName?: string, platform: 'web' | 'mobile' = 'web') {
155
164
  this.selectorsDir = selectorsDir || path.join(process.cwd(), 'qa', 'selectors', 'screens');
156
165
  this.screenName = screenName;
166
+ this.platform = platform;
167
+ }
168
+
169
+ /**
170
+ * A role that resolved with NO accessible name, on the mobile adapter — a dead selector (#665).
171
+ *
172
+ * `appium-selector.hbs` renders a role as `~{{name || value}}`, so with no name it emits the role
173
+ * TOKEN itself: `~listitem`, `~status`. A native a11y tree has no role concept and contains no
174
+ * such string, so the locator can never match — yet it compiled clean and only failed at run time
175
+ * with `element with selector "~listitem" wasn't found`.
176
+ *
177
+ * The invariant is "role with no name", NOT "role in LIST_MEMBER_ROLES". Both are reachable and
178
+ * both are dead:
179
+ * · auto-infer — only the three LIST_MEMBER_ROLES are nameless (the other 22 default the label)
180
+ * · an entry — a list-member role withholds the name by design, and `name: null` is the
181
+ * documented opt-out for a content-named role, so `type: role` reaches it too
182
+ * One predicate covers every door; the CALLERS choose the remedy, because it differs: with no
183
+ * entry the fix is "add one", with an entry the fix is "change its type".
184
+ *
185
+ * Web is unaffected — there the role IS the locator (see LIST_MEMBER_ROLES).
186
+ */
187
+ private isUnusableMobileRole(r: ResolvedSelector | null | undefined): boolean {
188
+ return this.platform === 'mobile' && !!r && r.strategy === 'role' && !r.name;
157
189
  }
158
190
 
159
191
  /** Attach the compile-diagnostics collector (SG-W003) */
@@ -452,11 +484,28 @@ export class SelectorResolver {
452
484
  // Auto-infer locator from elementType when no exact YAML entry exists
453
485
  if (elementType) {
454
486
  const inferred = SelectorResolver.inferFromElementType(elementType, displayLabel);
455
- if (inferred) {
487
+
488
+ // #665 — auto-infer is an ARIA concept. It survives on mobile only because a label-named
489
+ // role hands the appium template a `name` to render as an accessibility-id (`[Submit]
490
+ // button` → `~Submit`, a plausible guess). A nameless role has nothing to hand it, and the
491
+ // template's `~{{name || value}}` fell through to the role TOKEN — `[Job List] item` →
492
+ // `~listitem`, a string no Android/iOS a11y tree contains. Clean compile, guaranteed miss.
493
+ //
494
+ // Declining rather than warning is what repairs the workflow. `sungen-selector-fix` Phase 0
495
+ // decides whether to scan the live page by asking whether the ref "can't be auto-inferred";
496
+ // while auto-infer answered yes, Phase 0 skipped the scan, so no real selector was ever
497
+ // written and a warning would have reported the breakage with the repair path still switched
498
+ // off. Declining makes Phase 0 scan, and if it was skipped the not-found throw below names
499
+ // the exact key to add — the remedy here is "add an entry", which is what that error says.
500
+ //
501
+ // Web is untouched: `getByRole('listitem')` with no name is a real, working locator there.
502
+ if (inferred && !this.isUnusableMobileRole(inferred)) {
456
503
  // Same SG-W003 class as the explicit-entry path: an auto-inferred name on a
457
504
  // name-from-content role ([Loading] spinner → getByRole('status', { name: 'Loading' }))
458
505
  // can never match. Add a YAML entry with name: null to opt out.
459
- if (inferred.strategy === 'role' && inferred.name && inferred.role && this.contentNamedRoles.includes(inferred.role)) {
506
+ // WEB ONLY — see the note at the entry-path emission (#665).
507
+ if (this.platform !== 'mobile'
508
+ && inferred.strategy === 'role' && inferred.name && inferred.role && this.contentNamedRoles.includes(inferred.role)) {
460
509
  const warnKey = `${inferred.role}:${displayLabel}`;
461
510
  if (!this.warnedContentNamed.has(warnKey)) {
462
511
  this.warnedContentNamed.add(warnKey);
@@ -584,6 +633,7 @@ export class SelectorResolver {
584
633
  if (entry.contenteditable) v2Fields.contenteditable = true;
585
634
  if (entry.columns) v2Fields.columns = entry.columns;
586
635
  if (entry.pagination) v2Fields.pagination = entry.pagination;
636
+ if (typeof entry.rows === 'string' && entry.rows.trim()) v2Fields.rows = entry.rows.trim();
587
637
  if (entry.attribute) v2Fields.attribute = entry.attribute;
588
638
  if (entry.pattern) v2Fields.pattern = entry.pattern;
589
639
  if (entry.expanded) v2Fields.expanded = entry.expanded;
@@ -658,7 +708,16 @@ export class SelectorResolver {
658
708
  // reference text. For name-from-content roles (status/alert/…) that filter can
659
709
  // never match the live element (its accessible name is computed from its content,
660
710
  // e.g. a toast's message text) — issue #467. Opt out with `name: null`.
661
- if (entry.name === undefined && name && this.contentNamedRoles.includes(value)) {
711
+ //
712
+ // WEB ONLY. "The accessible name is computed from content" is an ARIA rule with no mobile
713
+ // counterpart: the appium template turns a role into an accessibility-id lookup, so
714
+ // `[Loading] spinner` → `~Loading` is exactly as legitimate a guess as `[Submit] button`
715
+ // → `~Submit`, which this compiler emits happily. On mobile the warning was therefore a
716
+ // false positive — and worse, its remedy made things strictly worse: `name: null` leaves
717
+ // the role nameless, which now renders as the role TOKEN and is refused outright (#665).
718
+ // A wrong selector fails loudly at run time; advice that manufactures a dead one does not.
719
+ if (this.platform !== 'mobile'
720
+ && entry.name === undefined && name && this.contentNamedRoles.includes(value)) {
662
721
  const warnKey = `${value}:${originalLabel}`;
663
722
  if (!this.warnedContentNamed.has(warnKey)) {
664
723
  this.warnedContentNamed.add(warnKey);
@@ -669,14 +728,31 @@ export class SelectorResolver {
669
728
  );
670
729
  }
671
730
  }
672
- return withExtras({
673
- strategy: 'role',
674
- role: value,
675
- name: name, // Use the name field for accessible name
676
- value,
677
- nth,
678
- exact,
679
- });
731
+ {
732
+ const resolvedRole = withExtras({
733
+ strategy: 'role',
734
+ role: value,
735
+ name: name, // Use the name field for accessible name
736
+ value,
737
+ nth,
738
+ exact,
739
+ });
740
+ // #665 — the same dead selector as the auto-infer path, reached through an ENTRY: a
741
+ // list-member role withholds its name by design, and `name: null` is the documented
742
+ // opt-out for a content-named role. Either way a mobile role with no name renders as
743
+ // the role token. Different remedy from the auto-infer case, so a different message:
744
+ // the entry exists, it is the TYPE that cannot work here.
745
+ if (this.isUnusableMobileRole(resolvedRole)) {
746
+ throw new Error(
747
+ `Selector "${originalLabel}" is \`type: role, value: ${value}\`${name === null ? ' with `name: null`' : ''}, ` +
748
+ `which resolves to a role with no accessible name. A native app has no role concept, so this compiles to ` +
749
+ `the accessibility-id "~${value}" — a string no Android/iOS a11y tree contains, and the step can never match. ` +
750
+ `Give this entry a locator the device actually exposes: \`type: accessibility-id\` (a content-desc / ` +
751
+ `accessibilityIdentifier), \`type: xpath\`, or \`type: id\`.`
752
+ );
753
+ }
754
+ return resolvedRole;
755
+ }
680
756
 
681
757
  case 'testid':
682
758
  return withExtras({
@@ -13,20 +13,20 @@ import { checkViewpointBaseline, ViewpointBaseline } from './viewpoint-baseline'
13
13
  import { flowQuality, statefulDepthFor, continuityGaps, flowInventory, misfiledPhases, FlowQualityResult, InventoryResult } from './flow-contract';
14
14
  import { featureFilesFor } from './unit-paths';
15
15
  import {
16
- loadCatalog, viewpointGate, assertionDepth, dataThemesFor, depthThresholdFor, coverageBalance, duplicateClusters, traceability, claimProof, taxonomyLint,
16
+ loadCatalog, viewpointGate, assertionDepth, dataThemesFor, depthThresholdFor, coverageBalance, duplicateClusters, scenarioIdReuse, ReusedScenarioId, traceability, claimProof, taxonomyLint,
17
17
  automatableManual, flowCoveredThemes, flowRegressionDepth, oracleStrength, declaredPageType,
18
18
  numericClaimGaps, priorityMismatches,
19
19
  GateResult, DepthResult, BalanceResult, DuplicateResult, TraceResult, ClaimProofResult, TaxonomyResult, Catalog, AutomatableManualResult, FlowDepthResult, OracleStrengthResult,
20
20
  } from './sensors';
21
21
  import { loadFlowScenarios } from './flow-check';
22
- import { manualReasonMismatches, MANUAL_REASONS, buildPlan } from './capability-plan';
22
+ import { manualReasonMismatches, MANUAL_REASONS, buildPlan, KEEP_REASON_CODES, MANUAL_REASON_RANGE } from './capability-plan';
23
23
  import { readCapabilities, verificationScopeFindings } from './capability';
24
24
  import { readIntent, projectRootFromScreenDir, IntentProfile } from './intent';
25
25
  import { getProvenance, Provenance } from './provenance';
26
26
  import { specCoverage, SpecCoverageResult, parseSpecClauses, restatedRequirementSources } from './spec-coverage';
27
27
  import { downstreamScope, manualOracle, readText, DownstreamResult, ManualOracleResult,
28
28
  negativeSideEffect, sourceBacked, crossArtifactOwnership, isolationRisk, serialCascadeRisk } from './quality-gates';
29
- import { viewpointLedger, parseViewpointItems, browserGestureSubstitutions, viewpointIntegrity, LedgerResult } from './viewpoint-ledger';
29
+ import { viewpointLedger, parseViewpointItems, browserGestureSubstitutions, substitutionRemedy, viewpointIntegrity, LedgerResult } from './viewpoint-ledger';
30
30
  import { specBranches, unenumeratedBranches, sameOracleClusters, permissionPairGaps, awaitingSeedData, unconsideredRisks, MULTI_SCREEN_RISKS } from './spec-branches';
31
31
  const MULTI_SCREEN_RISK_COUNT = MULTI_SCREEN_RISKS.length;
32
32
  import { capabilityRegistry } from '../capabilities/registry';
@@ -45,6 +45,8 @@ export interface AuditReport {
45
45
  duplicates: DuplicateResult;
46
46
  trace: TraceResult;
47
47
  taxonomyMismatch: boolean; // scenarios use IDs not in the project's test-viewpoint.md
48
+ /** #694 — two different scenarios in one .feature carrying the same VP id. Gate-blocking. */
49
+ reusedScenarioIds: ReusedScenarioId[];
48
50
  downstream: DownstreamResult; // downstream screens referenced but under-covered
49
51
  manualOracle: ManualOracleResult; // @manual scenarios lacking setup/action/oracle
50
52
  automatableManual: AutomatableManualResult; // @manual that is actually automatable (deferred, not judgment) — TQ-2
@@ -460,30 +462,15 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
460
462
  if (!flowQ.contract!.minimalGuarantee) {
461
463
  findings.push('FLOW-GUARANTEE-MISSING: the contract declares no `minimalGuarantee:` — the postcondition that must hold in EVERY flow, including failure ("no second account for the same email", "no verification mail on a rejected submit"). Exception/guard scenarios have nothing to assert against without it; add it (and `successGuarantee:` for the goal-reached side).');
462
464
  }
463
- // #599 — integrity of the DECLARATION itself: three ways it can be internally wrong while
464
- // every coverage number still reads well.
465
- {
466
- const testDataDir = path.join(screenDir, 'test-data');
467
- const extra: Array<{ label: string; text: string }> = [];
468
- try {
469
- for (const f of fs.readdirSync(testDataDir).filter((x) => x.endsWith('.yaml'))) {
470
- extra.push({ label: `test-data/${f}`, text: readTextFile(path.join(testDataDir, f)) ?? '' });
471
- }
472
- } catch { /* api units and fresh scaffolds carry none */ }
473
- const vi = viewpointIntegrity(viewpointPath, scenarios.map((sc) => sc.vpId ?? '').filter(Boolean), extra);
474
- for (const d of vi.duplicateIds.slice(0, 4)) {
475
- findings.push(`VIEWPOINT-ID-REUSED: "${d.id}" labels ${d.count} different claims (${d.texts.map((t) => `"${t.slice(0, 44)}…"`).join(' · ')}). The ledger then counts ${d.count} items while traceability sees ONE id, so both the coverage arithmetic and "which test proves this?" go wrong — and neither number looks suspicious. Give each atomic claim its own id; several ids may share a flow.`);
476
- }
477
- for (const d of vi.danglingRefs.slice(0, 5)) {
478
- findings.push(`TRACE-REF-DANGLING: ${d.where} cites "${d.ref}", which no scenario carries. Nothing breaks at runtime, so a wrong pointer survives every refactor and quietly misleads the next maintainer. Fix the reference, or the scenario id it was meant to name.`);
479
- }
480
- if (vi.uniformPriority) {
481
- findings.push(`VIEWPOINT-PRIORITY-UNIFORM: all ${vi.uniformPriority.rows} rows of the Priority Viewpoints table are "${vi.uniformPriority.levels[0]}". That is a formality, not a priority: nothing can be selected for a smoke run and the release conversation has no input. Rank by business impact and risk — the basic flow and the data-integrity guards are not the same bet as an exit path a screen suite already covers.`);
482
- }
483
- }
484
465
  // #595 — a viewpoint item naming a browser-level gesture, answered with an in-app control.
485
466
  for (const g of browserGestureSubstitutions(viewpointPath, scenarios, platform).slice(0, 4)) {
486
- findings.push(`VIEWPOINT-GESTURE-SUBSTITUTED: the viewpoint asks for "${g.gesture}" ("${g.item.slice(0, 80)}…") but the suite exercises an in-app control instead. They are different mechanisms — an app button runs the screen's own re-hydrate logic, browser history does not, and a defect that only shows on history navigation lives in exactly that gap. The step vocabulary has no reload/back step (#593), so defer it with \`@manual\` naming the gesture rather than substituting silently.`);
467
+ // The remedy is not the same on both platforms, and getting it wrong is the difference between
468
+ // useful advice and a lie: web still has no reload/back step (#593), so `@manual` is the honest
469
+ // reply — but every mobile gesture in this set now compiles, so telling a mobile author to defer
470
+ // would send them to a manual procedure for work the compiler does. Read from the ledger entry
471
+ // (`substitutionRemedy`), never re-derived here, so a step landing for web needs no edit here.
472
+ const remedy = substitutionRemedy(g);
473
+ findings.push(`VIEWPOINT-GESTURE-SUBSTITUTED: the viewpoint asks for "${g.gesture}" ("${g.item.slice(0, 80)}…") but the suite exercises an in-app control instead. They are different mechanisms — an app button runs the screen's own re-hydrate logic and the gesture does not, so a defect that only shows on the gesture's own path lives in exactly that gap. ${remedy}`);
487
474
  }
488
475
  // #595 — a flow whose requirement list is a hand restatement of the screen specs it
489
476
  // traverses. `specFR` read 100% over two restated FRs while the guard clause the flow most
@@ -523,6 +510,41 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
523
510
  findings.push(`FLOW-HANDOFF-SHALLOW: only ${flowQ.handoffs.asserted}/${flowQ.handoffs.total} cross-screen transitions are followed by an assertion in the new screen — carried state (the thing a flow exists to verify) is unproven at the handoff. businessDepth is capped until asserted.`);
524
511
  }
525
512
  }
513
+ // #694 — two DIFFERENT scenarios wearing the same VP id. BLOCKING (it joins the gate
514
+ // conjunction below) where the declaration-integrity findings that follow are advisory: a
515
+ // reused id does not stop here, it reaches the customer. `generateTcId` builds the delivered
516
+ // testcase id straight out of the vpId, so the collision ships as two rows carrying ONE id in
517
+ // the deliverable — and the result-merge fallback has already attached one sibling's run
518
+ // result to another, delivering 7 never-executed testcases as "Passed" (see
519
+ // `exporters/scenario-merger.ts`). `matrix/gates.ts` already grades this `severity: 'error'`,
520
+ // but only at delivery; blocking at audit is what makes it cheap to fix (renumber, regenerate).
521
+ const reusedIds = scenarioIdReuse(scenarios);
522
+ for (const r of reusedIds.slice(0, 6)) {
523
+ findings.push(`SCENARIO-ID-REUSED: ${r.count} different scenarios in ${r.file || 'this feature'} carry the id "${r.id}" (${r.titles.map((t) => `"${t.slice(0, 52)}…"`).join(' · ')}). Every axis stays green — the duplicate sensor clusters identical STEPS, so scenarios that merely share an id sail past it — while the deliverable gets ${r.count} rows under one testcase id and the run-result merge can attach the wrong scenario's result. Give each scenario its own sequence number within its category; the ledger item that now reads UNCOVERED is the sibling that lost its number.`);
524
+ }
525
+ // #599 — integrity of the DECLARATION itself: three ways it can be internally wrong while
526
+ // every coverage number still reads well. Advisory. This used to sit inside the
527
+ // `flowQ.hasContract` branch above, so a SCREEN unit never reached it at all — the checks ran
528
+ // on flows only, silently, for every release since they landed (#694).
529
+ {
530
+ const testDataDir = path.join(screenDir, 'test-data');
531
+ const extra: Array<{ label: string; text: string }> = [];
532
+ try {
533
+ for (const f of fs.readdirSync(testDataDir).filter((x) => x.endsWith('.yaml'))) {
534
+ extra.push({ label: `test-data/${f}`, text: readTextFile(path.join(testDataDir, f)) ?? '' });
535
+ }
536
+ } catch { /* api units and fresh scaffolds carry none */ }
537
+ const vi = viewpointIntegrity(viewpointPath, scenarios.map((sc) => sc.vpId ?? '').filter(Boolean), extra);
538
+ for (const d of vi.duplicateIds.slice(0, 4)) {
539
+ findings.push(`VIEWPOINT-ID-REUSED: "${d.id}" labels ${d.count} different claims (${d.texts.map((t) => `"${t.slice(0, 44)}…"`).join(' · ')}). The ledger then counts ${d.count} items while traceability sees ONE id, so both the coverage arithmetic and "which test proves this?" go wrong — and neither number looks suspicious. Give each atomic claim its own id; several ids may share a flow.`);
540
+ }
541
+ for (const d of vi.danglingRefs.slice(0, 5)) {
542
+ findings.push(`TRACE-REF-DANGLING: ${d.where} cites "${d.ref}", which no scenario carries. Nothing breaks at runtime, so a wrong pointer survives every refactor and quietly misleads the next maintainer. Fix the reference, or the scenario id it was meant to name.`);
543
+ }
544
+ if (vi.uniformPriority) {
545
+ findings.push(`VIEWPOINT-PRIORITY-UNIFORM: all ${vi.uniformPriority.rows} rows of the Priority Viewpoints table are "${vi.uniformPriority.levels[0]}". That is a formality, not a priority: nothing can be selected for a smoke run and the release conversation has no input. Rank by business impact and risk — the basic flow and the data-integrity guards are not the same bet as an exit path a screen suite already covers.`);
546
+ }
547
+ }
526
548
  // #592 W5 — a continuity mechanism proved on one side only.
527
549
  if (isUiFlow) {
528
550
  const scenarioTexts = scenarios.map((s) => `${s.name} ${s.stepsText}`);
@@ -536,7 +558,7 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
536
558
  // manual, most of them M5-inbox — invisible while tagged bare).
537
559
  const bareManual = (featureText.match(/@manual(?![:\w])/g) || []).length;
538
560
  if (bareManual > 0) {
539
- findings.push(`MANUAL-CODE-MISSING: ${bareManual} @manual tag(s) carry no reason code — tag @manual:M1..M9 (M5 = external dependency e.g. inbox, M3 = mock/network, M7 = environment) so the capability planner can route what a driver would automate.`);
561
+ findings.push(`MANUAL-CODE-MISSING: ${bareManual} @manual tag(s) carry no reason code — tag @manual:${MANUAL_REASON_RANGE} (M5 = external dependency e.g. inbox, M3 = mock/network, M7 = environment, M10 = device/OS capability, M11 = real-device only) so the capability planner can route what a driver would automate.`);
540
562
  }
541
563
  }
542
564
  for (const c of flowCredits) {
@@ -671,7 +693,7 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
671
693
  const plan = buildPlan(screenDir, screenName, catalogScreenName, platform);
672
694
  if (plan.recommendations.length) {
673
695
  const recs = plan.recommendations.map((r) => `\`sungen capability add ${r.driver}\` (automates ${r.count})`).join(' · ');
674
- 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.`);
696
+ 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 (${KEEP_REASON_CODES.join('/')}) correctly stay manual.`);
675
697
  }
676
698
  // Named, not dropped: without this the scenarios read as "nothing could help", and the
677
699
  // operator's only clue was an install that dead-ends (#597).
@@ -835,15 +857,18 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
835
857
  const calibration = { axes, weakest, inflated, notApplicable, cappedAt: capped ? cap : undefined };
836
858
 
837
859
  // Gate spans coverage (viewpoint themes), depth, claim-proof, spec-clause coverage,
838
- // AND taxonomy-match (scenarios must use the project's viewpoint IDs when defined).
860
+ // taxonomy-match (scenarios must use the project's viewpoint IDs when defined),
861
+ // AND scenario-id uniqueness — a reused id reaches the customer's deliverable as two rows
862
+ // under one testcase id, and has already shipped a never-executed testcase as "Passed" (#694).
839
863
  const gateStatus: 'PASS' | 'FAIL' =
840
- gate.gaps.length === 0 && depth.verdict !== 'fail' && claim.verdict !== 'fail' && spec.verdict !== 'fail' && !taxonomyMismatch && !gateSensorError ? 'PASS' : 'FAIL';
864
+ gate.gaps.length === 0 && depth.verdict !== 'fail' && claim.verdict !== 'fail' && spec.verdict !== 'fail' && !taxonomyMismatch && !gateSensorError
865
+ && reusedIds.length === 0 ? 'PASS' : 'FAIL';
841
866
 
842
867
  return {
843
868
  screen: screenName,
844
869
  scenarioCount: scenarios.length,
845
870
  gate, depth, claim, taxonomy, balance, duplicates, trace, spec,
846
- taxonomyMismatch, downstream, manualOracle: manualOracleResult, automatableManual: autoManual, flowDepth, oracle, ledger, viewpointBaseline, calibration,
871
+ taxonomyMismatch, reusedScenarioIds: reusedIds, downstream, manualOracle: manualOracleResult, automatableManual: autoManual, flowDepth, oracle, ledger, viewpointBaseline, calibration,
847
872
  ...(isUiFlow ? { flowQuality: flowQ, flowInventory: inventory ?? undefined } : {}),
848
873
  score: {
849
874
  overall: Math.round(overall * 10) / 10,
@@ -2,7 +2,7 @@
2
2
  * Capability Planner (Phase 2b) — deterministic, recommend-only.
3
3
  *
4
4
  * Classifies each scenario's execution mode + each @manual case by reason code
5
- * (M1–M9), maps capability-reasons to drivers, and emits the manual-reason KPI.
5
+ * (M1–M11), maps capability-reasons to drivers, and emits the manual-reason KPI.
6
6
  * Never installs anything (that's `sungen capability add`). See
7
7
  * docs/spec/sungen_phase2b_spec.md.
8
8
  */
@@ -22,13 +22,37 @@ export const MANUAL_REASONS: Record<string, ReasonDef> = {
22
22
  M4: { code: 'M4', label: 'Missing stable selector/test-id', cls: 'capability', drivers: [] }, // locator contract, no driver
23
23
  M5: { code: 'M5', label: 'External dependency', cls: 'capability', drivers: ['mail-file', 'contract'] },
24
24
  M6: { code: 'M6', label: 'Visual / UX judgment', cls: 'keep', drivers: ['specialized'] },
25
+ // `capability` with no driver is deliberate and matches M4: the case IS automatable once the
26
+ // environment exists, it just is not an `sungen capability add` away. Reclassifying it as `keep`
27
+ // would claim the opposite — that no environment could ever run it — and would silently move
28
+ // every existing M7 scenario from the capability-manual count to judgment-manual.
25
29
  M7: { code: 'M7', label: 'Environment limitation', cls: 'capability', drivers: [] }, // testability, no driver
26
30
  M8: { code: 'M8', label: 'Not worth automating', cls: 'keep', drivers: [] },
27
31
  M9: { code: 'M9', label: 'True human judgment', cls: 'keep', drivers: [] },
32
+ // Mobile-specific (M10/M11). Both are `keep` with no driver, and that is the honest answer:
33
+ // nothing installable automates them, so the planner must not imply otherwise. Before these
34
+ // existed every mobile-specific reason collapsed into M7, which made "needs a real device" and
35
+ // "waiting on staging infra" indistinguishable in the report.
36
+ M10: { code: 'M10', label: 'Device/OS capability', cls: 'keep', drivers: [] },
37
+ M11: { code: 'M11', label: 'Real-device only', cls: 'keep', drivers: [] },
28
38
  // Not a single-screen driver gap: automatable cross-screen via a flow (/sungen:add-flow).
29
39
  XS: { code: 'XS', label: 'Cross-screen (automate via flow)', cls: 'flow', drivers: [] },
30
40
  };
31
41
 
42
+ /** Codes that stay manual whatever is installed — derived, so a new `keep` code joins by itself. */
43
+ export const KEEP_REASON_CODES: string[] = Object.values(MANUAL_REASONS)
44
+ .filter((r) => r.cls === 'keep')
45
+ .map((r) => r.code);
46
+
47
+ /** The documented reason-code range, e.g. "M1..M11". Derived so guidance can never go stale. */
48
+ export const MANUAL_REASON_RANGE: string = (() => {
49
+ const ns = Object.keys(MANUAL_REASONS)
50
+ .filter((c) => /^M\d+$/.test(c))
51
+ .map((c) => Number(c.slice(1)))
52
+ .sort((a, b) => a - b);
53
+ return ns.length ? `M${ns[0]}..M${ns[ns.length - 1]}` : '';
54
+ })();
55
+
32
56
  // Keyword inference (best-effort retrofit). Order matters — first match wins.
33
57
  // Cross-screen "deferred to a flow" is checked FIRST: it is not a driver gap on
34
58
  // this screen, it is handled by a flow — so it must not recommend a driver.
@@ -41,6 +65,14 @@ const INFER: { code: string; re: RegExp }[] = [
41
65
  // "email" alone is too greedy (every subscription test mentions it) → require a real external-mail
42
66
  // signal (verification mail / OTP / inbox), so an API/DB-oracle test isn't misread as M5.
43
67
  { code: 'M5', re: /\b(external|third[-\s]?party|sandbox|payment gateway|invoice|download|verification (e?mail|link)|otp|mailbox|inbox|e?mail link)\b/i },
68
+ // Mobile-specific, and they MUST sit ahead of M6/M7 or they never fire: M6 matches
69
+ // /keyboard|accessibilit|responsive/ and M7 matches /environment/, either of which would take a
70
+ // mobile reason first. They stay BEHIND M1–M5 on purpose, so a mobile phrasing of an ordinary
71
+ // driver gap ("assert via API", "needs a stable test-id") still routes to the driver that solves
72
+ // it. One known consequence of that order: "IAP sandbox" hits M5's `sandbox` before M10 — the
73
+ // external-payment reading is the more useful one, and the order test pins it either way.
74
+ { code: 'M11', re: /\b(real device|máy thật|physical device|nfc|biometric|fingerprint|face id|touch id|camera thật|real camera|push token)\b/i },
75
+ { code: 'M10', re: /\b(notification shade|app switcher|recents|in-app purchase|iap|interruption|incoming call|permission variance|os version)\b/i },
44
76
  { code: 'M6', re: /\b(visual|responsive|layout|accessibilit|a11y|keyboard|screen reader|ux|breakpoint)\b/i },
45
77
  { code: 'M7', re: /\b(environment|staging[-\s]?only|infra|env limitation)\b/i },
46
78
  { code: 'M8', re: /\b(not worth|exploratory|one[-\s]?off)\b/i },
@@ -10,7 +10,7 @@
10
10
  # status: shipped → published as an npm package (R5); planned → not built yet.
11
11
  # bundled: true → installed automatically (a dependency of @sun-asterisk/sungen),
12
12
  # so `capability add` is unnecessary.
13
- # unblocks: manual-reason codes (M1–M9) this driver resolves (Phase 2b taxonomy).
13
+ # unblocks: manual-reason codes (M1–M11) this driver resolves (Phase 2b taxonomy).
14
14
  #
15
15
  # R5 status: the three real capabilities ship as packages — @sungen/driver-ui (web UI,
16
16
  # bundled as the default), @sungen/driver-db, @sungen/driver-api. The web *platform*
@@ -106,6 +106,125 @@ page_types:
106
106
  - theme: navigation-correctness
107
107
  keywords: [tap, navigate, "leaves home", "is hidden", opens, navigation]
108
108
 
109
+ # ---- mobile page-types -------------------------------------------------------------------
110
+ # All `mobile-*`, so `sensors.ts` never offers them to a web target. Two rules shaped the
111
+ # keywords below:
112
+ # 1. Detection needs 2 distinct hits AND a 1-hit lead (MIN_HITS/MIN_MARGIN), so keywords must
113
+ # DISCRIMINATE, not merely occur. Generic mobile words ("tap", "screen") are absent.
114
+ # 2. A mobile target still considers the web types, and that is correct: a mobile commerce
115
+ # screen SHOULD select ecommerce-list, and a mobile form leaning on generic form
116
+ # vocabulary (field/submit/validation) SHOULD select `form`. These types are for what a
117
+ # web type has no concept of, so each keyword set is built from natively-distinctive nouns.
118
+ # Themes carry `depth:` wherever the honest oracle is a value rather than a sighting — that is
119
+ # what separates a suite that scrolls and asserts a value from one that only asserts presence.
120
+ # Every theme is expressible in the SHIPPED mobile vocabulary (scroll/swipe/pull-to-refresh/
121
+ # rotate/background/restart/grant-permission/set-connectivity/deep-link), so none of them forces
122
+ # an @manual. OS-back themes remain absent, but the REASON has changed: the `go back` step now
123
+ # exists (per-OS KEYCODE_BACK / WDA navigation-back), so the blocker is no longer vocabulary. What
124
+ # stops it now is blast radius — a new `must_cover` theme is a new gap for every existing mobile
125
+ # suite, which is a scoring decision for an owner, not a drive-by. `viewpoint-ledger` already
126
+ # covers the dangerous half by refusing `tap [Back]` as an answer to an OS-back viewpoint item.
127
+ mobile-list:
128
+ detect_keywords: [feed, "pull to refresh", "infinite scroll", "load more", "list item", "list row"]
129
+ must_cover:
130
+ - theme: list-data
131
+ keywords: [list, feed, row, "list item", displayed]
132
+ depth:
133
+ requires: data-assertion
134
+ cross_screen: false
135
+ keywords: ["row contains", "item contains", "every row", "all items", "each row", "list shows"]
136
+ template: "User see all [List Item] text contains {{expected_label}}"
137
+ - theme: refresh-correctness
138
+ keywords: ["pull to refresh", refresh, reload, refreshed]
139
+ depth:
140
+ requires: data-assertion
141
+ cross_screen: false
142
+ keywords: ["after refresh", "refreshed list", "new item", "updated list", "after pull to refresh"]
143
+ template: "User pull to refresh [List] … User see all [List Item] text contains {{expected_label}}"
144
+ - theme: scroll-reachability
145
+ keywords: [scroll, swipe, "load more", "infinite scroll", "below the fold"]
146
+ depth:
147
+ requires: data-assertion
148
+ cross_screen: false
149
+ keywords: ["after scrolling", "scrolled to", "last item", "load more", "next page"]
150
+ template: "User scroll to [Last List Item] … User see [Last List Item] text contains {{expected_last}}"
151
+
152
+ mobile-detail:
153
+ detect_keywords: ["detail screen", "detail view", "detail header", "item detail", "detail pane"]
154
+ must_cover:
155
+ - theme: detail-data
156
+ keywords: [detail, header, displayed, field]
157
+ depth:
158
+ requires: data-assertion
159
+ cross_screen: false
160
+ keywords: ["detail shows", "detail contains", "detail header", "detail value", "detail field"]
161
+ template: "User see [Detail Title] text contains {{expected_title}}"
162
+ - theme: list-detail-consistency
163
+ keywords: [consistent, consistency, match, "same item", "correct item"]
164
+ depth:
165
+ requires: data-assertion
166
+ cross_screen: true
167
+ keywords: ["same item name", "matching name", "same value", "consistency", "same title"]
168
+ template: "User remember [List Item] text as {{v}} … User see [Detail Title] text contains {{v}}"
169
+
170
+ mobile-form:
171
+ detect_keywords: ["soft keyboard", "keyboard overlap", "hide keyboard", "on-screen keyboard", "keyboard covers"]
172
+ must_cover:
173
+ - theme: field-validation
174
+ keywords: [validation, invalid, required, error]
175
+ depth:
176
+ requires: data-assertion
177
+ cross_screen: false
178
+ keywords: ["error message", "validation message", "invalid input", "required message"]
179
+ template: "User see [Field Error] message contains {{error_text}}"
180
+ # No `depth:` on purpose: the claim IS a sighting — after the keyboard is hidden the field
181
+ # and its action are visible again. Forcing a value assertion here would invent an oracle.
182
+ - theme: keyboard-overlap
183
+ keywords: ["soft keyboard", "keyboard overlap", "hide keyboard", "keyboard covers", "keyboard hides"]
184
+ - theme: submit-correctness
185
+ keywords: [submit, save, submitted, saved]
186
+ depth:
187
+ requires: data-assertion
188
+ cross_screen: false
189
+ keywords: ["after submit", "saved value", "success message", "after saving"]
190
+ template: "User see [Success Message] text contains {{success_text}}"
191
+
192
+ mobile-auth:
193
+ detect_keywords: [biometric, fingerprint, "face id", "touch id", "app lock"]
194
+ must_cover:
195
+ - theme: credential-validation
196
+ keywords: [invalid, "wrong password", error, incorrect]
197
+ depth:
198
+ requires: data-assertion
199
+ cross_screen: false
200
+ keywords: ["error message", "invalid credential", "wrong password", "incorrect password"]
201
+ template: "User see [Login Error] message contains {{error_text}}"
202
+ - theme: session-persistence
203
+ keywords: [session, restart, persist, "stays signed in"]
204
+ depth:
205
+ requires: data-assertion
206
+ cross_screen: false
207
+ keywords: ["after restart", "still signed in", "session persists", "remains signed in"]
208
+ template: "User restart the app … User see [Account Name] text contains {{user_name}}"
209
+
210
+ mobile-settings:
211
+ detect_keywords: ["settings screen", toggle, preference, "settings list", "settings item"]
212
+ must_cover:
213
+ - theme: toggle-state-correctness
214
+ keywords: [toggle, switch, enabled, disabled]
215
+ depth:
216
+ requires: data-assertion
217
+ cross_screen: false
218
+ keywords: ["toggle state", "switch is on", "enabled state", "reflects the", "shows enabled"]
219
+ template: "User see [Notifications Toggle] text contains {{expected_state}}"
220
+ - theme: setting-persistence
221
+ keywords: [persist, restart, saved, "after reopening"]
222
+ depth:
223
+ requires: data-assertion
224
+ cross_screen: false
225
+ keywords: ["after restart", "setting persists", "still enabled", "saved setting", "after reopening"]
226
+ template: "User restart the app … User see [Notifications Toggle] text contains {{expected_state}}"
227
+
109
228
  auth:
110
229
  detect_keywords: [login, logout, password, signin, "sign in", credential]
111
230
  must_cover:
@@ -127,3 +246,29 @@ universal:
127
246
  keywords: ["empty", "no data", "no result", "failed", "error state"]
128
247
  - theme: accessibility
129
248
  keywords: ["keyboard", "tab order", "accessible", "aria", "focus"]
249
+
250
+ # Same role as `universal`, but only offered to MOBILE targets — `universal` applies to web too,
251
+ # where "rotate to landscape" or "airplane mode" is meaningless.
252
+ #
253
+ # READ THIS BEFORE ADDING TEETH HERE: like `universal`, these themes are REMINDERS. They surface
254
+ # through `universalGaps`, which reaches the audit narrative and the capability sensors but is NOT
255
+ # an input to the score. A theme placed here can never move a number.
256
+ #
257
+ # That is deliberate, not an oversight. These concerns are real on every mobile screen, but not
258
+ # every mobile screen owes every one of them — a settings screen has no deep link, a read-only list
259
+ # has no permission prompt. Scoring them uniformly would penalise honest suites for gaps that are
260
+ # not gaps. The themes that DO carry weight sit in each page-type's `must_cover` above, where they
261
+ # apply to a screen that genuinely owes them.
262
+ mobile_universal:
263
+ - theme: orientation-stability
264
+ keywords: [rotate, landscape, portrait, orientation]
265
+ - theme: connectivity-degraded
266
+ keywords: ["airplane mode", offline, "wifi", "mobile data", "no connection"]
267
+ - theme: app-lifecycle-resume
268
+ keywords: [background, resume, foreground, "returns to the app", restart]
269
+ - theme: permission-prompt
270
+ keywords: [permission, grant, denied, allow]
271
+ - theme: gesture-reachability
272
+ keywords: [scroll, swipe, "off screen", "below the fold"]
273
+ - theme: deep-link-entry
274
+ keywords: ["deep link", deeplink, "universal link"]
@@ -41,6 +41,12 @@ export interface ScenarioInfo {
41
41
  ownedByFlow?: string; // @owned-by:<flow> — the flow that owns this deferred scenario (H6)
42
42
  /** Ordered steps with their resolved bucket (And/But inherit) — flow handoff analysis (#569). */
43
43
  steps?: Array<{ bucket: 'given' | 'when' | 'then'; text: string }>;
44
+ /**
45
+ * The `.feature` this scenario was read from. A unit may hold several (`add --feature`), and
46
+ * `scenarioIdReuse` scopes its uniqueness check to ONE file — so the sensor needs to know
47
+ * which. Set by `loadScenarios`; absent on hand-built fixtures, which then count as one file.
48
+ */
49
+ sourceFile?: string;
44
50
  }
45
51
 
46
52
  /** Format-tolerant: is this token an ID (project's scheme), not a prose word?
@@ -270,7 +276,7 @@ export function loadScenarios(featurePath: string): ScenarioInfo[] {
270
276
  const feature = parser.parseFeatureFile(featurePath);
271
277
  return (feature.scenarios || [])
272
278
  .filter((s) => !s.stepsName && !s.hookType) // skip @steps/@hook blocks
273
- .map((s) => classifyScenario(withBackground(s, feature.background)));
279
+ .map((s) => ({ ...classifyScenario(withBackground(s, feature.background)), sourceFile: featurePath }));
274
280
  }
275
281
 
276
282
  /**
@@ -10,6 +10,7 @@ import * as path from 'path';
10
10
  import { capabilityRegistry } from '../capabilities/registry';
11
11
  import { discoverAndRegisterCapabilities } from '../capabilities/discover';
12
12
  import { scoringCapabilityFor } from './audit';
13
+ import { readCapabilities } from './capability';
13
14
 
14
15
  export interface RepairProposal { source: 'audit' | 'runtime'; signal: string; ruleId: string; fix: string }
15
16
  export interface RepairPlan {
@@ -50,7 +51,13 @@ function failuresFromResult(file: string): string[] {
50
51
  */
51
52
  export function planRepair(unitId: string, reportName: string, cwd: string, generatedDir?: string): RepairPlan {
52
53
  discoverAndRegisterCapabilities();
53
- const capId = scoringCapabilityFor(unitId, capabilityRegistry.defaultCapabilityId());
54
+ // The ACTIVE PLATFORM is part of capability resolution, and leaving it out silently sent every
55
+ // mobile unit to the web catalog: `scoringCapabilityFor`'s third parameter is what lets `mobile`
56
+ // beat the default `ui`, `runAudit` passes it, and this call did not. A bare screen on a
57
+ // `platform: mobile` project therefore resolved to `ui` — whose repair rules are the WEB ones —
58
+ // so registering a mobile catalog would have changed nothing here. `cwd` is the project root
59
+ // (the CLI passes `process.cwd()`), which is where `qa/capabilities.yaml` lives.
60
+ const capId = scoringCapabilityFor(unitId, capabilityRegistry.defaultCapabilityId(), readCapabilities(cwd).platform);
54
61
  const rules = (capId ? capabilityRegistry.get(capId)?.repair?.rules : undefined) ?? [];
55
62
 
56
63
  const signals: { source: 'audit' | 'runtime'; text: string }[] = [];