@sun-asterisk/sungen 3.2.26 → 3.2.27

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 (36) hide show
  1. package/dist/cli/commands/next.js +2 -2
  2. package/dist/cli/commands/next.js.map +1 -1
  3. package/dist/generators/test-generator/utils/selector-resolver.d.ts.map +1 -1
  4. package/dist/generators/test-generator/utils/selector-resolver.js +2 -1
  5. package/dist/generators/test-generator/utils/selector-resolver.js.map +1 -1
  6. package/dist/harness/audit.js +3 -3
  7. package/dist/harness/audit.js.map +1 -1
  8. package/dist/harness/flow-contract.d.ts +0 -14
  9. package/dist/harness/flow-contract.d.ts.map +1 -1
  10. package/dist/harness/flow-contract.js +44 -9
  11. package/dist/harness/flow-contract.js.map +1 -1
  12. package/dist/harness/next-step.d.ts +8 -2
  13. package/dist/harness/next-step.d.ts.map +1 -1
  14. package/dist/harness/next-step.js +2 -2
  15. package/dist/harness/next-step.js.map +1 -1
  16. package/dist/harness/sensors.d.ts.map +1 -1
  17. package/dist/harness/sensors.js +9 -1
  18. package/dist/harness/sensors.js.map +1 -1
  19. package/dist/orchestrator/templates/ai-src/commands/create-test.md +1 -1
  20. package/dist/orchestrator/templates/ai-src/skills/sungen-harness-audit/SKILL.md +17 -0
  21. package/dist/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +11 -1
  22. package/dist/utils/ref-key.d.ts +30 -0
  23. package/dist/utils/ref-key.d.ts.map +1 -0
  24. package/dist/utils/ref-key.js +48 -0
  25. package/dist/utils/ref-key.js.map +1 -0
  26. package/package.json +3 -3
  27. package/src/cli/commands/next.ts +2 -2
  28. package/src/generators/test-generator/utils/selector-resolver.ts +2 -1
  29. package/src/harness/audit.ts +3 -3
  30. package/src/harness/flow-contract.ts +41 -8
  31. package/src/harness/next-step.ts +10 -4
  32. package/src/harness/sensors.ts +9 -1
  33. package/src/orchestrator/templates/ai-src/commands/create-test.md +1 -1
  34. package/src/orchestrator/templates/ai-src/skills/sungen-harness-audit/SKILL.md +17 -0
  35. package/src/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +11 -1
  36. package/src/utils/ref-key.ts +43 -0
@@ -22,6 +22,7 @@
22
22
  * declared collection (order, application, submission …)
23
23
  */
24
24
  import * as fs from 'fs';
25
+ import { refNamespaces, screenKey } from '../utils/ref-key';
25
26
  import * as path from 'path';
26
27
  import { parse as parseYaml } from 'yaml';
27
28
  import { ScenarioInfo } from './parse';
@@ -179,7 +180,7 @@ export function loadFlowContract(unitDir: string): { contract: FlowContract | nu
179
180
  actor: raw.actor !== undefined ? String(raw.actor) : undefined,
180
181
  trigger: raw.trigger !== undefined ? String(raw.trigger) : undefined,
181
182
  precondition: raw.precondition !== undefined ? String(raw.precondition) : undefined,
182
- outcome: { screen: String(outcome!.screen).toLowerCase(), assertion: outcome!.assertion !== undefined ? String(outcome!.assertion) : undefined },
183
+ outcome: { screen: screenKey(String(outcome!.screen)), assertion: outcome!.assertion !== undefined ? String(outcome!.assertion) : undefined },
183
184
  value: raw.value !== undefined ? String(raw.value) : undefined,
184
185
  successGuarantee: raw.successGuarantee !== undefined ? String(raw.successGuarantee) : (raw.success_guarantee !== undefined ? String(raw.success_guarantee) : undefined),
185
186
  minimalGuarantee: raw.minimalGuarantee !== undefined ? String(raw.minimalGuarantee) : (raw.minimal_guarantee !== undefined ? String(raw.minimal_guarantee) : undefined),
@@ -210,7 +211,7 @@ export function loadFlowContract(unitDir: string): { contract: FlowContract | nu
210
211
  .map((e) => ({
211
212
  name: String(e.name),
212
213
  owner: e.owner !== undefined ? String(e.owner) : undefined,
213
- screens: Array.isArray(e.screens) ? (e.screens as unknown[]).map((x) => String(x).toLowerCase()) : [],
214
+ screens: Array.isArray(e.screens) ? (e.screens as unknown[]).map((x) => screenKey(String(x))) : [],
214
215
  }))
215
216
  : undefined,
216
217
  golden: raw.golden === true,
@@ -223,13 +224,12 @@ export function loadFlowContract(unitDir: string): { contract: FlowContract | nu
223
224
 
224
225
  /** `[screen:element]` namespaces referenced by a scenario's steps, in step order. */
225
226
  function namespacesInOrder(s: ScenarioInfo): string[] {
226
- const out: string[] = [];
227
- for (const m of s.stepsText.matchAll(/\[([a-z0-9_.-]+):/g)) out.push(m[1]);
228
- return out;
227
+ // Through the shared vocabulary — a private regex here once dropped every multi-word screen (#660).
228
+ return refNamespaces(s.stepsText);
229
229
  }
230
230
 
231
231
  function touchesOutcome(s: ScenarioInfo, outcomeScreen: string): boolean {
232
- return namespacesInOrder(s).includes(outcomeScreen);
232
+ return namespacesInOrder(s).includes(screenKey(outcomeScreen));
233
233
  }
234
234
 
235
235
  /**
@@ -244,7 +244,8 @@ function reachesOutcome(s: ScenarioInfo, outcomeScreen: string): boolean {
244
244
  // anywhere in the scenario would suppress every positive assertion in it.
245
245
  const steps = (s.steps ?? []).map((st) => st.text.toLowerCase());
246
246
  if (steps.length === 0) return touchesOutcome(s, outcomeScreen);
247
- return steps.some((t) => t.includes(`[${outcomeScreen}`)
247
+ const key = screenKey(outcomeScreen);
248
+ return steps.some((t) => refNamespaces(t, { bare: true }).includes(key)
248
249
  && !/\b(is hidden|is not visible|does not exist|is absent|no longer)\b/.test(t));
249
250
  }
250
251
 
@@ -366,15 +367,39 @@ export function flowInventory(contract: FlowContract, scenarios: ScenarioInfo[])
366
367
  * Both inflate the flow count while adding no branch coverage, which is exactly what makes a
367
368
  * suite look complete to the harness and thin to a reviewer.
368
369
  */
370
+ /** An interaction, whichever keyword the author put in front of it — `Then User click` exists. */
371
+ const INTERACTION_VERB = /\buser\s+(?:click|clicks|tap|taps|fill|fills|type|types|select|selects|press|presses|check|checks|uncheck|unchecks|upload|uploads|submit|submits|enter|enters|choose|chooses|toggle|toggles|drag|drags|swipe|swipes|scroll|scrolls|hover|hovers|clear|clears)\b/i;
372
+
369
373
  export function misfiledPhases(
370
374
  contract: FlowContract, scenarios: ScenarioInfo[], basicPhase: string,
371
375
  ): Array<{ scenario: string; id: string; why: string }> {
372
376
  const out: Array<{ scenario: string; id: string; why: string }> = [];
373
377
  const outcome = contract.outcome.screen;
378
+ // A branch point is a STRUCTURAL fact before it is a vocabulary one. The check used to demand a
379
+ // branch WORD (back / cancel / retry…), so a precondition guard — `Given` already authenticated,
380
+ // or no originating session → `Then` redirected — had no way to satisfy it: such a scenario has
381
+ // no interaction step at all, by construction, and its branch is in the `Given`, not in a verb.
382
+ // Reported under AF and EF alike on a real project (#660). Two structural signals now count:
383
+ // no `When` at all (a guard by construction), or a `Given` the basic flow never establishes
384
+ // (a precondition branch). Vocabulary remains as the fallback for scenarios without step data.
385
+ const basicGivens = new Set(scenarios
386
+ .filter((sc) => phaseToken(flowIdOf(sc, contract.phases) ?? '') === phaseToken(basicPhase))
387
+ .flatMap((sc) => (sc.steps ?? []).filter((st) => st.bucket === 'given').map((st) => st.text.toLowerCase().trim())));
388
+ const branchesStructurally = (sc: ScenarioInfo): boolean => {
389
+ const steps = sc.steps ?? [];
390
+ if (steps.length === 0) return false;
391
+ // No interaction AND the journey does not complete: a guard by construction (the redirect or
392
+ // the blocked screen IS the branch). No interaction but the outcome IS reached is a different
393
+ // animal — a postcondition on the basic path — and stays subject to the checks below.
394
+ const interacts = steps.some((st) => st.bucket === 'when' || INTERACTION_VERB.test(st.text));
395
+ if (!interacts && !reachesOutcome(sc, outcome)) return true;
396
+ return steps.some((st) => st.bucket === 'given' && !basicGivens.has(st.text.toLowerCase().trim()));
397
+ };
374
398
  for (const s of scenarios) {
375
399
  const id = flowIdOf(s, contract.phases);
376
400
  if (!id || phaseToken(id) === phaseToken(basicPhase)) continue;
377
401
  const ph = phaseToken(id);
402
+ if (branchesStructurally(s)) continue;
378
403
  if (ph === 'EF' && reachesOutcome(s, outcome)) {
379
404
  // An error-then-recover flow legitimately ends at the outcome — it is the recovery that
380
405
  // is being proven, and a guard flow is blocked rather than "failed". Only a scenario with
@@ -415,8 +440,16 @@ export function flowQuality(unitDir: string, scenarios: ScenarioInfo[]): FlowQua
415
440
  // guard/error phase is evidence of a SECOND business goal in this flow.
416
441
  const declaredPhases = contract.phases;
417
442
  const basic = declaredPhases.filter((p) => p !== 'UI')[0];
443
+ // A flow the inventory declares WITH ITS OWN outcome is measured against that outcome, never
444
+ // against the use case's. A spec that keeps an entry variant deliberately light ("prove the
445
+ // entry reaches the action bar; do not repeat the full assertion") could only satisfy the old
446
+ // check by widening the scenario — which the human reviewer then called over-testing. The gate
447
+ // and the reviewer were optimising different things; the declaration is where they agree (#660).
448
+ const ownOutcome = new Set((contract.flows ?? []).filter((f) => f.outcome).map((f) => f.id));
418
449
  const offGoalScenarios = scenarios.filter((s) => {
419
450
  if (touchesOutcome(s, outcomeScreen)) return false;
451
+ const fid = flowIdOf(s, declaredPhases);
452
+ if (fid && ownOutcome.has(fid)) return false;
420
453
  const ph = phaseOf(s, declaredPhases, contract.phaseDetails);
421
454
  // Any NON-basic declared phase (guards, error recovery, alternate branches) legitimately
422
455
  // stops before the outcome — that is what a branch IS. Only an unclassified scenario that
@@ -470,7 +503,7 @@ export function flowQuality(unitDir: string, scenarios: ScenarioInfo[]): FlowQua
470
503
  const steps = s.steps ?? [];
471
504
  let current: string | null = null;
472
505
  for (let i = 0; i < steps.length; i++) {
473
- const ns = (steps[i].text.match(/\[([A-Za-z0-9_.-]+):/) || [])[1]?.toLowerCase() ?? null;
506
+ const ns = refNamespaces(steps[i].text)[0] ?? null;
474
507
  if (!ns) continue;
475
508
  if (current !== null && ns !== current) {
476
509
  total++;
@@ -25,8 +25,14 @@ export interface NextStep {
25
25
  command: string;
26
26
  /** Why it is next — stated so an agent can weigh it, not just obey it. */
27
27
  because: string;
28
- /** `blocked` steps are not runnable yet; they name what to resolve first. */
29
- kind: 'do' | 'blocked' | 'optional';
28
+ /**
29
+ * `blocked` steps are not runnable yet; they name what to resolve first. `decide` steps are a
30
+ * HUMAN'S to run: an agent presents them and stops. Accepting the viewpoint baseline is the
31
+ * case — offered as a plain `do`, an agent that had just rewritten `test-viewpoint.md` from the
32
+ * spec ran it, and `atomicLedger`/`traceability` read 100% against a checklist it wrote itself
33
+ * in the same session (#660).
34
+ */
35
+ kind: 'do' | 'blocked' | 'optional' | 'decide';
30
36
  }
31
37
 
32
38
  export interface UnitState {
@@ -228,9 +234,9 @@ export function deriveSteps(s: UnitState): NextStep[] {
228
234
  }
229
235
  if (s.audit.viewpointBaselineStatus === 'changed') {
230
236
  out.push({
231
- kind: 'do',
237
+ kind: 'decide',
232
238
  command: `sungen audit --screen ${s.unit} --accept-viewpoint`,
233
- because: 'test-viewpoint.md changed since the accepted baseline, so the ledger and traceability axes are not evidence until you confirm the new declaration',
239
+ because: 'test-viewpoint.md changed since the accepted baseline, so the ledger and traceability axes are not evidence until a QA confirms the new declaration. A decision, not a step: an agent presents it and does not run it — accepting the yardstick you just wrote makes both axes read 100% by construction',
234
240
  });
235
241
  }
236
242
  const repair = s.audit.findings
@@ -659,7 +659,15 @@ const CLAIM_RULES: ClaimRule[] = [
659
659
  // category behind "browser back does not re-submit", "does not re-charge the card",
660
660
  // "double-click does not create two orders" — not a per-feature keyword.
661
661
  claim: 'no-side-effect/no-duplicate',
662
- 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,
662
+ // The negation must attach to an EFFECT — something created, sent, stored, charged, fired
663
+ // twice — not to the INPUT being refused. "duplicate address is rejected" is an observable
664
+ // rejection, proven by the error assertion in front of the user; "no duplicate record is
665
+ // created" is an absence nothing on the page can show. The rule used to fire on any action
666
+ // word beside any negation word, so ordinary rejection titles were told to go find a DB count,
667
+ // and renaming around the trigger words became the workflow (#660). Two shapes now qualify:
668
+ // (a) a negation followed by an effect token, (b) a repeat token (twice / again / double- or
669
+ // re-submit / second …) beside a negation, "only once" or a disabled control.
670
+ title: /(?:\b(?:no|not|n['’]t|never|without|cannot|không|chưa)\b[^.;]{0,40}?\b(?:creat(?:e|es|ed|ion)|record(?:s|ed)?|rows?|entr(?:y|ies)|sen[dt]|resen[dt]|charg(?:e|es|ed)|order(?:s|ed)?|payment|paid|insert(?:s|ed)?|submit(?:s|ted)?|submission|stor(?:e|es|ed)|sav(?:e|es|ed)|persist(?:s|ed)?|fir(?:e|es|ed)|issu(?:e|es|ed)|post(?:s|ed)?|email(?:s|ed)?|request(?:s|ed)?|\botp\b|twice|again|second|double|duplicate[sd]?|re-?submit(?:s|ted)?|tạo|gửi|lưu)\b)|(?=.*\b(?:double[- ]?(?:submit|click|tap|activation)|re-?submi(?:t|ts|tted|ssion)|twice|again|second (?:submit|submission|request|click|tap|order|charge|email|record))\b)(?=.*\b(?:no|not|n['’]t|never|without|cannot|prevent(?:s|ed)?|block(?:s|ed)?|avoid(?:s|ed)?|only once|exactly one|single|idempotent|disabled|không|chưa)\b)/i,
663
671
  // `is disabled` counts: when the spec's own mechanism against a repeat is "the control
664
672
  // is disabled immediately" (FR-014-style), asserting the disabled state IS the contrast —
665
673
  // the second activation cannot occur. Without it the canonical double-submit proof shape
@@ -96,7 +96,7 @@ dropped along the way stops being missing from anything. `sungen audit` now repo
96
96
  `VIEWPOINT-ADOPTED-POST-HOC` on the first sighting of a viewpoint alongside an existing suite and
97
97
  holds `atomicLedger` + `traceability` as unverified until a human accepts it — so the honest move
98
98
  is to write the claims from `spec.md` and the contract, mark plainly that they are a draft for QA
99
- review, and let the QA revise before `--accept-viewpoint`.
99
+ review, and let the QA revise before `--accept-viewpoint`. **You never run `--accept-viewpoint` yourself** — `sungen next` lists it as a `⚖ decide` step for exactly this reason: an agent accepting the yardstick it wrote in the same session makes `atomicLedger` and `traceability` read 100% against its own checklist, and the Exception Flow it forgot stops being missing from anything. Present the acceptance to the QA (`AskUserQuestion`) with what the file declares, and stop.
100
100
 
101
101
  **When you give a claim an id, a scenario must carry that id.** The id is the contract: a claim
102
102
  whose id no scenario carries is reported MISSING, and word overlap will not rescue it. Several
@@ -68,6 +68,23 @@ Use these when repairing GATE/DEPTH findings for the hard viewpoints (cart/detai
68
68
  4. **EP/data families are OK.** A `duplicates` cluster with `sameDataLikely=false` is an intentional equivalence-partition family (e.g. many invalid-email cases) — keep it; only collapse `sameDataLikely=true` exact duplicates.
69
69
  5. **Advisory findings — surface, don't gate.** `MANUAL-REASON-MISMATCH` → fix the scenario's `@manual:Mx` code (so the planner recommends the right driver) during repair. `CAPABILITY-SUGGESTION` → **present it to the user as a next-step option** (e.g. "N @manual could be automated — `sungen capability add api db`?"), **recommend-only — never auto-install**. `VERIFICATION-OUT-OF-SCOPE (api|db)` → the unit uses `@api`/`@query`/`@requires:api|db` verification the project's recorded **test type** never opted into (E2E/UI-only per `capabilities.yaml`, and the viewpoint doesn't ask for it). During repair: **rewrite the oracle to be UI-observable** (or downgrade the deep check to an `(optional deep check: …)` note); only keep the API/DB verification if the project genuinely tests that layer — then record it (`verification: [ui, api]` / `sungen capability add`). Keep API/DB-in-E2E to the ≤20% band. Neither of these fails the gate.
70
70
 
71
+ 6. **A gate that contradicts the spec's intent is a question, not a repair.** When the only way to
72
+ satisfy a finding is to change what the spec deliberately asked for — widening an entry variant the
73
+ spec keeps light so `FLOW-SCOPE-CREEP` stops firing, renaming a title around a trigger word so a
74
+ claim rule stops firing, adding an assertion the spec never demands — **stop and put the conflict
75
+ to the user** (`AskUserQuestion`: the finding, the spec passage it collides with, the two ways out).
76
+ A score reached by editing toward the gate is not a measurement of the suite; it is a measurement
77
+ of how well the gate was gamed, and the human reviewer will read it as over-testing or a false
78
+ claim. Where the harness provides a declaration route — `flows:` with a per-flow `outcome:`, a
79
+ `status: deferred` with a reason, `@manual` naming the gesture — that route is the fix, never the
80
+ scenario.
81
+ 7. **`--accept-viewpoint` is a QA decision. Never run it.** `atomicLedger` and `traceability` are
82
+ evidence only while `test-viewpoint.md` is independent of what was generated. `sungen next` lists
83
+ the acceptance as a `⚖ decide` step: present it (the diff of declared ids, what was added, what was
84
+ dropped) and let the QA run the command. An agent that accepts the yardstick it just wrote makes
85
+ both axes read 100% by construction — and the spec's Exception Flow it forgot stops being missing
86
+ from anything.
87
+
71
88
  ## Discovery / fallback tree (when input is limited)
72
89
 
73
90
  ```
@@ -340,6 +340,9 @@ Security: [S1 – admin only]
340
340
  `sungen audit` enforces these. Generate compliant output up front:
341
341
 
342
342
  1. **Taxonomy-match** (`VP-TAXONOMY-MISMATCH`, gate-FAIL) — when `test-viewpoint.md` declares its own viewpoint IDs (e.g. `VP0`, `VP1`, … `VP12`, `MS-HP-001`, `MS-EH-001`), **reuse those IDs verbatim as the scenario codes**. Do NOT invent a generic `VP-UI / VP-LOGIC / VP-VAL` scheme — that breaks the coverage matrix. Only fall back to `VP-<CATEGORY>-<NNN>` when the viewpoint file declares no IDs.
343
+ - **`--accept-viewpoint` is the QA's command, not yours.** `sungen next` shows it as a `⚖ decide`
344
+ step. Present the declared-id diff and stop; a yardstick accepted by the process it measures
345
+ reads 100% by construction.
343
346
  - **Match the scenarios to the file — never the file to the scenarios.** A filled `test-viewpoint.md` is an input; do not rewrite its declarations to fit what you generated. That is not compliance, it is moving the yardstick: `traceability` + `atomicLedger` then read 100% by construction and a dropped viewpoint stops being missing from anything. Disagree with the taxonomy → propose the diff and ask. `sungen audit` reports `VIEWPOINT-BASELINE-CHANGED` and excludes both axes until a human accepts the change (`sungen audit --screen <name> --accept-viewpoint`).
344
347
  2. **Spec-coverage triggers** (`TRIGGER-UNCOVERED`, gate-FAIL) — the Validation-Rules table lists a **trigger** per constraint (e.g. `blur, submit`). Generate one scenario **per (constraint × trigger)** — a `format` rule validating *on blur AND on submit* needs BOTH a blur scenario (`press Tab`) and a submit scenario (`click [Submit]` / `press Enter`). Never collapse the trigger × input matrix to one representative case.
345
348
  3. **Claim-Proof** (`CLAIM-UNPROVEN`) — a title claiming `all`/`only`/`every`/`single`/`correct`/`same`/`changes`/`hidden`/`cleared`/`restored`/`independent`/`sanitized`/`announces` MUST have the matching assertion (`see all …`, count, `remember`+compare, `is hidden`, return-and-assert-empty, etc.). If the title promises it, the steps must prove it.
@@ -821,6 +824,13 @@ is a gap to REPORT, not to quietly absorb.
821
824
  that never touches `outcome.screen` and is not a guard (`EH`) or error-recovery (`ER`) belongs in a
822
825
  DIFFERENT flow — propose the split instead of writing it here (`FLOW-SCOPE-CREEP` will flag it).
823
826
  Auth persistence across transitions is part of `EH` unless the project declares it its own phase.
827
+ **The exception is declared, not widened:** when the spec KEEPS a variant deliberately light — an
828
+ entry variant that only proves the alternative entry reaches the action bar, without repeating the
829
+ full assertion the basic flow already carries — declare that flow in `flows:` with its own
830
+ `outcome:` and the audit judges it against that outcome. Never widen the scenario to satisfy the
831
+ gate; a reviewer reads the result as over-testing, and the finding was the thing that was wrong.
832
+ When a gate and the spec's stated intent collide and no declaration route exists, stop and put the
833
+ conflict to the user — do not edit toward the score.
824
834
 
825
835
  **Manual in flows**: always `@manual:Mx` with the reason code — bare `@manual` is flagged
826
836
  (`MANUAL-CODE-MISSING`) because the capability planner cannot route it. Typical flow deferrals:
@@ -847,7 +857,7 @@ Feature: Award Submission Flow
847
857
 
848
858
  @high
849
859
  Scenario: FL-EH-001 Direct access to the award form without login redirects to login
850
- When User go to [Awards] page
860
+ Given User is on [Awards] page
851
861
  Then User see [Login] page
852
862
  ```
853
863
 
@@ -0,0 +1,43 @@
1
+ /**
2
+ * ONE vocabulary for what a `[Reference]` means.
3
+ *
4
+ * The compiler's documented key rule is "the lowercase of the `[Reference]` text, spaces
5
+ * preserved" (`SelectorResolver.generateKey`). The harness re-implemented that rule with its own
6
+ * regex — `/\[([a-z0-9_.-]+):/` — which has no space in it, so a multi-word screen such as
7
+ * `[Basic Info:Email]` compiled, ran and passed, and was NEVER seen by `touchesOutcome`,
8
+ * `reachesOutcome`, phase coverage, handoffs or the navigation-target check. A real project
9
+ * hyphenated every namespace with zero functional change and its audit went 6.9 → 8.9 (#660).
10
+ *
11
+ * Two rules, one place:
12
+ * - `normalizeRefLabel` IS the compiler's key rule. The compiler delegates here.
13
+ * - `screenKey` is the COMPARISON form the harness uses for a screen/namespace: separators are
14
+ * equivalent (`basic info` ≡ `basic-info` ≡ `basic_info`), because a contract author writes
15
+ * `outcome.screen: basic-info` as readily as the feature writes `[Basic Info:…]`, and the
16
+ * harness only ever asks "is this the same screen?" — never "which YAML key resolves?".
17
+ */
18
+
19
+ /** The compiler's selector-key rule: NFC, lowercase, trimmed, inner whitespace collapsed. */
20
+ export function normalizeRefLabel(label: string): string {
21
+ return label.normalize('NFC').toLowerCase().trim().replace(/\s+/g, ' ');
22
+ }
23
+
24
+ /** Comparison form for a screen / namespace name — separator-insensitive. */
25
+ export function screenKey(label: string): string {
26
+ return normalizeRefLabel(label).replace(/[\s_-]+/g, ' ').trim();
27
+ }
28
+
29
+ /**
30
+ * Every namespace a piece of step text references, in order, as `screenKey`s.
31
+ * `[Screen:Element]` → `screen`; a bare `[Screen]` page reference is included too when `bare`
32
+ * is set (a `see [Done] page` assertion names the screen without an element).
33
+ */
34
+ export function refNamespaces(text: string, opts: { bare?: boolean } = {}): string[] {
35
+ const out: string[] = [];
36
+ for (const m of text.matchAll(/\[([^\]]+)\]/g)) {
37
+ const inner = m[1];
38
+ const colon = inner.indexOf(':');
39
+ if (colon !== -1) out.push(screenKey(inner.slice(0, colon)));
40
+ else if (opts.bare) out.push(screenKey(inner));
41
+ }
42
+ return out;
43
+ }