@sun-asterisk/sungen 3.2.23 → 3.2.24-beta.1
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/audit.d.ts.map +1 -1
- package/dist/cli/commands/audit.js +21 -6
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/dashboard/templates/index.html +1 -1
- package/dist/exporters/csv-exporter.d.ts.map +1 -1
- package/dist/exporters/csv-exporter.js +3 -1
- package/dist/exporters/csv-exporter.js.map +1 -1
- package/dist/exporters/feature-parser.d.ts +6 -0
- package/dist/exporters/feature-parser.d.ts.map +1 -1
- package/dist/exporters/feature-parser.js +21 -0
- package/dist/exporters/feature-parser.js.map +1 -1
- package/dist/exporters/xlsx-report-builder.d.ts.map +1 -1
- package/dist/exporters/xlsx-report-builder.js +3 -1
- package/dist/exporters/xlsx-report-builder.js.map +1 -1
- package/dist/harness/audit.d.ts +13 -0
- package/dist/harness/audit.d.ts.map +1 -1
- package/dist/harness/audit.js +41 -10
- package/dist/harness/audit.js.map +1 -1
- package/dist/harness/flow-contract.d.ts +29 -0
- package/dist/harness/flow-contract.d.ts.map +1 -1
- package/dist/harness/flow-contract.js +77 -11
- package/dist/harness/flow-contract.js.map +1 -1
- package/dist/harness/parse.d.ts.map +1 -1
- package/dist/harness/parse.js +22 -3
- package/dist/harness/parse.js.map +1 -1
- package/dist/harness/quality-gates.d.ts +2 -1
- package/dist/harness/quality-gates.d.ts.map +1 -1
- package/dist/harness/quality-gates.js +47 -5
- package/dist/harness/quality-gates.js.map +1 -1
- package/dist/harness/sensors.d.ts.map +1 -1
- package/dist/harness/sensors.js +48 -5
- package/dist/harness/sensors.js.map +1 -1
- package/dist/harness/viewpoint-ledger.d.ts.map +1 -1
- package/dist/harness/viewpoint-ledger.js +17 -1
- package/dist/harness/viewpoint-ledger.js.map +1 -1
- package/dist/orchestrator/templates/ai-src/commands/create-test.md +17 -0
- package/dist/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +62 -5
- package/dist/orchestrator/templates/ai-src/skills/sungen-viewpoint/group-e-identity.md +1 -1
- package/dist/orchestrator/test-data-guide.d.ts.map +1 -1
- package/dist/orchestrator/test-data-guide.js +8 -0
- package/dist/orchestrator/test-data-guide.js.map +1 -1
- package/package.json +3 -3
- package/src/cli/commands/audit.ts +18 -6
- package/src/dashboard/templates/index.html +1 -1
- package/src/exporters/csv-exporter.ts +3 -1
- package/src/exporters/feature-parser.ts +16 -0
- package/src/exporters/xlsx-report-builder.ts +3 -1
- package/src/harness/audit.ts +49 -11
- package/src/harness/flow-contract.ts +100 -11
- package/src/harness/parse.ts +22 -3
- package/src/harness/quality-gates.ts +48 -5
- package/src/harness/sensors.ts +47 -4
- package/src/harness/viewpoint-ledger.ts +16 -1
- package/src/orchestrator/templates/ai-src/commands/create-test.md +17 -0
- package/src/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +62 -5
- package/src/orchestrator/templates/ai-src/skills/sungen-viewpoint/group-e-identity.md +1 -1
- package/src/orchestrator/test-data-guide.ts +8 -0
|
@@ -34,6 +34,12 @@ export interface FlowContract {
|
|
|
34
34
|
precondition?: string;
|
|
35
35
|
outcome: { screen: string; assertion?: string };
|
|
36
36
|
value?: string;
|
|
37
|
+
/** Postcondition when the goal IS reached — the business value, not "API 200". */
|
|
38
|
+
successGuarantee?: string;
|
|
39
|
+
/** Postcondition that holds in EVERY flow, INCLUDING failure — the question
|
|
40
|
+
* "if this fails, what must still be true?" that Exception Flows assert against.
|
|
41
|
+
* Without it an EF has no anchor and its assertions are guesswork (#592). */
|
|
42
|
+
minimalGuarantee?: string;
|
|
37
43
|
/** Journey phases this flow declares. Default [HP, ER, EH]; UI is allowed but
|
|
38
44
|
* never demanded (presentation is the balance axis's business, not coverage's). */
|
|
39
45
|
phases: string[];
|
|
@@ -72,6 +78,9 @@ export interface FlowQualityResult {
|
|
|
72
78
|
|
|
73
79
|
const DEFAULT_PHASES = ['HP', 'ER', 'EH'];
|
|
74
80
|
|
|
81
|
+
/** The phase token of a flow id segment, branch number removed: `EF01` → `EF`, `HP` → `HP`. */
|
|
82
|
+
const phaseToken = (seg: string): string => seg.replace(/\d+$/, '').toUpperCase();
|
|
83
|
+
|
|
75
84
|
export function flowContractPath(unitDir: string): string {
|
|
76
85
|
return path.join(unitDir, 'requirements', 'flow-contract.yaml');
|
|
77
86
|
}
|
|
@@ -105,6 +114,8 @@ export function loadFlowContract(unitDir: string): { contract: FlowContract | nu
|
|
|
105
114
|
precondition: raw.precondition !== undefined ? String(raw.precondition) : undefined,
|
|
106
115
|
outcome: { screen: String(outcome!.screen).toLowerCase(), assertion: outcome!.assertion !== undefined ? String(outcome!.assertion) : undefined },
|
|
107
116
|
value: raw.value !== undefined ? String(raw.value) : undefined,
|
|
117
|
+
successGuarantee: raw.successGuarantee !== undefined ? String(raw.successGuarantee) : (raw.success_guarantee !== undefined ? String(raw.success_guarantee) : undefined),
|
|
118
|
+
minimalGuarantee: raw.minimalGuarantee !== undefined ? String(raw.minimalGuarantee) : (raw.minimal_guarantee !== undefined ? String(raw.minimal_guarantee) : undefined),
|
|
108
119
|
phases,
|
|
109
120
|
stateful: raw.stateful !== undefined ? String(raw.stateful).toLowerCase() : undefined,
|
|
110
121
|
budgets: (raw.budgets && typeof raw.budgets === 'object') ? raw.budgets as Record<string, number> : undefined,
|
|
@@ -138,8 +149,12 @@ function touchesOutcome(s: ScenarioInfo, outcomeScreen: string): boolean {
|
|
|
138
149
|
* when present, else vocabulary detection. */
|
|
139
150
|
export function phaseOf(s: ScenarioInfo, declared: string[]): string | null {
|
|
140
151
|
const id = (s.vpId ?? '').toUpperCase();
|
|
152
|
+
// A DECLARED phase wins, matched on the id's segments with the branch number removed —
|
|
153
|
+
// so the use-case vocabulary (VP-EF01-01, VP-AF02-01: one id per Exception/Alternate
|
|
154
|
+
// Flow) resolves to its phase exactly like the flat HP/ER/EH form does.
|
|
155
|
+
const segs = id.split('-').map(phaseToken);
|
|
141
156
|
for (const ph of declared) {
|
|
142
|
-
if (
|
|
157
|
+
if (segs.includes(phaseToken(ph))) return ph;
|
|
143
158
|
}
|
|
144
159
|
const hay = s.haystack;
|
|
145
160
|
if (declared.includes('EH') && /\b(direct access|without (a |the )?(submit|login)|browser back|refresh|expired|tamper|unauthoriz|redirect(ed)? (back )?to|guard)\b/.test(hay)) return 'EH';
|
|
@@ -171,10 +186,14 @@ export function flowQuality(unitDir: string, scenarios: ScenarioInfo[]): FlowQua
|
|
|
171
186
|
// --- Scope creep: a scenario that never touches the outcome and is not a ----
|
|
172
187
|
// guard/error phase is evidence of a SECOND business goal in this flow.
|
|
173
188
|
const declaredPhases = contract.phases;
|
|
189
|
+
const basic = declaredPhases.filter((p) => p !== 'UI')[0];
|
|
174
190
|
const offGoalScenarios = scenarios.filter((s) => {
|
|
175
191
|
if (touchesOutcome(s, outcomeScreen)) return false;
|
|
176
192
|
const ph = phaseOf(s, declaredPhases);
|
|
177
|
-
|
|
193
|
+
// Any NON-basic declared phase (guards, error recovery, alternate branches) legitimately
|
|
194
|
+
// stops before the outcome — that is what a branch IS. Only an unclassified scenario that
|
|
195
|
+
// never reaches the outcome is evidence of a second business goal.
|
|
196
|
+
return ph === null || ph === basic;
|
|
178
197
|
});
|
|
179
198
|
const offGoalRatio = scenarios.length ? offGoalScenarios.length / scenarios.length : 0;
|
|
180
199
|
const offGoalCategories = Array.from(new Set(
|
|
@@ -182,10 +201,13 @@ export function flowQuality(unitDir: string, scenarios: ScenarioInfo[]): FlowQua
|
|
|
182
201
|
|
|
183
202
|
// --- Phase coverage: the flow's coverage axis (UI never demanded) -----------
|
|
184
203
|
const demanded = declaredPhases.filter((p) => p !== 'UI');
|
|
204
|
+
// The FIRST declared phase is the Basic Flow (the primer allows exactly one) — it is the
|
|
205
|
+
// phase that must reach the declared outcome, whether the project spells it HP or BF.
|
|
206
|
+
const basicPhase = demanded[0];
|
|
185
207
|
const phases = demanded.map((phase) => {
|
|
186
208
|
const inPhase = scenarios.filter((s) => phaseOf(s, declaredPhases) === phase);
|
|
187
|
-
//
|
|
188
|
-
const relevant = phase ===
|
|
209
|
+
// The basic flow must additionally prove the outcome — a data assertion elsewhere is not the goal.
|
|
210
|
+
const relevant = phase === basicPhase ? inPhase.filter((s) => touchesOutcome(s, outcomeScreen)) : inPhase;
|
|
189
211
|
return {
|
|
190
212
|
phase,
|
|
191
213
|
covered: relevant.length > 0,
|
|
@@ -243,6 +265,47 @@ export function flowQuality(unitDir: string, scenarios: ScenarioInfo[]): FlowQua
|
|
|
243
265
|
};
|
|
244
266
|
}
|
|
245
267
|
|
|
268
|
+
/**
|
|
269
|
+
* A continuity claim states that data SURVIVES a step boundary (buffered, persists,
|
|
270
|
+
* round-trips, restored). Every such mechanism has two sides, and a suite that proves
|
|
271
|
+
* only the surviving side has not tested the mechanism — it has tested the happy half.
|
|
272
|
+
*
|
|
273
|
+
* Field report (#592): the viewpoint declared "basic-info values are buffered in
|
|
274
|
+
* sessionStorage … a page reload or session-storage clear between those steps LOSES the
|
|
275
|
+
* buffered data". The suite proved back-navigation RESTORES the values and never proved a
|
|
276
|
+
* reload loses them — one mechanism, one side. The QA framework caught it by construction
|
|
277
|
+
* (AF-02 "giữ được" beside EF-11 "mất"); this sensor catches it deterministically.
|
|
278
|
+
*
|
|
279
|
+
* Advisory. Reported per claim, naming which side is missing.
|
|
280
|
+
*/
|
|
281
|
+
export interface ContinuityGap { claim: string; missing: 'loss' | 'persistence' }
|
|
282
|
+
|
|
283
|
+
const CONTINUITY_CLAIM = /\b(buffer(?:s|ed|ing)?|persist(?:s|ed|ence)?|round[- ]?trips?|restor(?:e|es|ed|ation)|carr(?:y|ied|ies)|retain(?:s|ed)?|session[- ]?storage|local[- ]?storage)\b/i;
|
|
284
|
+
const SIDE_PERSIST = /\b(restor(?:e|es|ed)|persist(?:s|ed)?|unchanged|same value|round[- ]?trips?|re-?hydrat\w*|still (?:shows|holds)|carr(?:y|ied|ies)|retain(?:s|ed)?|prefilled|pre-?filled)\b/i;
|
|
285
|
+
const SIDE_LOSS = /\b(lose|loses|lost|cleared?|clears|discard\w*|reload\w*|refresh\w*|expire\w*|empty|blank|not restored|no longer|wiped?|reset)\b/i;
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Continuity claims in the viewpoint file whose feature proves only ONE side.
|
|
289
|
+
* `claims` are the viewpoint's atomic item texts; `scenarioTexts` are per-scenario
|
|
290
|
+
* name+steps blobs (a claim's two sides may live in different scenarios).
|
|
291
|
+
*/
|
|
292
|
+
export function continuityGaps(claims: string[], scenarioTexts: string[]): ContinuityGap[] {
|
|
293
|
+
const out: ContinuityGap[] = [];
|
|
294
|
+
const anySide = (re: RegExp) => scenarioTexts.some((t) => re.test(t));
|
|
295
|
+
for (const claim of claims) {
|
|
296
|
+
if (!CONTINUITY_CLAIM.test(claim)) continue;
|
|
297
|
+
// Only judge a claim that itself names BOTH sides, or names the loss side: a claim that
|
|
298
|
+
// only ever promises persistence has no second side to demand.
|
|
299
|
+
const claimNamesLoss = SIDE_LOSS.test(claim);
|
|
300
|
+
if (!claimNamesLoss) continue;
|
|
301
|
+
const provesPersist = anySide(SIDE_PERSIST);
|
|
302
|
+
const provesLoss = anySide(SIDE_LOSS);
|
|
303
|
+
if (provesPersist && !provesLoss) out.push({ claim: claim.slice(0, 110), missing: 'loss' });
|
|
304
|
+
else if (provesLoss && !provesPersist) out.push({ claim: claim.slice(0, 110), missing: 'persistence' });
|
|
305
|
+
}
|
|
306
|
+
return out;
|
|
307
|
+
}
|
|
308
|
+
|
|
246
309
|
/**
|
|
247
310
|
* Generalized stateful regression depth: the contract names the mutated collection,
|
|
248
311
|
* so the three dims (count-proof · teardown · multi-source) stop being cart-only.
|
|
@@ -251,11 +314,37 @@ export function statefulDepthFor(collection: string, scenarios: ScenarioInfo[]):
|
|
|
251
314
|
const hay = scenarios.map((s) => s.haystack);
|
|
252
315
|
const any = (re: RegExp) => hay.some((h) => re.test(h));
|
|
253
316
|
const noun = collection.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
317
|
+
|
|
318
|
+
// Not every stateful flow is a COLLECTION. A cart accumulates rows, so counting them and
|
|
319
|
+
// feeding them from several sources are real regression dimensions; a registration state
|
|
320
|
+
// machine has neither, and demanding them produced advice no author could act on ("add to
|
|
321
|
+
// the cart from EVERY source" on a signup journey — #592). So each dimension is demanded
|
|
322
|
+
// only where the suite shows the vocabulary that makes it meaningful, and the ratio is
|
|
323
|
+
// renormalised over the applicable ones — the same "absent evidence scores nothing" rule
|
|
324
|
+
// the score axes already follow.
|
|
325
|
+
const accumulates = any(new RegExp(`\\b(rows?|items?|lines?|entries|quantity|qty|count)\\b`)) ||
|
|
326
|
+
any(new RegExp(`\\b${noun}\\s+(?:rows?|items?|lines?|count)\\b`));
|
|
327
|
+
const hasSources = any(/\b(recommended|related|you may also|another source|second (?:list|source)|from (?:the )?(?:list|rail|grid))\b/);
|
|
328
|
+
|
|
329
|
+
const countProof = any(/\b(quantity|qty|row count|count|number of|two (?:rows|lines|items)|\d+ (?:rows|lines|items))\b/);
|
|
330
|
+
// The inverse operation. A collection's is remove/clear; a state machine's is lose, expire,
|
|
331
|
+
// reset, restart, invalidate — and the outcome side below already accepted `lost`/`gone`
|
|
332
|
+
// while the verb side had no `lose`, so a suite proving exactly that was told it had not
|
|
333
|
+
// (#592: "add a REMOVE scenario" on a registration journey that already proves the buffer
|
|
334
|
+
// is lost and the link expires).
|
|
335
|
+
const teardown = any(/\b(remove|delete|clear|cancel|withdraw|abandon|lapse|expire|lose|lost|reset|restart|discard|invalidate|revert)(?:s|d|es|ed|ing|n)?\b/) &&
|
|
336
|
+
any(new RegExp(`\\b(empty|emptied|no items|zero|removed|cleared|cancelled|withdrawn|lost|gone|0 items|does not exist|no longer|awaiting|restart)\\b|empty[- ]${noun}`));
|
|
337
|
+
const multiSource = hasSources &&
|
|
338
|
+
any(new RegExp(`\\b(add|submit|create|place).{0,40}${noun}|${noun}.{0,40}\\b(add|submit|create|place)`));
|
|
339
|
+
|
|
340
|
+
const dims: Array<[string, boolean, boolean]> = [
|
|
341
|
+
['count-proof', countProof, accumulates],
|
|
342
|
+
['teardown', teardown, true],
|
|
343
|
+
['multi-source', multiSource, hasSources || accumulates],
|
|
344
|
+
];
|
|
345
|
+
const applicable = dims.filter(([, , app]) => app);
|
|
346
|
+
const missing = applicable.filter(([, v]) => !v).map(([k]) => k);
|
|
347
|
+
const ratio = applicable.length ? (applicable.length - missing.length) / applicable.length : 1;
|
|
348
|
+
return { countProof, teardown, multiSource, missing, ratio };
|
|
261
349
|
}
|
|
350
|
+
|
package/src/harness/parse.ts
CHANGED
|
@@ -65,7 +65,10 @@ export function idPrefix(id: string): string {
|
|
|
65
65
|
* QA to re-tag scenarios that were already correct.
|
|
66
66
|
*/
|
|
67
67
|
function isViewpointId(s: string): boolean {
|
|
68
|
-
|
|
68
|
+
// `FL-BF` / `FL-EF` are category ids exactly like `VP-LOGIC` — a flow declaring its
|
|
69
|
+
// phases under the FL- family had NO parsed viewpoints at all, which silently zeroed
|
|
70
|
+
// taxonomy and excluded traceability (#592).
|
|
71
|
+
return isIdLike(s) || /^(?:VP|FL)-[A-Z][A-Z0-9]*$/i.test(s);
|
|
69
72
|
}
|
|
70
73
|
|
|
71
74
|
export function parseViewpointOverview(filePath: string): ViewpointEntry[] {
|
|
@@ -141,6 +144,19 @@ export function parseViewpointOverview(filePath: string): ViewpointEntry[] {
|
|
|
141
144
|
|
|
142
145
|
const PRIORITY_TAGS: Record<string, Priority> = { '@high': 'high', '@normal': 'normal', '@low': 'low' };
|
|
143
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Background steps EXECUTE for every scenario, so a sensor that reads only a scenario's own
|
|
149
|
+
* steps mis-measures the suite: a flow whose start state lives in `Background: Given User is
|
|
150
|
+
* on [Registration] page` looked like no scenario ever opened there, and downstream-scope
|
|
151
|
+
* reported the entry screen as merely navigated-to (#592). Prepending them here fixes every
|
|
152
|
+
* sensor at once — the same steps the runtime prepends.
|
|
153
|
+
*/
|
|
154
|
+
function withBackground(sc: ParsedScenario, background?: ParsedScenario): ParsedScenario {
|
|
155
|
+
const bg = background?.steps ?? [];
|
|
156
|
+
if (bg.length === 0) return sc;
|
|
157
|
+
return { ...sc, steps: [...bg, ...((sc.steps as ParsedStep[]) || [])] } as ParsedScenario;
|
|
158
|
+
}
|
|
159
|
+
|
|
144
160
|
function classifyScenario(sc: ParsedScenario): ScenarioInfo {
|
|
145
161
|
const tags = sc.tags || [];
|
|
146
162
|
const deferredToFlow = tags.includes('@deferred:flow');
|
|
@@ -171,7 +187,10 @@ function classifyScenario(sc: ParsedScenario): ScenarioInfo {
|
|
|
171
187
|
// not just single-word ones. A single-word category (VP-CART-001) still works. (H1)
|
|
172
188
|
// Flows use journey-phase ids (FL-HP-001 / FL-ER-002) — the VP- anchor rejected them, so every
|
|
173
189
|
// flow scenario had NO category and the whole suite bucketed `other` (taxonomy=0%, #569).
|
|
174
|
-
|
|
190
|
+
// Segments may carry digits: the use-case framework numbers its flows (VP-AF02-01,
|
|
191
|
+
// VP-EF11-01 — one id per Alternate/Exception Flow), and `[A-Z]+` also silently truncated
|
|
192
|
+
// VP-I18N-001 to no category at all (the `18` broke the class mid-segment).
|
|
193
|
+
const codeMatch = sc.name.match(/\b(?:VP|FL)-([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*)-\d+/i);
|
|
175
194
|
const vpCode = codeMatch ? codeMatch[0].toUpperCase() : undefined;
|
|
176
195
|
const category = codeMatch ? codeMatch[1].toUpperCase() : undefined;
|
|
177
196
|
// Project-scheme ID: the leading token of the title (VP0-001 / MS-HP-001 / VP-LIST-001).
|
|
@@ -245,7 +264,7 @@ export function loadScenarios(featurePath: string): ScenarioInfo[] {
|
|
|
245
264
|
const feature = parser.parseFeatureFile(featurePath);
|
|
246
265
|
return (feature.scenarios || [])
|
|
247
266
|
.filter((s) => !s.stepsName && !s.hookType) // skip @steps/@hook blocks
|
|
248
|
-
.map(classifyScenario);
|
|
267
|
+
.map((s) => classifyScenario(withBackground(s, feature.background)));
|
|
249
268
|
}
|
|
250
269
|
|
|
251
270
|
/**
|
|
@@ -39,9 +39,20 @@ function downstreamRoutes(specText: string): string[] {
|
|
|
39
39
|
* share state, so cart count/quantity asserts race. Mitigations: @cleanup:cart, @isolate, a fresh
|
|
40
40
|
* browser context, or a "Given … empty cart" background. Returns true when the risk is unmitigated.
|
|
41
41
|
*/
|
|
42
|
-
export function isolationRisk(featureText: string, stateful: boolean): boolean {
|
|
42
|
+
export function isolationRisk(featureText: string, stateful: boolean, collection = 'cart'): boolean {
|
|
43
43
|
if (!stateful || !/@parallel\b/i.test(featureText)) return false;
|
|
44
|
-
|
|
44
|
+
// What discharges the risk is isolation of THE MUTATED COLLECTION — so the cleanup tag has
|
|
45
|
+
// to name it (`@cleanup:cart` for a cart; `@cleanup:registration` for a registration flow).
|
|
46
|
+
// A cleanup of something else (`@cleanup:overlay`) leaves the shared state shared.
|
|
47
|
+
// Collection-independent mechanisms count for any flow: an explicit @isolate, a fresh
|
|
48
|
+
// browser context, unique data per run, or a Background restoring an empty start state.
|
|
49
|
+
// The collection is contract-declared now, so this no longer assumes a cart (#592: a
|
|
50
|
+
// registration flow was told it "mutates the cart"); `cart` stays the default for suites
|
|
51
|
+
// whose statefulness was inferred rather than declared.
|
|
52
|
+
const slug = collection.replace(/[^a-z0-9]+/gi, '[- ]?');
|
|
53
|
+
const named = new RegExp(`@cleanup:${slug}\\b|empty ${slug}\\b`, 'i');
|
|
54
|
+
const generic = /@isolate\b|empty state\b|fresh (?:browser )?context|new context|own fresh|{{\$timestamp}}/i;
|
|
55
|
+
return !named.test(featureText) && !generic.test(featureText);
|
|
45
56
|
}
|
|
46
57
|
|
|
47
58
|
/**
|
|
@@ -64,18 +75,50 @@ export function serialCascadeRisk(featureText: string, scenarioCount: number): n
|
|
|
64
75
|
return scenarioCount;
|
|
65
76
|
}
|
|
66
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Does a scenario OPEN ON the screen this route names? The route gives a URL slug
|
|
80
|
+
* (`basic-info`, `register`); the feature names screens by human label (`[Basic Info]`,
|
|
81
|
+
* `[Registration]`). A raw substring test fails on BOTH shapes of that mismatch — and
|
|
82
|
+
* did, in the field: `"registration".includes("register")` is FALSE ("registration" is
|
|
83
|
+
* regist+ration), and `"basic info".includes("basic-info")` is false on the separator.
|
|
84
|
+
* Every `Given User is on [Registration] page` was therefore invisible, so the sensor
|
|
85
|
+
* reported "only a page-nav assertion" for routes the suite genuinely operates on.
|
|
86
|
+
*
|
|
87
|
+
* Compare normalized (alphanumeric-only, lowercase): containment either way, or a shared
|
|
88
|
+
* prefix long enough to be the same word family (≥5 chars AND ≥60% of the shorter form) —
|
|
89
|
+
* which pairs register/registration and basic-info/"Basic Info", while keeping
|
|
90
|
+
* login/logout apart (shared prefix "log" is 3).
|
|
91
|
+
*/
|
|
92
|
+
const norm = (s: string): string => s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
93
|
+
|
|
94
|
+
export function sameScreenName(slug: string, label: string): boolean {
|
|
95
|
+
const a = norm(slug), b = norm(label);
|
|
96
|
+
if (!a || !b) return false;
|
|
97
|
+
if (a.includes(b) || b.includes(a)) return true;
|
|
98
|
+
let i = 0;
|
|
99
|
+
while (i < a.length && i < b.length && a[i] === b[i]) i++;
|
|
100
|
+
return i >= 5 && i / Math.min(a.length, b.length) >= 0.6;
|
|
101
|
+
}
|
|
102
|
+
|
|
67
103
|
export function downstreamScope(specText: string, scenarios: ScenarioInfo[]): DownstreamResult {
|
|
68
104
|
const routes = downstreamRoutes(specText);
|
|
69
105
|
const underCovered: { route: string; slug: string }[] = [];
|
|
70
106
|
for (const route of routes) {
|
|
71
107
|
const slug = (route.split('/').filter(Boolean).pop() || route).toLowerCase();
|
|
72
|
-
|
|
108
|
+
// Referenced when the route/slug appears literally, OR when any `[Ref]` in the suite
|
|
109
|
+
// names the same screen under its human label (register ↔ [Registration]).
|
|
110
|
+
const labelsOf = (s: ScenarioInfo): string[] =>
|
|
111
|
+
[...s.haystack.matchAll(/\[([^\]]+)\]/g)].map((m) => m[1].split(':')[0]);
|
|
112
|
+
const refs = scenarios.filter((s) =>
|
|
113
|
+
s.haystack.includes(slug) || s.haystack.includes(route.toLowerCase()) ||
|
|
114
|
+
labelsOf(s).some((l) => sameScreenName(slug, l)));
|
|
73
115
|
if (!refs.length) continue; // not referenced at all — out of this screen's scope entirely
|
|
74
116
|
// Substantively covered only if some scenario OPERATES on the downstream — i.e. it
|
|
75
117
|
// starts there (`is on [<downstream>]`) — not merely navigates to it as a terminal
|
|
76
118
|
// `see [<downstream>] page` assertion. The latter just proves the transition.
|
|
77
|
-
const
|
|
78
|
-
|
|
119
|
+
const contentCovered = refs.some((s) =>
|
|
120
|
+
[...s.haystack.matchAll(/\bis on \[([^\]]+)\]/g)]
|
|
121
|
+
.some((m) => sameScreenName(slug, m[1].split(':')[0])));
|
|
79
122
|
if (!contentCovered) underCovered.push({ route, slug });
|
|
80
123
|
}
|
|
81
124
|
return { downstreamRoutes: routes, underCovered };
|
package/src/harness/sensors.ts
CHANGED
|
@@ -40,6 +40,11 @@ const PHASE_BUCKETS: Record<string, string> = {
|
|
|
40
40
|
HP: 'business-core', // happy path = the business goal itself
|
|
41
41
|
ER: 'validation-security', // error recovery (validation must not trap the journey)
|
|
42
42
|
EH: 'validation-security', // guards & leakage (direct access, back, refresh)
|
|
43
|
+
// Use-case vocabulary (ISTQB/UC primer): one Basic Flow, N Alternate, N Exception.
|
|
44
|
+
// Alternate is a designed-for branch (not an error) → behaviour; Exception is blocked → guard side.
|
|
45
|
+
BF: 'business-core',
|
|
46
|
+
AF: 'behavior',
|
|
47
|
+
EF: 'validation-security',
|
|
43
48
|
};
|
|
44
49
|
|
|
45
50
|
/** Classify a VP category into a balance bucket by keyword containment + precedence (H1). */
|
|
@@ -47,7 +52,9 @@ export function bucketForCategory(category: string | undefined): string {
|
|
|
47
52
|
const cat = (category || '').toUpperCase();
|
|
48
53
|
if (!cat) return 'other';
|
|
49
54
|
for (const seg of cat.split('-')) {
|
|
50
|
-
|
|
55
|
+
// A flow id numbers its branch (AF02, EF11) — the phase is the letter part.
|
|
56
|
+
const phase = seg.replace(/\d+$/, '');
|
|
57
|
+
if (PHASE_BUCKETS[phase]) return PHASE_BUCKETS[phase];
|
|
51
58
|
}
|
|
52
59
|
for (const [bucket, kws] of BUCKET_ORDER) {
|
|
53
60
|
if (kws.some((k) => cat.includes(k))) return bucket;
|
|
@@ -498,8 +505,17 @@ export interface TraceResult {
|
|
|
498
505
|
note: string;
|
|
499
506
|
}
|
|
500
507
|
|
|
508
|
+
/**
|
|
509
|
+
* A viewpoint id without its family prefix. The use-case framework numbers flows and the
|
|
510
|
+
* two prefixes in circulation mean the same thing: a viewpoint declared `VP-BF` is the same
|
|
511
|
+
* family as a scenario coded `FL-BF-001` (and vice versa). Matching on the raw string made
|
|
512
|
+
* a correctly-traced flow suite read as 15% traceable (#592).
|
|
513
|
+
*/
|
|
514
|
+
const unprefixed = (id: string): string => id.replace(/^(?:VP|FL)-/i, '').toUpperCase();
|
|
515
|
+
|
|
501
516
|
export function traceability(scenarios: ScenarioInfo[], viewpoints: ViewpointEntry[]): TraceResult {
|
|
502
517
|
const overviewIds = new Set(viewpoints.map((v) => v.id.toUpperCase()));
|
|
518
|
+
const overviewBare = new Set(viewpoints.map((v) => unprefixed(v.id)));
|
|
503
519
|
// A scenario carries an ID if it has a project-scheme leading ID (vpId) or a VP-CAT code.
|
|
504
520
|
const withCode = scenarios.filter((s) => s.vpId || s.vpCode);
|
|
505
521
|
// Maps to overview if the scenario's ID, its sequence-stripped prefix, or its VP-CAT code
|
|
@@ -507,6 +523,12 @@ export function traceability(scenarios: ScenarioInfo[], viewpoints: ViewpointEnt
|
|
|
507
523
|
const mapped = withCode.filter((s) => {
|
|
508
524
|
const id = (s.vpId || s.vpCode || '').toUpperCase();
|
|
509
525
|
if (overviewIds.has(id) || overviewIds.has(idPrefix(id))) return true;
|
|
526
|
+
// Prefix-agnostic within the VP-/FL- family, and phase-family aware: FL-EF01-001 traces
|
|
527
|
+
// to a declared VP-EF (the branch number is part of the flow id, not of the family).
|
|
528
|
+
const bare = unprefixed(id), barePrefix = unprefixed(idPrefix(id));
|
|
529
|
+
if (overviewBare.has(bare) || overviewBare.has(barePrefix)) return true;
|
|
530
|
+
if ([...overviewBare].some((ob) => bare.startsWith(ob) || barePrefix.startsWith(ob) ||
|
|
531
|
+
bare.replace(/\d+$/, '').startsWith(ob.replace(/\d+$/, '')) && ob.length >= 2)) return true;
|
|
510
532
|
return [...overviewIds].some((oid) => id.startsWith(oid) || oid.startsWith(idPrefix(id)) || (!!s.category && oid.includes(s.category)));
|
|
511
533
|
});
|
|
512
534
|
return {
|
|
@@ -621,7 +643,11 @@ const CLAIM_RULES: ClaimRule[] = [
|
|
|
621
643
|
// "double-click does not create two orders" — not a per-feature keyword.
|
|
622
644
|
claim: 'no-side-effect/no-duplicate',
|
|
623
645
|
title: /(?=.*\b(submit|sen[dt]|resend|resubmit|re-?fire|re-?issue|re-?post|repost|create|charge|order|payment|\bpay\b|email|request|\botp\b|insert|register|book|duplicate|double[- ]?submit|again|twice)\b)(?=.*(\bno\b|\bnot\b|n['’]t\b|\bnever\b|\bwithout\b|\bcannot\b|prevent|block|avoid|reject|disabl|\bdeny\b|denies|\bkhông\b|\bchưa\b))/i,
|
|
624
|
-
|
|
646
|
+
// `is disabled` counts: when the spec's own mechanism against a repeat is "the control
|
|
647
|
+
// is disabled immediately" (FR-014-style), asserting the disabled state IS the contrast —
|
|
648
|
+
// the second activation cannot occur. Without it the canonical double-submit proof shape
|
|
649
|
+
// was unrecognised (#592).
|
|
650
|
+
proof: /\bcount\b|ok_count|status_counts|row with \{\{|table with|tohavecount|is hidden|are hidden|is disabled|are disabled|not complete|no longer/,
|
|
625
651
|
need: 'a record/request-count proof (count stays at one, e.g. `User see [Table] row with {{count}}`, an API `{{name.ok_count}}` invariant, or a `@query` DB count) or @manual with a request-count oracle',
|
|
626
652
|
hint: 'a "does-not-happen / does-not-repeat" claim about a state-changing action is NOT proven by a terminal `see [...] page` — that page is identical whether or not the action (re-)fired. Prove the side-effect count is unchanged, or mark @manual with a setup→action→assert-no-duplicate oracle.',
|
|
627
653
|
severity: 'fail',
|
|
@@ -728,9 +754,23 @@ const CATEGORY_SIGNALS: { cat: string; re: RegExp }[] = [
|
|
|
728
754
|
{ cat: 'LIST', re: /\b(product (list|grid)|featured product|product card|each (featured )?card|every (featured )?card)\b/ },
|
|
729
755
|
{ cat: 'NAV', re: /\b(navigates?|returns the user|redirect\w*|logo returns|menu navigates)\b/ },
|
|
730
756
|
{ cat: 'VAL', re: /\b(valid email|invalid\b|without (a |an )?@|without a domain|empty (email|input|field)|validation|required field)\b/ },
|
|
731
|
-
|
|
757
|
+
// An access GUARD is security even when its only observable is a redirect: "direct access
|
|
758
|
+
// … without a valid context redirects to X" used to match NAV (`redirect\w*`) and nothing in
|
|
759
|
+
// SEC, so the linter told QA to re-tag genuine auth-gate tests as navigation — diluting the
|
|
760
|
+
// visibility of the security suite (QA field report, 3.2.23). The guard vocabulary below is
|
|
761
|
+
// what makes the INTENT matchable, and GUARD_INTENT then wins over a mere observable.
|
|
762
|
+
{ cat: 'SEC', re: /\b(xss|sql|injection|payload|without authentication|unauthenticated|without an? (?:authenticated|valid|active) \w+|direct(?:ly)? access\w*|deep[- ]link\w*|auth[- ]?guard|not skippable|without having (?:completed|submitted|logged)|remains? unauthenticated|\binert\b|tamper\w*|malformed|url parameter|query param\w*|falls? back|gracefull?y|without a crash|not[- ]found|\b404\b|special character|abuse)\b/ },
|
|
732
763
|
];
|
|
733
764
|
|
|
765
|
+
/**
|
|
766
|
+
* Categories whose signal describes the test's INTENT/risk rather than the observable action.
|
|
767
|
+
* When one of these matches, a competing observable-only category (NAV: "redirects", "navigates")
|
|
768
|
+
* must not out-vote it — the redirect is HOW the guard shows itself, not what the test is about.
|
|
769
|
+
*/
|
|
770
|
+
const INTENT_OVER_OBSERVABLE: Record<string, Set<string>> = {
|
|
771
|
+
SEC: new Set(['NAV']),
|
|
772
|
+
};
|
|
773
|
+
|
|
734
774
|
export function taxonomyLint(scenarios: ScenarioInfo[]): TaxonomyResult {
|
|
735
775
|
const mislabeled: TaxonomyFinding[] = [];
|
|
736
776
|
let checked = 0;
|
|
@@ -741,7 +781,10 @@ export function taxonomyLint(scenarios: ScenarioInfo[]): TaxonomyResult {
|
|
|
741
781
|
const title = s.name.toLowerCase();
|
|
742
782
|
const matched = [...new Set(CATEGORY_SIGNALS.filter((c) => c.re.test(title)).map((c) => c.cat))];
|
|
743
783
|
if (matched.includes(cat)) continue; // title supports its own category → fine
|
|
744
|
-
|
|
784
|
+
// The declared category may be an INTENT category whose signal this title spells
|
|
785
|
+
// differently; never "correct" it toward a category that only names the observable.
|
|
786
|
+
const suppressed = INTENT_OVER_OBSERVABLE[cat];
|
|
787
|
+
const others = matched.filter((c) => c !== cat && !(suppressed && suppressed.has(c)));
|
|
745
788
|
if (others.length === 1) { // unambiguous mismatch
|
|
746
789
|
const sig = CATEGORY_SIGNALS.find((c) => c.cat === others[0])!;
|
|
747
790
|
const m = title.match(sig.re);
|
|
@@ -23,6 +23,19 @@ export interface LedgerResult {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
const ID_RE = /\b([A-Z]{1,5}\d{0,2}(?:[.\-][A-Za-z0-9]+)*-?\d{0,3})\b/; // VP0.Title, VP7-002, MS-HP-001, TV-01
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Lines that DECLARE something about the checklist rather than being a checklist item —
|
|
29
|
+
* atomizing them manufactures gaps that can never be closed (QA field report: 4 of 6
|
|
30
|
+
* "missing items" were a `qa/bug-reports` placeholder plus three Priority-Viewpoints
|
|
31
|
+
* rows whose third cell is the PROSE REASON for a priority, not a claim to test).
|
|
32
|
+
*/
|
|
33
|
+
// "None on file yet …", "N/A", "TBD", "Do not invent …" — an explicit statement that there
|
|
34
|
+
// is nothing here. A gap report built from these tells the author to test the absence.
|
|
35
|
+
const PLACEHOLDER_ITEM = /^(none\b|n\/a\b|tbd\b|no known\b|nothing\b|do not invent\b|-+$)/i;
|
|
36
|
+
/** A priority-DECLARATION row: `| VP-LOGIC | High | <reason prose> |`. The category id and its
|
|
37
|
+
* priority are consumed by the traceability + balance axes; the reason is rationale. */
|
|
38
|
+
const PRIORITY_ROW = /^(?:VP|FL)[A-Z0-9._-]*\s+—\s+(?:critical|high|medium|normal|low|deferred)\b/i;
|
|
26
39
|
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']);
|
|
27
40
|
|
|
28
41
|
/** Extract atomic checklist items from a viewpoint file (format-tolerant). */
|
|
@@ -42,11 +55,13 @@ export function parseViewpointItems(viewpointPath: string): { id?: string; text:
|
|
|
42
55
|
else if (line.startsWith('|')) { // table data row
|
|
43
56
|
if (/^\|[\s|:-]+\|?$/.test(line)) continue; // separator
|
|
44
57
|
const cells = line.split('|').map((c) => c.trim()).filter(Boolean);
|
|
45
|
-
if (/^(vp|id|viewpoint|priority|reason|no\.?|category|item|trigger|#|pattern|applicable|notes|field|constraint|code|description|status)$/i.test(cells[0] || '')) continue; // header
|
|
58
|
+
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
|
|
46
59
|
text = cells.join(' — ');
|
|
47
60
|
} else continue;
|
|
48
61
|
text = text.replace(/[*`]/g, '').trim();
|
|
49
62
|
if (!text) continue;
|
|
63
|
+
if (PLACEHOLDER_ITEM.test(text)) continue; // "None on file yet" is not a claim
|
|
64
|
+
if (PRIORITY_ROW.test(text)) continue; // priority declaration, not a checklist item
|
|
50
65
|
const idM = text.match(ID_RE);
|
|
51
66
|
const id = idM && /\d/.test(idM[1]) ? idM[1] : undefined; // require a digit so prose words aren't IDs
|
|
52
67
|
const words = (text.toLowerCase().match(/[a-z][a-z-]{3,}/g) || []).filter((w) => !GENERIC.has(w));
|
|
@@ -30,6 +30,23 @@ If `spec_figma.md` exists OR the user provides a Figma URL for the PAT flow:
|
|
|
30
30
|
**Input**: Screen or flow name (e.g., `/sungen-create-test admin-users`).
|
|
31
31
|
{{/cap}}
|
|
32
32
|
|
|
33
|
+
## ⛔ HARD RULE — in flow mode, AUTHOR the flow contract before generating
|
|
34
|
+
|
|
35
|
+
If `requirements/flow-contract.yaml` is missing, **write it first** — never cite a contract you
|
|
36
|
+
did not create, and never generate scenarios without one. Without it the flow is scored as a
|
|
37
|
+
generic screen (page-type themes that do not fit a journey), and the audit reports
|
|
38
|
+
`FLOW-CONTRACT-MISSING`.
|
|
39
|
+
|
|
40
|
+
It needs the use-case declaration ONCE — actor · trigger · goal · precondition · `outcome` —
|
|
41
|
+
plus **both** postconditions: `successGuarantee` (true when the goal is reached) and
|
|
42
|
+
`minimalGuarantee` (what must still hold when the journey FAILS). Exception flows assert against
|
|
43
|
+
the second one; with no `minimalGuarantee` they can only prove "an error appeared"
|
|
44
|
+
(`FLOW-GUARANTEE-MISSING`). Then declare `phases:` — `[BF, AF, EF]` for a use-case decomposition
|
|
45
|
+
(the first phase is the Basic Flow) — and enumerate flows with the step × risk matrix in the
|
|
46
|
+
`sungen-tc-generation` skill.
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
33
50
|
## Platform detection (do this FIRST)
|
|
34
51
|
|
|
35
52
|
Read `qa/capabilities.yaml` and check the `platform` field — **and the verification scope** (`verification:` if set, else derived from `enabled`: `ui` always; `api`/`db` only if that driver is on). This is the project's recorded **test type**: an **E2E/UI-only** project (no `api`/`db`) must NOT get `@api`/`@query`/`@requires:api|db` verification unless the test-viewpoint explicitly asks for it — keep oracles UI-observable, and cap any in-scope API/DB-in-E2E verification at the **≤20% band** (see `sungen-tc-generation` → "Respect the project's verification scope"). `sungen audit` flags `VERIFICATION-OUT-OF-SCOPE`.
|
|
@@ -636,10 +636,21 @@ those namespaces are the **System INTEGRATION** group — keep their oracles abo
|
|
|
636
636
|
|
|
637
637
|
**Read `requirements/flow-contract.yaml` FIRST — it is the flow's boundary and the yardstick
|
|
638
638
|
`sungen audit` scores the flow against** (`flowCoverage` axis = journey phases HP/ER/EH automated;
|
|
639
|
-
`FLOW-OUTCOME-UNPROVEN`; `FLOW-SCOPE-CREEP`).
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
639
|
+
`FLOW-OUTCOME-UNPROVEN`; `FLOW-SCOPE-CREEP`). **A filled contract is an INPUT — never rewrite it
|
|
640
|
+
to match your output** (same rule as `test-viewpoint.md`).
|
|
641
|
+
|
|
642
|
+
> **HARD RULE — the contract must be AUTHORED, never merely cited.** If
|
|
643
|
+
> `requirements/flow-contract.yaml` is absent, WRITE it (with the user, via the boundary checklist
|
|
644
|
+
> in `add-flow`: one business goal · clear trigger · ONE observable outcome valuable to the actor ·
|
|
645
|
+
> name = "Verb + outcome") **before** generating scenarios. Citing a contract you did not create
|
|
646
|
+
> leaves the flow scored as a generic screen — `flowCoverage` never applies, `coverage` falls back
|
|
647
|
+
> to page-type themes that do not fit a journey, and the audit reports `FLOW-CONTRACT-MISSING`.
|
|
648
|
+
> A contract also needs **both** guarantees, not just the happy one:
|
|
649
|
+
>
|
|
650
|
+
> - `successGuarantee` — everything true once the goal IS reached (what the Basic Flow proves).
|
|
651
|
+
> - `minimalGuarantee` — what must still hold when the journey **FAILS** (no duplicate record, no
|
|
652
|
+
> mail on a rejected submit, no half-written state). Without it an Exception Flow has nothing to
|
|
653
|
+
> assert against beyond "an error appeared", and `FLOW-GUARANTEE-MISSING` is reported.
|
|
643
654
|
|
|
644
655
|
| Aspect | Screen | Flow |
|
|
645
656
|
|---|---|---|
|
|
@@ -647,7 +658,7 @@ never rewrite it to match your output** (same rule as `test-viewpoint.md`).
|
|
|
647
658
|
| Selector format | `[Element]` | `[Screen:Element]` (namespaced) |
|
|
648
659
|
| Test data keys | `{{variable}}` | `{{phase.variable}}` |
|
|
649
660
|
| Feature tag | `@auto` / `@smoke` etc. | `@flow` (required) |
|
|
650
|
-
| Scenario ids | `VP-<CATEGORY>-NNN` | `FL-<PHASE>-NNN` — phases
|
|
661
|
+
| Scenario ids | `VP-<CATEGORY>-NNN` | `FL-<PHASE>-NNN` — use-case phases `BF`/`AF0n`/`EF0n` (see below), or the flat `HP` (happy path) / `ER` (error recovery) / `EH` (guards) / `UI` for a short single-outcome flow |
|
|
651
662
|
|
|
652
663
|
**Scenarios to generate — every phase demanded by the contract, automated:**
|
|
653
664
|
|
|
@@ -659,6 +670,52 @@ never rewrite it to match your output** (same rule as `test-viewpoint.md`).
|
|
|
659
670
|
| Cross-screen handoff | After every screen transition, assert the CARRIED state on the new screen (the added product's name in the cart, the email echoed on the sent screen). | blind tails cap `businessDepth` (`FLOW-HANDOFF-SHALLOW`) |
|
|
660
671
|
| Stateful regression (when `stateful:` declared) | Count/quantity proof · teardown (remove → empty) · multi-source add. | missing dims cap `businessDepth` (`FLOW-DEPTH`) |
|
|
661
672
|
|
|
673
|
+
### Use-case decomposition — how a flow becomes 15 scenarios instead of 3
|
|
674
|
+
|
|
675
|
+
`HP`/`ER`/`EH` are phase *buckets*; they do not tell you how many flows a use case HAS. The
|
|
676
|
+
use-case framework does, and it is the default decomposition for a multi-screen journey:
|
|
677
|
+
|
|
678
|
+
**1 Use Case = 1 Basic Flow + N Alternate Flows + N Exception Flows.**
|
|
679
|
+
|
|
680
|
+
| Phase | Meaning | Test |
|
|
681
|
+
|---|---|---|
|
|
682
|
+
| `BF` Basic Flow | **Exactly one.** The path where nothing goes wrong, end to end. | Reaches `outcome.assertion` and satisfies `successGuarantee`. |
|
|
683
|
+
| `AF` Alternate Flow | A **designed-for branch** — the user does something else legitimate. NOT an error. | Its own outcome: either re-enters the BF at a named step, or ends elsewhere on purpose. |
|
|
684
|
+
| `EF` Exception Flow | **Blocked.** The goal is not reached. | Asserts `minimalGuarantee` — what must STILL be true now that this failed. |
|
|
685
|
+
|
|
686
|
+
`AF` is the bucket most generators skip entirely, and it is where the interesting bugs live
|
|
687
|
+
(going back and finding the form re-hydrated, two tabs, an escape hatch out of the journey).
|
|
688
|
+
Use `FL-BF-NNN` / `FL-AF0n-NNN` / `FL-EF0n-NNN` ids — `sungen audit` buckets them (BF→business
|
|
689
|
+
core, AF→behaviour, EF→validation/security) and traces them to the viewpoint's `FL-BF`/`FL-AF`/
|
|
690
|
+
`FL-EF` priority rows. Declare `phases: [BF, AF, EF]` in the contract; the FIRST phase is the
|
|
691
|
+
Basic Flow and is the one that must reach `outcome`.
|
|
692
|
+
|
|
693
|
+
**Declare once, then declare only the differences.** Actor · Trigger · Goal · Precondition ·
|
|
694
|
+
`successGuarantee` · `minimalGuarantee` are use-case-level: they live in `flow-contract.yaml`
|
|
695
|
+
and are never repeated per flow. Each flow in `test-viewpoint.md` then states only three things:
|
|
696
|
+
**where it branches from · its own steps · its own outcome.** Repeating the precondition in
|
|
697
|
+
fifteen rows is noise; the branch point is the information.
|
|
698
|
+
|
|
699
|
+
**Enumerate flows with the step × risk matrix.** Walk every BF step and ask each column. A hit
|
|
700
|
+
with a basis in the spec becomes a flow; a hit with no basis becomes an **open question** — never
|
|
701
|
+
an invented behaviour:
|
|
702
|
+
|
|
703
|
+
| Risk family | The question at this step |
|
|
704
|
+
|---|---|
|
|
705
|
+
| Double submit | Is the control tapped twice guarded? One request, one record? (Spec silent → open question, not an assumed guard.) |
|
|
706
|
+
| Client-side buffer | Is state held client-side between this step and the next? Then test **both** directions — it survives a legitimate back, and it is **LOST** on reload. A suite proving only the surviving side has tested half the mechanism (`CONTINUITY-ONE-SIDED`). |
|
|
707
|
+
| Concurrency | Two tabs / two devices at this step — whose state wins? |
|
|
708
|
+
| Server error | This step's submit fails server-side: what is kept, what is retryable, is anything half-written? |
|
|
709
|
+
| Abandonment / TTL | The user stops here. Does the partial state lapse, expire, or linger forever? |
|
|
710
|
+
| Direct access | This step's URL opened cold, without the preceding state → refused, and the screen never renders. |
|
|
711
|
+
| Auth transition | Where exactly does the session begin? Before that point the actor is unauthenticated — prove it. |
|
|
712
|
+
| Escape hatch | Is there a documented way OUT of the journey here (back to top, cancel)? That is an `AF`, and its outcome is "goal deliberately not reached". |
|
|
713
|
+
|
|
714
|
+
**Do NOT re-derive field validation.** Per-field equivalence and boundary values belong to the
|
|
715
|
+
OWNING SCREEN's suite. A flow takes representative inputs (see the system-test rule above). State
|
|
716
|
+
that exclusion in the viewpoint's Design Decisions — and if the screen suite does not exist, that
|
|
717
|
+
is a gap to REPORT, not to quietly absorb.
|
|
718
|
+
|
|
662
719
|
**Boundary discipline while generating:** every scenario must serve the contract's goal. A scenario
|
|
663
720
|
that never touches `outcome.screen` and is not a guard (`EH`) or error-recovery (`ER`) belongs in a
|
|
664
721
|
DIFFERENT flow — propose the split instead of writing it here (`FLOW-SCOPE-CREEP` will flag it).
|
|
@@ -107,7 +107,7 @@ See `SKILL.md` for the 4 Viewpoints, Shared Checks, and Security Tag Rules.
|
|
|
107
107
|
|
|
108
108
|
**[VP-VAL] Edge cases**
|
|
109
109
|
|
|
110
|
-
- [@normal] Register with a sub-address email (user+tag@
|
|
110
|
+
- [@normal] Register with a sub-address email (user+tag@sun-asterisk.com) → treated as a unique email, creation succeeds
|
|
111
111
|
- [@low] Browser autofill fills the fields → the form receives the correct values, no conflict with a custom input component
|
|
112
112
|
|
|
113
113
|
---
|
|
@@ -50,6 +50,14 @@ payload \`<script>alert(1)</script>\`; the exact expected error message from the
|
|
|
50
50
|
**Self-check:** *"If I swapped this for another value of the same kind, would the
|
|
51
51
|
test still mean the same thing?"* → yes = INVENTED.
|
|
52
52
|
|
|
53
|
+
**Email domains — never a live mailbox provider.** An invented address still gets
|
|
54
|
+
mail sent to it: a registration or password-reset test submits it and the system
|
|
55
|
+
dispatches for real. \`@gmail.com\`, \`@yahoo.com\`, \`@outlook.com\` and friends all
|
|
56
|
+
resolve, so that mail reaches whoever owns the address — a stranger, or someone's
|
|
57
|
+
personal account. Use the project domain \`@sun-asterisk.com\`, or \`@example.com\`
|
|
58
|
+
(RFC 2606 — reserved, guaranteed never to deliver). Make each run unique with
|
|
59
|
+
\`{{$timestamp}}\` in the local part: \`"signup+{{\$timestamp}}@sun-asterisk.com"\`.
|
|
60
|
+
|
|
53
61
|
## 2) ENV-BOUND — real records of the test environment (not secret)
|
|
54
62
|
|
|
55
63
|
**What it is:** the value must EXIST in the environment for the test to work — a
|