@sun-asterisk/sungen 3.2.16-beta.1 → 3.2.16-beta.11

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 (146) hide show
  1. package/dist/cli/commands/audit.d.ts.map +1 -1
  2. package/dist/cli/commands/audit.js +6 -0
  3. package/dist/cli/commands/audit.js.map +1 -1
  4. package/dist/cli/commands/delivery.d.ts.map +1 -1
  5. package/dist/cli/commands/delivery.js +67 -20
  6. package/dist/cli/commands/delivery.js.map +1 -1
  7. package/dist/exporters/feature-parser.d.ts +16 -1
  8. package/dist/exporters/feature-parser.d.ts.map +1 -1
  9. package/dist/exporters/feature-parser.js +21 -2
  10. package/dist/exporters/feature-parser.js.map +1 -1
  11. package/dist/exporters/matrix/build.d.ts +27 -2
  12. package/dist/exporters/matrix/build.d.ts.map +1 -1
  13. package/dist/exporters/matrix/build.js +277 -47
  14. package/dist/exporters/matrix/build.js.map +1 -1
  15. package/dist/exporters/matrix/export.d.ts +7 -4
  16. package/dist/exporters/matrix/export.d.ts.map +1 -1
  17. package/dist/exporters/matrix/export.js +22 -6
  18. package/dist/exporters/matrix/export.js.map +1 -1
  19. package/dist/exporters/matrix/gates.d.ts.map +1 -1
  20. package/dist/exporters/matrix/gates.js +112 -3
  21. package/dist/exporters/matrix/gates.js.map +1 -1
  22. package/dist/exporters/matrix/map-loader.d.ts.map +1 -1
  23. package/dist/exporters/matrix/map-loader.js +20 -0
  24. package/dist/exporters/matrix/map-loader.js.map +1 -1
  25. package/dist/exporters/matrix/render-csv.d.ts +3 -2
  26. package/dist/exporters/matrix/render-csv.d.ts.map +1 -1
  27. package/dist/exporters/matrix/render-csv.js +53 -30
  28. package/dist/exporters/matrix/render-csv.js.map +1 -1
  29. package/dist/exporters/matrix/render-xlsx.d.ts +32 -8
  30. package/dist/exporters/matrix/render-xlsx.d.ts.map +1 -1
  31. package/dist/exporters/matrix/render-xlsx.js +215 -83
  32. package/dist/exporters/matrix/render-xlsx.js.map +1 -1
  33. package/dist/exporters/matrix/types.d.ts +53 -7
  34. package/dist/exporters/matrix/types.d.ts.map +1 -1
  35. package/dist/exporters/matrix/types.js +2 -2
  36. package/dist/exporters/matrix/types.js.map +1 -1
  37. package/dist/exporters/matrix/wording.d.ts +61 -0
  38. package/dist/exporters/matrix/wording.d.ts.map +1 -0
  39. package/dist/exporters/matrix/wording.js +221 -0
  40. package/dist/exporters/matrix/wording.js.map +1 -0
  41. package/dist/exporters/scenario-merger.d.ts.map +1 -1
  42. package/dist/exporters/scenario-merger.js +2 -1
  43. package/dist/exporters/scenario-merger.js.map +1 -1
  44. package/dist/exporters/spec-parser.d.ts.map +1 -1
  45. package/dist/exporters/spec-parser.js +2 -1
  46. package/dist/exporters/spec-parser.js.map +1 -1
  47. package/dist/harness/audit.d.ts +6 -0
  48. package/dist/harness/audit.d.ts.map +1 -1
  49. package/dist/harness/audit.js +80 -15
  50. package/dist/harness/audit.js.map +1 -1
  51. package/dist/harness/blindspot.d.ts.map +1 -1
  52. package/dist/harness/blindspot.js +2 -1
  53. package/dist/harness/blindspot.js.map +1 -1
  54. package/dist/harness/capability-plan.d.ts.map +1 -1
  55. package/dist/harness/capability-plan.js +3 -2
  56. package/dist/harness/capability-plan.js.map +1 -1
  57. package/dist/harness/feedback.d.ts.map +1 -1
  58. package/dist/harness/feedback.js +3 -2
  59. package/dist/harness/feedback.js.map +1 -1
  60. package/dist/harness/flow-check.d.ts.map +1 -1
  61. package/dist/harness/flow-check.js +2 -1
  62. package/dist/harness/flow-check.js.map +1 -1
  63. package/dist/harness/flow-plan.d.ts.map +1 -1
  64. package/dist/harness/flow-plan.js +3 -2
  65. package/dist/harness/flow-plan.js.map +1 -1
  66. package/dist/harness/intent.d.ts.map +1 -1
  67. package/dist/harness/intent.js +2 -1
  68. package/dist/harness/intent.js.map +1 -1
  69. package/dist/harness/journey.d.ts.map +1 -1
  70. package/dist/harness/journey.js +3 -2
  71. package/dist/harness/journey.js.map +1 -1
  72. package/dist/harness/ledger.d.ts.map +1 -1
  73. package/dist/harness/ledger.js +3 -2
  74. package/dist/harness/ledger.js.map +1 -1
  75. package/dist/harness/manifest.d.ts.map +1 -1
  76. package/dist/harness/manifest.js +4 -3
  77. package/dist/harness/manifest.js.map +1 -1
  78. package/dist/harness/parse.d.ts.map +1 -1
  79. package/dist/harness/parse.js +16 -3
  80. package/dist/harness/parse.js.map +1 -1
  81. package/dist/harness/quality-gates.d.ts.map +1 -1
  82. package/dist/harness/quality-gates.js +2 -1
  83. package/dist/harness/quality-gates.js.map +1 -1
  84. package/dist/harness/read-text.d.ts +22 -0
  85. package/dist/harness/read-text.d.ts.map +1 -0
  86. package/dist/harness/read-text.js +64 -0
  87. package/dist/harness/read-text.js.map +1 -0
  88. package/dist/harness/script-check.d.ts.map +1 -1
  89. package/dist/harness/script-check.js +3 -2
  90. package/dist/harness/script-check.js.map +1 -1
  91. package/dist/harness/sensors.d.ts +13 -1
  92. package/dist/harness/sensors.d.ts.map +1 -1
  93. package/dist/harness/sensors.js +63 -20
  94. package/dist/harness/sensors.js.map +1 -1
  95. package/dist/harness/spec-coverage.d.ts +5 -0
  96. package/dist/harness/spec-coverage.d.ts.map +1 -1
  97. package/dist/harness/spec-coverage.js +17 -7
  98. package/dist/harness/spec-coverage.js.map +1 -1
  99. package/dist/harness/trace.d.ts.map +1 -1
  100. package/dist/harness/trace.js +4 -3
  101. package/dist/harness/trace.js.map +1 -1
  102. package/dist/harness/viewpoint-ledger.d.ts.map +1 -1
  103. package/dist/harness/viewpoint-ledger.js +2 -1
  104. package/dist/harness/viewpoint-ledger.js.map +1 -1
  105. package/dist/orchestrator/templates/ai-src/commands/create-test.md +9 -0
  106. package/dist/orchestrator/templates/ai-src/commands/delivery.md +103 -18
  107. package/dist/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +77 -11
  108. package/dist/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +1 -0
  109. package/dist/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +22 -0
  110. package/package.json +4 -4
  111. package/src/cli/commands/audit.ts +5 -0
  112. package/src/cli/commands/delivery.ts +68 -22
  113. package/src/exporters/feature-parser.ts +21 -2
  114. package/src/exporters/matrix/build.ts +281 -43
  115. package/src/exporters/matrix/export.ts +31 -6
  116. package/src/exporters/matrix/gates.ts +119 -3
  117. package/src/exporters/matrix/map-loader.ts +22 -1
  118. package/src/exporters/matrix/render-csv.ts +53 -30
  119. package/src/exporters/matrix/render-xlsx.ts +216 -85
  120. package/src/exporters/matrix/types.ts +58 -8
  121. package/src/exporters/matrix/wording.ts +221 -0
  122. package/src/exporters/scenario-merger.ts +2 -1
  123. package/src/exporters/spec-parser.ts +2 -1
  124. package/src/harness/audit.ts +84 -16
  125. package/src/harness/blindspot.ts +2 -1
  126. package/src/harness/capability-plan.ts +3 -2
  127. package/src/harness/feedback.ts +3 -2
  128. package/src/harness/flow-check.ts +2 -1
  129. package/src/harness/flow-plan.ts +3 -2
  130. package/src/harness/intent.ts +2 -1
  131. package/src/harness/journey.ts +3 -2
  132. package/src/harness/ledger.ts +3 -2
  133. package/src/harness/manifest.ts +4 -3
  134. package/src/harness/parse.ts +17 -3
  135. package/src/harness/quality-gates.ts +2 -1
  136. package/src/harness/read-text.ts +28 -0
  137. package/src/harness/script-check.ts +3 -2
  138. package/src/harness/sensors.ts +55 -8
  139. package/src/harness/spec-coverage.ts +22 -7
  140. package/src/harness/trace.ts +4 -3
  141. package/src/harness/viewpoint-ledger.ts +2 -1
  142. package/src/orchestrator/templates/ai-src/commands/create-test.md +9 -0
  143. package/src/orchestrator/templates/ai-src/commands/delivery.md +103 -18
  144. package/src/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +77 -11
  145. package/src/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +1 -0
  146. package/src/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +22 -0
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Wording normalization — deterministic rendering step AFTER semantic
3
+ * normalization (review feedback §10): turn sungen DSL steps into controlled
4
+ * manual-test English without changing the target, condition, trigger,
5
+ * precondition, oracle, or trace.
6
+ *
7
+ * - Actions render in the imperative: "User fill [Email] field with X"
8
+ * → "Enter X in the Email field."
9
+ * - Expected results render as observable assertions (never tester actions):
10
+ * "User see [Jobs] page" → "The Jobs page is displayed."
11
+ * - Manual `# Tester verifies:` labels (Setup:/Action:/Observable:/Oracle:)
12
+ * become structured fields instead of prose: Setup → precondition,
13
+ * Action → action, Observable → expected, Oracle → verification method.
14
+ */
15
+
16
+ /** A step that CAPTURES a runtime value: `User remember [X] text as {{var}}`. */
17
+ export const RUNTIME_CAPTURE = /\bremember\b/i;
18
+
19
+ /**
20
+ * Variable names a scenario defines at RUN TIME rather than reading from
21
+ * test-data: values captured from the page (`remember … as {{v}}`) and values
22
+ * bound by a capability precondition tag (`@query:`, `@api:`, `@dataFactory:`).
23
+ * The compiler already treats these as "captured" (DataResolver.registerCaptured);
24
+ * the delivery matrix must agree or it demands a static value that cannot exist.
25
+ */
26
+ export function runtimeVarsOf(stepTexts: string[], tags: string[]): Set<string> {
27
+ const out = new Set<string>();
28
+ for (const text of stepTexts) {
29
+ if (!RUNTIME_CAPTURE.test(text)) continue;
30
+ const m = text.match(/\bas\s+\{\{\s*([^}]+?)\s*\}\}/i);
31
+ if (m) out.add(m[1].trim());
32
+ }
33
+ // A capability precondition binds its result under the endpoint/query/recipe
34
+ // name, so `{{<name>}}` and `{{<name>.field}}` exist only during the run.
35
+ for (const t of tags) {
36
+ const m = t.match(/^@(?:query|api|dataFactory):([^(\s]+)/);
37
+ if (m) out.add(m[1]);
38
+ }
39
+ return out;
40
+ }
41
+
42
+ // `[Email] field` → `Email field` (the visible label + its element type).
43
+ function deRef(text: string): string {
44
+ return text.replace(/\[([^\]]+)\]/g, '$1');
45
+ }
46
+
47
+ function sentence(text: string): string {
48
+ // Trim the ENDS only — internal whitespace may be the test data itself
49
+ // (a padded email, a spaces-only value); collapsing it would silently
50
+ // change what the tester types (review GAP-04).
51
+ let s = text.trim();
52
+ if (!s) return s;
53
+ s = s.charAt(0).toUpperCase() + s.slice(1);
54
+ if (!/[.!?…"]$/.test(s)) s += '.';
55
+ return s;
56
+ }
57
+
58
+ /**
59
+ * Render one action step in the imperative. Pattern table covers the common
60
+ * sungen step verbs; anything unmatched falls back to actor-stripped text —
61
+ * still readable, never a raw `User fill`.
62
+ */
63
+ export function renderAction(raw: string): string {
64
+ let s = raw.trim().replace(/^(User|The user)\s+/i, '');
65
+ // `remember … as {{v}}` DECLARES a runtime variable — the trailing token is a
66
+ // name, not a value to resolve. Quote it so no `{{brace}}` reaches the sheet
67
+ // whatever rule matches below.
68
+ if (RUNTIME_CAPTURE.test(s)) s = s.replace(/\bas\s+\{\{\s*([^}]+?)\s*\}\}/i, 'as "$1"');
69
+
70
+ const rules: Array<[RegExp, (m: RegExpMatchArray) => string]> = [
71
+ // remember [X] (text|value) as "v" — capture a value for a later comparison
72
+ [/^remembers? \[([^\]]+)\]\s*(text|value)?\s*as "([^"]+)"$/i,
73
+ (m) => `Note the ${m[1]} ${m[2] || 'text'} for later (as "${m[3]}")`],
74
+ // fill [X] field with V
75
+ [/^fills? \[([^\]]+)\][a-z ]* with (.+)$/i, (m) => `Enter ${m[2]} in the ${m[1]} field`],
76
+ // clear [X] field
77
+ [/^clears? \[([^\]]+)\](.*)$/i, (m) => `Clear the ${m[1]}${m[2] || ' field'}`],
78
+ // click [X] <type>
79
+ [/^clicks? \[([^\]]+)\]\s*(\w+)?$/i, (m) => `Click the ${m[1]}${m[2] ? ` ${m[2]}` : ''}`],
80
+ // press <Key> at/in [X] field
81
+ [/^press(?:es)? (.+?) (?:at|in|on|inside) \[([^\]]+)\](?: field)?$/i, (m) => `Press ${m[1]} in the ${m[2]} field`],
82
+ [/^press(?:es)? (.+)$/i, (m) => `Press ${m[1]}`],
83
+ // select V in/from [X] dropdown
84
+ [/^selects? (.+?) (?:in|from) \[([^\]]+)\]\s*(\w+)?$/i, (m) => `Select ${m[1]} in the ${m[2]}${m[3] ? ` ${m[3]}` : ''}`],
85
+ // check/uncheck [X] checkbox
86
+ [/^(un)?checks? \[([^\]]+)\]\s*(\w+)?$/i, (m) => `${m[1] ? 'Uncheck' : 'Check'} the ${m[2]}${m[3] ? ` ${m[3]}` : ''}`],
87
+ // hover [X]
88
+ [/^hovers? (?:over )?\[([^\]]+)\]\s*(\w+)?$/i, (m) => `Hover over the ${m[1]}${m[2] ? ` ${m[2]}` : ''}`],
89
+ // upload V to [X]
90
+ [/^uploads? (.+?) (?:to|into) \[([^\]]+)\]\s*(\w+)?$/i, (m) => `Upload ${m[1]} to the ${m[2]}${m[3] ? ` ${m[3]}` : ''}`],
91
+ // is on [X] page (as an action = navigate)
92
+ [/^is on \[([^\]]+)\] page(.*)$/i, (m) => `Open the ${m[1]} page${m[2] ?? ''}`],
93
+ // wait for [X] <type> (is )?visible
94
+ [/^waits? for \[([^\]]+)\]\s*(\w+)?(?: is)?(?: visible)?$/i, (m) => `Wait until the ${m[1]}${m[2] ? ` ${m[2]}` : ''} is visible`],
95
+ // scroll to [X]
96
+ [/^scrolls? (?:to|into) \[([^\]]+)\]\s*(\w+)?$/i, (m) => `Scroll to the ${m[1]}${m[2] ? ` ${m[2]}` : ''}`],
97
+ ];
98
+
99
+ for (const [re, out] of rules) {
100
+ const m = s.match(re);
101
+ if (m) return sentence(deRef(out(m)));
102
+ }
103
+ return sentence(deRef(s));
104
+ }
105
+
106
+ /**
107
+ * Render one expected step as an observable assertion (no tester action, no
108
+ * `should`, no DSL `User see`).
109
+ */
110
+ export function renderExpected(raw: string): string {
111
+ // A capture step is an INSTRUCTION wherever it sits — Gherkin often chains
112
+ // `remember … / remember … / expect a is not b` inside one Then block. Render
113
+ // it as the note-it-down action, never as an assertion (and never leaving the
114
+ // declared `{{name}}` token behind).
115
+ if (RUNTIME_CAPTURE.test(raw) && /\bas\s+\{\{/.test(raw)) return renderAction(raw);
116
+ let s = raw.trim().replace(/^(User|The user)\s+/i, '');
117
+
118
+ const rules: Array<[RegExp, (m: RegExpMatchArray) => string]> = [
119
+ // expect A is (not) B — comparing two captured values (order matters: "is not" first)
120
+ [/^expects? (.+?) is not (.+)$/i, (m) => `${m[1]} differs from ${m[2]}`],
121
+ [/^expects? (.+?) is (.+)$/i, (m) => `${m[1]} equals ${m[2]}`],
122
+ // see all [X] <type> contain(s) V — every matching element, not just the first
123
+ [/^sees? all \[([^\]]+)\]\s*(\w+)? (?:contains?|match(?:es)?|belongs?) (.+)$/i,
124
+ (m) => `Every ${m[1]}${m[2] ? ` ${m[2]}` : ''} contains ${m[3]}`],
125
+ // see all [X] <type> contain [Child] <type> — structural per-item proof
126
+ [/^sees? all \[([^\]]+)\]\s*(\w+)? (?:contains?|includes?) \[([^\]]+)\]\s*(\w+)?$/i,
127
+ (m) => `Every ${m[1]}${m[2] ? ` ${m[2]}` : ''} contains a ${m[3]}${m[4] ? ` ${m[4]}` : ''}`],
128
+ // see [X] page
129
+ [/^sees? \[([^\]]+)\] page$/i, (m) => `The ${m[1]} page is displayed`],
130
+ // see [X] <type> with V
131
+ [/^sees? \[([^\]]+)\]\s*(\w+)? with (.+)$/i, (m) => `The ${m[1]}${m[2] ? ` ${m[2]}` : ''} shows ${m[3]}`],
132
+ // see [X] <type> contains V
133
+ [/^sees? \[([^\]]+)\]\s*(\w+)? contains (.+)$/i, (m) => `The ${m[1]}${m[2] ? ` ${m[2]}` : ''} contains ${m[3]}`],
134
+ // see [X] <type> has text V
135
+ [/^sees? \[([^\]]+)\]\s*(\w+)? has text (.+)$/i, (m) => `The ${m[1]}${m[2] ? ` ${m[2]}` : ''} shows ${m[3]}`],
136
+ // see [X] <type> is hidden / is disabled / is enabled / …
137
+ [/^sees? \[([^\]]+)\]\s*(\w+)? is (.+)$/i, (m) => `The ${m[1]}${m[2] ? ` ${m[2]}` : ''} is ${m[3]}`],
138
+ // not see [X] <type>
139
+ [/^(?:do(?:es)? )?not sees? \[([^\]]+)\]\s*(\w+)?$/i, (m) => `The ${m[1]}${m[2] ? ` ${m[2]}` : ''} is not displayed`],
140
+ // see [X] <type>
141
+ [/^sees? \[([^\]]+)\]\s*(\w+)?$/i, (m) => `The ${m[1]}${m[2] ? ` ${m[2]}` : ''} is visible`],
142
+ ];
143
+
144
+ for (const [re, out] of rules) {
145
+ const m = s.match(re);
146
+ if (m) return sentence(deRef(out(m)));
147
+ }
148
+ return sentence(deRef(s));
149
+ }
150
+
151
+ /** Precondition wording: a state, not an action ("The user is signed out."). */
152
+ export function renderPrecondition(raw: string): string {
153
+ const s = raw.trim().replace(/^(User|The user)\s+/i, '');
154
+ const m = s.match(/^is on \[([^\]]+)\] page(.*)$/i);
155
+ if (m) return sentence(`The user is on the ${m[1]} page${m[2] ?? ''}`);
156
+ // "wait for [X] <type> (is) visible" is a STATE once established, not an action.
157
+ const w = s.match(/^waits? for \[([^\]]+)\]\s*(\w+)?(?: is)?(?: visible)?$/i);
158
+ if (w) return sentence(`The ${w[1]}${w[2] ? ` ${w[2]}` : ''} is visible`);
159
+ const auth = s.match(/^is (signed|logged) (in|out)(.*)$/i);
160
+ if (auth) return sentence(`The user is ${auth[1]} ${auth[2]}${auth[3] ?? ''}`);
161
+ return sentence(deRef(`The user ${s.charAt(0).toLowerCase()}${s.slice(1)}`));
162
+ }
163
+
164
+ /**
165
+ * A manual `Setup:` line is an INSTRUCTION the tester performs to establish the
166
+ * state — imperative reads correctly ("Seed the locked account."), while
167
+ * "The user seed …" is broken grammar (review GAP-08).
168
+ */
169
+ export function renderSetupInstruction(raw: string): string {
170
+ return renderAction(raw);
171
+ }
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // Manual `# Tester verifies:` comment classification (structured, label-free)
175
+ // ---------------------------------------------------------------------------
176
+
177
+ export interface ManualProcedure {
178
+ /** Setup/Precondition/Arrange lines — the state to establish first. */
179
+ preconditions: string[];
180
+ /** Action/unlabelled lines — the imperative procedure. */
181
+ actions: string[];
182
+ /** Observable/Expect/Result/Assert lines — the observable outcome. */
183
+ expected: string[];
184
+ /** Oracle/Verify lines — HOW to check (tools, panes, queries). */
185
+ verification: string[];
186
+ }
187
+
188
+ /**
189
+ * Split a manual scenario's numbered comment lines into the four structured
190
+ * fields. Labels are consumed (structured), never left inside the prose —
191
+ * review feedback §10.2(3). Continuation lines append to the previous item;
192
+ * pre-amble (rationale/header/dividers) is skipped.
193
+ */
194
+ export function classifyManualComments(comments: string[]): ManualProcedure {
195
+ const out: ManualProcedure = { preconditions: [], actions: [], expected: [], verification: [] };
196
+ let last: { list: string[]; idx: number } | null = null;
197
+
198
+ const bucketOf = (label: string): keyof ManualProcedure => {
199
+ if (/setup|precondition|arrange|given/i.test(label)) return 'preconditions';
200
+ if (/oracle|verify|verification|how to check/i.test(label)) return 'verification';
201
+ if (/observ|expect|result|then|assert/i.test(label)) return 'expected';
202
+ return 'actions';
203
+ };
204
+
205
+ for (const raw of comments) {
206
+ const line = raw.trim();
207
+ if (!line) continue;
208
+ if (/^[-=*_]{2,}/.test(line)) { last = null; continue; }
209
+ const m = line.match(/^\d+[.)]\s*(?:([A-Za-z][A-Za-z /]*?):\s*)?(.+)$/);
210
+ if (m) {
211
+ const label = (m[1] || '').trim();
212
+ const text = m[2].trim();
213
+ const list = out[bucketOf(label)];
214
+ list.push(text);
215
+ last = { list, idx: list.length - 1 };
216
+ } else if (last) {
217
+ last.list[last.idx] += ' ' + line;
218
+ }
219
+ }
220
+ return out;
221
+ }
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { FeatureMetadata, OrderedStep, ScenarioMetadata, SpecFileData, SpecTest } from './types';
12
12
  import { isStepsBaseScenario, isSampleScaffoldScenario, extractTestcaseType } from './feature-parser';
13
+ import { SCENARIO_ID } from './feature-parser';
13
14
 
14
15
  /**
15
16
  * Parse a @manual scenario's `# Tester verifies:` comment block into procedure vs
@@ -162,7 +163,7 @@ function findMatchingSpecTest(scenario: ScenarioMetadata, tests: SpecTest[]): Sp
162
163
  if (exact) return exact;
163
164
 
164
165
  // Fallback: match by VP-ID prefix (ignore trailing text differences)
165
- const vpMatch = scenario.name.match(/^(VP-[A-Z][A-Z0-9]*-\d+(?:[a-zA-Z]|-[A-Z0-9]+)?)/);
166
+ const vpMatch = scenario.name.match(SCENARIO_ID);
166
167
  if (vpMatch) {
167
168
  const vpId = vpMatch[1];
168
169
  const byVp = tests.find((t) => t.vpId === vpId);
@@ -5,6 +5,7 @@
5
5
 
6
6
  import * as fs from 'fs';
7
7
  import { SpecTest, SpecFileData } from './types';
8
+ import { SCENARIO_ID } from './feature-parser';
8
9
 
9
10
  /**
10
11
  * Extract the inner body of a test('title', async (...) => { BODY }) block.
@@ -165,7 +166,7 @@ function classifyBlock(block: CodeBlock): 'precondition' | 'step' | 'expectation
165
166
  * Try to extract VP ID (e.g., VP-UI-001) from the start of a scenario title.
166
167
  */
167
168
  function extractVpId(title: string): string | undefined {
168
- const match = title.match(/^(VP-[A-Z][A-Z0-9]*-\d+(?:[a-zA-Z]|-[A-Z0-9]+)?)\b/);
169
+ const match = title.match(new RegExp(SCENARIO_ID.source + '\\b'));
169
170
  return match ? match[1] : undefined;
170
171
  }
171
172
 
@@ -27,6 +27,7 @@ import { viewpointLedger, parseViewpointItems, LedgerResult } from './viewpoint-
27
27
  import { capabilityRegistry } from '../capabilities/registry';
28
28
  import { discoverAndRegisterCapabilities } from '../capabilities/discover';
29
29
  import { contextRouter } from '../capabilities/context-router';
30
+ import { readTextFile } from './read-text';
30
31
 
31
32
  export interface AuditReport {
32
33
  screen: string;
@@ -49,9 +50,15 @@ export interface AuditReport {
49
50
  axes: Record<string, number>;
50
51
  weakest: { axis: string; value: number };
51
52
  inflated: boolean;
53
+ /** Axes excluded for lack of evidence (no spec / viewpoint file / decidable page type). */
54
+ notApplicable?: string[];
55
+ /** Set when a weak critical axis holds the overall down. */
56
+ cappedAt?: number;
52
57
  };
53
58
  score: {
54
- overall: number; // 0..10, business-weighted
59
+ overall: number; // 0..10, business-weighted over the APPLICABLE axes
60
+ notApplicable?: string[]; // axes with no evidence — excluded, not scored as 1.0
61
+ cappedAt?: number; // set when a weak critical axis holds the score down
55
62
  coverage: number; // 0..1
56
63
  businessDepth: number; // 0..1
57
64
  balance: number; // 0..1
@@ -118,7 +125,7 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
118
125
  const catalogScreenName = catalogIdFromScreenDir(screenDir);
119
126
 
120
127
  const specPath = path.join(screenDir, 'requirements', 'spec.md');
121
- const featureText = fs.existsSync(featurePath) ? fs.readFileSync(featurePath, 'utf-8') : '';
128
+ const featureText = fs.existsSync(featurePath) ? readTextFile(featurePath) : '';
122
129
 
123
130
  const scenarios: ScenarioInfo[] = loadScenarios(featurePath);
124
131
  const viewpoints: ViewpointEntry[] = parseViewpointOverview(viewpointPath);
@@ -147,7 +154,8 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
147
154
  const capGate = scoringCap?.gateProvider as
148
155
  ((i: { scenarios: ScenarioInfo[]; viewpoints: ViewpointEntry[]; catalog: Catalog; focus: typeof intent.focus; cwd: string; screenName: string; threshold: number; businessCriticalMethods?: string[] }) => { gate: GateResult; depth: DepthResult }) | undefined;
149
156
  const provided = capGate?.({ scenarios, viewpoints, catalog, focus: intent.focus, cwd: projectRootFromScreenDir(screenDir), screenName: catalogScreenName, threshold: depthThresholdFor(intent.focus), businessCriticalMethods: intent.businessCriticalMethods });
150
- const gate = provided?.gate ?? viewpointGate(scenarios, viewpoints, catalog, platform === 'mobile');
157
+ const viewpointText = fs.existsSync(viewpointPath) ? readTextFile(viewpointPath) : '';
158
+ const gate = provided?.gate ?? viewpointGate(scenarios, viewpoints, catalog, platform === 'mobile', viewpointText);
151
159
  const depth = provided?.depth ?? assertionDepth(scenarios, dataThemesFor(catalog, gate.pageType), intent.focus);
152
160
 
153
161
  // TQ-4 — deferral-aware coverage credit: an inherently cross-screen theme (cart / detail /
@@ -211,8 +219,51 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
211
219
  : 1;
212
220
  const traceScore = 0.5 * trace.withVpCodeRatio + 0.5 * trace.mappedRatio;
213
221
 
214
- // Business-weighted overall (coverage + depth dominate)
215
- const rawOverall = (0.4 * coverage + 0.3 * businessDepth + 0.15 * balanceScore + 0.15 * traceScore) * 10;
222
+ // ---- Business-weighted overall -------------------------------------------
223
+ //
224
+ // Two rules make the number mean what it says:
225
+ //
226
+ // 1. Only axes the project supplies EVIDENCE for are scored, and the weights
227
+ // are renormalised over them. The old formula spent 40% on page-type theme
228
+ // coverage — which is 1.0 whenever no page type is detected — so a screen
229
+ // with no detectable type collected 4 free points, while a mis-detected one
230
+ // was judged against themes it can never have. Missing spec / viewpoint
231
+ // files are treated the same way: absent evidence scores nothing, in either
232
+ // direction.
233
+ // 2. The three axes the harness already measured but never counted —
234
+ // claimProof, specFR, atomicLedger — now carry weight. A suite where two
235
+ // thirds of the scenarios do not prove their own title used to score 7.9.
236
+ const specRatio = spec.frTotal ? spec.frCovered / spec.frTotal : 1;
237
+ const axisDefs: Array<{ key: string; value: number; weight: number; applicable: boolean; critical: boolean }> = [
238
+ { key: 'coverage', value: coverage, weight: 0.22, applicable: !!gate.pageType && gate.themesTotal > 0, critical: true },
239
+ { key: 'specFR', value: specRatio, weight: 0.15, applicable: spec.hasSpec && spec.frTotal > 0, critical: true },
240
+ { key: 'atomicLedger', value: ledger.ratio, weight: 0.13, applicable: ledger.hasViewpoint && ledger.total > 0, critical: true },
241
+ { key: 'businessDepth', value: businessDepth, weight: 0.20, applicable: true, critical: true },
242
+ { key: 'claimProof', value: claim.ratio, weight: 0.15, applicable: claim.withClaims > 0, critical: true },
243
+ { key: 'traceability', value: traceScore, weight: 0.09, applicable: viewpoints.length > 0, critical: false },
244
+ { key: 'balance', value: balanceScore, weight: 0.06, applicable: true, critical: false },
245
+ ];
246
+ const scored = axisDefs.filter((a) => a.applicable);
247
+ const weightSum = scored.reduce((t, a) => t + a.weight, 0) || 1;
248
+ const weighted = scored.reduce((t, a) => t + a.weight * a.value, 0) / weightSum;
249
+
250
+ // A weak axis is a hole, and a hole must not be averaged away: the pass
251
+ // threshold teams set (7.x) has to mean "no hole", not "good on balance".
252
+ const criticalScored = scored.filter((a) => a.critical);
253
+ const weakestCritical = criticalScored.slice().sort((a, b) => a.value - b.value)[0];
254
+ const CAP_SEVERE = 6.9; // below any 7.x gate
255
+ const CAP_WEAK = 7.9;
256
+ // Dropping an axis for lack of evidence must not become a shortcut to a perfect
257
+ // score: with no spec and no viewpoint file the remaining axes are easy to max.
258
+ // A top mark has to rest on complete evidence, so partial evidence caps below it.
259
+ const CAP_PARTIAL_EVIDENCE = 8.9;
260
+ let cap = 10;
261
+ if (weakestCritical && weakestCritical.value < 0.5) cap = CAP_SEVERE;
262
+ else if (weakestCritical && weakestCritical.value < 0.7) cap = CAP_WEAK;
263
+ const missingEvidence = axisDefs.filter((a) => !a.applicable).map((a) => a.key);
264
+ if (missingEvidence.length > 0) cap = Math.min(cap, CAP_PARTIAL_EVIDENCE);
265
+ const rawOverall = Math.min(weighted * 10, cap);
266
+ const capped = weakestCritical ? weighted * 10 > cap : false;
216
267
  // H7 — senior-grade band: the top decile (≥9) is reserved for suites that also clear the senior
217
268
  // axes — a stateful flow with FULL regression depth (count + teardown + multi-source), no weak
218
269
  // facet oracle, and no parallel-cart isolation risk. Otherwise the score is held just below 9, so
@@ -221,11 +272,20 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
221
272
  const overall = seniorBandedOverall(rawOverall, { flowStateful: flowDepth.stateful, flowRatio: flowDepth.ratio, oracleWeak: oracle.weak.length, isolationRisk: isoRisk });
222
273
 
223
274
  const findings: string[] = [];
275
+ if (capped) {
276
+ const why = weakestCritical && weakestCritical.value < 0.7
277
+ ? `"${weakestCritical.key}" is ${(weakestCritical.value * 100).toFixed(0)}% — a weak axis is a hole, not something the other axes can average away`
278
+ : `this unit supplies no evidence for [${missingEvidence.join(', ')}], and a top mark has to rest on complete evidence`;
279
+ findings.push(`SCORE-CAPPED: overall held at ${cap.toFixed(1)} because ${why}.`);
280
+ }
281
+ if (gate.pageTypeSource === 'undetermined') {
282
+ findings.push(`PAGE-TYPE-UNDETERMINED: no page type fits this screen with enough confidence (best ${gate.pageTypeEvidence?.hits ?? 0} keyword hit(s)), so NO critical themes were demanded and the coverage axis is excluded from the score — declare it in test-viewpoint.md (\`page-type: <id>\`) to have the theme checklist applied.`);
283
+ }
224
284
  for (const c of flowCredits) {
225
285
  findings.push(`COVERED-VIA-FLOW: critical theme "${c.theme}" is not covered on this screen but is deeply covered by flow "${c.flow}" — cross-screen depth correctly owned by the flow, so it is credited (not a screen gap). Verify with \`sungen flow-check\`.`);
226
286
  }
227
287
  // Verification-scope (advisory): API/DB verification the project's recorded test type never asked for.
228
- for (const v of verificationScopeFindings(featureText, fs.existsSync(viewpointPath) ? fs.readFileSync(viewpointPath, 'utf-8') : '', capProfile)) {
288
+ for (const v of verificationScopeFindings(featureText, fs.existsSync(viewpointPath) ? readTextFile(viewpointPath) : '', capProfile)) {
229
289
  findings.push(`VERIFICATION-OUT-OF-SCOPE (${v.layer}): this unit ${v.message}`);
230
290
  }
231
291
  for (const g of gate.gaps) {
@@ -289,6 +349,14 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
289
349
  for (const u of spec.uncoveredMust) {
290
350
  findings.push(`SPEC-UNCOVERED: ${u.id} (MUST) has no covering scenario — "${u.text}" → add a scenario or tag one @spec:${u.id}.`);
291
351
  }
352
+ if (spec.inferredOnly.length > 0) {
353
+ // Covered, but only by keyword inference — no scenario cites the id. Delivery's
354
+ // requirement table (and any later refactor) cannot follow an inferred link, so
355
+ // the trace has to be written down while the author still knows which scenario
356
+ // proves what.
357
+ const shown = spec.inferredOnly.slice(0, 8).join(', ');
358
+ findings.push(`SPEC-TRACE-IMPLICIT: ${spec.inferredOnly.length} requirement(s) are covered by keyword inference only (${shown}${spec.inferredOnly.length > 8 ? ', …' : ''}) — add \`@spec:<id>\` to the scenario that proves each so the trace survives edits and delivery can report it.`);
359
+ }
292
360
  if (taxonomyMismatch) {
293
361
  findings.push(`VP-TAXONOMY-MISMATCH: only ${(trace.mappedRatio * 100).toFixed(0)}% of scenarios use the viewpoint IDs declared in test-viewpoint.md — scenarios invented a generic VP-<CAT> scheme. Re-tag to the project's viewpoint IDs so the coverage matrix is accurate.`);
294
362
  }
@@ -367,15 +435,15 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
367
435
  // #8 — multi-axis calibration: a high overall must not hide a weak axis.
368
436
  const manualCompleteness = manualOracleResult.manualTotal
369
437
  ? 1 - manualOracleResult.insufficient.length / manualOracleResult.manualTotal : 1;
370
- const axes: Record<string, number> = {
371
- coverage: Math.round(coverage * 100) / 100,
372
- businessDepth: Math.round(businessDepth * 100) / 100,
373
- claimProof: Math.round(claim.ratio * 100) / 100,
374
- specFR: spec.frTotal ? Math.round((spec.frCovered / spec.frTotal) * 100) / 100 : 1,
375
- atomicLedger: Math.round(ledger.ratio * 100) / 100,
376
- manualOracle: Math.round(manualCompleteness * 100) / 100,
377
- taxonomy: taxonomyMismatch ? 0 : Math.round(trace.mappedRatio * 100) / 100,
378
- };
438
+ // Only the axes that were actually scored appear here — an axis with no evidence
439
+ // (no spec, no viewpoint file, no decidable page type) is reported as such rather
440
+ // than as a silent 100%, which is what let empty inputs look like perfect ones.
441
+ const axes: Record<string, number> = {};
442
+ for (const a of scored) axes[a.key] = Math.round(a.value * 100) / 100;
443
+ // Measured but not weighted: they drive findings, not the headline.
444
+ axes.manualOracle = Math.round(manualCompleteness * 100) / 100;
445
+ axes.taxonomy = taxonomyMismatch ? 0 : Math.round(trace.mappedRatio * 100) / 100;
446
+ const notApplicable = axisDefs.filter((a) => !a.applicable).map((a) => a.key);
379
447
  const weakestEntry = Object.entries(axes).sort((a, b) => a[1] - b[1])[0];
380
448
  const weakest = { axis: weakestEntry[0], value: weakestEntry[1] };
381
449
  // Inflated when breadth hides a weak axis, OR when businessDepth is high but rests on a
@@ -386,7 +454,7 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
386
454
  if (inflated) {
387
455
  findings.push(`SCORE-INFLATED-BY-BREADTH: overall ${Math.round(overall * 10) / 10}/10 but the weakest axis "${weakest.axis}" is ${(weakest.value * 100).toFixed(0)}% — breadth is hiding a weak dimension. Raise "${weakest.axis}" before trusting the headline.`);
388
456
  }
389
- const calibration = { axes, weakest, inflated };
457
+ const calibration = { axes, weakest, inflated, notApplicable, cappedAt: capped ? cap : undefined };
390
458
 
391
459
  // Gate spans coverage (viewpoint themes), depth, claim-proof, spec-clause coverage,
392
460
  // AND taxonomy-match (scenarios must use the project's viewpoint IDs when defined).
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import * as fs from 'fs';
12
12
  import * as path from 'path';
13
+ import { readTextFile } from './read-text';
13
14
 
14
15
  export interface Blindspot {
15
16
  ts: string;
@@ -35,7 +36,7 @@ export function addBlindspot(entry: Omit<Blindspot, 'ts'> & { ts?: string }): st
35
36
  export function listBlindspots(): Blindspot[] {
36
37
  const p = storePath();
37
38
  if (!fs.existsSync(p)) return [];
38
- return fs.readFileSync(p, 'utf-8').split('\n').filter(Boolean).map((l) => {
39
+ return readTextFile(p).split('\n').filter(Boolean).map((l) => {
39
40
  try { return JSON.parse(l) as Blindspot; } catch { return null; }
40
41
  }).filter(Boolean) as Blindspot[];
41
42
  }
@@ -9,6 +9,7 @@
9
9
  import * as fs from 'fs';
10
10
  import * as path from 'path';
11
11
  import { loadDriverCatalog } from './capability';
12
+ import { readTextFile } from './read-text';
12
13
 
13
14
  export type ReasonClass = 'capability' | 'keep' | 'flow';
14
15
  export interface ReasonDef { code: string; label: string; cls: ReasonClass; drivers: string[] }
@@ -61,7 +62,7 @@ interface ParsedScenario { name: string; tags: string[]; manual: boolean; reason
61
62
  /** Parse scenarios with their tags + the reason comment line above (for @manual). */
62
63
  export function parseScenarios(featurePath: string): ParsedScenario[] {
63
64
  if (!fs.existsSync(featurePath)) return [];
64
- const lines = fs.readFileSync(featurePath, 'utf-8').split('\n');
65
+ const lines = readTextFile(featurePath).split('\n');
65
66
  const out: ParsedScenario[] = [];
66
67
  for (let i = 0; i < lines.length; i++) {
67
68
  const m = lines[i].match(/^\s*Scenario:\s*(.+)$/);
@@ -123,7 +124,7 @@ export interface ReasonMismatch { scenario: string; explicit: string; inferred:
123
124
  */
124
125
  export function manualReasonMismatches(featurePath: string): ReasonMismatch[] {
125
126
  if (!fs.existsSync(featurePath)) return [];
126
- const lines = fs.readFileSync(featurePath, 'utf-8').split('\n');
127
+ const lines = readTextFile(featurePath).split('\n');
127
128
  const out: ReasonMismatch[] = [];
128
129
  for (let i = 0; i < lines.length; i++) {
129
130
  const m = lines[i].match(/^\s*Scenario:\s*(.+)$/);
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import * as fs from 'fs';
14
14
  import * as path from 'path';
15
+ import { readTextFile } from './read-text';
15
16
 
16
17
  export type FeedbackType = 'test-design' | 'product' | 'other';
17
18
  export type FeedbackDecision = 'accept' | 'reject' | 'edit' | 'add' | 'none';
@@ -36,7 +37,7 @@ function currentAuditScore(screen?: string): number | undefined {
36
37
  if (!screen) return undefined;
37
38
  const p = path.join(process.cwd(), '.sungen', 'reports', `${screen}-audit.json`);
38
39
  if (!fs.existsSync(p)) return undefined;
39
- try { return JSON.parse(fs.readFileSync(p, 'utf-8'))?.score?.overall; } catch { return undefined; }
40
+ try { return JSON.parse(readTextFile(p))?.score?.overall; } catch { return undefined; }
40
41
  }
41
42
 
42
43
  export function recordFeedback(entry: Omit<FeedbackEntry, 'ts' | 'source' | 'auditScore'> & { ts?: string; source?: string }): string {
@@ -60,7 +61,7 @@ export function recordFeedback(entry: Omit<FeedbackEntry, 'ts' | 'source' | 'aud
60
61
  export function readFeedback(): FeedbackEntry[] {
61
62
  const p = feedbackPath();
62
63
  if (!fs.existsSync(p)) return [];
63
- return fs.readFileSync(p, 'utf-8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
64
+ return readTextFile(p).split('\n').filter(Boolean).map((l) => JSON.parse(l));
64
65
  }
65
66
 
66
67
  export interface FeedbackSummary {
@@ -13,6 +13,7 @@ import * as fs from 'fs';
13
13
  import * as path from 'path';
14
14
  import { loadScenarios } from './parse';
15
15
  import { parseScenarios } from './capability-plan';
16
+ import { readTextFile } from './read-text';
16
17
 
17
18
  const KNOWN_TARGETS = ['cart', 'category', 'brand', 'detail', 'checkout', 'wishlist', 'order', 'payment', 'login'];
18
19
 
@@ -33,7 +34,7 @@ function featurePath(base: string, kind: 'screens' | 'flows', name: string): str
33
34
  }
34
35
  function featureTags(fp: string): string[] {
35
36
  if (!fs.existsSync(fp)) return [];
36
- for (const line of fs.readFileSync(fp, 'utf-8').split('\n')) {
37
+ for (const line of readTextFile(fp).split('\n')) {
37
38
  const t = line.trim();
38
39
  if (t.startsWith('Feature:')) break;
39
40
  if (t.startsWith('@')) return t.split(/\s+/).filter((x) => x.startsWith('@'));
@@ -12,6 +12,7 @@ import * as path from 'path';
12
12
  import { parse as parseYaml } from 'yaml';
13
13
  import { MANUAL_REASONS, inferReasonCode } from './capability-plan';
14
14
  import { buildFlowCheck, Contract } from './flow-check';
15
+ import { readTextFile } from './read-text';
15
16
 
16
17
  const REF_RE = /\[([A-Za-z][\w ]*?):([\w &.-]+?)\]/g;
17
18
 
@@ -19,7 +20,7 @@ interface FlowScenario { name: string; manual: boolean; reason: string; tags: st
19
20
 
20
21
  function parseFlowScenarios(featurePath: string): FlowScenario[] {
21
22
  if (!fs.existsSync(featurePath)) return [];
22
- const lines = fs.readFileSync(featurePath, 'utf-8').split('\n');
23
+ const lines = readTextFile(featurePath).split('\n');
23
24
  const out: FlowScenario[] = [];
24
25
  const idx: number[] = [];
25
26
  lines.forEach((l, i) => { if (/^\s*Scenario:/.test(l)) idx.push(i); });
@@ -50,7 +51,7 @@ function parseFlowScenarios(featurePath: string): FlowScenario[] {
50
51
  function selectorKeys(selectorsPath: string): Set<string> {
51
52
  if (!fs.existsSync(selectorsPath)) return new Set();
52
53
  try {
53
- const y = parseYaml(fs.readFileSync(selectorsPath, 'utf-8')) || {};
54
+ const y = parseYaml(readTextFile(selectorsPath)) || {};
54
55
  return new Set(Object.keys(y).map((k) => k.toLowerCase()));
55
56
  } catch { return new Set(); }
56
57
  }
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import * as fs from 'fs';
16
16
  import * as path from 'path';
17
+ import { readTextFile } from './read-text';
17
18
 
18
19
  export type IntentFocus = 'functional' | 'e-commerce' | 'security' | 'smoke';
19
20
 
@@ -50,7 +51,7 @@ export function readIntent(projectRoot: string): IntentProfile {
50
51
  const ctx = path.join(projectRoot, 'qa', 'context.md');
51
52
  if (!fs.existsSync(ctx)) return DEFAULT_INTENT;
52
53
  let text: string;
53
- try { text = fs.readFileSync(ctx, 'utf-8').toLowerCase(); } catch { return DEFAULT_INTENT; }
54
+ try { text = readTextFile(ctx).toLowerCase(); } catch { return DEFAULT_INTENT; }
54
55
 
55
56
  const grab = (key: string): string | undefined => {
56
57
  const m = text.match(new RegExp(`(?:^|\\n)\\s*${key}\\s*:\\s*([a-z0-9-]+)`));
@@ -16,6 +16,7 @@ import * as fs from 'fs';
16
16
  import * as path from 'path';
17
17
  import * as crypto from 'crypto';
18
18
  import { reportSlug } from './unit-paths';
19
+ import { readTextFile } from './read-text';
19
20
 
20
21
  export type ObStatus = 'satisfied' | 'needs-work' | 'pending' | 'waived';
21
22
 
@@ -44,13 +45,13 @@ export interface JourneyReport {
44
45
  }
45
46
 
46
47
  function readJSON(p: string): any {
47
- try { return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf-8')) : null; } catch { return null; }
48
+ try { return fs.existsSync(p) ? JSON.parse(readTextFile(p)) : null; } catch { return null; }
48
49
  }
49
50
 
50
51
  function readLedgerPhases(p: string): string[] {
51
52
  if (!fs.existsSync(p)) return [];
52
53
  const out: string[] = [];
53
- for (const line of fs.readFileSync(p, 'utf-8').split('\n')) {
54
+ for (const line of readTextFile(p).split('\n')) {
54
55
  if (!line.trim()) continue;
55
56
  try { const d = JSON.parse(line); if (d.step) out.push(String(d.step)); } catch { /* skip */ }
56
57
  }
@@ -14,6 +14,7 @@
14
14
  import * as fs from 'fs';
15
15
  import * as path from 'path';
16
16
  import { reportSlug } from './unit-paths';
17
+ import { readTextFile } from './read-text';
17
18
 
18
19
  export interface LedgerEvent {
19
20
  ts: string;
@@ -75,7 +76,7 @@ export function recordEvent(screen: string, ev: Omit<LedgerEvent, 'ts'> & { ts?:
75
76
  export function readEvents(screen: string): LedgerEvent[] {
76
77
  const p = ledgerPath(screen);
77
78
  if (!fs.existsSync(p)) return [];
78
- return fs.readFileSync(p, 'utf-8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
79
+ return readTextFile(p).split('\n').filter(Boolean).map((l) => JSON.parse(l));
79
80
  }
80
81
 
81
82
  export interface LedgerReport {
@@ -121,7 +122,7 @@ export function buildReport(screen: string, opts: { allRuns?: boolean } = {}): L
121
122
  const auditPath = path.join(process.cwd(), '.sungen', 'reports', `${reportSlug(screen)}-audit.json`);
122
123
  if (fs.existsSync(auditPath)) {
123
124
  try {
124
- const a = JSON.parse(fs.readFileSync(auditPath, 'utf-8'));
125
+ const a = JSON.parse(readTextFile(auditPath));
125
126
  coveredCritical = a?.gate?.themesCovered ?? null;
126
127
  scenarioCount = a?.scenarioCount ?? null;
127
128
  } catch { /* ignore */ }
@@ -12,6 +12,7 @@ import * as fs from 'fs';
12
12
  import * as path from 'path';
13
13
  import { createHash } from 'crypto';
14
14
  import { featureBasename, reportSlug } from './unit-paths';
15
+ import { readTextFile } from './read-text';
15
16
 
16
17
  export interface SpecSection { name: string; hash: string }
17
18
  export interface ManifestEntry { scenario: string; vpCode?: string; section: string; specHash: string }
@@ -59,7 +60,7 @@ function matchSection(featureSection: string, specNames: string[]): string | nul
59
60
  /** Parse spec.md into hashable sections (## and ### headings; "Section:" prefix stripped). */
60
61
  export function parseSpecSections(specPath: string): SpecSection[] {
61
62
  if (!fs.existsSync(specPath)) return [];
62
- const lines = fs.readFileSync(specPath, 'utf-8').split('\n');
63
+ const lines = readTextFile(specPath).split('\n');
63
64
  const sections: { name: string; body: string[] }[] = [];
64
65
  let cur: { name: string; body: string[] } | null = null;
65
66
  for (const line of lines) {
@@ -79,7 +80,7 @@ export function parseSpecSections(specPath: string): SpecSection[] {
79
80
  /** Parse feature into scenario → section mapping using `# --- Section: X ---` comments. */
80
81
  function parseFeatureSections(featurePath: string): { scenario: string; vpCode?: string; section: string }[] {
81
82
  if (!fs.existsSync(featurePath)) return [];
82
- const lines = fs.readFileSync(featurePath, 'utf-8').split('\n');
83
+ const lines = readTextFile(featurePath).split('\n');
83
84
  const out: { scenario: string; vpCode?: string; section: string }[] = [];
84
85
  let section = '(unsectioned)';
85
86
  for (const raw of lines) {
@@ -164,7 +165,7 @@ export function manifestPath(screenName: string): string {
164
165
  }
165
166
  export function loadManifest(screenName: string): Manifest | null {
166
167
  const p = manifestPath(screenName);
167
- return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf-8')) : null;
168
+ return fs.existsSync(p) ? JSON.parse(readTextFile(p)) : null;
168
169
  }
169
170
  export function saveManifest(m: Manifest): string {
170
171
  const p = manifestPath(m.screen);