@sun-asterisk/sungen 3.2.33 → 3.2.34

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 (210) hide show
  1. package/dist/cli/commands/audit.d.ts +4 -0
  2. package/dist/cli/commands/audit.d.ts.map +1 -1
  3. package/dist/cli/commands/audit.js +16 -3
  4. package/dist/cli/commands/audit.js.map +1 -1
  5. package/dist/cli/commands/changed.d.ts.map +1 -1
  6. package/dist/cli/commands/changed.js +2 -3
  7. package/dist/cli/commands/changed.js.map +1 -1
  8. package/dist/cli/commands/dashboard.d.ts.map +1 -1
  9. package/dist/cli/commands/dashboard.js +2 -28
  10. package/dist/cli/commands/dashboard.js.map +1 -1
  11. package/dist/cli/commands/delivery.d.ts.map +1 -1
  12. package/dist/cli/commands/delivery.js +40 -35
  13. package/dist/cli/commands/delivery.js.map +1 -1
  14. package/dist/cli/commands/results-merge.d.ts +9 -0
  15. package/dist/cli/commands/results-merge.d.ts.map +1 -1
  16. package/dist/cli/commands/results-merge.js +7 -0
  17. package/dist/cli/commands/results-merge.js.map +1 -1
  18. package/dist/cli/commands/select.d.ts +12 -0
  19. package/dist/cli/commands/select.d.ts.map +1 -0
  20. package/dist/cli/commands/select.js +122 -0
  21. package/dist/cli/commands/select.js.map +1 -0
  22. package/dist/cli/commands/update.d.ts.map +1 -1
  23. package/dist/cli/commands/update.js +3 -0
  24. package/dist/cli/commands/update.js.map +1 -1
  25. package/dist/cli/index.js +7 -2
  26. package/dist/cli/index.js.map +1 -1
  27. package/dist/dashboard/templates/index.html +1 -1
  28. package/dist/dashboard/types.d.ts +1 -0
  29. package/dist/dashboard/types.d.ts.map +1 -1
  30. package/dist/exporters/csv-exporter.d.ts.map +1 -1
  31. package/dist/exporters/csv-exporter.js +11 -10
  32. package/dist/exporters/csv-exporter.js.map +1 -1
  33. package/dist/exporters/json-exporter.d.ts.map +1 -1
  34. package/dist/exporters/json-exporter.js +7 -0
  35. package/dist/exporters/json-exporter.js.map +1 -1
  36. package/dist/exporters/manual-hybrid-oracle.d.ts +54 -0
  37. package/dist/exporters/manual-hybrid-oracle.d.ts.map +1 -0
  38. package/dist/exporters/manual-hybrid-oracle.js +114 -0
  39. package/dist/exporters/manual-hybrid-oracle.js.map +1 -0
  40. package/dist/exporters/matrix/build.d.ts.map +1 -1
  41. package/dist/exporters/matrix/build.js +6 -2
  42. package/dist/exporters/matrix/build.js.map +1 -1
  43. package/dist/exporters/matrix/export.d.ts +3 -0
  44. package/dist/exporters/matrix/export.d.ts.map +1 -1
  45. package/dist/exporters/matrix/export.js +1 -0
  46. package/dist/exporters/matrix/export.js.map +1 -1
  47. package/dist/exporters/matrix/gates.js +6 -0
  48. package/dist/exporters/matrix/gates.js.map +1 -1
  49. package/dist/exporters/matrix/render-testcase-sheet.d.ts.map +1 -1
  50. package/dist/exporters/matrix/render-testcase-sheet.js +10 -4
  51. package/dist/exporters/matrix/render-testcase-sheet.js.map +1 -1
  52. package/dist/exporters/matrix/types.d.ts +8 -1
  53. package/dist/exporters/matrix/types.d.ts.map +1 -1
  54. package/dist/exporters/matrix/types.js.map +1 -1
  55. package/dist/exporters/matrix/wording.d.ts +1 -1
  56. package/dist/exporters/matrix/wording.d.ts.map +1 -1
  57. package/dist/exporters/matrix/wording.js +10 -1
  58. package/dist/exporters/matrix/wording.js.map +1 -1
  59. package/dist/exporters/run-environment.d.ts +32 -0
  60. package/dist/exporters/run-environment.d.ts.map +1 -0
  61. package/dist/exporters/run-environment.js +125 -0
  62. package/dist/exporters/run-environment.js.map +1 -0
  63. package/dist/exporters/scenario-merger.d.ts +18 -0
  64. package/dist/exporters/scenario-merger.d.ts.map +1 -1
  65. package/dist/exporters/scenario-merger.js +47 -3
  66. package/dist/exporters/scenario-merger.js.map +1 -1
  67. package/dist/exporters/test-data-resolver.d.ts +0 -4
  68. package/dist/exporters/test-data-resolver.d.ts.map +1 -1
  69. package/dist/exporters/test-data-resolver.js +0 -7
  70. package/dist/exporters/test-data-resolver.js.map +1 -1
  71. package/dist/generators/gherkin-parser/index.d.ts +11 -4
  72. package/dist/generators/gherkin-parser/index.d.ts.map +1 -1
  73. package/dist/generators/gherkin-parser/index.js +49 -19
  74. package/dist/generators/gherkin-parser/index.js.map +1 -1
  75. package/dist/generators/test-generator/adapters/adapter-interface.d.ts +4 -0
  76. package/dist/generators/test-generator/adapters/adapter-interface.d.ts.map +1 -1
  77. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/click-select-action.hbs +1 -1
  78. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/select-action.hbs +1 -1
  79. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/pick-option.hbs +13 -0
  80. package/dist/generators/test-generator/adapters/playwright/templates/test-file.hbs +16 -0
  81. package/dist/generators/test-generator/code-generator.d.ts.map +1 -1
  82. package/dist/generators/test-generator/code-generator.js +23 -2
  83. package/dist/generators/test-generator/code-generator.js.map +1 -1
  84. package/dist/generators/test-generator/diagnostics.d.ts +3 -1
  85. package/dist/generators/test-generator/diagnostics.d.ts.map +1 -1
  86. package/dist/generators/test-generator/diagnostics.js +3 -1
  87. package/dist/generators/test-generator/diagnostics.js.map +1 -1
  88. package/dist/harness/audit.d.ts +3 -0
  89. package/dist/harness/audit.d.ts.map +1 -1
  90. package/dist/harness/audit.js +17 -8
  91. package/dist/harness/audit.js.map +1 -1
  92. package/dist/harness/data-driven-lint.d.ts.map +1 -1
  93. package/dist/harness/data-driven-lint.js +69 -27
  94. package/dist/harness/data-driven-lint.js.map +1 -1
  95. package/dist/harness/next-step.d.ts.map +1 -1
  96. package/dist/harness/next-step.js +1 -0
  97. package/dist/harness/next-step.js.map +1 -1
  98. package/dist/harness/parse.d.ts +24 -0
  99. package/dist/harness/parse.d.ts.map +1 -1
  100. package/dist/harness/parse.js +117 -13
  101. package/dist/harness/parse.js.map +1 -1
  102. package/dist/harness/quality-gates.d.ts.map +1 -1
  103. package/dist/harness/quality-gates.js +6 -4
  104. package/dist/harness/quality-gates.js.map +1 -1
  105. package/dist/harness/read-text.d.ts +15 -0
  106. package/dist/harness/read-text.d.ts.map +1 -1
  107. package/dist/harness/read-text.js +25 -0
  108. package/dist/harness/read-text.js.map +1 -1
  109. package/dist/harness/select-by-ids.d.ts +31 -0
  110. package/dist/harness/select-by-ids.d.ts.map +1 -0
  111. package/dist/harness/select-by-ids.js +106 -0
  112. package/dist/harness/select-by-ids.js.map +1 -0
  113. package/dist/harness/sensors.d.ts +8 -1
  114. package/dist/harness/sensors.d.ts.map +1 -1
  115. package/dist/harness/sensors.js +61 -9
  116. package/dist/harness/sensors.js.map +1 -1
  117. package/dist/harness/spec-branches.js +1 -1
  118. package/dist/harness/spec-branches.js.map +1 -1
  119. package/dist/harness/ui-inventory.d.ts +40 -0
  120. package/dist/harness/ui-inventory.d.ts.map +1 -0
  121. package/dist/harness/ui-inventory.js +191 -0
  122. package/dist/harness/ui-inventory.js.map +1 -0
  123. package/dist/harness/viewpoint-ledger.d.ts.map +1 -1
  124. package/dist/harness/viewpoint-ledger.js +64 -9
  125. package/dist/harness/viewpoint-ledger.js.map +1 -1
  126. package/dist/orchestrator/screen-manager.d.ts.map +1 -1
  127. package/dist/orchestrator/screen-manager.js +3 -11
  128. package/dist/orchestrator/screen-manager.js.map +1 -1
  129. package/dist/orchestrator/templates/ai-src/agents/discovery.md +11 -3
  130. package/dist/orchestrator/templates/ai-src/agents/reviewer.md +8 -0
  131. package/dist/orchestrator/templates/ai-src/commands/create-test.md +2 -2
  132. package/dist/orchestrator/templates/ai-src/commands/review.md +2 -2
  133. package/dist/orchestrator/templates/ai-src/commands/run-test.md +70 -13
  134. package/dist/orchestrator/templates/ai-src/skills/sungen-capture/mode-local.md +2 -2
  135. package/dist/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +20 -0
  136. package/dist/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +17 -8
  137. package/dist/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +58 -14
  138. package/dist/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +41 -1
  139. package/dist/orchestrator/templates/ai-src/skills/sungen-tc-review/SKILL.md +7 -0
  140. package/dist/orchestrator/templates/playwright.config.ts +4 -2
  141. package/dist/orchestrator/templates/specs-url-assert.d.ts.map +1 -1
  142. package/dist/orchestrator/templates/specs-url-assert.js +24 -2
  143. package/dist/orchestrator/templates/specs-url-assert.js.map +1 -1
  144. package/dist/orchestrator/templates/specs-url-assert.ts +23 -2
  145. package/dist/orchestrator/trace-mode-install.d.ts +14 -0
  146. package/dist/orchestrator/trace-mode-install.d.ts.map +1 -0
  147. package/dist/orchestrator/trace-mode-install.js +132 -0
  148. package/dist/orchestrator/trace-mode-install.js.map +1 -0
  149. package/dist/orchestrator/viewpoint-pattern-catalog.d.ts +20 -0
  150. package/dist/orchestrator/viewpoint-pattern-catalog.d.ts.map +1 -0
  151. package/dist/orchestrator/viewpoint-pattern-catalog.js +45 -0
  152. package/dist/orchestrator/viewpoint-pattern-catalog.js.map +1 -0
  153. package/package.json +3 -3
  154. package/src/cli/commands/audit.ts +14 -3
  155. package/src/cli/commands/changed.ts +2 -4
  156. package/src/cli/commands/dashboard.ts +1 -26
  157. package/src/cli/commands/delivery.ts +37 -32
  158. package/src/cli/commands/results-merge.ts +7 -1
  159. package/src/cli/commands/select.ts +88 -0
  160. package/src/cli/commands/update.ts +3 -0
  161. package/src/cli/index.ts +7 -2
  162. package/src/dashboard/templates/index.html +1 -1
  163. package/src/dashboard/types.ts +1 -0
  164. package/src/exporters/csv-exporter.ts +7 -10
  165. package/src/exporters/json-exporter.ts +6 -1
  166. package/src/exporters/manual-hybrid-oracle.ts +112 -0
  167. package/src/exporters/matrix/build.ts +6 -2
  168. package/src/exporters/matrix/export.ts +4 -0
  169. package/src/exporters/matrix/gates.ts +6 -0
  170. package/src/exporters/matrix/render-testcase-sheet.ts +10 -4
  171. package/src/exporters/matrix/types.ts +8 -1
  172. package/src/exporters/matrix/wording.ts +10 -1
  173. package/src/exporters/run-environment.ts +89 -0
  174. package/src/exporters/scenario-merger.ts +49 -4
  175. package/src/exporters/test-data-resolver.ts +0 -6
  176. package/src/generators/gherkin-parser/index.ts +50 -18
  177. package/src/generators/test-generator/adapters/adapter-interface.ts +4 -0
  178. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/click-select-action.hbs +1 -1
  179. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/select-action.hbs +1 -1
  180. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/pick-option.hbs +13 -0
  181. package/src/generators/test-generator/adapters/playwright/templates/test-file.hbs +16 -0
  182. package/src/generators/test-generator/code-generator.ts +23 -2
  183. package/src/generators/test-generator/diagnostics.ts +3 -1
  184. package/src/harness/audit.ts +20 -9
  185. package/src/harness/data-driven-lint.ts +82 -24
  186. package/src/harness/next-step.ts +1 -0
  187. package/src/harness/parse.ts +123 -16
  188. package/src/harness/quality-gates.ts +6 -4
  189. package/src/harness/read-text.ts +25 -0
  190. package/src/harness/select-by-ids.ts +83 -0
  191. package/src/harness/sensors.ts +59 -9
  192. package/src/harness/spec-branches.ts +1 -1
  193. package/src/harness/ui-inventory.ts +164 -0
  194. package/src/harness/viewpoint-ledger.ts +57 -11
  195. package/src/orchestrator/screen-manager.ts +3 -11
  196. package/src/orchestrator/templates/ai-src/agents/discovery.md +11 -3
  197. package/src/orchestrator/templates/ai-src/agents/reviewer.md +8 -0
  198. package/src/orchestrator/templates/ai-src/commands/create-test.md +2 -2
  199. package/src/orchestrator/templates/ai-src/commands/review.md +2 -2
  200. package/src/orchestrator/templates/ai-src/commands/run-test.md +70 -13
  201. package/src/orchestrator/templates/ai-src/skills/sungen-capture/mode-local.md +2 -2
  202. package/src/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +20 -0
  203. package/src/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +17 -8
  204. package/src/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +58 -14
  205. package/src/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +41 -1
  206. package/src/orchestrator/templates/ai-src/skills/sungen-tc-review/SKILL.md +7 -0
  207. package/src/orchestrator/templates/playwright.config.ts +4 -2
  208. package/src/orchestrator/templates/specs-url-assert.ts +23 -2
  209. package/src/orchestrator/trace-mode-install.ts +94 -0
  210. package/src/orchestrator/viewpoint-pattern-catalog.ts +58 -0
@@ -8,6 +8,7 @@ import * as path from 'path';
8
8
  import { keywordHit } from './keyword-match';
9
9
  import { ScenarioInfo, loadScenarios, idPrefix } from './parse';
10
10
  import { parseManualComments } from '../exporters/scenario-merger';
11
+ import { commentLinesOf, isPresencePlaceholderHybrid, keywordStepsOf } from '../exporters/manual-hybrid-oracle';
11
12
  import { readTextFile } from './read-text';
12
13
 
13
14
  // ---------- #2 Downstream-scope ----------
@@ -220,14 +221,15 @@ export function manualOracle(featureText: string): ManualOracleResult {
220
221
  // Observable:/Oracle:/Expected: line (parseManualComments — the SAME parser the
221
222
  // delivery exporter uses, so this gate can't drift from the exported cells).
222
223
  // Prose paragraphs / rationale-only comments export EMPTY Steps + Expected.
224
+ // • HAS a Then whose every assertion is a bare presence placeholder, with the real oracle
225
+ // in the comments (#810) — the same hybrid wearing a Then (manual-hybrid-oracle.ts, the
226
+ // SAME predicate as compile-time SG-W008).
223
227
  const hasGherkinSteps = /^\s*(Given|When|Then)\s/m.test(b);
228
+ const commentLines = commentLinesOf(b);
224
229
  let bad: boolean;
225
230
  if (hasGherkinSteps) {
226
- bad = !/^\s*Then\s/m.test(b);
231
+ bad = !/^\s*Then\s/m.test(b) || isPresencePlaceholderHybrid(keywordStepsOf(b), commentLines);
227
232
  } else {
228
- const commentLines = b.split('\n')
229
- .filter((l) => /^\s*#/.test(l))
230
- .map((l) => l.replace(/^\s*#\s?/, ''));
231
233
  const parsed = parseManualComments(commentLines);
232
234
  bad = !parsed.steps.length || !parsed.expected.length;
233
235
  }
@@ -32,3 +32,28 @@ export function readTextFile(filePath: string): string {
32
32
  export function readTextLines(filePath: string): string[] {
33
33
  return readTextFile(filePath).split('\n');
34
34
  }
35
+
36
+ /**
37
+ * Drop HTML comments (`<!-- … -->`, multi-line too) — they are author notes and scaffold samples,
38
+ * never content. Newlines inside a comment are kept so every later line keeps its position (#841).
39
+ *
40
+ * Only a CLOSED comment is dropped. An unclosed `<!--` (a forgotten `-->`) stays text: dropping it
41
+ * to the end of the file would silently delete every declaration below it. A fenced code block is
42
+ * content, and so is a `<!--` written inside backticks: both are how a document SHOWS the syntax.
43
+ */
44
+ export function stripHtmlComments(text: string): string {
45
+ // A comment body never holds another `<!--`: an opener with no `-->` before the next opener is
46
+ // the unclosed one, and must not reach a LATER comment's closer and eat everything between.
47
+ const blank = (s: string): string => s.replace(/(?<!`)<!--(?:(?!<!--)[\s\S])*?-->/g, (m) => m.replace(/[^\n]/g, ''));
48
+ // split() with a capture group keeps the fences at the odd indexes.
49
+ return text.split(/(^```[^\n]*\n[\s\S]*?^```[^\n]*$)/m).map((part, i) => (i % 2 ? part : blank(part))).join('');
50
+ }
51
+
52
+ /**
53
+ * `test-viewpoint.md` as every harness sensor must read it: normalised, comments removed, and
54
+ * '' when the unit has none. `sungen add` scaffolds its samples inside `<!-- -->`; read raw, an
55
+ * untouched scaffold declared VP-UI/VP-VAL/VP-LOGIC/VP-SEC and carried 12 phantom claims (#841).
56
+ */
57
+ export function readViewpointText(filePath: string): string {
58
+ return fs.existsSync(filePath) ? stripHtmlComments(readTextFile(filePath)) : '';
59
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Select scenarios by test-case ID — backs `sungen select` and `run-test --ids` (#835).
3
+ *
4
+ * An ID means what it means everywhere else in the harness: `vpId`, the leading ID token of the
5
+ * scenario title (`VP-UI-001`, `MS-HP-001`, `VP0-001`). Matching is exact and case-insensitive —
6
+ * `VP-UI-001` never selects `VP-UI-0010` or `VP-UI-001-B` — and every ID that selects nothing
7
+ * runnable is reported, because the alternative is a 0-test run that reports green.
8
+ */
9
+ import * as path from 'path';
10
+ import { ScenarioInfo } from './parse';
11
+
12
+ /**
13
+ * Escape scenario titles into one `-g` / `--mochaOpts.grep` regex. Full titles, not bare IDs:
14
+ * a title starts with its own ID, so it can only appear inside its own test's title — and a
15
+ * `@cases` row test (`<title> — <row label>`) still contains it.
16
+ */
17
+ export function titlesToGrep(titles: string[]): string {
18
+ return titles.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
19
+ }
20
+
21
+ export interface IdSelectionGroup {
22
+ /** Feature basename (`products`, `home-modal`) — names the spec and the result file. */
23
+ feature: string;
24
+ ids: string[];
25
+ titles: string[];
26
+ grep: string;
27
+ }
28
+
29
+ export interface IdSelection {
30
+ features: IdSelectionGroup[];
31
+ /** IDs no scenario carries, with near matches (`VP-VAL-001` → `VP-VAL-001-B`). */
32
+ unknown: Array<{ id: string; didYouMean: string[] }>;
33
+ /** IDs whose every scenario is manual, owned by a flow or waiting on a capability — nothing to run. */
34
+ notRunnable: Array<{ id: string; reason: string }>;
35
+ }
36
+
37
+ /** `"VP-UI-001, vp-ui-002 VP-UI-001"` → `['VP-UI-001', 'VP-UI-002']`. */
38
+ export function parseIdList(raw: string): string[] {
39
+ return [...new Set(raw.split(/[\s,]+/).map((s) => s.trim().toUpperCase()).filter(Boolean))];
40
+ }
41
+
42
+ /** `VP-C-002.` → `VP-C-002`: the title token may carry trailing punctuation the user never types. */
43
+ const idOf = (s: ScenarioInfo): string | undefined => s.vpId?.replace(/[.-]+$/, '') || undefined;
44
+
45
+ function notRunnableReason(s: ScenarioInfo, enabledCaps: Set<string>): string | null {
46
+ if (s.deferredToFlow) return `owned by a flow${s.ownedByFlow ? ` (${s.ownedByFlow})` : ''} — run it there`;
47
+ if (s.featureManual) return 'its Feature is @manual — no spec is generated for it';
48
+ if (s.manual) return 'manual — no automated test exists';
49
+ // Compiled as `test.skip` while the capability is off: the run would report a skip, not a result.
50
+ const missing = (s.requiresCaps ?? []).filter((c) => !enabledCaps.has(c));
51
+ if (missing.length) return `needs capability ${missing.join(', ')} — compiled as a skip until \`sungen capability add ${missing.join(' ')}\``;
52
+ return null;
53
+ }
54
+
55
+ export function selectByIds(scenarios: ScenarioInfo[], ids: string[], unit: string, enabledCaps: Set<string> = new Set()): IdSelection {
56
+ const out: IdSelection = { features: [], unknown: [], notRunnable: [] };
57
+ const groups = new Map<string, IdSelectionGroup>();
58
+ const allIds = [...new Set(scenarios.map(idOf).filter((v): v is string => !!v))];
59
+
60
+ for (const id of ids) {
61
+ const hits = scenarios.filter((s) => idOf(s) === id);
62
+ if (hits.length === 0) {
63
+ const didYouMean = allIds.filter((v) => v.startsWith(`${id}-`) || id.startsWith(`${v}-`)).slice(0, 5);
64
+ out.unknown.push({ id, didYouMean });
65
+ continue;
66
+ }
67
+ const runnable = hits.filter((s) => !notRunnableReason(s, enabledCaps));
68
+ if (runnable.length === 0) {
69
+ out.notRunnable.push({ id, reason: notRunnableReason(hits[0], enabledCaps)! });
70
+ continue;
71
+ }
72
+ for (const s of runnable) {
73
+ const feature = s.sourceFile ? path.basename(s.sourceFile, '.feature') : unit;
74
+ const g = groups.get(feature) ?? { feature, ids: [], titles: [], grep: '' };
75
+ if (!g.ids.includes(id)) g.ids.push(id);
76
+ if (!g.titles.includes(s.name)) g.titles.push(s.name);
77
+ groups.set(feature, g);
78
+ }
79
+ }
80
+ for (const g of groups.values()) g.grep = titlesToGrep(g.titles);
81
+ out.features = [...groups.values()];
82
+ return out;
83
+ }
@@ -634,6 +634,9 @@ export interface TraceResult {
634
634
  mappedToOverview: number;
635
635
  withVpCodeRatio: number;
636
636
  mappedRatio: number;
637
+ /** Viewpoint IDs read from test-viewpoint.md. 0 = nothing to map to, so `mappedRatio` is not a
638
+ * measurement — the scenario IDs were never checked (#832). */
639
+ overviewIds: number;
637
640
  note: string;
638
641
  }
639
642
 
@@ -645,6 +648,24 @@ export interface TraceResult {
645
648
  */
646
649
  const unprefixed = (id: string): string => id.replace(/^(?:VP|FL)-/i, '').toUpperCase();
647
650
 
651
+ /**
652
+ * The traceability note, worded from the SAME ratio the audit's TRACE finding fires on —
653
+ * mapped / ALL scenarios (#852). It used to compare mapped against scenarios WITH an id, so 0 of
654
+ * 0 (no scenario yet, or none tagged) and 2 of 2 tagged out of 10 all read "Traceable." while
655
+ * the audit reported `TRACE: Traceable.` for a 0–20% mapping.
656
+ */
657
+ function traceNote(total: number, withCode: number, mapped: number, declared: number, exampleId?: string): string {
658
+ if (declared === 0) {
659
+ return 'No viewpoint ID found in test-viewpoint.md — the file is missing, or none of its sections declares one — so the scenario IDs were not checked. Declarations are read from a `## Priority Viewpoints` table, `### Required / Recommended / Optional` lists, and tables whose first column is a `VP-`/`FL-` ID or an ID of a family declared there (Known Issues / Traceability / References sections are skipped).';
660
+ }
661
+ if (total === 0) return 'No scenarios yet — traceability is measured once the feature has scenarios.';
662
+ if (mapped >= total * 0.5) return 'Traceable.';
663
+ const example = exampleId ? ` (e.g. \`${exampleId}-001 …\`)` : '';
664
+ if (withCode === 0) return `No scenario carries a viewpoint ID — start each title with one of the IDs declared in test-viewpoint.md${example}.`;
665
+ if (mapped < withCode * 0.5) return 'Scenario IDs do not match the viewpoint-overview ids (weak traceability — re-tag to the project viewpoint IDs).';
666
+ return `Only ${mapped} of ${total} scenarios carry a declared viewpoint ID — ${total - withCode} have no ID at all; tag them with the project's IDs${example}.`;
667
+ }
668
+
648
669
  export function traceability(scenarios: ScenarioInfo[], viewpoints: ViewpointEntry[]): TraceResult {
649
670
  const overviewIds = new Set(viewpoints.map((v) => v.id.toUpperCase()));
650
671
  const overviewBare = new Set(viewpoints.map((v) => unprefixed(v.id)));
@@ -669,9 +690,10 @@ export function traceability(scenarios: ScenarioInfo[], viewpoints: ViewpointEnt
669
690
  mappedToOverview: mapped.length,
670
691
  withVpCodeRatio: scenarios.length ? withCode.length / scenarios.length : 0,
671
692
  mappedRatio: scenarios.length ? mapped.length / scenarios.length : 0,
672
- note: withCode.length && mapped.length < withCode.length * 0.5
673
- ? 'Scenario IDs do not match the viewpoint-overview ids (weak traceability — re-tag to the project viewpoint IDs).'
674
- : 'Traceable.',
693
+ overviewIds: overviewIds.size,
694
+ // With no ID read there is nothing to match against: telling the author to re-tag sends them
695
+ // to change scenarios that may be right (#832). Name the file and the sections read instead.
696
+ note: traceNote(scenarios.length, withCode.length, mapped.length, overviewIds.size, viewpoints[0]?.id),
675
697
  };
676
698
  }
677
699
 
@@ -733,6 +755,31 @@ const CLAIM_RULES: ClaimRule[] = [
733
755
  hint: 'swap `contains {{v}}` for `with {{v}}` on the continuity rows. A substring match cannot distinguish "unchanged" from "changed by adding a prefix" — and if the screen genuinely renders extra text around the value, say so in the title instead of claiming exactness.',
734
756
  severity: 'warn',
735
757
  },
758
+ {
759
+ // An ORDER claim ("sorted by date descending", "ties broken by last-updated") is invisible to
760
+ // a presence check: `see [List] table` passes whatever order the rows are in. It sits ahead of
761
+ // the generic rules so "all results sorted …" is judged as an ordering claim, not an all-claim.
762
+ // A tiebreaker is a Tier-1 guard in the tc-generation skill, yet no rule recognised it (#810).
763
+ claim: 'ordering/sort',
764
+ title: /\b(ascending|descending|sorted|sorts|sort order|ordered by|order by|in (?:chronological|alphabetical|reverse|numeric) order|(?:newest|oldest|latest|earliest|highest|lowest) first|tie-?break(?:s|er|ers)?|ties?)\b|降順|昇順|並び順|giảm dần|tăng dần|sắp xếp/,
765
+ proof: /match data|is sorted|sorted (?:ascending|descending)|\brow \d+\b|\.first\.|\.last\.|\[\d+\]\.|order by/,
766
+ need: 'an order-sensitive assertion (`table match data:` rows in the expected order, `is sorted ascending|descending`, or positional `row N` checks)',
767
+ hint: 'a presence check passes in any order — assert the rows in their expected sequence (`User see [T] table match data:`), or the column sort state; a tiebreak needs two rows sharing the primary key.',
768
+ severity: 'fail',
769
+ },
770
+ {
771
+ // A LIMIT claim ("never exceeds 10", "at most 5 files") is a count claim in disguise.
772
+ // Ahead of 'no-side-effect', whose negation+effect pattern would otherwise swallow
773
+ // "never exceeds 10 records" and ask for a duplicate-count proof.
774
+ claim: 'limit/cap',
775
+ // The quantity must be ≥2 ("at most ONE order" is a no-duplicate claim — left to the rules
776
+ // below) and not a duration ("up to 30 seconds" is a timing claim, not a cap).
777
+ title: /\b(?:never exceeds?|(?:does not|doesn['’]t|not) exceed|at most|no more than|up to|capped at|caps? at|maximum of|max(?:imum)?)\s+(?:\d{2,}|[2-9]|two|three|four|five|six|seven|eight|nine|ten)\b(?!\s*(?:ms|milliseconds?|s|secs?|seconds?|mins?|minutes?|hours?|days?)\b)|\b(?:never exceeds?|(?:does not|doesn['’]t) exceed) (?:the )?(?:limit|cap|quota|maximum)\b|以内|上限|tối đa/,
778
+ proof: /\bcount\b|tohavecount|table with|row with \{\{|match data|is at most|\bexactly\b|field with \{\{|\berror\b|invalid|is disabled/,
779
+ need: 'a count/boundary assertion (count is N, `is at most`, the N+1th input rejected or truncated)',
780
+ hint: 'assert the boundary itself — the count at the cap, or the value/error when one more is attempted — not just that the list or field is present.',
781
+ severity: 'fail',
782
+ },
736
783
  {
737
784
  claim: 'all/every/each',
738
785
  title: /\b(all|every|each)\b/,
@@ -1025,14 +1072,16 @@ export function numericClaimGaps(scenarios: ScenarioInfo[]): Array<{ name: strin
1025
1072
  * The Priority-Viewpoints table is where release selection is supposed to come from. When the
1026
1073
  * table says a flow is `Low` and its scenario is tagged `@high`, a smoke run picks up work the QA
1027
1074
  * deliberately deprioritised — and nobody can tell which of the two was the decision (#622).
1075
+ *
1076
+ * Reads the SAME parse traceability does (`parseViewpointOverview`). It used to scan the raw text
1077
+ * for any `| VP-… | high |` row, so one audit run "saw" an id for this check and not for
1078
+ * traceability (#833), and a row in a Known Issues table could raise a contradiction.
1028
1079
  */
1029
1080
  export function priorityMismatches(
1030
- viewpointText: string, scenarios: ScenarioInfo[],
1081
+ viewpoints: ViewpointEntry[], scenarios: ScenarioInfo[],
1031
1082
  ): Array<{ id: string; declared: string; tagged: string; scenario: string }> {
1032
1083
  const declared = new Map<string, string>();
1033
- for (const m of viewpointText.matchAll(/^\|\s*((?:VP|FL)[A-Z0-9._-]*)\s*\|\s*(critical|high|medium|normal|low|deferred)\s*\|/gim)) {
1034
- declared.set(m[1].toUpperCase(), m[2].toLowerCase());
1035
- }
1084
+ for (const v of viewpoints) if (v.priorityWord) declared.set(v.id.toUpperCase(), v.priorityWord);
1036
1085
  if (declared.size === 0) return [];
1037
1086
  // `medium` and `normal` are the same rank under different names in real documents.
1038
1087
  const rank = (p: string): string => (p === 'medium' ? 'normal' : p);
@@ -1045,8 +1094,9 @@ export function priorityMismatches(
1045
1094
  if (!key) continue;
1046
1095
  const want = rank(declared.get(key)!);
1047
1096
  const got = rank(String(s.priority).toLowerCase());
1048
- // `critical` and `deferred` have no tag equivalent — nothing to contradict.
1049
- if (want === 'critical' || want === 'deferred') continue;
1097
+ // `critical`, `deferred` and `skip` (declared not applicable, #853) have no tag equivalent —
1098
+ // nothing to contradict.
1099
+ if (want === 'critical' || want === 'deferred' || want === 'skip') continue;
1050
1100
  if (want !== got) out.push({ id: key, declared: declared.get(key)!, tagged: got, scenario: s.name });
1051
1101
  }
1052
1102
  return out;
@@ -194,7 +194,7 @@ export function sameOracleClusters(scenarios: ScenarioInfo[]): Array<{ oracle: s
194
194
  if (s.manual) continue; // a procedure's oracle is prose
195
195
  const asserts = [...(s.steps ?? [])]
196
196
  .filter((st) => st.bucket === 'then')
197
- .map((st) => st.text.toLowerCase().replace(/\s+/g, ' ').trim())
197
+ .map((st) => `${st.text} ${st.arg ?? ''}`.toLowerCase().replace(/\s+/g, ' ').trim()) // the table is the oracle too (#817)
198
198
  .sort();
199
199
  if (asserts.length === 0) continue;
200
200
  const key = asserts.join(' ; ');
@@ -0,0 +1,164 @@
1
+ /**
2
+ * #811 — the design inventory: is every item the DESIGN shows accounted for by the suite?
3
+ *
4
+ * Design sources in `requirements/ui/` (images, `figma-meta.md`) and `requirements/spec_figma.md`
5
+ * were read "for visual context" and never turned into a checkable list, so a column that exists
6
+ * only on the design — or contradicts the spec — was missed by the author AND the reviewer, and a
7
+ * deliberate deferral looked exactly like an oversight.
8
+ *
9
+ * create-test now writes `requirements/ui-inventory.md`: one `UI-NNN` row per component / column /
10
+ * button / state / annotation, with its source (`spec+design` | `design-only` | `conflict`). An
11
+ * item is accounted for by a scenario tag (`@ui:UI-014`) or a reasoned deferral comment
12
+ * (`# UI-DEFERRED: UI-002..013 — <reason>`) — no other mention counts; ranges and `/`
13
+ * enumerations read the same way as spec ids (spec-coverage `citedIds`). `spec+design` items are already measured
14
+ * by spec-coverage, so only `design-only` / `conflict` items are reported uncovered.
15
+ *
16
+ * Advisory: findings never move the score or the gate.
17
+ */
18
+ import * as fs from 'fs';
19
+ import * as path from 'path';
20
+ import { citedIds } from './spec-coverage';
21
+ import { readTextFile } from './read-text';
22
+
23
+ export type UiItemSource = 'spec+design' | 'design-only' | 'conflict';
24
+
25
+ export interface UiItem {
26
+ id: string;
27
+ item: string;
28
+ kind: string;
29
+ source: UiItemSource;
30
+ }
31
+
32
+ export interface UiInventoryResult {
33
+ /** Design sources the unit holds: image files, figma-meta.md, spec_figma.md. */
34
+ designSources: string[];
35
+ /** `requirements/ui-inventory.md` exists. */
36
+ hasInventory: boolean;
37
+ items: UiItem[];
38
+ /** `images read: a/b` from the inventory header — null when the line is absent. */
39
+ imagesRead: { read: number; total: number } | null;
40
+ /** Images on disk, for comparing against `imagesRead.total`. */
41
+ imagesOnDisk: number;
42
+ covered: string[];
43
+ deferred: string[];
44
+ /** design-only / conflict items neither covered nor deferred. */
45
+ uncovered: UiItem[];
46
+ }
47
+
48
+ const IMAGE = /\.(png|jpe?g|webp|gif)$/i;
49
+
50
+ /** Every design source in a unit's `requirements/` — what makes an inventory necessary. */
51
+ export function designSourcesOf(reqDir: string): string[] {
52
+ const out: string[] = [];
53
+ const uiDir = path.join(reqDir, 'ui');
54
+ try {
55
+ for (const f of fs.readdirSync(uiDir).sort()) {
56
+ if (IMAGE.test(f) || f === 'figma-meta.md') out.push(`ui/${f}`);
57
+ }
58
+ } catch { /* no ui/ dir */ }
59
+ if (fs.existsSync(path.join(reqDir, 'spec_figma.md'))) out.push('spec_figma.md');
60
+ return out;
61
+ }
62
+
63
+ /**
64
+ * The Source cell, read leniently ("design only", "Spec + Design", "conflict — …"). Anything else
65
+ * is NOT assumed to be spec-backed: an unreadable source is reported like a design-only item, so a
66
+ * typo can never make an item disappear from the check.
67
+ */
68
+ function sourceOf(cell: string): UiItemSource | null {
69
+ const c = cell.trim().toLowerCase();
70
+ if (/^design[\s_-]*only\b/.test(c)) return 'design-only';
71
+ if (/^conflict\b/.test(c)) return 'conflict';
72
+ if (/^spec\s*(?:\+|&|and)\s*design\b/.test(c)) return 'spec+design';
73
+ return null;
74
+ }
75
+
76
+ /**
77
+ * Parse the inventory table: any markdown row whose first cell is a `UI-<n>` id. Leading/trailing
78
+ * pipes are optional (GFM), and the Source cell is FOUND rather than counted — a `|` written inside
79
+ * the Item cell shifts the columns, and a positional read then misfiled a design-only row.
80
+ */
81
+ export function parseUiInventory(text: string): { items: UiItem[]; imagesRead: UiInventoryResult['imagesRead'] } {
82
+ const items: UiItem[] = [];
83
+ for (const line of text.split('\n')) {
84
+ if (!line.includes('|')) continue;
85
+ const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
86
+ if (!/^UI-\d+$/i.test(cells[0] ?? '')) continue;
87
+ // Source = the last cell that reads as one (Seen-in follows it; prose in Item never starts so).
88
+ let srcIdx = -1;
89
+ for (let i = cells.length - 1; i >= 2; i--) if (sourceOf(cells[i])) { srcIdx = i; break; }
90
+ const source: UiItemSource = srcIdx >= 0 ? sourceOf(cells[srcIdx])! : 'design-only';
91
+ const kindIdx = srcIdx >= 0 ? srcIdx - 1 : Math.min(2, cells.length - 1);
92
+ items.push({
93
+ id: cells[0].toUpperCase(),
94
+ item: cells.slice(1, Math.max(2, kindIdx)).join(' | '),
95
+ kind: cells[kindIdx] ?? '',
96
+ source,
97
+ });
98
+ }
99
+ const m = text.match(/images read:\s*(\d+)\s*\/\s*(\d+)/i);
100
+ return { items, imagesRead: m ? { read: Number(m[1]), total: Number(m[2]) } : null };
101
+ }
102
+
103
+ /**
104
+ * Ids cited on a `UI-DEFERRED` comment line — a deliberate, REASONED omission. A line that lists
105
+ * ids and nothing else is not a decision anyone can review, so it defers nothing.
106
+ */
107
+ function deferredIds(featureText: string): Set<string> {
108
+ const lines = featureText.split('\n').filter((l) => {
109
+ if (!/^\s*#\s*UI-DEFERRED\b/i.test(l)) return false;
110
+ const reason = l.replace(/^\s*#\s*UI-DEFERRED\b\s*:?/i, '').replace(/\bUI-\d+(?:\s*(?:\.\.|–|—|~|\/)\s*(?:UI-)?\d+)*/gi, '');
111
+ return /[\p{L}]{3,}/u.test(reason);
112
+ });
113
+ return citedIds(lines.join('\n'));
114
+ }
115
+
116
+ export function uiInventoryCoverage(reqDir: string, featureText: string): UiInventoryResult {
117
+ const designSources = designSourcesOf(reqDir);
118
+ const invPath = path.join(reqDir, 'ui-inventory.md');
119
+ const invText = fs.existsSync(invPath) ? readTextFile(invPath) : null;
120
+ const { items, imagesRead } = invText ? parseUiInventory(invText) : { items: [], imagesRead: null };
121
+ // Coverage is the `@ui:` TAG, nothing else: a `UI-001`-shaped string elsewhere (an `@spec:UI-001`
122
+ // requirement in a project that numbers its FRs that way, prose, an unreasoned deferral line)
123
+ // must not mark a design item as proven.
124
+ const cites = citedIds((featureText.match(/@ui:\S+/gi) ?? []).join(' '));
125
+ const deferSet = deferredIds(featureText);
126
+ const covered: string[] = [];
127
+ const deferred: string[] = [];
128
+ const uncovered: UiItem[] = [];
129
+ for (const it of items) {
130
+ if (deferSet.has(it.id)) deferred.push(it.id);
131
+ else if (cites.has(it.id)) covered.push(it.id);
132
+ else if (it.source !== 'spec+design') uncovered.push(it);
133
+ }
134
+ return {
135
+ designSources,
136
+ hasInventory: invText !== null,
137
+ items,
138
+ imagesRead,
139
+ imagesOnDisk: designSources.filter((s) => IMAGE.test(s)).length,
140
+ covered,
141
+ deferred,
142
+ uncovered,
143
+ };
144
+ }
145
+
146
+ /** Advisory findings for the audit report. */
147
+ export function uiInventoryFindings(r: UiInventoryResult): string[] {
148
+ const out: string[] = [];
149
+ if (r.designSources.length === 0) return out;
150
+ if (!r.hasInventory) {
151
+ out.push(`UI-INVENTORY-MISSING: the unit has design sources (${r.designSources.slice(0, 4).join(', ')}${r.designSources.length > 4 ? ', …' : ''}) but no \`requirements/ui-inventory.md\` — nothing shows the design was read item by item, so an item that exists only on the design (or contradicts the spec) is invisible to the gate and to review. Re-run /sungen:create-test so it writes the inventory, or write it by hand: one \`| UI-NNN | item | kind | spec+design / design-only / conflict |\` row per component, column, button, state and annotation.`);
152
+ return out;
153
+ }
154
+ if (r.imagesRead && r.imagesRead.read < Math.max(r.imagesRead.total, r.imagesOnDisk)) {
155
+ out.push(`UI-INVENTORY-INCOMPLETE: the inventory records "images read: ${r.imagesRead.read}/${r.imagesRead.total}" and the unit holds ${r.imagesOnDisk} image(s) — the unread design is not inventoried. Read the remaining images and add their items.`);
156
+ } else if (!r.imagesRead && r.imagesOnDisk > 0) {
157
+ out.push(`UI-INVENTORY-INCOMPLETE: the unit holds ${r.imagesOnDisk} image(s) but the inventory does not record "images read: N/N" — nothing shows every image was opened. Add the line after reading them all.`);
158
+ }
159
+ for (const it of r.uncovered.slice(0, 10)) {
160
+ out.push(`UI-ITEM-UNCOVERED: ${it.id} "${it.item}" (${it.kind}, ${it.source}) has no scenario and no deferral — ${it.source === 'conflict' ? 'the design and spec.md disagree on it; resolve which is right, then cover it' : 'it exists only on the design, outside spec.md'}. Cover it (\`@ui:${it.id}\` on the scenario that proves it), or record the decision: \`# UI-DEFERRED: ${it.id} — <reason>\`.`);
161
+ }
162
+ if (r.uncovered.length > 10) out.push(`UI-ITEM-UNCOVERED: … and ${r.uncovered.length - 10} more design-only/conflict item(s).`);
163
+ return out;
164
+ }
@@ -9,8 +9,8 @@
9
9
  * gaps that inflate a "looks-covered" score; it does not fail the gate.
10
10
  */
11
11
  import * as fs from 'fs';
12
- import { ScenarioInfo } from './parse';
13
- import { readTextFile } from './read-text';
12
+ import { NON_DECLARATION_SECTION, PRIORITY_WORDS, ScenarioInfo } from './parse';
13
+ import { readViewpointText } from './read-text';
14
14
  import { keywordHit } from './keyword-match';
15
15
 
16
16
  export interface LedgerItem { id?: string; text: string; covered: boolean }
@@ -69,7 +69,10 @@ const ID_RE = /\b([A-Z]{1,5}\d{0,2}(?:[.\-][A-Za-z0-9]+)*-?\d{0,3})\b/; // VP0.T
69
69
  const PLACEHOLDER_ITEM = /^(none\b|n\/a\b|tbd\b|no known\b|nothing\b|do not invent\b|-+$)/i;
70
70
  /** A priority-DECLARATION row: `| VP-LOGIC | High | <reason prose> |`. The category id and its
71
71
  * priority are consumed by the traceability + balance axes; the reason is rationale. */
72
- const PRIORITY_ROW = /^(?:VP|FL)[A-Z0-9._-]*\s+—\s+(?:critical|high|medium|normal|low|deferred)\b/i;
72
+ // The word must be the WHOLE second cell: `skip` / `none` / `n/a` also open real claims
73
+ // ("Skip link moves focus to main", "None of the optional fields block submit") — and so did
74
+ // `high`/`low` ("High contrast mode …"), which the old word-boundary match swallowed too.
75
+ const PRIORITY_ROW = new RegExp(`^(?:VP|FL)[A-Z0-9._-]*\\s+—\\s+[*_\`]*(?:${PRIORITY_WORDS})[*_\`.]*(?=\\s+—|\\s*$)`, 'i');
73
76
  /**
74
77
  * Whole SECTIONS that declare something ABOUT the checklist rather than listing claims to prove.
75
78
  *
@@ -82,13 +85,26 @@ const PRIORITY_ROW = /^(?:VP|FL)[A-Z0-9._-]*\s+—\s+(?:critical|high|medium|nor
82
85
  * `Design Decisions` deliberately stays IN: "values are buffered between step 3 and 4, a reload
83
86
  * loses them" is a real claim, and it is what the continuity check reads.
84
87
  */
85
- const DECLARATION_SECTION = /^(?:known issues?|open questions?|deferred|out of scope|viewpoint grouping|traceability|metadata|scope|journey phases?|revision history|references?|sources?|changelog)\b/i;
88
+ const DECLARATION_SECTION = NON_DECLARATION_SECTION; // shared with the overview parser (#833)
89
+ /**
90
+ * A lead-in that introduces a manual check's PROCEDURE — `How to run VP-SEC-1:`, `Procedures:`,
91
+ * `Steps to reproduce:`. The list under it is how to EXECUTE a claim already declared above;
92
+ * counting each step as its own claim made gaps no scenario can close (8 of 13 surplus items on a
93
+ * field unit, #855).
94
+ *
95
+ * Deliberately narrow, because a false match HIDES claims: the lead may name one id and nothing
96
+ * else (`How to verify the following claims hold:` introduces claims, not steps), and a bare
97
+ * `Steps:` (a flow lists real step claims under it) and `Tester verifies:` (expected outcomes —
98
+ * exactly what the ledger counts) never qualify.
99
+ */
100
+ const PROCEDURE_LEAD = /^(?:how to (?:run|test|reproduce|verify)|(?:manual )?procedures?|manual steps|steps to reproduce)(?:\s+(?:VP|FL)-[\w.-]+)?\s*:$/i;
101
+ const LIST_ITEM = /^(?:[-*+]|\d+[.)])\s+/;
86
102
  const GENERIC = new Set(['display', 'shown', 'value', 'field', 'input', 'page', 'screen', 'button', 'link', 'text', 'check', 'verify', 'should', 'with', 'when', 'then', 'user', 'this', 'that', 'each', 'item', 'items']);
87
103
 
88
104
  /** Extract atomic checklist items from a viewpoint file (format-tolerant). */
89
105
  export function parseViewpointItems(viewpointPath: string): { id?: string; text: string }[] {
90
106
  if (!fs.existsSync(viewpointPath)) return [];
91
- const lines = readTextFile(viewpointPath).split('\n');
107
+ const lines = readViewpointText(viewpointPath).split('\n');
92
108
  const items: { id?: string; text: string }[] = [];
93
109
  let inFence = false;
94
110
  let inDeclarationSection = false;
@@ -96,10 +112,36 @@ export function parseViewpointItems(viewpointPath: string): { id?: string; text:
96
112
  // nested under `## 8. Traceability Index` used to re-open the section and hand its rows
97
113
  // back as claims, because exclusion was recomputed from scratch at every heading.
98
114
  let excludedAt: number | null = null;
99
- for (const raw of lines) {
100
- const line = raw.trim();
115
+ // Inside the list a PROCEDURE_LEAD introduced: the indent of the lead (-1 for a paragraph lead),
116
+ // whose list items — and their wrapped continuation lines — are steps. A heading, a table, a
117
+ // new lead, or (after a paragraph lead) a blank line once a step was read ends it: when in
118
+ // doubt a step stays a claim, because a swallowed claim is a gap nobody ever sees — so a LOOSE
119
+ // procedure (steps separated by blank lines) leaks its later steps as claims, by design.
120
+ // Only an INDENTED line continues a step (Markdown's continuation); unindented prose ends it.
121
+ let procIndent: number | null = null;
122
+ let procSteps = 0;
123
+ let prevBlank = false;
124
+ for (let i = 0; i < lines.length; i++) {
125
+ const line = lines[i].trim();
101
126
  if (line.startsWith('```')) { inFence = !inFence; continue; }
102
- if (inFence || !line) continue;
127
+ if (inFence) continue;
128
+ if (!line) { prevBlank = true; continue; }
129
+ const wasBlank = prevBlank;
130
+ prevBlank = false;
131
+ const isItem = LIST_ITEM.test(line);
132
+ const indent = lines[i].length - lines[i].trimStart().length;
133
+ if (PROCEDURE_LEAD.test(line.replace(LIST_ITEM, '').replace(/[*_`]/g, '').trim())) {
134
+ procIndent = isItem ? indent : -1;
135
+ procSteps = 0;
136
+ continue;
137
+ }
138
+ if (procIndent !== null) {
139
+ const listEnded = wasBlank && procIndent < 0 && procSteps > 0;
140
+ if (!listEnded && isItem && indent > procIndent) { procSteps++; continue; } // a step
141
+ if (!listEnded && !isItem && !wasBlank && procSteps > 0 && indent > Math.max(procIndent, 0)
142
+ && !/^[#|]/.test(line) && !line.endsWith(':')) continue; // a step that wraps
143
+ procIndent = null;
144
+ }
103
145
  const heading = line.match(/^(#{1,6})\s+(.*)$/);
104
146
  if (heading) {
105
147
  const level = heading[1].length;
@@ -121,6 +163,11 @@ export function parseViewpointItems(viewpointPath: string): { id?: string; text:
121
163
  if (bullet) { text = bullet[1]; lead = bullet[1]; }
122
164
  else if (line.startsWith('|')) { // table data row
123
165
  if (/^\|[\s|:-]+\|?$/.test(line)) continue; // separator
166
+ // The row right above a `|---|` separator IS the header, whatever its words (`Skill #`,
167
+ // `Case #`) — structure, not a guessed word list, so a data row such as `C#` or `Issue #`
168
+ // is never mistaken for one (#832). The word list below still covers a separator-less table.
169
+ const next = (lines[i + 1] ?? '').trim();
170
+ if (/^\|[\s|:-]+\|?$/.test(next) && next.includes('-')) continue; // header
124
171
  const cells = line.split('|').map((c) => c.trim()).filter(Boolean);
125
172
  if (/^(vp|id|viewpoint|priority|reason|no\.?|category|item|trigger|#|pattern|applicable|notes|field|constraint|code|description|status|step|flow|ref|level|question|screen|actor|branches from|own steps|component|thành phần|trường|bước)$/i.test(cells[0] || '')) continue; // header
126
173
  text = cells.join(' — ');
@@ -349,10 +396,9 @@ export function viewpointIntegrity(
349
396
  }
350
397
 
351
398
  // The Priority-Viewpoints table: `| <id> | <priority> | <reason> |`.
352
- // `readTextFile` throws on a missing file, and a unit may legitimately have no viewpoint yet.
353
- const vpText = fs.existsSync(viewpointPath) ? readTextFile(viewpointPath) : '';
399
+ const vpText = readViewpointText(viewpointPath);
354
400
  const rows = [...vpText
355
- .matchAll(/^\|\s*((?:VP|FL)[A-Z0-9._-]*)\s*\|\s*(critical|high|medium|normal|low|deferred)\s*\|/gim)]
401
+ .matchAll(new RegExp(`^\\|\\s*((?:VP|FL)[A-Z0-9._-]*)\\s*\\|\\s*(${PRIORITY_WORDS})\\s*\\|`, 'gim'))]
356
402
  .map((m) => m[2].toLowerCase());
357
403
  const levels = [...new Set(rows)];
358
404
  const uniformPriority = rows.length >= 3 && levels.length === 1 ? { levels, rows: rows.length } : null;
@@ -9,6 +9,7 @@ import { chromium } from '@playwright/test';
9
9
  import { readCapabilities } from '../harness/capability';
10
10
  import { testDataHeader, ensureTestDataGuideFiles } from './test-data-guide';
11
11
  import { resolveWebBaseUrl } from '../exporters/env-qa';
12
+ import { uiPatternRows } from './viewpoint-pattern-catalog';
12
13
 
13
14
  export interface ScreenOptions {
14
15
  name: string;
@@ -191,20 +192,11 @@ export class ScreenManager {
191
192
  '',
192
193
  '## UI Patterns Identified',
193
194
  '',
194
- '<!-- Check applicable patterns for this screen (from sungen-viewpoint skill):',
195
+ '<!-- Check applicable patterns for this screen (numbered as in the sungen-viewpoint skill):',
195
196
  '',
196
197
  '| # | Pattern | Applicable? | Notes |',
197
198
  '|---|---------|-------------|-------|',
198
- '| 1 | Form & Inputs | ☐ | |',
199
- '| 2 | Data Table | ☐ | |',
200
- '| 3 | Create / Add | ☐ | |',
201
- '| 4 | Update / Edit | ☐ | |',
202
- '| 5 | Delete | ☐ | |',
203
- '| 6 | Search | ☐ | |',
204
- '| 7 | Filter | ☐ | |',
205
- '| 8 | Pagination | ☐ | |',
206
- '| 9 | Modal / Dialog | ☐ | |',
207
- '| 10 | List / Card | ☐ | |',
199
+ ...uiPatternRows(isMobile),
208
200
  '-->',
209
201
  '',
210
202
  '## Priority Viewpoints',
@@ -9,7 +9,9 @@ You are a **test-design context explorer**. You run in an **isolated context** s
9
9
  ## Explore (do NOT ask the user to pick a source — read all that exist)
10
10
  - `requirements/spec.md` — primary behavior source.
11
11
  - `requirements/spec_figma.md` — Figma supplement (if present; do NOT call Figma MCP when this exists).
12
- - `requirements/ui/*` images — layout/visual context.
12
+ - `requirements/ui/*` images — read **every** image (group primary vs states/variants when there are many, but open them all).
13
+ - `requirements/ui/figma-meta.md` — Figma MCP capture: `## Components`, `## Text Content`.
14
+ - `requirements/ui-inventory.md` — an existing Design Inventory: keep its `UI-NNN` ids.
13
15
  - Live page — only if a base URL is configured and reachable: `browser_navigate` + `browser_snapshot` for real element roles/names. Fall back gracefully if it requires login / fails.
14
16
 
15
17
  ## Cross-check
@@ -20,7 +22,13 @@ Flag any **conflicts** between sources (field names, labels, behavior, states th
20
22
  SOURCES: spec=<yes/no, completeness high|med|low> | figma=<...> | ui=<n images> | live=<reached|offline>
21
23
  PAGE TYPE: <e.g. ecommerce-list / form / auth / ...>
22
24
  RECOMMENDED ROUTE: <spec-first | source-first | ...> — authoritative source: <which>
23
- CONFLICTS: <list, or "none">
25
+ CONFLICTS: <list, or "none"> — each conflict on a design item also appears in UI INVENTORY with source `conflict`
26
+ UI INVENTORY: images read: <read>/<total> · figma-meta.md: <read|absent> · spec_figma.md: <read|absent>
27
+ | UI-001 | <item> | <component|column|button|field|state|annotation> | spec+design | <image> |
28
+ | UI-014 | <item> | column | design-only | <image> |
29
+ | UI-015 | <item> | column | conflict — <how spec and design disagree> | <image> |
30
+ → one row per item the design shows (format and id rules: `sungen-tc-generation` § Design Inventory);
31
+ omit the block only when the unit has no design source
24
32
  KEY FACTS (condensed):
25
33
  - Sections: <list>
26
34
  - Fields/validation: <key constraints + exact error messages>
@@ -29,4 +37,4 @@ KEY FACTS (condensed):
29
37
  ASSUMPTIONS / MISSING INFO: <what's unknown — to be marked in output>
30
38
  ```
31
39
 
32
- Keep it tight. Do not generate test cases — that is the orchestrator's job. Do not write files.
40
+ Keep it tight — except UI INVENTORY, which is listed in full: it is the checklist the orchestrator writes to `ui-inventory.md`. Do not generate test cases — that is the orchestrator's job. Do not write files.
@@ -10,6 +10,8 @@ You are an **independent Senior QA Reviewer**. You did **not** write these tests
10
10
  - `qa/<screens|flows>/<name>/features/<name>.feature` — the scenarios under review.
11
11
  - `qa/<screens|flows>/<name>/requirements/test-viewpoint.md` — the viewpoint overview (priority).
12
12
  - `qa/<screens|flows>/<name>/requirements/spec.md` — source of truth for behavior.
13
+ - `qa/<screens|flows>/<name>/requirements/ui/*` images, `ui/figma-meta.md`, `spec_figma.md` — the design, when present. Open **every** image.
14
+ - `qa/<screens|flows>/<name>/requirements/ui-inventory.md` — the Design Inventory create-test wrote (`UI-NNN` rows).
13
15
  - `.sungen/reports/<name>-audit.json` — the deterministic findings (don't repeat them; go deeper).
14
16
 
15
17
  ## What to judge (semantic — the gate misses these)
@@ -25,6 +27,7 @@ You are an **independent Senior QA Reviewer**. You did **not** write these tests
25
27
  - **Flows self-clean.** A CRUD/auth chain deletes what it created (final `@api:delete_*`) or is `@cleanup`-tagged.
26
28
  - **Idempotency uses the DB oracle.** A "no double-charge / exactly once" claim is proven by `@concurrent` + a `@query` count, not HTTP status alone (status can lie under a race).
27
29
  - **Auth negatives** exist for protected mutations (401/403), not just the happy path.
30
+ 7. **Design ↔ Feature** (only when the unit holds design sources). Read the design yourself — do not trust the inventory to be complete. Report: items the design shows that are **missing from `ui-inventory.md`**; inventory items **mislabelled** `spec+design` that `spec.md` never names (design-only) or names differently (conflict); and `design-only`/`conflict` items whose `@ui:` scenario does not actually prove them, or whose `# UI-DEFERRED:` reason does not hold. The audit only sees what the inventory lists — you are the check on the inventory itself.
28
31
 
29
32
  ## Output (do NOT edit any file)
30
33
  Return a concise verdict:
@@ -38,6 +41,11 @@ ISSUES (most important first):
38
41
  FIX: <the exact Gherkin step(s) to add/change>
39
42
  2. ...
40
43
 
44
+ DESIGN DIFF (omit when the unit has no design source):
45
+ - not inventoried: <item> (<image>)
46
+ - mislabelled: UI-NNN <item> — is <design-only|conflict>, not spec+design
47
+ - unproven: UI-NNN <item> — <why the @ui scenario / deferral does not hold>
48
+
41
49
  STRENGTHS: <1-2 lines, what is genuinely good>
42
50
  ```
43
51