@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.
Files changed (57) hide show
  1. package/dist/cli/commands/audit.d.ts.map +1 -1
  2. package/dist/cli/commands/audit.js +21 -6
  3. package/dist/cli/commands/audit.js.map +1 -1
  4. package/dist/dashboard/templates/index.html +1 -1
  5. package/dist/exporters/csv-exporter.d.ts.map +1 -1
  6. package/dist/exporters/csv-exporter.js +3 -1
  7. package/dist/exporters/csv-exporter.js.map +1 -1
  8. package/dist/exporters/feature-parser.d.ts +6 -0
  9. package/dist/exporters/feature-parser.d.ts.map +1 -1
  10. package/dist/exporters/feature-parser.js +21 -0
  11. package/dist/exporters/feature-parser.js.map +1 -1
  12. package/dist/exporters/xlsx-report-builder.d.ts.map +1 -1
  13. package/dist/exporters/xlsx-report-builder.js +3 -1
  14. package/dist/exporters/xlsx-report-builder.js.map +1 -1
  15. package/dist/harness/audit.d.ts +13 -0
  16. package/dist/harness/audit.d.ts.map +1 -1
  17. package/dist/harness/audit.js +41 -10
  18. package/dist/harness/audit.js.map +1 -1
  19. package/dist/harness/flow-contract.d.ts +29 -0
  20. package/dist/harness/flow-contract.d.ts.map +1 -1
  21. package/dist/harness/flow-contract.js +77 -11
  22. package/dist/harness/flow-contract.js.map +1 -1
  23. package/dist/harness/parse.d.ts.map +1 -1
  24. package/dist/harness/parse.js +22 -3
  25. package/dist/harness/parse.js.map +1 -1
  26. package/dist/harness/quality-gates.d.ts +2 -1
  27. package/dist/harness/quality-gates.d.ts.map +1 -1
  28. package/dist/harness/quality-gates.js +47 -5
  29. package/dist/harness/quality-gates.js.map +1 -1
  30. package/dist/harness/sensors.d.ts.map +1 -1
  31. package/dist/harness/sensors.js +48 -5
  32. package/dist/harness/sensors.js.map +1 -1
  33. package/dist/harness/viewpoint-ledger.d.ts.map +1 -1
  34. package/dist/harness/viewpoint-ledger.js +17 -1
  35. package/dist/harness/viewpoint-ledger.js.map +1 -1
  36. package/dist/orchestrator/templates/ai-src/commands/create-test.md +17 -0
  37. package/dist/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +62 -5
  38. package/dist/orchestrator/templates/ai-src/skills/sungen-viewpoint/group-e-identity.md +1 -1
  39. package/dist/orchestrator/test-data-guide.d.ts.map +1 -1
  40. package/dist/orchestrator/test-data-guide.js +8 -0
  41. package/dist/orchestrator/test-data-guide.js.map +1 -1
  42. package/package.json +3 -3
  43. package/src/cli/commands/audit.ts +18 -6
  44. package/src/dashboard/templates/index.html +1 -1
  45. package/src/exporters/csv-exporter.ts +3 -1
  46. package/src/exporters/feature-parser.ts +16 -0
  47. package/src/exporters/xlsx-report-builder.ts +3 -1
  48. package/src/harness/audit.ts +49 -11
  49. package/src/harness/flow-contract.ts +100 -11
  50. package/src/harness/parse.ts +22 -3
  51. package/src/harness/quality-gates.ts +48 -5
  52. package/src/harness/sensors.ts +47 -4
  53. package/src/harness/viewpoint-ledger.ts +16 -1
  54. package/src/orchestrator/templates/ai-src/commands/create-test.md +17 -0
  55. package/src/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +62 -5
  56. package/src/orchestrator/templates/ai-src/skills/sungen-viewpoint/group-e-identity.md +1 -1
  57. 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 (new RegExp(`(^|-)${ph}(-|$)`).test(id)) return ph;
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
- return ph !== 'EH' && ph !== 'ER'; // guards/error-recovery legitimately stop early
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
- // HP must additionally prove the outcome — a data assertion elsewhere is not the goal.
188
- const relevant = phase === 'HP' ? inPhase.filter((s) => touchesOutcome(s, outcomeScreen)) : inPhase;
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
- const countProof = any(new RegExp(`\\b(quantity|qty|row count|count|number of|two (rows|lines|items)|\\d+ (rows|lines|items))\\b`)) ;
255
- const teardown = any(/\b(remove|delete|clear|cancel|withdraw)(?:s|d|ed|ing|n)?\b/) && any(new RegExp(`\\b(empty|emptied|no items|zero|removed|cleared|cancelled|withdrawn|0 items)\\b|empty[- ]${noun}`));
256
- const adds = hay.filter((h) => new RegExp(`\\b(add|submit|create|place).{0,40}${noun}|${noun}.{0,40}\\b(add|submit|create|place)`).test(h));
257
- const multiSource = any(/\b(recommended|related|you may also|another source|both sources|second (list|source))\b/) && adds.length > 0;
258
- const dims: Array<[string, boolean]> = [['count-proof', countProof], ['teardown', teardown], ['multi-source', multiSource]];
259
- const missing = dims.filter(([, v]) => !v).map(([k]) => k);
260
- return { countProof, teardown, multiSource, missing, ratio: (dims.length - missing.length) / dims.length };
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
+
@@ -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
- return isIdLike(s) || /^VP-[A-Z][A-Z0-9]*$/i.test(s);
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
- const codeMatch = sc.name.match(/\b(?:VP|FL)-([A-Z]+(?:-[A-Z]+)*)-\d+/i);
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
- return !/@cleanup:cart\b|@isolate\b|empty cart|fresh (?:browser )?context|new context/i.test(featureText);
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
- const refs = scenarios.filter((s) => s.haystack.includes(slug) || s.haystack.includes(route.toLowerCase()));
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 opensOn = new RegExp(`\\bis on \\[[^\\]]*${slug}`, 'i');
78
- const contentCovered = refs.some((s) => opensOn.test(s.haystack));
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 };
@@ -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
- if (PHASE_BUCKETS[seg]) return PHASE_BUCKETS[seg];
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
- proof: /\bcount\b|ok_count|status_counts|row with \{\{|table with|tohavecount|is hidden|are hidden|not complete|no longer/,
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
- { cat: 'SEC', re: /\b(xss|sql|injection|payload|without authentication|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/ },
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
- const others = matched.filter((c) => c !== cat);
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`). No contract yet author it with the user via the
640
- boundary checklist in `add-flow` (one business goal · clear trigger · ONE observable outcome
641
- valuable to the actor · name = "Verb + outcome"), THEN generate. **A filled contract is an INPUT —
642
- never rewrite it to match your output** (same rule as `test-viewpoint.md`).
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: `HP` (happy path), `ER` (error recovery), `EH` (guards), `UI` (journey UI states, optional) |
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@gmail.com) → treated as a unique email, creation succeeds
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