@sun-asterisk/sungen 3.2.21 → 3.2.22-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 (103) hide show
  1. package/dist/cli/commands/audit.d.ts.map +1 -1
  2. package/dist/cli/commands/audit.js +8 -0
  3. package/dist/cli/commands/audit.js.map +1 -1
  4. package/dist/cli/commands/delivery.d.ts +3 -0
  5. package/dist/cli/commands/delivery.d.ts.map +1 -1
  6. package/dist/cli/commands/delivery.js +15 -0
  7. package/dist/cli/commands/delivery.js.map +1 -1
  8. package/dist/cli/commands/inspect.d.ts +41 -0
  9. package/dist/cli/commands/inspect.d.ts.map +1 -0
  10. package/dist/cli/commands/inspect.js +134 -0
  11. package/dist/cli/commands/inspect.js.map +1 -0
  12. package/dist/cli/commands/trace.d.ts.map +1 -1
  13. package/dist/cli/commands/trace.js +9 -0
  14. package/dist/cli/commands/trace.js.map +1 -1
  15. package/dist/cli/index.js +2 -0
  16. package/dist/cli/index.js.map +1 -1
  17. package/dist/exporters/matrix/build.d.ts +10 -0
  18. package/dist/exporters/matrix/build.d.ts.map +1 -1
  19. package/dist/exporters/matrix/build.js +38 -0
  20. package/dist/exporters/matrix/build.js.map +1 -1
  21. package/dist/exporters/matrix/export.d.ts.map +1 -1
  22. package/dist/exporters/matrix/export.js +11 -0
  23. package/dist/exporters/matrix/export.js.map +1 -1
  24. package/dist/exporters/matrix/render-xlsx.d.ts.map +1 -1
  25. package/dist/exporters/matrix/render-xlsx.js +44 -1
  26. package/dist/exporters/matrix/render-xlsx.js.map +1 -1
  27. package/dist/exporters/matrix/types.d.ts +10 -0
  28. package/dist/exporters/matrix/types.d.ts.map +1 -1
  29. package/dist/exporters/matrix/types.js.map +1 -1
  30. package/dist/exporters/playwright-report-parser.d.ts.map +1 -1
  31. package/dist/exporters/playwright-report-parser.js +1 -0
  32. package/dist/exporters/playwright-report-parser.js.map +1 -1
  33. package/dist/exporters/types.d.ts +2 -0
  34. package/dist/exporters/types.d.ts.map +1 -1
  35. package/dist/generators/test-generator/diagnostics.d.ts +6 -1
  36. package/dist/generators/test-generator/diagnostics.d.ts.map +1 -1
  37. package/dist/generators/test-generator/diagnostics.js +5 -0
  38. package/dist/generators/test-generator/diagnostics.js.map +1 -1
  39. package/dist/generators/test-generator/patterns/index.d.ts.map +1 -1
  40. package/dist/generators/test-generator/patterns/index.js +27 -19
  41. package/dist/generators/test-generator/patterns/index.js.map +1 -1
  42. package/dist/generators/test-generator/step-mapper.d.ts.map +1 -1
  43. package/dist/generators/test-generator/step-mapper.js +22 -0
  44. package/dist/generators/test-generator/step-mapper.js.map +1 -1
  45. package/dist/harness/audit.d.ts +2 -0
  46. package/dist/harness/audit.d.ts.map +1 -1
  47. package/dist/harness/audit.js +101 -10
  48. package/dist/harness/audit.js.map +1 -1
  49. package/dist/harness/flow-contract.d.ts +87 -0
  50. package/dist/harness/flow-contract.d.ts.map +1 -0
  51. package/dist/harness/flow-contract.js +259 -0
  52. package/dist/harness/flow-contract.js.map +1 -0
  53. package/dist/harness/flow-plan.d.ts +3 -0
  54. package/dist/harness/flow-plan.d.ts.map +1 -1
  55. package/dist/harness/flow-plan.js +6 -2
  56. package/dist/harness/flow-plan.js.map +1 -1
  57. package/dist/harness/parse.d.ts +5 -0
  58. package/dist/harness/parse.d.ts.map +1 -1
  59. package/dist/harness/parse.js +29 -1
  60. package/dist/harness/parse.js.map +1 -1
  61. package/dist/harness/perf.d.ts +40 -0
  62. package/dist/harness/perf.d.ts.map +1 -0
  63. package/dist/harness/perf.js +136 -0
  64. package/dist/harness/perf.js.map +1 -0
  65. package/dist/harness/sensors.d.ts.map +1 -1
  66. package/dist/harness/sensors.js +13 -1
  67. package/dist/harness/sensors.js.map +1 -1
  68. package/dist/harness/spec-coverage.d.ts +8 -0
  69. package/dist/harness/spec-coverage.d.ts.map +1 -1
  70. package/dist/harness/spec-coverage.js +60 -6
  71. package/dist/harness/spec-coverage.js.map +1 -1
  72. package/dist/orchestrator/templates/ai-src/commands/add-flow.md +51 -3
  73. package/dist/orchestrator/templates/ai-src/commands/create-test.md +10 -0
  74. package/dist/orchestrator/templates/ai-src/commands/run-test.md +23 -0
  75. package/dist/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +60 -16
  76. package/dist/orchestrator/templates/qa-context.md +14 -1
  77. package/package.json +3 -3
  78. package/src/cli/commands/audit.ts +8 -0
  79. package/src/cli/commands/delivery.ts +14 -2
  80. package/src/cli/commands/inspect.ts +128 -0
  81. package/src/cli/commands/trace.ts +9 -0
  82. package/src/cli/index.ts +2 -0
  83. package/src/exporters/matrix/build.ts +40 -0
  84. package/src/exporters/matrix/export.ts +11 -0
  85. package/src/exporters/matrix/render-xlsx.ts +45 -1
  86. package/src/exporters/matrix/types.ts +10 -0
  87. package/src/exporters/playwright-report-parser.ts +2 -0
  88. package/src/exporters/types.ts +2 -0
  89. package/src/generators/test-generator/diagnostics.ts +6 -1
  90. package/src/generators/test-generator/patterns/index.ts +30 -24
  91. package/src/generators/test-generator/step-mapper.ts +22 -0
  92. package/src/harness/audit.ts +104 -11
  93. package/src/harness/flow-contract.ts +261 -0
  94. package/src/harness/flow-plan.ts +10 -3
  95. package/src/harness/parse.ts +31 -1
  96. package/src/harness/perf.ts +112 -0
  97. package/src/harness/sensors.ts +13 -1
  98. package/src/harness/spec-coverage.ts +55 -5
  99. package/src/orchestrator/templates/ai-src/commands/add-flow.md +51 -3
  100. package/src/orchestrator/templates/ai-src/commands/create-test.md +10 -0
  101. package/src/orchestrator/templates/ai-src/commands/run-test.md +23 -0
  102. package/src/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +60 -16
  103. package/src/orchestrator/templates/qa-context.md +14 -1
@@ -603,24 +603,67 @@ error:
603
603
 
604
604
  ## Flow Test Generation
605
605
 
606
+ > **Terminology**: many QA teams call this level **System Test** (ISTQB: verify the fully
607
+ > integrated system against its specified requirements, designed around business
608
+ > processes/use cases). In sungen a *flow* is that system-test suite run through the real UI;
609
+ > its automation then serves as the regression suite on later runs. The names are
610
+ > interchangeable in conversation; the artifacts stay `qa/flows/<name>/`.
611
+
612
+ **Representative, not exhaustive (ISTQB system-test design rule).** A flow case takes ONE
613
+ representative input per branch — one valid value for the main flow, one error value that
614
+ triggers the exception branch and proves the journey recovers. The full equivalence/boundary
615
+ matrix belongs to the OWNING SCREEN (as `@cases` there), never re-run inside the flow: if the
616
+ screen already proved "the field rejects negatives", the flow only proves "the journey survives
617
+ one rejected input". `sungen audit` reports `FLOW-EXHAUSTIVE-INPUTS` when ≥3 same-shape flow
618
+ scenarios differ only in data. **Mocked dependencies**: a flow scenario using `@mock` is
619
+ delivered as *Pass (Mocked)* — verified handling logic, not the real dependency; the delivery's
620
+ External Dependency Tracking table lists it until re-verified real.
621
+
622
+ **Release selection & external legs**: when the contract declares `golden: true`, tag the
623
+ happy-path scenario(s) that prove the outcome with **`@golden`** — that is the set
624
+ `sungen inspect` (Final Inspection, Go/No-Go) runs before a release; `GOLDEN-MISSING` fires
625
+ until it exists, and a @golden scenario that passes only against mocks BLOCKS the release.
626
+ When the contract declares `external:` legs (another team/vendor's system), scenarios touching
627
+ those namespaces are the **System INTEGRATION** group — keep their oracles about the boundary
628
+ (contract/format/handoff), and expect `SIT-MOCK-ONLY` until they are re-verified real.
629
+
630
+
606
631
  > **Auto-detect**: if path is `qa/flows/<name>/` → use this section. Skip Steps 1–4 above.
607
632
 
633
+ **Read `requirements/flow-contract.yaml` FIRST — it is the flow's boundary and the yardstick
634
+ `sungen audit` scores the flow against** (`flowCoverage` axis = journey phases HP/ER/EH automated;
635
+ `FLOW-OUTCOME-UNPROVEN`; `FLOW-SCOPE-CREEP`). No contract yet → author it with the user via the
636
+ boundary checklist in `add-flow` (one business goal · clear trigger · ONE observable outcome
637
+ valuable to the actor · name = "Verb + outcome"), THEN generate. **A filled contract is an INPUT —
638
+ never rewrite it to match your output** (same rule as `test-viewpoint.md`).
639
+
608
640
  | Aspect | Screen | Flow |
609
641
  |---|---|---|
610
- | Section focus | UI patterns per section | Journey phases across screens |
642
+ | Section focus | UI patterns per section | Journey phases toward ONE declared outcome |
611
643
  | Selector format | `[Element]` | `[Screen:Element]` (namespaced) |
612
644
  | Test data keys | `{{variable}}` | `{{phase.variable}}` |
613
645
  | Feature tag | `@auto` / `@smoke` etc. | `@flow` (required) |
614
- | Viewpoints | VP-UI/VAL/LOGIC/SEC per section | VP-LOGIC (transitions), VP-SEC (auth persistence), VP-VAL (cross-screen data) |
646
+ | Scenario ids | `VP-<CATEGORY>-NNN` | `FL-<PHASE>-NNN` phases: `HP` (happy path), `ER` (error recovery), `EH` (guards), `UI` (journey UI states, optional) |
615
647
 
616
- **Scenarios to generate:**
648
+ **Scenarios to generate — every phase demanded by the contract, automated:**
617
649
 
618
- | Category | What to test |
619
- |---|---|
620
- | Happy path | Complete flow end-to-end with valid data |
621
- | Auth persistence | Auth state maintained across screen transitions |
622
- | Error recovery | Invalid input mid-flow fixcontinue |
623
- | Cross-screen data | Data entered on screen A visible on screen B |
650
+ | Phase | What to test | Scoring |
651
+ |---|---|---|
652
+ | `FL-HP` happy path | The complete journey ending in the contract's `outcome.assertion` — an AUTOMATED **data** assertion on `outcome.screen` (an order number, a summed total — not just "page visible"). This scenario is WHY the flow exists: it is the regression proof after a lib/framework upgrade. | uncovered → `flowCoverage` drops + `FLOW-OUTCOME-UNPROVEN` |
653
+ | `FL-ER` error recovery | Invalid input mid-flow error shown → fix → the journey still completes. Validation must not trap the journey. | uncovered → `flowCoverage` drops |
654
+ | `FL-EH` guards | Direct URL access without the precondition · browser back · refresh · expired context — each ends in a safe observable state. | uncovered `flowCoverage` drops |
655
+ | 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`) |
656
+ | Stateful regression (when `stateful:` declared) | Count/quantity proof · teardown (remove → empty) · multi-source add. | missing dims cap `businessDepth` (`FLOW-DEPTH`) |
657
+
658
+ **Boundary discipline while generating:** every scenario must serve the contract's goal. A scenario
659
+ that never touches `outcome.screen` and is not a guard (`EH`) or error-recovery (`ER`) belongs in a
660
+ DIFFERENT flow — propose the split instead of writing it here (`FLOW-SCOPE-CREEP` will flag it).
661
+ Auth persistence across transitions is part of `EH` unless the project declares it its own phase.
662
+
663
+ **Manual in flows**: always `@manual:Mx` with the reason code — bare `@manual` is flagged
664
+ (`MANUAL-CODE-MISSING`) because the capability planner cannot route it. Typical flow deferrals:
665
+ inbox/mail oracle → `M5`, network-request count → `M3`, context expiry control → `M7`. A
666
+ cross-screen scenario inside the flow's own goal is NOT manual — automate it here.
624
667
 
625
668
  ```gherkin
626
669
  @flow @auth:user
@@ -630,19 +673,20 @@ Feature: Award Submission Flow
630
673
  Given User is on [Login] page
631
674
 
632
675
  @high
633
- Scenario: User logs in successfully
676
+ Scenario: FL-HP-001 A signed-in user's nomination is submitted and confirmed
634
677
  When User fill [Login:Email] field with {{login.email}}
635
678
  And User fill [Login:Password] field with {{login.password}}
636
679
  And User click [Login:Submit] button
637
680
  Then User see [Dashboard] page
638
-
639
- @high
640
- Scenario: User submits nomination
641
681
  When User click [Dashboard:Awards] link
642
- Then User see [Awards] page
643
- When User fill [Awards:Nominee] field with {{submission.nominee}}
682
+ And User fill [Awards:Nominee] field with {{submission.nominee}}
644
683
  And User click [Awards:Submit] button
645
- Then User see {{success_message}} message
684
+ Then User see [Awards:Success Message] text with {{success_message}}
685
+
686
+ @high
687
+ Scenario: FL-EH-001 Direct access to the award form without login redirects to login
688
+ When User go to [Awards] page
689
+ Then User see [Login] page
646
690
  ```
647
691
 
648
692
  ```yaml
@@ -45,8 +45,21 @@ Example:
45
45
 
46
46
  ## Testing Strategy
47
47
 
48
+ Machine-readable intent — `sungen audit` reads these keys (Intent Profile). Values here are
49
+ live even when the surrounding text changes; an invalid value silently falls back to the default.
50
+
51
+ focus: functional
52
+ <!-- focus: functional | e-commerce | security | smoke — drives the audit's depth threshold -->
53
+
54
+ risk_tier: normal
55
+ <!-- risk_tier: high | normal | low -->
56
+
57
+ To silence driver suggestions in audit findings, add a line: capability_suggestions with value off.
58
+
48
59
  **Focus areas** — what to cover thoroughly:
49
- <!-- List from: functional, security, ui, accessibility, performance -->
60
+ <!-- Prose for humans; the parseable value is the `focus:` key above.
61
+ Response-time budgets are NOT a focus value — declare them in qa/perf.yaml
62
+ (percentile + scenario_ms budgets; reported by `sungen delivery`). -->
50
63
  <!-- Example: functional, security -->
51
64
 
52
65
  **Mandatory coverage:**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sun-asterisk/sungen",
3
- "version": "3.2.21",
3
+ "version": "3.2.22-beta.1",
4
4
  "description": "Deterministic E2E Test Compiler - Gherkin + Selectors → Playwright tests",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -39,8 +39,8 @@
39
39
  "@babel/types": "^7.28.5",
40
40
  "@cucumber/gherkin": "^37.0.0",
41
41
  "@cucumber/messages": "^31.0.0",
42
- "@sungen/driver-data-factory": "3.2.21",
43
- "@sungen/driver-ui": "3.2.21",
42
+ "@sungen/driver-data-factory": "3.2.22-beta.1",
43
+ "@sungen/driver-ui": "3.2.22-beta.1",
44
44
  "chalk": "^5.6.2",
45
45
  "commander": "^14.0.2",
46
46
  "dotenv": "^17.2.3",
@@ -40,6 +40,14 @@ function render(r: AuditReport): void {
40
40
  if (r.gate.gaps.length) for (const g of r.gate.gaps) L(` ✗ ${g.status === 'shallow' ? 'SHALLOW' : 'MISSING'}: ${g.theme}`);
41
41
  else L(' ✓ all critical themes covered (with data assertions)');
42
42
  L('');
43
+ if (r.flowQuality?.hasContract) {
44
+ const fq = r.flowQuality;
45
+ L(` ①F Flow contract — goal: ${fq.contract!.goal}`);
46
+ L(` outcome [${fq.contract!.outcome.screen}]: ${fq.outcomeProven ? '✓ proven (automated data assertion)' : fq.outcomeManualOnly ? '⚠ manual-only' : '✗ UNPROVEN'}`);
47
+ L(` phases: ${fq.phases.map((p) => `${p.phase}=${p.covered ? (p.automated ? '✓' : 'manual') : '✗'}`).join(' ')} → flowCoverage ${(fq.phaseRatio * 100).toFixed(0)}%`);
48
+ L(` handoffs asserted: ${fq.handoffs.asserted}/${fq.handoffs.total}${fq.offGoal.length ? ` ⚠ off-goal scenarios: ${fq.offGoal.length} (${fq.offGoalCategories.join(', ')})` : ''}`);
49
+ L('');
50
+ }
43
51
  L(` ② Assertion depth — ${r.depth.businessCriticalShallow}/${r.depth.businessCriticalTotal} business-critical scenarios are shallow (visibility/nav only)`);
44
52
  for (const s of r.depth.shallowBusinessCritical.slice(0, 8)) L(` ⚠ ${s.category}: ${s.name}`);
45
53
  if (r.depth.shallowBusinessCritical.length > 8) L(` … +${r.depth.shallowBusinessCritical.length - 8} more`);
@@ -100,7 +100,7 @@ function listFeatureTargets(cwd: string, screen: string, kind: UnitKind): Delive
100
100
  .sort((a, b) => a.featureBaseName.localeCompare(b.featureBaseName));
101
101
  }
102
102
 
103
- function listAllTargets(cwd: string): DeliveryTarget[] {
103
+ export function listAllTargets(cwd: string): DeliveryTarget[] {
104
104
  const targets: DeliveryTarget[] = [];
105
105
  const scan = (kind: UnitKind, skip: (n: string) => boolean = () => false) => {
106
106
  const root = path.join(cwd, 'qa', qaParent(kind));
@@ -629,7 +629,7 @@ async function exportTarget(
629
629
  // Matrix export (Delivery v2 — the default; --legacy keeps the classic workbook)
630
630
  // ----------------------------------------------------------------------------
631
631
 
632
- function matrixPathsFor(cwd: string, target: DeliveryTarget): MatrixTargetPaths {
632
+ export function matrixPathsFor(cwd: string, target: DeliveryTarget): MatrixTargetPaths {
633
633
  const base = qaDir(cwd, target);
634
634
  const genBase = generatedDir(cwd, target);
635
635
  return {
@@ -690,6 +690,18 @@ async function exportMatrixTarget(
690
690
  }
691
691
 
692
692
  log(`${COLOR.bold}${paths.label}${COLOR.reset} — ${model.manifest.itemCount} delivery item(s), ${model.manifest.variantCount} coverage variant(s), state: ${model.manifest.approvalState}`);
693
+ {
694
+ const mocked = model.items.flatMap((it) => it.variants.filter((v) => v.mocked));
695
+ if (mocked.length > 0) {
696
+ log(` ${COLOR.yellow}MOCKED${COLOR.reset}: ${mocked.length} variant(s) verified against mocks (${mocked.slice(0, 4).map((v) => v.ref).join(', ')}${mocked.length > 4 ? ', …' : ''}) — Pass (Mocked) ≠ Pass (Real); see the External Dependency Tracking block on the Coverage sheet.`);
697
+ }
698
+ }
699
+ if (model.perf) {
700
+ const p = model.perf;
701
+ const tone = p.pass ? COLOR.green : COLOR.yellow;
702
+ log(` ${tone}PERF ${p.pass ? 'PASS' : 'OVER BUDGET'}${COLOR.reset}: p${p.percentile} ${p.metric.replace('_ms', '')} ${p.measuredMs}ms ${p.pass ? '≤' : '>'} budget ${p.budgetMs}ms (${p.samples} run${p.samples === 1 ? '' : 's'})`);
703
+ for (const s of p.slowest) log(` ${COLOR.yellow}slowest:${COLOR.reset} ${s.ms}ms — ${s.title.slice(0, 90)}`);
704
+ }
693
705
  printFindings(model.findings);
694
706
 
695
707
  if (opts.check) {
@@ -0,0 +1,128 @@
1
+ /**
2
+ * `sungen inspect` — Final Inspection: the deterministic Go/No-Go view. (#580 P9)
3
+ *
4
+ * The QA practice this implements: ONCE per release, run only the release-critical
5
+ * (@golden) journeys on the real environment with real (masked) data, on FROZEN code,
6
+ * and answer one question — is this build safe to release? Not a place to find new
7
+ * bugs; a place to refuse a release that is not proven.
8
+ *
9
+ * Every ingredient already exists, this command only reads them:
10
+ * - selection : @golden scenarios (tags pass through to Playwright — `--grep @golden`)
11
+ * - results : the units' Playwright JSON runs (same source delivery reads)
12
+ * - freeze : the approved delivery map's fingerprints — Gate E drift = not frozen
13
+ * - honesty : a @golden variant verified against MOCKS blocks GO — Pass (Mocked)
14
+ * says nothing about the real dependency (External Dependency Tracking)
15
+ * - perf : the qa/perf.yaml verdict, when configured
16
+ *
17
+ * Exit code 0 = GO, 2 = NO-GO — CI-usable as a release gate.
18
+ */
19
+ import { Command } from 'commander';
20
+ import * as fs from 'fs';
21
+ import { listAllTargets, matrixPathsFor } from './delivery';
22
+ import { loadMatrixModel } from '../../exporters/matrix/export';
23
+
24
+ export interface UnitInspection {
25
+ unit: string;
26
+ approved: boolean;
27
+ frozen: boolean; // no Gate E drift against the approved fingerprints
28
+ goldenTotal: number;
29
+ goldenPassed: number;
30
+ goldenFailed: number;
31
+ goldenPending: number; // golden variants with no run result yet
32
+ goldenMocked: number; // golden variants verified only against mocks
33
+ perfPass: boolean | null; // null = no budget configured / no samples
34
+ reasons: string[]; // what blocks GO for this unit (empty = clean)
35
+ }
36
+
37
+ export interface ProjectInspection {
38
+ units: UnitInspection[];
39
+ goldenTotal: number;
40
+ verdict: 'GO' | 'NO-GO';
41
+ reasons: string[];
42
+ }
43
+
44
+ /** Pure aggregation over the delivery models — testable without the CLI. */
45
+ export function inspectProject(cwd: string): ProjectInspection {
46
+ const units: UnitInspection[] = [];
47
+ for (const target of listAllTargets(cwd)) {
48
+ const paths = matrixPathsFor(cwd, target);
49
+ if (!fs.existsSync(paths.mapFile)) continue; // no delivery map → not a delivered unit
50
+ const { model } = loadMatrixModel(paths);
51
+ if (!model) continue; // structural map errors surface in delivery
52
+
53
+ const variants = model.items.flatMap((it) => it.variants);
54
+ const golden = variants.filter((v) => v.golden);
55
+ if (golden.length === 0) continue; // Final Inspection reads only the golden set
56
+
57
+ const passed = golden.filter((v) => v.result?.status === 'passed');
58
+ const failed = golden.filter((v) => v.result && ['failed', 'timedOut', 'interrupted'].includes(v.result.status));
59
+ const pending = golden.filter((v) => !v.result);
60
+ const mocked = golden.filter((v) => v.mocked);
61
+ const approved = model.manifest.approvalState === 'approved';
62
+ const frozen = !model.findings.some((f) => f.gate === 'E');
63
+ const perfPass = model.perf ? model.perf.pass : null;
64
+
65
+ const reasons: string[] = [];
66
+ if (!approved) reasons.push('delivery map not approved');
67
+ if (!frozen) reasons.push('sources drifted since approval (not frozen — Gate E)');
68
+ if (failed.length) reasons.push(`${failed.length} golden variant(s) FAILED`);
69
+ if (pending.length) reasons.push(`${pending.length} golden variant(s) have no run result`);
70
+ if (mocked.length) reasons.push(`${mocked.length} golden variant(s) are mock-verified — Pass (Mocked) cannot release`);
71
+ if (perfPass === false) reasons.push('performance budget OVER (qa/perf.yaml)');
72
+
73
+ units.push({
74
+ unit: paths.label ?? target.name,
75
+ approved, frozen,
76
+ goldenTotal: golden.length,
77
+ goldenPassed: passed.length,
78
+ goldenFailed: failed.length,
79
+ goldenPending: pending.length,
80
+ goldenMocked: mocked.length,
81
+ perfPass,
82
+ reasons,
83
+ });
84
+ }
85
+
86
+ const goldenTotal = units.reduce((n, u) => n + u.goldenTotal, 0);
87
+ const reasons = units.flatMap((u) => u.reasons.map((r) => `${u.unit}: ${r}`));
88
+ if (goldenTotal === 0) reasons.push('no @golden scenario anywhere — nothing proves the release (tag the release-critical journeys)');
89
+ return { units, goldenTotal, verdict: reasons.length === 0 ? 'GO' : 'NO-GO', reasons };
90
+ }
91
+
92
+ export function registerInspectCommand(program: Command): void {
93
+ program
94
+ .command('inspect')
95
+ .description('Final Inspection: Go/No-Go over the @golden set — results, freeze, mocks, perf (exit 2 = NO-GO)')
96
+ .option('--json', 'Output the raw JSON report')
97
+ .action((options) => {
98
+ try {
99
+ const report = inspectProject(process.cwd());
100
+ if (options.json) {
101
+ console.log(JSON.stringify(report, null, 2));
102
+ } else {
103
+ const L = console.log;
104
+ L('');
105
+ L(`━━━ Final Inspection — ${report.goldenTotal} @golden variant(s) across ${report.units.length} unit(s) ━━━`);
106
+ L('');
107
+ for (const u of report.units) {
108
+ const ok = u.reasons.length === 0;
109
+ L(` ${ok ? '✓' : '✗'} ${u.unit} — golden ${u.goldenPassed}/${u.goldenTotal} passed` +
110
+ `${u.goldenMocked ? ` · ${u.goldenMocked} mocked` : ''}` +
111
+ `${u.perfPass === null ? '' : u.perfPass ? ' · perf PASS' : ' · perf OVER'}` +
112
+ ` · ${u.approved ? 'approved' : 'NOT approved'} · ${u.frozen ? 'frozen' : 'DRIFTED'}`);
113
+ for (const r of u.reasons) L(` • ${r}`);
114
+ }
115
+ L('');
116
+ L(` Verdict: ${report.verdict === 'GO' ? '✅ GO' : '⛔ NO-GO'}`);
117
+ for (const r of report.units.length === 0 ? report.reasons : (report.verdict === 'NO-GO' ? report.reasons.slice(0, 12) : [])) {
118
+ L(` • ${r}`);
119
+ }
120
+ L('');
121
+ }
122
+ if (report.verdict !== 'GO') process.exitCode = 2;
123
+ } catch (error) {
124
+ console.error('Error:', error instanceof Error ? error.message : error);
125
+ process.exit(1);
126
+ }
127
+ });
128
+ }
@@ -55,6 +55,15 @@ export function registerTraceCommand(program: Command): void {
55
55
  for (const line of r.mermaid.split('\n')) L(' ' + line);
56
56
  L(' ```');
57
57
  L('');
58
+ // ⑥ Deterministic next-step hand-back. The AI commands are INSTRUCTED to close
59
+ // every run by offering next actions, but a long generation run can end without
60
+ // it — leaving the operator guessing. Printing the choices here makes the
61
+ // hand-back survive the AI forgetting: trace is the converge step's last call.
62
+ L(' ⑥ NEXT STEPS');
63
+ L(` → /sungen:run-test ${name} (generate selectors, compile, run, auto-fix)`);
64
+ L(` → /sungen:create-test ${name} (extend coverage / next tier)`);
65
+ L(` → /sungen:delivery ${name} (export the Test Case & Coverage Matrix)`);
66
+ L('');
58
67
  } catch (error) {
59
68
  console.error('Error:', error instanceof Error ? error.message : error);
60
69
  process.exit(1);
package/src/cli/index.ts CHANGED
@@ -15,6 +15,7 @@ import { registerFigmaCommand } from './commands/figma';
15
15
  import { registerAddFlowCommand } from './commands/add-flow';
16
16
  import { registerDashboardCommand } from './commands/dashboard';
17
17
  import { registerAuditCommand } from './commands/audit';
18
+ import { registerInspectCommand } from './commands/inspect';
18
19
  import { registerDepthLintCommand } from './commands/depth-lint';
19
20
  import { registerJourneyCommand } from './commands/journey';
20
21
  import { registerGateCommand } from './commands/gate';
@@ -63,6 +64,7 @@ async function main() {
63
64
  registerAddFlowCommand(program);
64
65
  registerDashboardCommand(program);
65
66
  registerAuditCommand(program);
67
+ registerInspectCommand(program);
66
68
  registerDepthLintCommand(program);
67
69
  registerJourneyCommand(program);
68
70
  registerGateCommand(program);
@@ -32,6 +32,8 @@ import {
32
32
  MAX_VARIANTS_PER_ITEM,
33
33
  } from './types';
34
34
  import { runGates } from './gates';
35
+ import { capabilityRegistry } from '../../capabilities/registry';
36
+ import { discoverAndRegisterCapabilities } from '../../capabilities/discover';
35
37
 
36
38
  // ---------------------------------------------------------------------------
37
39
  // Variant derivation
@@ -42,6 +44,27 @@ function normalizeShape(text: string): string {
42
44
  return text.replace(/\{\{[^}]*\}\}/g, '{{*}}').replace(/\s+/g, ' ').trim();
43
45
  }
44
46
 
47
+ /**
48
+ * Is this scenario verified against MOCKED responses, and against which named deps? (#580)
49
+ * The mock capability owns its grammar: prefer the registry's `annotations` + `detectsStep`
50
+ * (driver-mock exports isMockStep — install AND call-count forms); fall back to literal
51
+ * twins of that grammar when the driver is not installed on this machine.
52
+ */
53
+ export function detectMocked(tags: string[], stepTexts: string[]): { mocked: boolean; mockDeps: string[] } {
54
+ discoverAndRegisterCapabilities();
55
+ const cap = capabilityRegistry.get('mock');
56
+ const tagForms = cap?.annotations ?? ['@mock', '@network'];
57
+ const tagged = tags.some((t) => tagForms.some((a) => t === a || t.startsWith(`${a}:`)));
58
+ const isMockStep = cap?.detectsStep ?? ((t: string) =>
59
+ /\[[^\]]+\]\s+mock\s+is\s+active\b/i.test(t) ||
60
+ /\[[^\]]+\]\s+mock\s+called\s+(?:\{\{[^}]+\}\}|\d+)\s+times?\b/i.test(t));
61
+ const mockDeps = Array.from(new Set(stepTexts
62
+ .filter((t) => isMockStep(t))
63
+ .map((t) => (t.match(/\[([^\]]+)\]\s+mock\b/i) || [])[1])
64
+ .filter((x): x is string => Boolean(x))));
65
+ return { mocked: tagged || mockDeps.length > 0, mockDeps };
66
+ }
67
+
45
68
  function deriveLayers(tags: string[]): MatrixLayer[] {
46
69
  const layers = new Set<MatrixLayer>(['ui']);
47
70
  if (tags.some((t) => t === '@api' || t.startsWith('@api:') || t === '@requires:api')) layers.add('api');
@@ -178,12 +201,29 @@ export function deriveVariants(inputs: Pick<BuildInputs, 'feature' | 'merged' |
178
201
  ...(apiCallsAreTrigger ? [] : apiRequestLines),
179
202
  ]));
180
203
 
204
+ // Mocked-run detection (#580): a pass against a scripted response must never read as a
205
+ // pass against the real dependency. The GRAMMAR authority is the mock capability itself
206
+ // (driver-mock declares `annotations` and exports `detectsStep`) — consult it instead of
207
+ // re-writing its regexes here, so new tag/step forms are picked up automatically. The
208
+ // literal fallback keeps delivery honest on machines WITHOUT the driver installed
209
+ // (delivery reads only feature text + results, it never compiles). Background steps are
210
+ // scanned too — a Background install mocks every scenario in the feature, and the
211
+ // driver's own spec warns that per-scenario tracking misses them.
212
+ const scenarioStepTexts = [
213
+ ...inputs.feature.backgroundOrderedSteps.map((st) => st.text),
214
+ ...m.feature.orderedSteps.map((st) => st.text),
215
+ ];
216
+ const { mocked, mockDeps } = detectMocked(tags, scenarioStepTexts);
217
+
181
218
  const base = {
182
219
  vpId,
183
220
  vpCategory,
184
221
  priority: extractPriority(tags),
185
222
  mode: mode as 'auto' | 'manual',
186
223
  manualReason: manualReason(tags),
224
+ mocked,
225
+ ...(mockDeps.length ? { mockDeps } : {}),
226
+ golden: tags.includes('@golden'),
187
227
  layers: deriveLayers(tags),
188
228
  traces: tags.filter((t) => t.startsWith('@spec:')).map((t) => t.slice('@spec:'.length)),
189
229
  runtimeVars: [...runtimeVars],
@@ -17,6 +17,7 @@ import { mergeFeatureAndSpec } from '../scenario-merger';
17
17
  import { getPackageVersion } from '../package-info';
18
18
  import { writeCsv } from '../csv-exporter';
19
19
  import { writeXlsx } from '../xlsx-exporter';
20
+ import { loadPerfConfig, perfVerdict } from '../../harness/perf';
20
21
  import { loadDeliveryMap, writeDeliveryMap } from './map-loader';
21
22
  import { mapContentFingerprint } from './fingerprint';
22
23
  import { buildMatrix, deriveVariants } from './build';
@@ -89,6 +90,16 @@ export function loadMatrixModel(paths: MatrixTargetPaths): MatrixLoadResult {
89
90
  specText: readSpecText(paths),
90
91
  apiCatalog: catalogFor(paths),
91
92
  });
93
+ // Perf verdict (#569): budgets are config (qa/perf.yaml), samples are the run's
94
+ // per-test durations. Advisory — reported here because delivery is where runs
95
+ // are read; the audit's score stays a pure function of the design artifacts.
96
+ const perfConfig = loadPerfConfig(paths.cwd);
97
+ if (perfConfig && results) {
98
+ const samples = [...results.values()]
99
+ .filter((r) => (r.status === 'passed' || r.status === 'failed' || r.status === 'timedOut') && typeof r.duration === 'number')
100
+ .map((r) => ({ title: r.testTitle, ms: r.duration as number }));
101
+ model.perf = perfVerdict(perfConfig, paths.unit, samples);
102
+ }
92
103
  return { model, map, mapErrors: [] };
93
104
  }
94
105
 
@@ -224,7 +224,7 @@ function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?
224
224
  // parent's steps + these read as one continuous procedure.
225
225
  numbered(item.triggerDeltas[v.ref] ?? [], item.trigger.length + 1),
226
226
  expectedWithVerification(v.oracle, v.verification),
227
- v.mode === 'manual' ? `Manual${v.manualReason ? ` (${v.manualReason})` : ''}` : 'Auto',
227
+ v.mode === 'manual' ? `Manual${v.manualReason ? ` (${v.manualReason})` : ''}` : (v.mocked ? 'Auto · Mocked' : 'Auto'),
228
228
  v.traces.join(', '),
229
229
  variantState(v),
230
230
  isoDate(v.result?.startTime),
@@ -315,6 +315,50 @@ function addCoverageSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersio
315
315
 
316
316
  let rowIdx = 7;
317
317
 
318
+ // --- Performance budget (#569) — advisory: config (qa/perf.yaml) vs measured runs.
319
+ if (model.perf) {
320
+ const p = model.perf;
321
+ dataCell(ws.getRow(rowIdx).getCell(2), 'Performance budget', { bold: true });
322
+ rowIdx++;
323
+ const r = ws.getRow(rowIdx);
324
+ dataCell(r.getCell(2), `p${p.percentile} ${p.metric.replace('_ms', ' time')}`);
325
+ dataCell(r.getCell(3), p.pass ? 'PASS' : 'OVER BUDGET', { bold: true });
326
+ dataCell(r.getCell(4), `measured ${p.measuredMs}ms`);
327
+ dataCell(r.getCell(5), `budget ${p.budgetMs}ms`);
328
+ dataCell(r.getCell(6), `${p.samples} runs`);
329
+ if (!p.pass && p.slowest.length) dataCell(r.getCell(8), `slowest: ${p.slowest.map((x) => `${x.ms}ms ${x.title.slice(0, 40)}`).join(' · ')}`);
330
+ rowIdx += 2;
331
+ }
332
+
333
+ // --- External Dependency Tracking (#580, ISTQB system-test practice): every case that
334
+ // ran against a MOCK is listed centrally. Its Result stays in the canonical vocabulary
335
+ // (the roll-up formulas depend on it); THIS table is where "Pass (Mocked) ≠ Pass (Real)"
336
+ // is visible — a case verified only against mocks is not Done until re-verified real.
337
+ {
338
+ const mockedVars = model.items.flatMap((it) => it.variants.filter((v) => v.mocked).map((v) => ({ it, v })));
339
+ if (mockedVars.length > 0) {
340
+ dataCell(ws.getRow(rowIdx).getCell(2), 'External Dependency Tracking — mock-verified cases (Pass here = Pass (Mocked), not Pass (Real))', { bold: true });
341
+ rowIdx++;
342
+ const hd = ws.getRow(rowIdx);
343
+ dataCell(hd.getCell(2), 'Variant');
344
+ dataCell(hd.getCell(3), 'Mocked dependency');
345
+ dataCell(hd.getCell(4), 'Delivery item');
346
+ dataCell(hd.getCell(5), 'Result');
347
+ dataCell(hd.getCell(8), 'Real-environment re-verify');
348
+ rowIdx++;
349
+ for (const { it, v } of mockedVars) {
350
+ const r = ws.getRow(rowIdx);
351
+ dataCell(r.getCell(2), v.ref);
352
+ dataCell(r.getCell(3), (v.mockDeps ?? []).join(', ') || '(scenario-level @mock)');
353
+ dataCell(r.getCell(4), it.id);
354
+ dataCell(r.getCell(5), variantState(v));
355
+ dataCell(r.getCell(8), '');
356
+ rowIdx++;
357
+ }
358
+ rowIdx++;
359
+ }
360
+ }
361
+
318
362
  // --- Requirement coverage (review §6): every requirement id gets ONE explicit status.
319
363
  if (model.requirements.length > 0) {
320
364
  dataCell(ws.getRow(rowIdx).getCell(2), 'Requirement coverage — every id has an explicit status', { bold: true });
@@ -92,6 +92,14 @@ export interface CoverageVariant {
92
92
  /** 'auto' | 'manual'; manual keeps its @manual:Mx reason. */
93
93
  mode: 'auto' | 'manual';
94
94
  manualReason?: string;
95
+ /** The scenario runs against MOCKED network responses (@mock / `[x] mock is active`) —
96
+ * a pass verifies the handling logic, not the real dependency (Pass (Mocked) ≠ Pass (Real),
97
+ * #580: ISTQB external-dependency tracking). */
98
+ mocked?: boolean;
99
+ /** Named mocked dependencies (`[name] mock is active` refs), for the tracking table. */
100
+ mockDeps?: string[];
101
+ /** Release-critical: tagged @golden — the Final Inspection set (`sungen inspect`, #580 P9). */
102
+ golden?: boolean;
95
103
  layers: MatrixLayer[];
96
104
  /** @spec:* requirement traces. */
97
105
  traces: string[];
@@ -225,6 +233,8 @@ export interface MatrixModel {
225
233
  requirements: RequirementCoverage[];
226
234
  findings: MatrixFinding[];
227
235
  manifest: MatrixManifest;
236
+ /** Perf verdict (#569) — present when qa/perf.yaml sets a budget AND the run produced durations. */
237
+ perf?: import('../../harness/perf').PerfVerdict | null;
228
238
  }
229
239
 
230
240
  /** Complexity warning threshold (rules draft Gate I; raised for compact grouping) — echoed in the manifest. */
@@ -54,6 +54,7 @@ interface PlaywrightSpec {
54
54
  projectId?: string;
55
55
  results?: Array<{
56
56
  status?: string; // 'passed' | 'failed' | 'timedOut' | 'skipped' | 'interrupted'
57
+ duration?: number; // ms — Playwright emits this on every result (#569 perf budgets)
57
58
  startTime?: string;
58
59
  error?: { message?: string; stack?: string };
59
60
  attachments?: Array<{ name?: string; path?: string; contentType?: string }>;
@@ -120,6 +121,7 @@ export function loadPlaywrightReport(reportPath: string): Map<string, Playwright
120
121
  result.set(stripped, {
121
122
  testTitle: stripped,
122
123
  status,
124
+ duration: typeof res?.duration === 'number' ? res.duration : undefined,
123
125
  startTime: res?.startTime,
124
126
  error: errorMsg,
125
127
  tracePath: trace,
@@ -121,6 +121,8 @@ export interface SpecFileData {
121
121
  export interface PlaywrightResult {
122
122
  testTitle: string;
123
123
  status: 'passed' | 'failed' | 'skipped' | 'timedOut' | 'interrupted' | 'unknown';
124
+ /** Wall-clock run time in ms (from Playwright's results[].duration) — perf budgets (#569). */
125
+ duration?: number;
124
126
  startTime?: string;
125
127
  error?: string;
126
128
  tracePath?: string;
@@ -35,12 +35,17 @@
35
35
  * nothing for the query half to assert, so the step checks the PATH only. Not an
36
36
  * error (the value may legitimately vary per environment), but the step asserts
37
37
  * strictly less than it appears to
38
+ * SG-W015 — `see [X] card/item with {{n}}` where the value is a bare number: this compiles
39
+ * to a TEXT assertion (toHaveText('3')), NOT a count — an author reaching for
40
+ * "3 cards are shown" gets an assertion that can only pass if the card's text
41
+ * IS the digit. Count semantics exist on row/table ("see [T] table with {{n}}",
42
+ * "see [Ref] row with {{n}}") and lists ("should have count") only
38
43
  *
39
44
  * Diagnostics never block generation by themselves; `sungen generate --strict`
40
45
  * turns any collected diagnostic into a non-zero exit.
41
46
  */
42
47
 
43
- export type DiagnosticCode = 'SG-W001' | 'SG-W002' | 'SG-W003' | 'SG-W005' | 'SG-W006' | 'SG-W007' | 'SG-W008' | 'SG-W009' | 'SG-W010' | 'SG-W011' | 'SG-W012' | 'SG-W013' | 'SG-W014';
48
+ export type DiagnosticCode = 'SG-W001' | 'SG-W002' | 'SG-W003' | 'SG-W005' | 'SG-W006' | 'SG-W007' | 'SG-W008' | 'SG-W009' | 'SG-W010' | 'SG-W011' | 'SG-W012' | 'SG-W013' | 'SG-W014' | 'SG-W015';
44
49
 
45
50
  export interface Diagnostic {
46
51
  code: DiagnosticCode;