@sun-asterisk/sungen 3.2.34 → 3.2.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. package/dist/cli/commands/audit.d.ts.map +1 -1
  2. package/dist/cli/commands/audit.js +9 -4
  3. package/dist/cli/commands/audit.js.map +1 -1
  4. package/dist/cli/commands/capability.d.ts.map +1 -1
  5. package/dist/cli/commands/capability.js +5 -1
  6. package/dist/cli/commands/capability.js.map +1 -1
  7. package/dist/cli/commands/defect.d.ts +7 -0
  8. package/dist/cli/commands/defect.d.ts.map +1 -0
  9. package/dist/cli/commands/defect.js +68 -0
  10. package/dist/cli/commands/defect.js.map +1 -0
  11. package/dist/cli/commands/delivery.d.ts.map +1 -1
  12. package/dist/cli/commands/delivery.js +35 -7
  13. package/dist/cli/commands/delivery.js.map +1 -1
  14. package/dist/cli/commands/triage.d.ts +7 -0
  15. package/dist/cli/commands/triage.d.ts.map +1 -0
  16. package/dist/cli/commands/triage.js +98 -0
  17. package/dist/cli/commands/triage.js.map +1 -0
  18. package/dist/cli/index.js +4 -0
  19. package/dist/cli/index.js.map +1 -1
  20. package/dist/exporters/csv-exporter.d.ts +4 -0
  21. package/dist/exporters/csv-exporter.d.ts.map +1 -1
  22. package/dist/exporters/csv-exporter.js +1 -1
  23. package/dist/exporters/csv-exporter.js.map +1 -1
  24. package/dist/exporters/feature-parser.d.ts +8 -0
  25. package/dist/exporters/feature-parser.d.ts.map +1 -1
  26. package/dist/exporters/feature-parser.js +12 -0
  27. package/dist/exporters/feature-parser.js.map +1 -1
  28. package/dist/exporters/matrix/build.d.ts.map +1 -1
  29. package/dist/exporters/matrix/build.js +49 -10
  30. package/dist/exporters/matrix/build.js.map +1 -1
  31. package/dist/exporters/matrix/carry-manual-results.d.ts +19 -0
  32. package/dist/exporters/matrix/carry-manual-results.d.ts.map +1 -1
  33. package/dist/exporters/matrix/carry-manual-results.js +76 -6
  34. package/dist/exporters/matrix/carry-manual-results.js.map +1 -1
  35. package/dist/exporters/matrix/export.d.ts +5 -2
  36. package/dist/exporters/matrix/export.d.ts.map +1 -1
  37. package/dist/exporters/matrix/export.js +14 -3
  38. package/dist/exporters/matrix/export.js.map +1 -1
  39. package/dist/exporters/matrix/render-csv.js +1 -1
  40. package/dist/exporters/matrix/render-csv.js.map +1 -1
  41. package/dist/exporters/matrix/render-xlsx.d.ts.map +1 -1
  42. package/dist/exporters/matrix/render-xlsx.js +3 -0
  43. package/dist/exporters/matrix/render-xlsx.js.map +1 -1
  44. package/dist/exporters/matrix/types.d.ts +5 -0
  45. package/dist/exporters/matrix/types.d.ts.map +1 -1
  46. package/dist/exporters/matrix/types.js.map +1 -1
  47. package/dist/exporters/playwright-report-parser.d.ts.map +1 -1
  48. package/dist/exporters/playwright-report-parser.js +1 -0
  49. package/dist/exporters/playwright-report-parser.js.map +1 -1
  50. package/dist/exporters/scenario-merger.d.ts.map +1 -1
  51. package/dist/exporters/scenario-merger.js +2 -0
  52. package/dist/exporters/scenario-merger.js.map +1 -1
  53. package/dist/exporters/test-data-resolver.d.ts.map +1 -1
  54. package/dist/exporters/test-data-resolver.js +12 -2
  55. package/dist/exporters/test-data-resolver.js.map +1 -1
  56. package/dist/exporters/types.d.ts +5 -0
  57. package/dist/exporters/types.d.ts.map +1 -1
  58. package/dist/generators/gherkin-parser/index.d.ts.map +1 -1
  59. package/dist/generators/gherkin-parser/index.js +1 -0
  60. package/dist/generators/gherkin-parser/index.js.map +1 -1
  61. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/contain-text-assertion.hbs +2 -1
  62. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/expanded-assertion.hbs +2 -0
  63. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/date-picker-unsupported.hbs +5 -0
  64. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/fill-action.hbs +13 -1
  65. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/contain-text-assertion.hbs +9 -1
  66. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/expanded-assertion.hbs +12 -0
  67. package/dist/generators/test-generator/code-generator.js +4 -5
  68. package/dist/generators/test-generator/code-generator.js.map +1 -1
  69. package/dist/generators/test-generator/diagnostics.d.ts +9 -3
  70. package/dist/generators/test-generator/diagnostics.d.ts.map +1 -1
  71. package/dist/generators/test-generator/diagnostics.js +9 -3
  72. package/dist/generators/test-generator/diagnostics.js.map +1 -1
  73. package/dist/generators/test-generator/patterns/types.d.ts +1 -1
  74. package/dist/generators/test-generator/patterns/types.d.ts.map +1 -1
  75. package/dist/generators/test-generator/step-mapper.d.ts +8 -0
  76. package/dist/generators/test-generator/step-mapper.d.ts.map +1 -1
  77. package/dist/generators/test-generator/step-mapper.js +52 -8
  78. package/dist/generators/test-generator/step-mapper.js.map +1 -1
  79. package/dist/generators/test-generator/utils/data-resolver.d.ts +16 -0
  80. package/dist/generators/test-generator/utils/data-resolver.d.ts.map +1 -1
  81. package/dist/generators/test-generator/utils/data-resolver.js +59 -0
  82. package/dist/generators/test-generator/utils/data-resolver.js.map +1 -1
  83. package/dist/generators/test-generator/utils/selector-resolver.d.ts.map +1 -1
  84. package/dist/generators/test-generator/utils/selector-resolver.js +10 -2
  85. package/dist/generators/test-generator/utils/selector-resolver.js.map +1 -1
  86. package/dist/harness/audit.d.ts.map +1 -1
  87. package/dist/harness/audit.js +18 -5
  88. package/dist/harness/audit.js.map +1 -1
  89. package/dist/harness/counted-assertions.d.ts +2 -0
  90. package/dist/harness/counted-assertions.d.ts.map +1 -0
  91. package/dist/harness/counted-assertions.js +38 -0
  92. package/dist/harness/counted-assertions.js.map +1 -0
  93. package/dist/harness/defect-draft.d.ts +14 -0
  94. package/dist/harness/defect-draft.d.ts.map +1 -0
  95. package/dist/harness/defect-draft.js +143 -0
  96. package/dist/harness/defect-draft.js.map +1 -0
  97. package/dist/harness/defects.d.ts +24 -0
  98. package/dist/harness/defects.d.ts.map +1 -0
  99. package/dist/harness/defects.js +118 -0
  100. package/dist/harness/defects.js.map +1 -0
  101. package/dist/harness/manifest.d.ts.map +1 -1
  102. package/dist/harness/manifest.js +2 -2
  103. package/dist/harness/manifest.js.map +1 -1
  104. package/dist/harness/next-step.d.ts +0 -4
  105. package/dist/harness/next-step.d.ts.map +1 -1
  106. package/dist/harness/next-step.js +34 -4
  107. package/dist/harness/next-step.js.map +1 -1
  108. package/dist/harness/parse.d.ts +18 -0
  109. package/dist/harness/parse.d.ts.map +1 -1
  110. package/dist/harness/parse.js +20 -11
  111. package/dist/harness/parse.js.map +1 -1
  112. package/dist/harness/script-check.d.ts.map +1 -1
  113. package/dist/harness/script-check.js +7 -1
  114. package/dist/harness/script-check.js.map +1 -1
  115. package/dist/harness/sensors.d.ts.map +1 -1
  116. package/dist/harness/sensors.js +23 -6
  117. package/dist/harness/sensors.js.map +1 -1
  118. package/dist/harness/triage-signals.d.ts +20 -0
  119. package/dist/harness/triage-signals.d.ts.map +1 -0
  120. package/dist/harness/triage-signals.js +30 -0
  121. package/dist/harness/triage-signals.js.map +1 -0
  122. package/dist/harness/triage-unit.d.ts +23 -0
  123. package/dist/harness/triage-unit.d.ts.map +1 -0
  124. package/dist/harness/triage-unit.js +142 -0
  125. package/dist/harness/triage-unit.js.map +1 -0
  126. package/dist/harness/triage.d.ts +28 -0
  127. package/dist/harness/triage.d.ts.map +1 -0
  128. package/dist/harness/triage.js +99 -0
  129. package/dist/harness/triage.js.map +1 -0
  130. package/dist/harness/viewpoint-ledger.d.ts.map +1 -1
  131. package/dist/harness/viewpoint-ledger.js +2 -1
  132. package/dist/harness/viewpoint-ledger.js.map +1 -1
  133. package/dist/index.d.ts.map +1 -1
  134. package/dist/index.js +2 -1
  135. package/dist/index.js.map +1 -1
  136. package/dist/orchestrator/ai-skills/commands.d.ts.map +1 -1
  137. package/dist/orchestrator/ai-skills/commands.js +5 -3
  138. package/dist/orchestrator/ai-skills/commands.js.map +1 -1
  139. package/dist/orchestrator/ai-skills/golden-skills.test.js +29 -0
  140. package/dist/orchestrator/ai-skills/golden-skills.test.js.map +1 -1
  141. package/dist/orchestrator/screen-manager.d.ts.map +1 -1
  142. package/dist/orchestrator/screen-manager.js +4 -2
  143. package/dist/orchestrator/screen-manager.js.map +1 -1
  144. package/dist/orchestrator/templates/ai-src/commands/add-flow.md +26 -4
  145. package/dist/orchestrator/templates/ai-src/commands/run-test.md +8 -2
  146. package/dist/orchestrator/templates/ai-src/config/claude.md +20 -7
  147. package/dist/orchestrator/templates/ai-src/config/copilot.md +20 -7
  148. package/dist/orchestrator/templates/ai-src/skills/sungen-api-coverage-model/SKILL.md +1 -1
  149. package/dist/orchestrator/templates/ai-src/skills/sungen-capture-mobile/SKILL.md +3 -3
  150. package/dist/orchestrator/templates/ai-src/skills/sungen-data-factory/SKILL.md +9 -0
  151. package/dist/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +2 -1
  152. package/dist/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +7 -4
  153. package/dist/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +25 -9
  154. package/dist/orchestrator/templates/ai-src/skills/sungen-harness-audit/SKILL.md +2 -0
  155. package/dist/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +4 -3
  156. package/dist/orchestrator/templates/specs-test-data.ts +22 -4
  157. package/package.json +3 -3
  158. package/src/cli/commands/audit.ts +9 -4
  159. package/src/cli/commands/capability.ts +3 -1
  160. package/src/cli/commands/defect.ts +49 -0
  161. package/src/cli/commands/delivery.ts +33 -10
  162. package/src/cli/commands/triage.ts +57 -0
  163. package/src/cli/index.ts +4 -0
  164. package/src/exporters/csv-exporter.ts +5 -1
  165. package/src/exporters/feature-parser.ts +12 -0
  166. package/src/exporters/matrix/build.ts +51 -9
  167. package/src/exporters/matrix/carry-manual-results.ts +70 -7
  168. package/src/exporters/matrix/export.ts +12 -2
  169. package/src/exporters/matrix/render-csv.ts +1 -1
  170. package/src/exporters/matrix/render-xlsx.ts +3 -0
  171. package/src/exporters/matrix/types.ts +5 -0
  172. package/src/exporters/playwright-report-parser.ts +1 -0
  173. package/src/exporters/scenario-merger.ts +2 -1
  174. package/src/exporters/test-data-resolver.ts +10 -2
  175. package/src/exporters/types.ts +5 -0
  176. package/src/generators/gherkin-parser/index.ts +1 -0
  177. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/contain-text-assertion.hbs +2 -1
  178. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/expanded-assertion.hbs +2 -0
  179. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/date-picker-unsupported.hbs +5 -0
  180. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/fill-action.hbs +13 -1
  181. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/contain-text-assertion.hbs +9 -1
  182. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/expanded-assertion.hbs +12 -0
  183. package/src/generators/test-generator/code-generator.ts +4 -4
  184. package/src/generators/test-generator/diagnostics.ts +9 -3
  185. package/src/generators/test-generator/patterns/types.ts +2 -0
  186. package/src/generators/test-generator/step-mapper.ts +56 -9
  187. package/src/generators/test-generator/utils/data-resolver.ts +53 -0
  188. package/src/generators/test-generator/utils/selector-resolver.ts +10 -2
  189. package/src/harness/audit.ts +19 -6
  190. package/src/harness/counted-assertions.ts +33 -0
  191. package/src/harness/defect-draft.ts +115 -0
  192. package/src/harness/defects.ts +94 -0
  193. package/src/harness/manifest.ts +2 -2
  194. package/src/harness/next-step.ts +29 -4
  195. package/src/harness/parse.ts +20 -11
  196. package/src/harness/script-check.ts +7 -1
  197. package/src/harness/sensors.ts +26 -7
  198. package/src/harness/triage-signals.ts +46 -0
  199. package/src/harness/triage-unit.ts +113 -0
  200. package/src/harness/triage.ts +127 -0
  201. package/src/harness/viewpoint-ledger.ts +2 -2
  202. package/src/index.ts +2 -1
  203. package/src/orchestrator/ai-skills/commands.ts +5 -3
  204. package/src/orchestrator/ai-skills/golden-skills.test.ts +23 -0
  205. package/src/orchestrator/screen-manager.ts +3 -1
  206. package/src/orchestrator/templates/ai-src/commands/add-flow.md +26 -4
  207. package/src/orchestrator/templates/ai-src/commands/run-test.md +8 -2
  208. package/src/orchestrator/templates/ai-src/config/claude.md +20 -7
  209. package/src/orchestrator/templates/ai-src/config/copilot.md +20 -7
  210. package/src/orchestrator/templates/ai-src/skills/sungen-api-coverage-model/SKILL.md +1 -1
  211. package/src/orchestrator/templates/ai-src/skills/sungen-capture-mobile/SKILL.md +3 -3
  212. package/src/orchestrator/templates/ai-src/skills/sungen-data-factory/SKILL.md +9 -0
  213. package/src/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +2 -1
  214. package/src/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +7 -4
  215. package/src/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +25 -9
  216. package/src/orchestrator/templates/ai-src/skills/sungen-harness-audit/SKILL.md +2 -0
  217. package/src/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +4 -3
  218. package/src/orchestrator/templates/specs-test-data.ts +22 -4
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Defect records — `qa/defects/DEF-NNN-<slug>.md`, one project-wide folder. A scenario points at a
3
+ * record with `@known-defect:DEF-NNN`; the record points back at units, scenarios, claims and legacy
4
+ * TCs in its frontmatter. This module reads the records and checks both directions of that link.
5
+ * Drafting a record from a triage row lives in defect-draft.ts.
6
+ */
7
+ import * as fs from 'fs';
8
+ import * as path from 'path';
9
+ import { parse as parseYaml } from 'yaml';
10
+
11
+ export const DEFECT_STATUSES = ['open', 'fixed', 'verified', 'closed', 'wont-fix'] as const;
12
+ export type DefectStatus = typeof DEFECT_STATUSES[number];
13
+
14
+ export interface DefectRecord {
15
+ id: string;
16
+ title: string;
17
+ severity: string;
18
+ status: string;
19
+ units: string[];
20
+ scenarios: string[];
21
+ /** Project-relative path of the record file. */
22
+ file: string;
23
+ }
24
+
25
+ const DEF_FILE = /^(DEF-(\d+))-.*\.md$/;
26
+ const KNOWN_DEFECT_TAG = /@known-defect:(DEF-\d+)\b/g;
27
+
28
+ export function defectsDir(cwd: string): string {
29
+ return path.join(cwd, 'qa', 'defects');
30
+ }
31
+
32
+ function defectFiles(cwd: string): string[] {
33
+ const dir = defectsDir(cwd);
34
+ return fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => DEF_FILE.test(f)).sort() : [];
35
+ }
36
+
37
+ /** The next free id: one above the highest number on disk, zero-padded to 3. */
38
+ export function nextDefectId(cwd: string): string {
39
+ const max = defectFiles(cwd).reduce((m, f) => Math.max(m, Number(f.match(DEF_FILE)![2])), 0);
40
+ return `DEF-${String(max + 1).padStart(3, '0')}`;
41
+ }
42
+
43
+ const list = (v: unknown): string[] => (Array.isArray(v) ? v.map(String) : []);
44
+
45
+ /** Every record whose frontmatter parses; a malformed file is skipped, never thrown on. */
46
+ export function readDefects(cwd: string): DefectRecord[] {
47
+ const out: DefectRecord[] = [];
48
+ for (const f of defectFiles(cwd)) {
49
+ const file = path.join(defectsDir(cwd), f);
50
+ try {
51
+ const fm = parseYaml(fs.readFileSync(file, 'utf-8').split(/^---\s*$/m)[1] ?? '') as Record<string, unknown> | null;
52
+ if (!fm || typeof fm !== 'object') continue;
53
+ out.push({
54
+ id: String(fm.id ?? f.match(DEF_FILE)![1]),
55
+ title: String(fm.title ?? ''),
56
+ severity: String(fm.severity ?? ''),
57
+ status: String(fm.status ?? ''),
58
+ units: list(fm.units),
59
+ scenarios: list(fm.scenarios),
60
+ file: path.relative(cwd, file),
61
+ });
62
+ } catch {
63
+ // unreadable frontmatter: not a record we can reason about
64
+ }
65
+ }
66
+ return out;
67
+ }
68
+
69
+ /**
70
+ * Advisory audit findings for one unit (never scored):
71
+ * DEFECT-REF-MISSING a `@known-defect:DEF-NNN` tag with no record file.
72
+ * DEFECT-ORPHAN (info) an OPEN record of this unit names a scenario the unit no longer has.
73
+ */
74
+ export function defectLinkFindings(cwd: string, unitId: string, featureText: string, scenarioNames: string[]): string[] {
75
+ const records = readDefects(cwd);
76
+ const findings: string[] = [];
77
+ const known = new Set(records.map((r) => r.id));
78
+ const tagged = new Set([...featureText.matchAll(KNOWN_DEFECT_TAG)].map((m) => m[1]));
79
+ for (const id of tagged) {
80
+ if (!known.has(id)) {
81
+ findings.push(`DEFECT-REF-MISSING: \`@known-defect:${id}\` has no record in qa/defects/ — draft it with \`sungen defect draft --screen ${unitId}\`, or correct the id.`);
82
+ }
83
+ }
84
+ const names = new Set(scenarioNames);
85
+ for (const r of records) {
86
+ if (r.status !== 'open' || !r.units.includes(unitId)) continue;
87
+ for (const s of r.scenarios) {
88
+ if (!names.has(s)) {
89
+ findings.push(`DEFECT-ORPHAN (info): ${r.id} is open and names scenario "${s}", which ${unitId} no longer has — update the record's scenarios, or close it.`);
90
+ }
91
+ }
92
+ }
93
+ return findings;
94
+ }
@@ -13,6 +13,7 @@ import * as path from 'path';
13
13
  import { createHash } from 'crypto';
14
14
  import { featureFilesFor, reportSlug } from './unit-paths';
15
15
  import { readTextFile } from './read-text';
16
+ import { vpCodeOf } from './parse';
16
17
 
17
18
  export interface SpecSection { name: string; hash: string }
18
19
  export interface ManifestEntry { scenario: string; vpCode?: string; section: string; specHash: string }
@@ -90,8 +91,7 @@ function parseFeatureSections(featurePath: string): { scenario: string; vpCode?:
90
91
  const s = line.match(/^Scenario:\s*(.*)$/);
91
92
  if (s) {
92
93
  const name = s[1].trim();
93
- const code = name.match(/\bVP-[A-Z]+-\d+/i)?.[0];
94
- out.push({ scenario: name, vpCode: code, section });
94
+ out.push({ scenario: name, vpCode: vpCodeOf(name).vpCode, section });
95
95
  }
96
96
  }
97
97
  return out;
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import * as fs from 'fs';
18
18
  import * as path from 'path';
19
+ import { parse as parseYaml } from 'yaml';
19
20
  import { reportSlug, featureFilesFor } from './unit-paths';
20
21
  import { readTextFile } from './read-text';
21
22
  import { checkAssetsDrift } from '../orchestrator/assets-drift';
@@ -87,6 +88,29 @@ function countScenarios(unitDir: string, unit: string): number {
87
88
  * Everything the resolver needs, read from disk. `unitId` is the catalog id
88
89
  * (`<screen>` · `flows/<f>` · `api/<a>`); `unitDir` is its directory.
89
90
  */
91
+ const RESULT_FILE = /-test-result(?:\.[^.]+)*\.json$/;
92
+
93
+ /**
94
+ * Does the selector text resolve at least one element? A scaffold resolves none: the web one holds
95
+ * only the `page` route, the mobile one only header comments. Matching the WORD "placeholder" was
96
+ * wrong both ways (#868): it flagged a real file whose key names an input's placeholder text, and
97
+ * missed both scaffolds, which never contain it.
98
+ */
99
+ function hasResolvedSelector(selectorText: string): boolean {
100
+ let doc: unknown;
101
+ try { doc = parseYaml(selectorText); } catch { return selectorText.trim().length >= 40; }
102
+ if (!doc || typeof doc !== 'object') return false;
103
+ // A real cell, not a marker left to be filled in. Numbers count (an id or index can be numeric).
104
+ const real = (v: unknown): boolean => (typeof v === 'string' || typeof v === 'number')
105
+ && String(v).trim() !== '' && !/^(?:todo|tbd|placeholder|replace me)$/i.test(String(v).trim());
106
+ return Object.values(doc as Record<string, unknown>).some((e) => {
107
+ if (!e || typeof e !== 'object') return false;
108
+ const { type, value, name, locator } = e as { type?: unknown; value?: unknown; name?: unknown; locator?: unknown };
109
+ // Every SelectorEntry shape: a CSS `locator`, or a non-page `type` with a value or a role name.
110
+ return real(locator) || (typeof type === 'string' && type !== 'page' && (real(value) || real(name)));
111
+ });
112
+ }
113
+
90
114
  export function readUnitState(
91
115
  projectRoot: string, unitDir: string, unit: string, kind: UnitState['kind'],
92
116
  ): UnitState {
@@ -101,7 +125,7 @@ export function readUnitState(
101
125
  // `readTextFile` throws on a missing file, and every path here is legitimately absent at some
102
126
  // point in a unit's life — the resolver exists to report exactly that, so it must not crash on it.
103
127
  const read = (p: string): string => (fs.existsSync(p) ? readTextFile(p) : '');
104
- const selectorText = selectorFiles.map((f) => read(path.join(selectorsDir, f))).join('\n');
128
+ const selectorTexts = selectorFiles.map((f) => read(path.join(selectorsDir, f)));
105
129
  const contractPath = path.join(req, 'flow-contract.yaml');
106
130
  const contractText = read(contractPath);
107
131
 
@@ -116,7 +140,9 @@ export function readUnitState(
116
140
  });
117
141
  const hasResults = specDirs.some((d) => {
118
142
  try {
119
- return fs.readdirSync(d).some((f) => f.endsWith('-test-result.json'));
143
+ // `<f>-test-result[.<os>][.<env>].json` — a mobile run writes the OS into the name, an env
144
+ // run (SUNGEN_ENV=staging) the env; both are runs (#868).
145
+ return fs.readdirSync(d).some((f) => RESULT_FILE.test(f));
120
146
  } catch { return false; }
121
147
  });
122
148
  let hasDeliverable = false;
@@ -139,8 +165,7 @@ export function readUnitState(
139
165
  hasSelectors: selectorFiles.length > 0,
140
166
  // A scaffolded selector file exists but resolves nothing; treating it as "done" is what
141
167
  // makes a run look further along than it is.
142
- selectorsArePlaceholder: selectorFiles.length > 0
143
- && (/TODO|PLACEHOLDER|REPLACE ME/i.test(selectorText) || selectorText.trim().length < 40),
168
+ selectorsArePlaceholder: selectorFiles.length > 0 && !selectorTexts.some(hasResolvedSelector),
144
169
  compiled, hasResults, hasDeliverable,
145
170
  assetsDrift: (() => {
146
171
  const d = checkAssetsDrift(projectRoot);
@@ -285,6 +285,25 @@ function withBackground(sc: ParsedScenario, background?: ParsedScenario): Parsed
285
285
  return { ...sc, steps: [...bg, ...((sc.steps as ParsedStep[]) || [])] } as ParsedScenario;
286
286
  }
287
287
 
288
+ /**
289
+ * The `VP-<CATEGORY>-<n>` / `FL-<PHASE>-<n>` code in a scenario title, and its category — the ONE
290
+ * reading of it; the manifest kept an older copy of this regex and recorded no code for
291
+ * VP-I18N-001, VP-LIST-DISPLAY-01, VP-AF02-01 or any FL- id (#889).
292
+ *
293
+ * Category is everything between `VP-` and the final `-<sequence>` — INCLUDING hyphens, so
294
+ * compound categories (VP-LIST-DISPLAY-01, VP-ADD-TO-CART-03, VP-PRODUCT-DISCOVERY-02) parse,
295
+ * not just single-word ones. A single-word category (VP-CART-001) still works. (H1)
296
+ * Flows use journey-phase ids (FL-HP-001 / FL-ER-002) — the VP- anchor rejected them, so every
297
+ * flow scenario had NO category and the whole suite bucketed `other` (taxonomy=0%, #569).
298
+ * Segments may carry digits: the use-case framework numbers its flows (VP-AF02-01,
299
+ * VP-EF11-01 — one id per Alternate/Exception Flow), and `[A-Z]+` also silently truncated
300
+ * VP-I18N-001 to no category at all (the `18` broke the class mid-segment).
301
+ */
302
+ export function vpCodeOf(title: string): { vpCode?: string; category?: string } {
303
+ const m = title.match(/\b(?:VP|FL)-([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*)-\d+/i);
304
+ return m ? { vpCode: m[0].toUpperCase(), category: m[1].toUpperCase() } : {};
305
+ }
306
+
288
307
  function classifyScenario(sc: ParsedScenario): ScenarioInfo {
289
308
  const tags = sc.tags || [];
290
309
  const deferredToFlow = tags.includes('@deferred:flow');
@@ -310,17 +329,7 @@ function classifyScenario(sc: ParsedScenario): ScenarioInfo {
310
329
  let priority: Priority = 'unknown';
311
330
  for (const t of tags) if (PRIORITY_TAGS[t]) priority = PRIORITY_TAGS[t];
312
331
 
313
- // Category is everything between `VP-` and the final `-<sequence>` — INCLUDING hyphens, so
314
- // compound categories (VP-LIST-DISPLAY-01, VP-ADD-TO-CART-03, VP-PRODUCT-DISCOVERY-02) parse,
315
- // not just single-word ones. A single-word category (VP-CART-001) still works. (H1)
316
- // Flows use journey-phase ids (FL-HP-001 / FL-ER-002) — the VP- anchor rejected them, so every
317
- // flow scenario had NO category and the whole suite bucketed `other` (taxonomy=0%, #569).
318
- // Segments may carry digits: the use-case framework numbers its flows (VP-AF02-01,
319
- // VP-EF11-01 — one id per Alternate/Exception Flow), and `[A-Z]+` also silently truncated
320
- // VP-I18N-001 to no category at all (the `18` broke the class mid-segment).
321
- const codeMatch = sc.name.match(/\b(?:VP|FL)-([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*)-\d+/i);
322
- const vpCode = codeMatch ? codeMatch[0].toUpperCase() : undefined;
323
- const category = codeMatch ? codeMatch[1].toUpperCase() : undefined;
332
+ const { vpCode, category } = vpCodeOf(sc.name);
324
333
  // Project-scheme ID: the leading token of the title (VP0-001 / MS-HP-001 / VP-LIST-001).
325
334
  const leadMatch = sc.name.match(/^\s*([A-Za-z][A-Za-z0-9.-]*)/);
326
335
  const vpId = leadMatch && isIdLike(leadMatch[1]) ? leadMatch[1].toUpperCase() : undefined;
@@ -107,6 +107,12 @@ const ASSERTION_STEP_COMMENT = /^\s*\/\/\s*Assert\b/;
107
107
  // but as a bare substring it would also exempt a genuinely hollow step whose test-data or step
108
108
  // text happened to contain it.
109
109
  const NOOP_STEP_MARKER = /^\s*\/\/\s*App launched via wdio capabilities/;
110
+ // The same kind of step on the web adapter, where the explanation IS the step comment: the
111
+ // step-mapper compiles `Given User is on [Edit] dialog` (and its exit) to `// Enter dialog scope:
112
+ // Edit` and no code, because it only re-roots the steps after it — the next assertion runs as
113
+ // `page.getByRole('dialog', …).getByRole(…)`. A field unit reported 13 BYPASS, every one of them
114
+ // this comment, and exited 2 on a correct suite (#881). Anchored to the whole marker line.
115
+ const SCOPE_STEP_MARKER = /^\s*\/\/\s*(?:Enter|Exit) dialog scope(?::\s*.*)?$/;
110
116
 
111
117
  /** A spec comment line reduced to its text, for comparison against a compiler step comment. */
112
118
  const commentText = (line: string): string => line.trim().replace(/^\/\/\s?/, '').trim();
@@ -241,7 +247,7 @@ export function analyzeFaithfulness(
241
247
  const hasCode = region.some((l) => !isComment(l) && CODE_LINE.test(l));
242
248
  // A documented no-op emits its explanation INTO the step's region, so read the region
243
249
  // (not just the marker line) for it — see NOOP_STEP_MARKER.
244
- if (hasCode || region.some((l) => NOOP_STEP_MARKER.test(l))) continue;
250
+ if (hasCode || region.some((l) => NOOP_STEP_MARKER.test(l)) || SCOPE_STEP_MARKER.test(body[markers[s]])) continue;
245
251
  if (tailAmbiguous && s === markers.length - 1) continue;
246
252
  hollowSteps.push({ test: title, step: body[markers[s]].trim().slice(0, 70) });
247
253
  }
@@ -12,6 +12,7 @@ import { parse as parseYaml } from 'yaml';
12
12
  import { ScenarioInfo, ViewpointEntry, idPrefix } from './parse';
13
13
  import { readTextFile } from './read-text';
14
14
  import { keywordHit, hasKeyword } from './keyword-match';
15
+ import { countedAssertions } from './counted-assertions';
15
16
 
16
17
  // Business-critical category keywords (matched by CONTAINMENT against the VP category, so a
17
18
  // compound category like LIST-DISPLAY / ADD-TO-CART / PRODUCT-DISCOVERY classifies correctly).
@@ -947,7 +948,14 @@ export interface TaxonomyResult {
947
948
 
948
949
  const TAXONOMY_KNOWN = new Set(['LIST', 'PRODUCT', 'CART', 'FILTER', 'NAV', 'VAL', 'SEC']);
949
950
 
950
- const CATEGORY_SIGNALS: { cat: string; re: RegExp }[] = [
951
+ /**
952
+ * Words of ACCESS — a title about who may reach something. They make the robustness/routing words
953
+ * below security evidence ("deep link without authentication"); alone those words describe routing.
954
+ */
955
+ const ACCESS_CONTEXT = /\b(xss|sql|injection|without authentication|unauthenticated|unauthori[sz]ed|without an? (?:authenticated|valid|active) \w+|without (?:permission|login|logging in|signing in|an? account|access)|not (?:logged|signed) in|logged[- ]out|direct(?:ly)? access\w*|auth[- ]?guard|forbidden|\b403\b|(?:no|insufficient|missing) permissions?|permission required|session expired|expired session|tamper\w*)\b/;
956
+
957
+ /** `requires`: the signal counts only when the title ALSO matches it (#888). */
958
+ const CATEGORY_SIGNALS: { cat: string; re: RegExp; requires?: RegExp }[] = [
951
959
  { cat: 'FILTER', re: /\b(categor(y|ies)|sub-?categor\w*|expand\w*|collaps\w*|\bfacet\b|filter\w*|belongs? to)\b/ },
952
960
  { cat: 'CART', re: /\b(add(ed|ing)? to cart|added (dialog|modal|message|confirmation)|cart (page|table|line|item)|view cart|continue shopping|quantit)\b/ },
953
961
  { cat: 'PRODUCT', re: /\b(product detail|product-detail|view product|product page|same product|product name and price|product identity|detail product)\b/ },
@@ -959,9 +967,16 @@ const CATEGORY_SIGNALS: { cat: string; re: RegExp }[] = [
959
967
  // SEC, so the linter told QA to re-tag genuine auth-gate tests as navigation — diluting the
960
968
  // visibility of the security suite (QA field report, 3.2.23). The guard vocabulary below is
961
969
  // what makes the INTENT matchable, and GUARD_INTENT then wins over a mere observable.
962
- { cat: 'SEC', re: /\b(xss|sql|injection|payload|without authentication|unauthenticated|without an? (?:authenticated|valid|active) \w+|direct(?:ly)? access\w*|deep[- ]link\w*|auth[- ]?guard|not skippable|without having (?:completed|submitted|logged)|remains? unauthenticated|\binert\b|tamper\w*|malformed|url parameter|query param\w*|falls? back|gracefull?y|without a crash|not[- ]found|\b404\b|special character|abuse)\b/ },
970
+ { cat: 'SEC', re: /\b(xss|sql|injection|payload|without authentication|unauthenticated|without an? (?:authenticated|valid|active) \w+|direct(?:ly)? access\w*|auth[- ]?guard|not skippable|without having (?:completed|submitted|logged)|remains? unauthenticated|\binert\b|tamper\w*|malformed|special character|abuse)\b/ },
971
+ // Robustness and routing words are SEC evidence only beside an ACCESS word. Alone they describe
972
+ // routing — "Unknown route shows the 404 page", "Deep link opens the item", "the tab from the url
973
+ // parameter" — and the linter told QA to re-tag those navigation tests as VP-SEC (#888).
974
+ { cat: 'SEC', re: /\b(deep[- ]link\w*|url parameter|query param\w*|falls? back|gracefull?y|without a crash|not[- ]found|\b404\b)\b/, requires: ACCESS_CONTEXT },
963
975
  ];
964
976
 
977
+ const signalMatches = (c: { re: RegExp; requires?: RegExp }, title: string): boolean =>
978
+ c.re.test(title) && (!c.requires || c.requires.test(title));
979
+
965
980
  /**
966
981
  * Categories whose signal describes the test's INTENT/risk rather than the observable action.
967
982
  * When one of these matches, a competing observable-only category (NAV: "redirects", "navigates")
@@ -979,14 +994,19 @@ export function taxonomyLint(scenarios: ScenarioInfo[]): TaxonomyResult {
979
994
  if (!cat || !TAXONOMY_KNOWN.has(cat)) continue;
980
995
  checked++;
981
996
  const title = s.name.toLowerCase();
982
- const matched = [...new Set(CATEGORY_SIGNALS.filter((c) => c.re.test(title)).map((c) => c.cat))];
997
+ const matched = [...new Set(CATEGORY_SIGNALS.filter((c) => signalMatches(c, title)).map((c) => c.cat))];
983
998
  if (matched.includes(cat)) continue; // title supports its own category → fine
999
+ // A gate (`requires`) only stops a word from SUGGESTING its category. An author who already
1000
+ // chose that category is supported by the word alone: a VP-SEC "Deep link to the filtered
1001
+ // list …" stays SEC instead of being pushed toward FILTER (#888 review).
1002
+ if (CATEGORY_SIGNALS.some((c) => c.cat === cat && c.requires && c.re.test(title))) continue;
984
1003
  // The declared category may be an INTENT category whose signal this title spells
985
1004
  // differently; never "correct" it toward a category that only names the observable.
986
1005
  const suppressed = INTENT_OVER_OBSERVABLE[cat];
987
1006
  const others = matched.filter((c) => c !== cat && !(suppressed && suppressed.has(c)));
988
1007
  if (others.length === 1) { // unambiguous mismatch
989
- const sig = CATEGORY_SIGNALS.find((c) => c.cat === others[0])!;
1008
+ // The entry that MATCHED — a category can have several (SEC: guard words, gated robustness words).
1009
+ const sig = CATEGORY_SIGNALS.find((c) => c.cat === others[0] && signalMatches(c, title))!;
990
1010
  const m = title.match(sig.re);
991
1011
  mislabeled.push({ name: s.name, current: cat, suggested: others[0], signal: m ? m[0] : others[0] });
992
1012
  }
@@ -1058,9 +1078,8 @@ export function numericClaimGaps(scenarios: ScenarioInfo[]): Array<{ name: strin
1058
1078
  if (!m) continue;
1059
1079
  const claimed = NUMBER_WORDS[m[1]] ?? Number(m[1]);
1060
1080
  if (!Number.isFinite(claimed) || claimed < 2) continue;
1061
- // Only value-bearing assertions count: `see [X] … with/contains {{v}}`. A visibility check
1062
- // does not demonstrate one of N values.
1063
- const asserted = (s.stepsText.match(/\bsee \[[^\]]+\][^|]*?\b(?:with|contains) \{\{/g) ?? []).length;
1081
+ // Values, positive states and indicator presence count; plain text visibility does not (#866).
1082
+ const asserted = countedAssertions(s.stepsText);
1064
1083
  if (asserted > 0 && asserted < claimed) out.push({ name: s.name, claimed, asserted, noun: m[2] });
1065
1084
  }
1066
1085
  return out;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Triage signal library — the ordered message patterns `sungen triage` matches a red scenario's
3
+ * error against. Seeded from the repair rules (`harness/repair.ts`, driver-mobile `mobile-repair.ts`);
4
+ * the patterns are COPIED, not imported, because core never loads a driver at runtime. The parity
5
+ * check in tests/harness/triage-classification.run.ts keeps the environment rules in step.
6
+ *
7
+ * Order matters and is the classification order: environment first (nothing about the test or the
8
+ * app can be judged when the session never started), then test defects (the test never reached the
9
+ * app). Assertions are decided in triage.ts from the parsed Expected/Received, not here.
10
+ */
11
+ export type SignalClass = 'environment' | 'test-defect';
12
+
13
+ export interface TriageSignal {
14
+ id: string;
15
+ class: SignalClass;
16
+ match: RegExp;
17
+ }
18
+
19
+ export const TRIAGE_SIGNALS: TriageSignal[] = [
20
+ // --- environment: the run could not reach a usable app/session -----------------------------------
21
+ { id: 'env-network', class: 'environment', match: /net::ERR_[A-Z_]+|\bECONNREFUSED\b|\bENOTFOUND\b|\bEAI_AGAIN\b|\bETIMEDOUT\b/ },
22
+ // mirrors mobile-session-not-created
23
+ { id: 'env-session-not-created', class: 'environment', match: /A new session could not be created|session not created/i },
24
+ // mirrors mobile-driver-proxy-lost
25
+ { id: 'env-driver-proxy-lost', class: 'environment', match: /Could not proxy command to (?:the )?remote server|socket hang up|ECONNRESET/ },
26
+ // mirrors mobile-instrumentation-failed
27
+ { id: 'env-instrumentation-failed', class: 'environment', match: /instrumentation process (?:cannot|could not) be (?:initialized|started)|Cannot start the instrumentation process/ },
28
+ // mirrors mobile-chromedriver-mismatch
29
+ { id: 'env-chromedriver-mismatch', class: 'environment', match: /No Chromedriver found|chromedriver.*(?:version|mismatch)|Chrome version must be/i },
30
+ // mirrors mobile-webview-context-absent
31
+ { id: 'env-webview-context-absent', class: 'environment', match: /no WEBVIEW\/CHROMIUM context appeared within/ },
32
+ { id: 'env-auth-state-missing', class: 'environment', match: /storageState|\.auth\/[\w.-]+\.json|ENOENT[^\n]*\.auth/ },
33
+ { id: 'env-base-url-unresolved', class: 'environment', match: /Cannot navigate to invalid URL|baseURL (?:is )?(?:not set|undefined)|Invalid URL/ },
34
+ { id: 'env-browser-closed', class: 'environment', match: /Target page, context or browser has been closed|browserType\.launch/ },
35
+
36
+ // --- test defect: the test could not reach the element or its data -------------------------------
37
+ { id: 'test-strict-mode', class: 'test-defect', match: /strict mode violation/ },
38
+ { id: 'test-locator-unresolved', class: 'test-defect', match: /locator\.\w+: [^\n]*(?:Timeout|timeout)[^\n]*exceeded[\s\S]*waiting for |resolved to 0 elements/ },
39
+ { id: 'test-element-absent', class: 'test-defect', match: /no such element|Element not found:|An element could not be located/ },
40
+ { id: 'test-data-missing', class: 'test-defect', match: /Test data key not found:|@cases dataset "[^"]+" not found/ },
41
+ { id: 'test-step-unmatched', class: 'test-defect', match: /\[sungen\] step "[^"]*" matched no pattern|\bSG\d{3}\b/ },
42
+ { id: 'test-tap-intercepted', class: 'test-defect', match: /element click intercepted|is not clickable at point|element is obscured|intercepts pointer events/i },
43
+ ];
44
+
45
+ /** A failure that is only Playwright's test-level timeout — no locator, no assertion detail (→ unclassified). */
46
+ export const BARE_TIMEOUT = /^(?:Error:\s*)?Test timeout of \d+ms exceeded\.?$/;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * `sungen triage` IO: find a unit's feature(s) and result file(s), join them on the same seam
3
+ * delivery and the dashboard use (`mergeFeatureAndSpec` + `resolveResultVariants`), classify each
4
+ * scenario, and persist `.sungen/triage/<unit>.json`. Read-only apart from that one report file.
5
+ */
6
+ import * as fs from 'fs';
7
+ import * as path from 'path';
8
+ import { parseFeatureMetadata } from '../exporters/feature-parser';
9
+ import { loadPlaywrightReport, resolveResultsPath } from '../exporters/playwright-report-parser';
10
+ import { collectQaEnvSecretDenylist } from '../exporters/qa-env-secret-denylist';
11
+ import { isSensitiveKey } from '../exporters/sensitive-key';
12
+ import { resolveResultVariants } from '../exporters/result-variants';
13
+ import { mergeFeatureAndSpec } from '../exporters/scenario-merger';
14
+ import { parseSpecFile } from '../exporters/spec-parser';
15
+ import { loadTestData } from '../exporters/test-data-resolver';
16
+ import type { ResolvedUnit } from '../cli/resolve-unit';
17
+ import { classifyScenario, TriageClass, TriageRow } from './triage';
18
+
19
+ export interface TriageReport {
20
+ unit: string;
21
+ generatedAt: string;
22
+ /** Result files read, relative to the project root. Empty → nothing has run yet. */
23
+ resultFiles: string[];
24
+ counts: Partial<Record<TriageClass, number>>;
25
+ rows: TriageRow[];
26
+ }
27
+
28
+ /**
29
+ * Values that must never reach a triage evidence line or a defect record: the resolved `${QA_*}`
30
+ * secrets AND the literal values of credential-named test-data keys — an assertion on a password
31
+ * field prints that value in its Expected/Received.
32
+ */
33
+ export function unitSecrets(testData: Record<string, string>, cwd: string): string[] {
34
+ const literal = Object.entries(testData)
35
+ .filter(([k, v]) => isSensitiveKey(k) && typeof v === 'string' && v.length >= 3 && !v.includes('${'))
36
+ .map(([, v]) => String(v));
37
+ return [...new Set([...collectQaEnvSecretDenylist(testData, cwd).values, ...literal])].sort((a, b) => b.length - a.length);
38
+ }
39
+
40
+ function generatedDir(cwd: string, u: ResolvedUnit): string {
41
+ return path.join(cwd, 'specs', 'generated', u.unitId);
42
+ }
43
+
44
+ /**
45
+ * Every result file of one feature, labelled by variant: `<base>-test-result.json` (no label),
46
+ * `<base>-test-result.<os|env>[.<env>].json` (label = the middle part). A mobile unit has only
47
+ * per-OS files, so all of them are read rather than the first one found. Falls back to the shared
48
+ * lookup (incl. `test-results/results.json`) when the generated dir holds none.
49
+ */
50
+ function resultFilesFor(cwd: string, genDir: string, base: string): Array<{ file: string; variant?: string }> {
51
+ const prefix = `${base}-test-result`;
52
+ const own = fs.existsSync(genDir)
53
+ ? fs.readdirSync(genDir).filter((f) => f.startsWith(prefix) && f.endsWith('.json')).sort()
54
+ : [];
55
+ if (own.length > 0) {
56
+ return own.map((f) => {
57
+ const variant = f.slice(prefix.length, -'.json'.length).replace(/^\./, '');
58
+ return { file: path.join(genDir, f), ...(variant ? { variant } : {}) };
59
+ });
60
+ }
61
+ const shared = resolveResultsPath(cwd, genDir, base);
62
+ return shared ? [{ file: shared }] : [];
63
+ }
64
+
65
+ export function triageUnit(cwd: string, u: ResolvedUnit): TriageReport {
66
+ const genDir = generatedDir(cwd, u);
67
+ const featuresDir = path.join(u.dir, 'features');
68
+ const features = fs.existsSync(featuresDir) ? fs.readdirSync(featuresDir).filter((f) => f.endsWith('.feature')).sort() : [];
69
+ const rows: TriageRow[] = [];
70
+ const resultFiles: string[] = [];
71
+
72
+ for (const f of features) {
73
+ const base = f.replace(/\.feature$/, '');
74
+ const files = resultFilesFor(cwd, genDir, base);
75
+ if (files.length === 0) continue;
76
+ const merged = mergeFeatureAndSpec(parseFeatureMetadata(path.join(featuresDir, f)), parseSpecFile(path.join(genDir, `${base}.spec.ts`)));
77
+ const dataFile = path.join(u.dir, 'test-data', `${base}.yaml`);
78
+ const testData = fs.existsSync(dataFile) ? loadTestData(dataFile) : {};
79
+ const secrets = unitSecrets(testData, cwd);
80
+
81
+ for (const { file, variant } of files) {
82
+ resultFiles.push(path.relative(cwd, file));
83
+ const results = loadPlaywrightReport(file);
84
+ for (const m of merged) {
85
+ for (const v of resolveResultVariants(m, results, testData)) {
86
+ const r = classifyScenario({ unit: u.unitId, title: `${m.feature.name}${v.nameSuffix}`, tags: m.feature.tags, result: v.result, variant }, secrets);
87
+ if (r) rows.push(r);
88
+ }
89
+ }
90
+ }
91
+ }
92
+
93
+ const counts: Partial<Record<TriageClass, number>> = {};
94
+ for (const r of rows) counts[r.class] = (counts[r.class] ?? 0) + 1;
95
+ return { unit: u.unitId, generatedAt: new Date().toISOString(), resultFiles, counts, rows };
96
+ }
97
+
98
+ export function triageReportPath(cwd: string, unitId: string): string {
99
+ return path.join(cwd, '.sungen', 'triage', `${unitId.replace(/\//g, '-')}.json`);
100
+ }
101
+
102
+ /** The last triage of a unit, or null when `sungen triage` has not run for it. */
103
+ export function readTriageReport(cwd: string, unitId: string): TriageReport | null {
104
+ try { return JSON.parse(fs.readFileSync(triageReportPath(cwd, unitId), 'utf-8')) as TriageReport; } catch { return null; }
105
+ }
106
+
107
+ /** Persist under `.sungen/triage/<unit>.json` (a flow `flows/x` → `flows-x.json`); returns the path. */
108
+ export function writeTriageReport(cwd: string, report: TriageReport): string {
109
+ const out = triageReportPath(cwd, report.unit);
110
+ fs.mkdirSync(path.dirname(out), { recursive: true });
111
+ fs.writeFileSync(out, `${JSON.stringify(report, null, 2)}\n`, 'utf-8');
112
+ return out;
113
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * `sungen triage` — classify each red or flaky scenario of a run into exactly one class, with the
3
+ * rule that fired and one evidence line. Deterministic and read-only; it reports, it never gates.
4
+ *
5
+ * Order (first match wins):
6
+ * 1. known-defect `@known-defect` / `@known-defect:<id>` — an expected red.
7
+ * 2. blocked-capability `@manual:*` / `@requires:*`, or skipped by design.
8
+ * 3. environment the session or network never gave the test a usable app.
9
+ * 4. test-defect the test could not reach its element or data.
10
+ * 5. product-candidate an assertion whose Expected and Received both exist and differ.
11
+ * 6. unclassified anything else — left for the agent or QA, with the raw first message.
12
+ * A `passed` scenario that needed a retry is `flaky` (green, listed separately).
13
+ *
14
+ * 5 is tested before 3 and 4 in code: a parsed Expected/Received pair that differs means the app
15
+ * answered, which outranks any wrapper text (a test timeout around the assertion included).
16
+ *
17
+ * A bare test timeout (no locator, no assertion) is `unclassified` too: a slow environment, a page
18
+ * that hangs on a product bug and a test waiting on the wrong thing all print the same line.
19
+ *
20
+ * An assertion on an element that never appeared (`Received: <element(s) not found>`) is NOT a test
21
+ * defect: a wrong selector and a missing message look identical, so it is left for judgment rather
22
+ * than hiding a possible product bug behind "fix the selector".
23
+ */
24
+ import { redactSecrets } from '../exporters/api-testcase-formatter';
25
+ import { parseExpectedReceived } from '../exporters/playwright-report-parser';
26
+ import type { PlaywrightResult } from '../exporters/types';
27
+ import { BARE_TIMEOUT, TRIAGE_SIGNALS } from './triage-signals';
28
+
29
+ export type TriageClass =
30
+ | 'known-defect' | 'blocked-capability' | 'environment' | 'test-defect' | 'product-candidate' | 'unclassified' | 'flaky';
31
+
32
+ export interface TriageInput {
33
+ unit: string;
34
+ title: string;
35
+ tags: string[];
36
+ result?: PlaywrightResult;
37
+ /** Result-file variant (`android`, `ios`, an env name) when the unit has several. */
38
+ variant?: string;
39
+ }
40
+
41
+ export interface TriageRow {
42
+ unit: string;
43
+ scenario: string;
44
+ variant?: string;
45
+ class: TriageClass;
46
+ ruleId: string;
47
+ evidence: string;
48
+ ids: string[];
49
+ legacy: string[];
50
+ defectId?: string;
51
+ tracePath?: string;
52
+ /** product-candidate only: the two halves of the assertion, for a defect draft. */
53
+ expected?: string;
54
+ received?: string;
55
+ }
56
+
57
+ const EVIDENCE_MAX = 200;
58
+ const VIEWPOINT_ID = /\b(?:VP|FL)-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/gi;
59
+ const ELEMENT_ABSENT = /Received:\s*<element\(s\) not found>/;
60
+
61
+ /** The part of a message the run itself wrote: before `Call log:`, without `Locator:` echo lines. */
62
+ const runHead = (m: string): string => m.split(/\n\s*Call log:/)[0].replace(/^\s*Locator:.*$/gm, '');
63
+
64
+ /** Every failure message, stack frames and a leading `Error:` dropped. */
65
+ function messagesOf(result: PlaywrightResult): string[] {
66
+ const raw = result.errorMessages?.length ? result.errorMessages : [result.error ?? ''];
67
+ return raw.map((m) => m.split(/\n\s*at\s/)[0].replace(/^Error:\s*/i, '').trim()).filter(Boolean);
68
+ }
69
+
70
+ function evidenceLine(text: string, secrets: string[]): string {
71
+ const flat = redactSecrets(text, secrets).replace(/\s+/g, ' ').trim();
72
+ return flat.length > EVIDENCE_MAX ? `${flat.slice(0, EVIDENCE_MAX - 1)}…` : flat;
73
+ }
74
+
75
+ /** Classify one scenario. Null when it needs no triage (clean pass, or never ran). */
76
+ export function classifyScenario(input: TriageInput, secrets: string[] = []): TriageRow | null {
77
+ const { result, tags } = input;
78
+ if (!result) return null;
79
+ const base = {
80
+ unit: input.unit,
81
+ scenario: input.title,
82
+ ...(input.variant ? { variant: input.variant } : {}),
83
+ ids: [...new Set([input.title, ...tags].join(' ').match(VIEWPOINT_ID) ?? [])],
84
+ legacy: tags.filter((t) => t.startsWith('@legacy:')).map((t) => t.slice('@legacy:'.length)),
85
+ ...(result.tracePath ? { tracePath: result.tracePath } : {}),
86
+ };
87
+ const row = (cls: TriageClass, ruleId: string, evidence: string): TriageRow =>
88
+ ({ ...base, class: cls, ruleId, evidence: evidenceLine(evidence, secrets) });
89
+
90
+ if (result.status === 'passed') {
91
+ return (result.attempts ?? 1) > 1 ? row('flaky', 'passed-after-retry', `passed on attempt ${result.attempts}`) : null;
92
+ }
93
+ const messages = messagesOf(result);
94
+ const first = messages[0] ?? result.status;
95
+
96
+ const known = tags.find((t) => t === '@known-defect' || t.startsWith('@known-defect:'));
97
+ if (known) {
98
+ const r = row('known-defect', 'tag-known-defect', first);
99
+ const id = known.split(':')[1];
100
+ return id ? { ...r, defectId: id } : r;
101
+ }
102
+ if (tags.some((t) => t.startsWith('@manual'))) return row('blocked-capability', 'tag-manual', `${tags.find((t) => t.startsWith('@manual'))} — ${first}`);
103
+ if (tags.some((t) => t.startsWith('@requires:'))) return row('blocked-capability', 'tag-requires', `${tags.find((t) => t.startsWith('@requires:'))} — ${first}`);
104
+ if (result.status === 'skipped') return row('blocked-capability', 'status-skipped', 'skipped');
105
+
106
+ // An assertion with both halves decides the case even when a timeout wraps it.
107
+ for (const m of messages) {
108
+ const { expected, received } = parseExpectedReceived(m);
109
+ if (expected && received && !ELEMENT_ABSENT.test(m) && expected !== received) {
110
+ return { ...row('product-candidate', 'assertion-mismatch', `Expected ${expected} · Received ${received}`),
111
+ expected: evidenceLine(expected, secrets), received: evidenceLine(received, secrets) };
112
+ }
113
+ }
114
+ for (const s of TRIAGE_SIGNALS) {
115
+ // Environment signals read only what the RUN said, never the test's own selector text that a
116
+ // message echoes in `Locator:` / `Call log:` — `getByText('Invalid URL')` is a missing message,
117
+ // not a bad base URL. Test-defect signals need the call log (`waiting for …`), so they read it all.
118
+ const hit = messages.find((m) => s.match.test(s.class === 'environment' ? runHead(m) : m));
119
+ if (hit) return row(s.class, s.id, hit);
120
+ }
121
+ const absent = messages.find((m) => ELEMENT_ABSENT.test(m));
122
+ if (absent) return row('unclassified', 'assertion-element-absent', absent);
123
+ if (messages.length > 0 && messages.every((m) => BARE_TIMEOUT.test(m))) {
124
+ return row('unclassified', 'bare-timeout', `${first} No locator or assertion detail: a slow environment, a hanging page, or a wait in the test. Re-run once, then read the trace's last action.`);
125
+ }
126
+ return row('unclassified', 'no-signal', first);
127
+ }
@@ -12,6 +12,7 @@ import * as fs from 'fs';
12
12
  import { NON_DECLARATION_SECTION, PRIORITY_WORDS, ScenarioInfo } from './parse';
13
13
  import { readViewpointText } from './read-text';
14
14
  import { keywordHit } from './keyword-match';
15
+ import { countedAssertions } from './counted-assertions';
15
16
 
16
17
  export interface LedgerItem { id?: string; text: string; covered: boolean }
17
18
 
@@ -238,8 +239,7 @@ export function viewpointLedger(viewpointPath: string, scenarios: ScenarioInfo[]
238
239
  const n = claimedCount(item.text);
239
240
  const auto = carriers.filter((s) => !s.manual);
240
241
  if (n !== null && auto.length > 0 && !auto.some((s) => /\bsee all\b/.test(s.stepsText))) {
241
- const asserted = Math.max(...auto.map((s) =>
242
- (s.stepsText.match(/\bsee \[[^\]]+\][^|]*?\b(?:with|contains) \{\{/g) ?? []).length));
242
+ const asserted = Math.max(...auto.map((s) => countedAssertions(s.stepsText)));
243
243
  if (asserted > 0 && asserted < n) partial.push({ id: item.id, text: item.text, claimed: n, asserted });
244
244
  }
245
245
  }
package/src/index.ts CHANGED
@@ -20,7 +20,8 @@ export type { ResolvedAppProfile } from './generators/test-generator/app-profile
20
20
  export type { MappedStep } from './generators/test-generator/step-mapper';
21
21
  // The shared "nothing honest to compile" step body. Exported for the driver SPI: a pattern that
22
22
  // must refuse (SG-W017 and siblings) emits THIS rather than shipping a one-sided refusal template,
23
- // which would fail the adapter-parity gate and push playwright off its refusal-only floor of 0.
23
+ // which would fail the adapter-parity gate and push playwright off its refusal-only floor (1 — the
24
+ // `date-picker-unsupported` refusal both adapters ship for the resolver-based fill-input pattern, #907).
24
25
  export { unsupportedStepCode } from './generators/test-generator/patterns/unsupported-step-code';
25
26
  export type { ParsedStep } from './generators/gherkin-parser';
26
27
  export { getPathCode, inferPath, resolvePathVariables } from './generators/test-generator/utils/path-inference';