@sun-asterisk/sungen 3.2.16-beta.1 → 3.2.16-beta.10
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.
- package/dist/cli/commands/delivery.d.ts.map +1 -1
- package/dist/cli/commands/delivery.js +67 -20
- package/dist/cli/commands/delivery.js.map +1 -1
- package/dist/exporters/feature-parser.d.ts +16 -1
- package/dist/exporters/feature-parser.d.ts.map +1 -1
- package/dist/exporters/feature-parser.js +21 -2
- package/dist/exporters/feature-parser.js.map +1 -1
- package/dist/exporters/matrix/build.d.ts +27 -2
- package/dist/exporters/matrix/build.d.ts.map +1 -1
- package/dist/exporters/matrix/build.js +277 -47
- package/dist/exporters/matrix/build.js.map +1 -1
- package/dist/exporters/matrix/export.d.ts +7 -4
- package/dist/exporters/matrix/export.d.ts.map +1 -1
- package/dist/exporters/matrix/export.js +22 -6
- package/dist/exporters/matrix/export.js.map +1 -1
- package/dist/exporters/matrix/gates.d.ts.map +1 -1
- package/dist/exporters/matrix/gates.js +112 -3
- package/dist/exporters/matrix/gates.js.map +1 -1
- package/dist/exporters/matrix/map-loader.d.ts.map +1 -1
- package/dist/exporters/matrix/map-loader.js +20 -0
- package/dist/exporters/matrix/map-loader.js.map +1 -1
- package/dist/exporters/matrix/render-csv.d.ts +3 -2
- package/dist/exporters/matrix/render-csv.d.ts.map +1 -1
- package/dist/exporters/matrix/render-csv.js +53 -30
- package/dist/exporters/matrix/render-csv.js.map +1 -1
- package/dist/exporters/matrix/render-xlsx.d.ts +32 -8
- package/dist/exporters/matrix/render-xlsx.d.ts.map +1 -1
- package/dist/exporters/matrix/render-xlsx.js +215 -83
- package/dist/exporters/matrix/render-xlsx.js.map +1 -1
- package/dist/exporters/matrix/types.d.ts +53 -7
- package/dist/exporters/matrix/types.d.ts.map +1 -1
- package/dist/exporters/matrix/types.js +2 -2
- package/dist/exporters/matrix/types.js.map +1 -1
- package/dist/exporters/matrix/wording.d.ts +61 -0
- package/dist/exporters/matrix/wording.d.ts.map +1 -0
- package/dist/exporters/matrix/wording.js +221 -0
- package/dist/exporters/matrix/wording.js.map +1 -0
- package/dist/exporters/scenario-merger.d.ts.map +1 -1
- package/dist/exporters/scenario-merger.js +2 -1
- package/dist/exporters/scenario-merger.js.map +1 -1
- package/dist/exporters/spec-parser.d.ts.map +1 -1
- package/dist/exporters/spec-parser.js +2 -1
- package/dist/exporters/spec-parser.js.map +1 -1
- package/dist/harness/audit.d.ts.map +1 -1
- package/dist/harness/audit.js +11 -2
- package/dist/harness/audit.js.map +1 -1
- package/dist/harness/blindspot.d.ts.map +1 -1
- package/dist/harness/blindspot.js +2 -1
- package/dist/harness/blindspot.js.map +1 -1
- package/dist/harness/capability-plan.d.ts.map +1 -1
- package/dist/harness/capability-plan.js +3 -2
- package/dist/harness/capability-plan.js.map +1 -1
- package/dist/harness/feedback.d.ts.map +1 -1
- package/dist/harness/feedback.js +3 -2
- package/dist/harness/feedback.js.map +1 -1
- package/dist/harness/flow-check.d.ts.map +1 -1
- package/dist/harness/flow-check.js +2 -1
- package/dist/harness/flow-check.js.map +1 -1
- package/dist/harness/flow-plan.d.ts.map +1 -1
- package/dist/harness/flow-plan.js +3 -2
- package/dist/harness/flow-plan.js.map +1 -1
- package/dist/harness/intent.d.ts.map +1 -1
- package/dist/harness/intent.js +2 -1
- package/dist/harness/intent.js.map +1 -1
- package/dist/harness/journey.d.ts.map +1 -1
- package/dist/harness/journey.js +3 -2
- package/dist/harness/journey.js.map +1 -1
- package/dist/harness/ledger.d.ts.map +1 -1
- package/dist/harness/ledger.js +3 -2
- package/dist/harness/ledger.js.map +1 -1
- package/dist/harness/manifest.d.ts.map +1 -1
- package/dist/harness/manifest.js +4 -3
- package/dist/harness/manifest.js.map +1 -1
- package/dist/harness/parse.d.ts.map +1 -1
- package/dist/harness/parse.js +16 -3
- package/dist/harness/parse.js.map +1 -1
- package/dist/harness/quality-gates.d.ts.map +1 -1
- package/dist/harness/quality-gates.js +2 -1
- package/dist/harness/quality-gates.js.map +1 -1
- package/dist/harness/read-text.d.ts +22 -0
- package/dist/harness/read-text.d.ts.map +1 -0
- package/dist/harness/read-text.js +64 -0
- package/dist/harness/read-text.js.map +1 -0
- package/dist/harness/script-check.d.ts.map +1 -1
- package/dist/harness/script-check.js +3 -2
- package/dist/harness/script-check.js.map +1 -1
- package/dist/harness/sensors.d.ts.map +1 -1
- package/dist/harness/sensors.js +2 -10
- package/dist/harness/sensors.js.map +1 -1
- package/dist/harness/spec-coverage.d.ts +5 -0
- package/dist/harness/spec-coverage.d.ts.map +1 -1
- package/dist/harness/spec-coverage.js +17 -7
- package/dist/harness/spec-coverage.js.map +1 -1
- package/dist/harness/trace.d.ts.map +1 -1
- package/dist/harness/trace.js +4 -3
- package/dist/harness/trace.js.map +1 -1
- package/dist/harness/viewpoint-ledger.d.ts.map +1 -1
- package/dist/harness/viewpoint-ledger.js +2 -1
- package/dist/harness/viewpoint-ledger.js.map +1 -1
- package/dist/orchestrator/templates/ai-src/commands/create-test.md +9 -0
- package/dist/orchestrator/templates/ai-src/commands/delivery.md +103 -18
- package/dist/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +77 -11
- package/dist/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +1 -0
- package/dist/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +22 -0
- package/package.json +4 -4
- package/src/cli/commands/delivery.ts +68 -22
- package/src/exporters/feature-parser.ts +21 -2
- package/src/exporters/matrix/build.ts +281 -43
- package/src/exporters/matrix/export.ts +31 -6
- package/src/exporters/matrix/gates.ts +119 -3
- package/src/exporters/matrix/map-loader.ts +22 -1
- package/src/exporters/matrix/render-csv.ts +53 -30
- package/src/exporters/matrix/render-xlsx.ts +216 -85
- package/src/exporters/matrix/types.ts +58 -8
- package/src/exporters/matrix/wording.ts +221 -0
- package/src/exporters/scenario-merger.ts +2 -1
- package/src/exporters/spec-parser.ts +2 -1
- package/src/harness/audit.ts +11 -2
- package/src/harness/blindspot.ts +2 -1
- package/src/harness/capability-plan.ts +3 -2
- package/src/harness/feedback.ts +3 -2
- package/src/harness/flow-check.ts +2 -1
- package/src/harness/flow-plan.ts +3 -2
- package/src/harness/intent.ts +2 -1
- package/src/harness/journey.ts +3 -2
- package/src/harness/ledger.ts +3 -2
- package/src/harness/manifest.ts +4 -3
- package/src/harness/parse.ts +17 -3
- package/src/harness/quality-gates.ts +2 -1
- package/src/harness/read-text.ts +28 -0
- package/src/harness/script-check.ts +3 -2
- package/src/harness/sensors.ts +2 -1
- package/src/harness/spec-coverage.ts +22 -7
- package/src/harness/trace.ts +4 -3
- package/src/harness/viewpoint-ledger.ts +2 -1
- package/src/orchestrator/templates/ai-src/commands/create-test.md +9 -0
- package/src/orchestrator/templates/ai-src/commands/delivery.md +103 -18
- package/src/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +77 -11
- package/src/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +1 -0
- 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(
|
|
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(
|
|
169
|
+
const match = title.match(new RegExp(SCENARIO_ID.source + '\\b'));
|
|
169
170
|
return match ? match[1] : undefined;
|
|
170
171
|
}
|
|
171
172
|
|
package/src/harness/audit.ts
CHANGED
|
@@ -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;
|
|
@@ -118,7 +119,7 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
|
|
|
118
119
|
const catalogScreenName = catalogIdFromScreenDir(screenDir);
|
|
119
120
|
|
|
120
121
|
const specPath = path.join(screenDir, 'requirements', 'spec.md');
|
|
121
|
-
const featureText = fs.existsSync(featurePath) ?
|
|
122
|
+
const featureText = fs.existsSync(featurePath) ? readTextFile(featurePath) : '';
|
|
122
123
|
|
|
123
124
|
const scenarios: ScenarioInfo[] = loadScenarios(featurePath);
|
|
124
125
|
const viewpoints: ViewpointEntry[] = parseViewpointOverview(viewpointPath);
|
|
@@ -225,7 +226,7 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
|
|
|
225
226
|
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
227
|
}
|
|
227
228
|
// 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) ?
|
|
229
|
+
for (const v of verificationScopeFindings(featureText, fs.existsSync(viewpointPath) ? readTextFile(viewpointPath) : '', capProfile)) {
|
|
229
230
|
findings.push(`VERIFICATION-OUT-OF-SCOPE (${v.layer}): this unit ${v.message}`);
|
|
230
231
|
}
|
|
231
232
|
for (const g of gate.gaps) {
|
|
@@ -289,6 +290,14 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
|
|
|
289
290
|
for (const u of spec.uncoveredMust) {
|
|
290
291
|
findings.push(`SPEC-UNCOVERED: ${u.id} (MUST) has no covering scenario — "${u.text}" → add a scenario or tag one @spec:${u.id}.`);
|
|
291
292
|
}
|
|
293
|
+
if (spec.inferredOnly.length > 0) {
|
|
294
|
+
// Covered, but only by keyword inference — no scenario cites the id. Delivery's
|
|
295
|
+
// requirement table (and any later refactor) cannot follow an inferred link, so
|
|
296
|
+
// the trace has to be written down while the author still knows which scenario
|
|
297
|
+
// proves what.
|
|
298
|
+
const shown = spec.inferredOnly.slice(0, 8).join(', ');
|
|
299
|
+
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.`);
|
|
300
|
+
}
|
|
292
301
|
if (taxonomyMismatch) {
|
|
293
302
|
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
303
|
}
|
package/src/harness/blindspot.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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 =
|
|
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*(.+)$/);
|
package/src/harness/feedback.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
|
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('@'));
|
package/src/harness/flow-plan.ts
CHANGED
|
@@ -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 =
|
|
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(
|
|
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
|
}
|
package/src/harness/intent.ts
CHANGED
|
@@ -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 =
|
|
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-]+)`));
|
package/src/harness/journey.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
}
|
package/src/harness/ledger.ts
CHANGED
|
@@ -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
|
|
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(
|
|
125
|
+
const a = JSON.parse(readTextFile(auditPath));
|
|
125
126
|
coveredCritical = a?.gate?.themesCovered ?? null;
|
|
126
127
|
scenarioCount = a?.scenarioCount ?? null;
|
|
127
128
|
} catch { /* ignore */ }
|
package/src/harness/manifest.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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(
|
|
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);
|
package/src/harness/parse.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import * as fs from 'fs';
|
|
9
9
|
import { GherkinParser, ParsedScenario, ParsedStep } from '../generators/gherkin-parser';
|
|
10
|
+
import { readTextFile } from './read-text';
|
|
10
11
|
|
|
11
12
|
export type Priority = 'high' | 'normal' | 'low' | 'unknown';
|
|
12
13
|
|
|
@@ -51,9 +52,22 @@ export function idPrefix(id: string): string {
|
|
|
51
52
|
|
|
52
53
|
// ---------- test-viewpoint.md ----------
|
|
53
54
|
|
|
55
|
+
/**
|
|
56
|
+
* A viewpoint OVERVIEW id may be a bare category (`VP-LOGIC`, `VP-SEC`) — that is
|
|
57
|
+
* the scheme sungen itself prescribes, with the sequence number living on the
|
|
58
|
+
* scenario (`VP-LOGIC-013`). `isIdLike` demands a digit to avoid picking prose out
|
|
59
|
+
* of tables, which silently rejected every category id: a project declaring
|
|
60
|
+
* VP-LOGIC/VP-VAL/VP-SEC/VP-UI/VP-NAV/VP-PERF/VP-I18N parsed as ONE viewpoint
|
|
61
|
+
* (only VP-I18N, for the "18"), traceability collapsed to ~0%, and the audit told
|
|
62
|
+
* QA to re-tag scenarios that were already correct.
|
|
63
|
+
*/
|
|
64
|
+
function isViewpointId(s: string): boolean {
|
|
65
|
+
return isIdLike(s) || /^VP-[A-Z][A-Z0-9]*$/i.test(s);
|
|
66
|
+
}
|
|
67
|
+
|
|
54
68
|
export function parseViewpointOverview(filePath: string): ViewpointEntry[] {
|
|
55
69
|
if (!fs.existsSync(filePath)) return [];
|
|
56
|
-
const text =
|
|
70
|
+
const text = readTextFile(filePath);
|
|
57
71
|
const lines = text.split('\n');
|
|
58
72
|
|
|
59
73
|
const entries = new Map<string, ViewpointEntry>();
|
|
@@ -68,7 +82,7 @@ export function parseViewpointOverview(filePath: string): ViewpointEntry[] {
|
|
|
68
82
|
const cells = line.split('|').map((c) => c.trim()).filter((_, i, a) => i > 0 && i < a.length - 1);
|
|
69
83
|
if (cells.length >= 3) {
|
|
70
84
|
const id = cells[0];
|
|
71
|
-
if (
|
|
85
|
+
if (isViewpointId(id) && !/^-+$/.test(cells[1])) {
|
|
72
86
|
const pr = /high/i.test(cells[1]) ? 'High' : /medium/i.test(cells[1]) ? 'Medium' : /low/i.test(cells[1]) ? 'Low' : 'Unknown';
|
|
73
87
|
entries.set(id.toUpperCase(), { id: id.toUpperCase(), priority: pr as any, reason: cells[2] });
|
|
74
88
|
}
|
|
@@ -85,7 +99,7 @@ export function parseViewpointOverview(filePath: string): ViewpointEntry[] {
|
|
|
85
99
|
if (/^##\s/.test(line)) { group = undefined; }
|
|
86
100
|
if (group) {
|
|
87
101
|
const m = line.match(/^[-*+]\s+([A-Za-z][A-Za-z0-9.-]*)/);
|
|
88
|
-
if (m &&
|
|
102
|
+
if (m && isViewpointId(m[1])) {
|
|
89
103
|
const id = m[1].toUpperCase();
|
|
90
104
|
const existing = entries.get(id);
|
|
91
105
|
if (existing) existing.group = group;
|
|
@@ -7,6 +7,7 @@ import * as fs from 'fs';
|
|
|
7
7
|
import * as path from 'path';
|
|
8
8
|
import { ScenarioInfo, loadScenarios, idPrefix } from './parse';
|
|
9
9
|
import { parseManualComments } from '../exporters/scenario-merger';
|
|
10
|
+
import { readTextFile } from './read-text';
|
|
10
11
|
|
|
11
12
|
// ---------- #2 Downstream-scope ----------
|
|
12
13
|
|
|
@@ -202,5 +203,5 @@ export function crossArtifactOwnership(screenDir: string, scenarios: ScenarioInf
|
|
|
202
203
|
|
|
203
204
|
// convenience reader
|
|
204
205
|
export function readText(p: string): string {
|
|
205
|
-
return fs.existsSync(p) ?
|
|
206
|
+
return fs.existsSync(p) ? readTextFile(p) : '';
|
|
206
207
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Read a user-authored text file with line endings NORMALISED to `\n`.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists: the harness parses spec.md / test-viewpoint.md / .feature with
|
|
7
|
+
* line-anchored regexes such as `/^\s*Scenario:\s*(.+)$/`. In JavaScript `.` does
|
|
8
|
+
* NOT match `\r`, so on a CRLF file `(.+)$` can never reach the end of the line
|
|
9
|
+
* and the match silently fails. The parser then reports "nothing found" rather
|
|
10
|
+
* than an error, which is far worse than crashing:
|
|
11
|
+
*
|
|
12
|
+
* - `spec-coverage` found 0 requirements → the FR sensor scored a vacuous
|
|
13
|
+
* 100% and never emitted SPEC-UNCOVERED / SPEC-TRACE-IMPLICIT.
|
|
14
|
+
* - `parseViewpointOverview` found 1 of N viewpoints → traceability collapsed
|
|
15
|
+
* to ~0% and the audit told QA to "re-tag" scenarios that were already
|
|
16
|
+
* correct.
|
|
17
|
+
*
|
|
18
|
+
* A Windows-authored spec is completely normal, so every harness parser that
|
|
19
|
+
* reads a user file must go through here.
|
|
20
|
+
*/
|
|
21
|
+
export function readTextFile(filePath: string): string {
|
|
22
|
+
return fs.readFileSync(filePath, 'utf-8').replace(/\r\n?/g, '\n');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** `readTextFile` split into lines — the common case for line-anchored parsers. */
|
|
26
|
+
export function readTextLines(filePath: string): string[] {
|
|
27
|
+
return readTextFile(filePath).split('\n');
|
|
28
|
+
}
|
|
@@ -17,6 +17,7 @@ import * as path from 'path';
|
|
|
17
17
|
import * as os from 'os';
|
|
18
18
|
import { loadScenarios, ScenarioInfo } from './parse';
|
|
19
19
|
import { featureBasename } from './unit-paths';
|
|
20
|
+
import { readTextFile } from './read-text';
|
|
20
21
|
|
|
21
22
|
export interface ScriptCheckResult {
|
|
22
23
|
screen: string;
|
|
@@ -153,7 +154,7 @@ export async function runScriptCheck(screenDir: string, screenName: string, kind
|
|
|
153
154
|
let specTitles: string[] = [];
|
|
154
155
|
let specSrc = '';
|
|
155
156
|
if (committedSpec) {
|
|
156
|
-
specSrc =
|
|
157
|
+
specSrc = readTextFile(committedSpec);
|
|
157
158
|
specTitles = extractTestTitles(specSrc);
|
|
158
159
|
} else {
|
|
159
160
|
findings.push('No generated spec found under specs/generated/ — run `sungen generate` / `/sungen:run-test` first.');
|
|
@@ -195,7 +196,7 @@ export async function runScriptCheck(screenDir: string, screenName: string, kind
|
|
|
195
196
|
const fresh = findSpec(tmp, screenName, kind);
|
|
196
197
|
if (fresh) {
|
|
197
198
|
const a = normalize(specSrc);
|
|
198
|
-
const b = normalize(
|
|
199
|
+
const b = normalize(readTextFile(fresh));
|
|
199
200
|
if (a !== b) {
|
|
200
201
|
drift = 'drift';
|
|
201
202
|
// collect a few differing lines
|
package/src/harness/sensors.ts
CHANGED
|
@@ -10,6 +10,7 @@ import * as fs from 'fs';
|
|
|
10
10
|
import * as path from 'path';
|
|
11
11
|
import { parse as parseYaml } from 'yaml';
|
|
12
12
|
import { ScenarioInfo, ViewpointEntry, idPrefix } from './parse';
|
|
13
|
+
import { readTextFile } from './read-text';
|
|
13
14
|
|
|
14
15
|
// Business-critical category keywords (matched by CONTAINMENT against the VP category, so a
|
|
15
16
|
// compound category like LIST-DISPLAY / ADD-TO-CART / PRODUCT-DISCOVERY classifies correctly).
|
|
@@ -56,7 +57,7 @@ export interface Catalog {
|
|
|
56
57
|
|
|
57
58
|
export function loadCatalog(): Catalog {
|
|
58
59
|
const p = path.join(__dirname, 'catalog', 'universal-viewpoints.yaml');
|
|
59
|
-
return parseYaml(
|
|
60
|
+
return parseYaml(readTextFile(p)) as Catalog;
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
// Word-aware keyword match: `\b<kw>(s|es|ed|ing)?\b`, so a keyword matches whole words (plus
|