@sun-asterisk/sungen 3.2.20-beta.1 → 3.2.20-beta.2

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 (63) 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.map +1 -1
  5. package/dist/cli/commands/delivery.js +7 -0
  6. package/dist/cli/commands/delivery.js.map +1 -1
  7. package/dist/exporters/matrix/export.d.ts.map +1 -1
  8. package/dist/exporters/matrix/export.js +11 -0
  9. package/dist/exporters/matrix/export.js.map +1 -1
  10. package/dist/exporters/matrix/render-xlsx.d.ts.map +1 -1
  11. package/dist/exporters/matrix/render-xlsx.js +15 -0
  12. package/dist/exporters/matrix/render-xlsx.js.map +1 -1
  13. package/dist/exporters/matrix/types.d.ts +2 -0
  14. package/dist/exporters/matrix/types.d.ts.map +1 -1
  15. package/dist/exporters/matrix/types.js.map +1 -1
  16. package/dist/exporters/playwright-report-parser.d.ts.map +1 -1
  17. package/dist/exporters/playwright-report-parser.js +1 -0
  18. package/dist/exporters/playwright-report-parser.js.map +1 -1
  19. package/dist/exporters/types.d.ts +2 -0
  20. package/dist/exporters/types.d.ts.map +1 -1
  21. package/dist/harness/audit.d.ts +2 -0
  22. package/dist/harness/audit.d.ts.map +1 -1
  23. package/dist/harness/audit.js +72 -9
  24. package/dist/harness/audit.js.map +1 -1
  25. package/dist/harness/flow-contract.d.ts +71 -0
  26. package/dist/harness/flow-contract.d.ts.map +1 -0
  27. package/dist/harness/flow-contract.js +235 -0
  28. package/dist/harness/flow-contract.js.map +1 -0
  29. package/dist/harness/flow-plan.d.ts +3 -0
  30. package/dist/harness/flow-plan.d.ts.map +1 -1
  31. package/dist/harness/flow-plan.js +6 -2
  32. package/dist/harness/flow-plan.js.map +1 -1
  33. package/dist/harness/parse.d.ts +5 -0
  34. package/dist/harness/parse.d.ts.map +1 -1
  35. package/dist/harness/parse.js +29 -1
  36. package/dist/harness/parse.js.map +1 -1
  37. package/dist/harness/perf.d.ts +40 -0
  38. package/dist/harness/perf.d.ts.map +1 -0
  39. package/dist/harness/perf.js +136 -0
  40. package/dist/harness/perf.js.map +1 -0
  41. package/dist/harness/sensors.d.ts.map +1 -1
  42. package/dist/harness/sensors.js +13 -1
  43. package/dist/harness/sensors.js.map +1 -1
  44. package/dist/orchestrator/templates/ai-src/commands/add-flow.md +41 -3
  45. package/dist/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +35 -16
  46. package/dist/orchestrator/templates/qa-context.md +14 -1
  47. package/package.json +3 -3
  48. package/src/cli/commands/audit.ts +8 -0
  49. package/src/cli/commands/delivery.ts +6 -0
  50. package/src/exporters/matrix/export.ts +11 -0
  51. package/src/exporters/matrix/render-xlsx.ts +15 -0
  52. package/src/exporters/matrix/types.ts +2 -0
  53. package/src/exporters/playwright-report-parser.ts +2 -0
  54. package/src/exporters/types.ts +2 -0
  55. package/src/harness/audit.ts +75 -10
  56. package/src/harness/flow-contract.ts +229 -0
  57. package/src/harness/flow-plan.ts +10 -3
  58. package/src/harness/parse.ts +31 -1
  59. package/src/harness/perf.ts +112 -0
  60. package/src/harness/sensors.ts +13 -1
  61. package/src/orchestrator/templates/ai-src/commands/add-flow.md +41 -3
  62. package/src/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +35 -16
  63. package/src/orchestrator/templates/qa-context.md +14 -1
@@ -605,22 +605,40 @@ error:
605
605
 
606
606
  > **Auto-detect**: if path is `qa/flows/<name>/` → use this section. Skip Steps 1–4 above.
607
607
 
608
+ **Read `requirements/flow-contract.yaml` FIRST — it is the flow's boundary and the yardstick
609
+ `sungen audit` scores the flow against** (`flowCoverage` axis = journey phases HP/ER/EH automated;
610
+ `FLOW-OUTCOME-UNPROVEN`; `FLOW-SCOPE-CREEP`). No contract yet → author it with the user via the
611
+ boundary checklist in `add-flow` (one business goal · clear trigger · ONE observable outcome
612
+ valuable to the actor · name = "Verb + outcome"), THEN generate. **A filled contract is an INPUT —
613
+ never rewrite it to match your output** (same rule as `test-viewpoint.md`).
614
+
608
615
  | Aspect | Screen | Flow |
609
616
  |---|---|---|
610
- | Section focus | UI patterns per section | Journey phases across screens |
617
+ | Section focus | UI patterns per section | Journey phases toward ONE declared outcome |
611
618
  | Selector format | `[Element]` | `[Screen:Element]` (namespaced) |
612
619
  | Test data keys | `{{variable}}` | `{{phase.variable}}` |
613
620
  | 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) |
621
+ | Scenario ids | `VP-<CATEGORY>-NNN` | `FL-<PHASE>-NNN` phases: `HP` (happy path), `ER` (error recovery), `EH` (guards), `UI` (journey UI states, optional) |
615
622
 
616
- **Scenarios to generate:**
623
+ **Scenarios to generate — every phase demanded by the contract, automated:**
617
624
 
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 |
625
+ | Phase | What to test | Scoring |
626
+ |---|---|---|
627
+ | `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` |
628
+ | `FL-ER` error recovery | Invalid input mid-flow error shown → fix → the journey still completes. Validation must not trap the journey. | uncovered → `flowCoverage` drops |
629
+ | `FL-EH` guards | Direct URL access without the precondition · browser back · refresh · expired context — each ends in a safe observable state. | uncovered `flowCoverage` drops |
630
+ | 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`) |
631
+ | Stateful regression (when `stateful:` declared) | Count/quantity proof · teardown (remove → empty) · multi-source add. | missing dims cap `businessDepth` (`FLOW-DEPTH`) |
632
+
633
+ **Boundary discipline while generating:** every scenario must serve the contract's goal. A scenario
634
+ that never touches `outcome.screen` and is not a guard (`EH`) or error-recovery (`ER`) belongs in a
635
+ DIFFERENT flow — propose the split instead of writing it here (`FLOW-SCOPE-CREEP` will flag it).
636
+ Auth persistence across transitions is part of `EH` unless the project declares it its own phase.
637
+
638
+ **Manual in flows**: always `@manual:Mx` with the reason code — bare `@manual` is flagged
639
+ (`MANUAL-CODE-MISSING`) because the capability planner cannot route it. Typical flow deferrals:
640
+ inbox/mail oracle → `M5`, network-request count → `M3`, context expiry control → `M7`. A
641
+ cross-screen scenario inside the flow's own goal is NOT manual — automate it here.
624
642
 
625
643
  ```gherkin
626
644
  @flow @auth:user
@@ -630,19 +648,20 @@ Feature: Award Submission Flow
630
648
  Given User is on [Login] page
631
649
 
632
650
  @high
633
- Scenario: User logs in successfully
651
+ Scenario: FL-HP-001 A signed-in user's nomination is submitted and confirmed
634
652
  When User fill [Login:Email] field with {{login.email}}
635
653
  And User fill [Login:Password] field with {{login.password}}
636
654
  And User click [Login:Submit] button
637
655
  Then User see [Dashboard] page
638
-
639
- @high
640
- Scenario: User submits nomination
641
656
  When User click [Dashboard:Awards] link
642
- Then User see [Awards] page
643
- When User fill [Awards:Nominee] field with {{submission.nominee}}
657
+ And User fill [Awards:Nominee] field with {{submission.nominee}}
644
658
  And User click [Awards:Submit] button
645
- Then User see {{success_message}} message
659
+ Then User see [Awards:Success Message] text with {{success_message}}
660
+
661
+ @high
662
+ Scenario: FL-EH-001 Direct access to the award form without login redirects to login
663
+ When User go to [Awards] page
664
+ Then User see [Login] page
646
665
  ```
647
666
 
648
667
  ```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.20-beta.1",
3
+ "version": "3.2.20-beta.2",
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.20-beta.1",
43
- "@sungen/driver-ui": "3.2.20-beta.1",
42
+ "@sungen/driver-data-factory": "3.2.20-beta.2",
43
+ "@sungen/driver-ui": "3.2.20-beta.2",
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`);
@@ -688,6 +688,12 @@ async function exportMatrixTarget(
688
688
  }
689
689
 
690
690
  log(`${COLOR.bold}${paths.label}${COLOR.reset} — ${model.manifest.itemCount} delivery item(s), ${model.manifest.variantCount} coverage variant(s), state: ${model.manifest.approvalState}`);
691
+ if (model.perf) {
692
+ const p = model.perf;
693
+ const tone = p.pass ? COLOR.green : COLOR.yellow;
694
+ 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'})`);
695
+ for (const s of p.slowest) log(` ${COLOR.yellow}slowest:${COLOR.reset} ${s.ms}ms — ${s.title.slice(0, 90)}`);
696
+ }
691
697
  printFindings(model.findings);
692
698
 
693
699
  if (opts.check) {
@@ -16,6 +16,7 @@ import { mergeFeatureAndSpec } from '../scenario-merger';
16
16
  import { getPackageVersion } from '../package-info';
17
17
  import { writeCsv } from '../csv-exporter';
18
18
  import { writeXlsx } from '../xlsx-exporter';
19
+ import { loadPerfConfig, perfVerdict } from '../../harness/perf';
19
20
  import { loadDeliveryMap, writeDeliveryMap } from './map-loader';
20
21
  import { mapContentFingerprint } from './fingerprint';
21
22
  import { buildMatrix, deriveVariants } from './build';
@@ -75,6 +76,16 @@ export function loadMatrixModel(paths: MatrixTargetPaths): MatrixLoadResult {
75
76
  transformerVersion: getPackageVersion(),
76
77
  specText: readSpecText(paths),
77
78
  });
79
+ // Perf verdict (#569): budgets are config (qa/perf.yaml), samples are the run's
80
+ // per-test durations. Advisory — reported here because delivery is where runs
81
+ // are read; the audit's score stays a pure function of the design artifacts.
82
+ const perfConfig = loadPerfConfig(paths.cwd);
83
+ if (perfConfig && results) {
84
+ const samples = [...results.values()]
85
+ .filter((r) => (r.status === 'passed' || r.status === 'failed' || r.status === 'timedOut') && typeof r.duration === 'number')
86
+ .map((r) => ({ title: r.testTitle, ms: r.duration as number }));
87
+ model.perf = perfVerdict(perfConfig, paths.unit, samples);
88
+ }
78
89
  return { model, map, mapErrors: [] };
79
90
  }
80
91
 
@@ -315,6 +315,21 @@ 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
+
318
333
  // --- Requirement coverage (review §6): every requirement id gets ONE explicit status.
319
334
  if (model.requirements.length > 0) {
320
335
  dataCell(ws.getRow(rowIdx).getCell(2), 'Requirement coverage — every id has an explicit status', { bold: true });
@@ -220,6 +220,8 @@ export interface MatrixModel {
220
220
  requirements: RequirementCoverage[];
221
221
  findings: MatrixFinding[];
222
222
  manifest: MatrixManifest;
223
+ /** Perf verdict (#569) — present when qa/perf.yaml sets a budget AND the run produced durations. */
224
+ perf?: import('../../harness/perf').PerfVerdict | null;
223
225
  }
224
226
 
225
227
  /** 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;
@@ -10,10 +10,11 @@ import * as path from 'path';
10
10
  import * as fs from 'fs';
11
11
  import { loadUnitScenarios, readUnitFeatureText, parseViewpointOverview, ScenarioInfo, ViewpointEntry } from './parse';
12
12
  import { checkViewpointBaseline, ViewpointBaseline } from './viewpoint-baseline';
13
+ import { flowQuality, statefulDepthFor, FlowQualityResult } from './flow-contract';
13
14
  import { featureFilesFor } from './unit-paths';
14
15
  import {
15
16
  loadCatalog, viewpointGate, assertionDepth, dataThemesFor, depthThresholdFor, coverageBalance, duplicateClusters, traceability, claimProof, taxonomyLint,
16
- automatableManual, flowCoveredThemes, flowRegressionDepth, oracleStrength,
17
+ automatableManual, flowCoveredThemes, flowRegressionDepth, oracleStrength, declaredPageType,
17
18
  GateResult, DepthResult, BalanceResult, DuplicateResult, TraceResult, ClaimProofResult, TaxonomyResult, Catalog, AutomatableManualResult, FlowDepthResult, OracleStrengthResult,
18
19
  } from './sensors';
19
20
  import { loadFlowScenarios } from './flow-check';
@@ -48,6 +49,7 @@ export interface AuditReport {
48
49
  oracle: OracleStrengthResult; // H4 — facet-oracle strength (weak name-substring vs title/detail/API/DB)
49
50
  ledger: LedgerResult; // atomic viewpoint-item coverage (per-bullet status)
50
51
  viewpointBaseline: ViewpointBaseline; // is the yardstick still the accepted one? (#557)
52
+ flowQuality?: FlowQualityResult; // #569 — flow contract verification (flows only)
51
53
  calibration: { // #8 — multi-axis score so a high overall can't hide a weak axis
52
54
  axes: Record<string, number>;
53
55
  weakest: { axis: string; value: number };
@@ -154,15 +156,28 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
154
156
  // back to the in-core functions if the capability provides none.
155
157
  // A capability gate may need project context (the API gate resolves endpoint methods from the
156
158
  // catalog) + the focus depth threshold (so it scores depth with the SAME bar as the UI gate).
157
- const capGate = scoringCap?.gateProvider as
158
- ((i: { scenarios: ScenarioInfo[]; viewpoints: ViewpointEntry[]; catalog: Catalog; focus: typeof intent.focus; cwd: string; screenName: string; threshold: number; businessCriticalMethods?: string[] }) => { gate: GateResult; depth: DepthResult }) | undefined;
159
- const provided = capGate?.({ scenarios, viewpoints, catalog, focus: intent.focus, cwd: projectRootFromScreenDir(screenDir), screenName: catalogScreenName, threshold: depthThresholdFor(intent.focus), businessCriticalMethods: intent.businessCriticalMethods });
160
159
  // Is the declaration this suite is measured against still the one a human accepted?
161
160
  const viewpointBaseline = checkViewpointBaseline(
162
161
  projectRootFromScreenDir(screenDir), catalogScreenName, viewpointPath);
163
162
  const viewpointText = fs.existsSync(viewpointPath) ? readTextFile(viewpointPath) : '';
164
- const gate = provided?.gate ?? viewpointGate(scenarios, viewpoints, catalog, platform === 'mobile', viewpointText);
165
- const depth = provided?.depth ?? assertionDepth(scenarios, dataThemesFor(catalog, gate.pageType), intent.focus);
163
+ // #569 a flow WITH a contract is measured against ITS declaration, not a screen's.
164
+ // Keyword page-type detection force-fit flows into screen checklists (a registration
165
+ // flow judged as a `form` screen scored coverage 0%), so for contract-holding flows the
166
+ // page-type machinery only applies when the project DECLARED a page type explicitly.
167
+ // The stripped catalog feeds the CAPABILITY gate too — driver-ui provides the gate on
168
+ // real projects, and handing it the unstripped catalog re-introduced the force-fit.
169
+ const isUiFlowUnit = /^flows\//.test(catalogScreenName);
170
+ const flowQ: FlowQualityResult = isUiFlowUnit
171
+ ? flowQuality(screenDir, scenarios)
172
+ : { hasContract: false, errors: [], outcomeProven: false, outcomeManualOnly: false, offGoal: [], offGoalRatio: 0, offGoalCategories: [], phases: [], phaseRatio: 1, handoffs: { total: 0, asserted: 0, ratio: 1 } };
173
+ const gateCatalog = (flowQ.hasContract && !declaredPageType(viewpointText, catalog))
174
+ ? { ...catalog, page_types: {} } as Catalog
175
+ : catalog;
176
+ const capGate = scoringCap?.gateProvider as
177
+ ((i: { scenarios: ScenarioInfo[]; viewpoints: ViewpointEntry[]; catalog: Catalog; focus: typeof intent.focus; cwd: string; screenName: string; threshold: number; businessCriticalMethods?: string[] }) => { gate: GateResult; depth: DepthResult }) | undefined;
178
+ const provided = capGate?.({ scenarios, viewpoints, catalog: gateCatalog, focus: intent.focus, cwd: projectRootFromScreenDir(screenDir), screenName: catalogScreenName, threshold: depthThresholdFor(intent.focus), businessCriticalMethods: intent.businessCriticalMethods });
179
+ const gate = provided?.gate ?? viewpointGate(scenarios, viewpoints, gateCatalog, platform === 'mobile', viewpointText);
180
+ const depth = provided?.depth ?? assertionDepth(scenarios, dataThemesFor(gateCatalog, gate.pageType), intent.focus);
166
181
 
167
182
  // TQ-4 — deferral-aware coverage credit: an inherently cross-screen theme (cart / detail /
168
183
  // filter correctness) belongs in a FLOW, not on the screen. When a flow deeply covers a screen
@@ -200,8 +215,15 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
200
215
  // collection, the regression dimensions (count/quantity proof · teardown · multi-source) cap the
201
216
  // businessDepth headroom: it can reach 1.0 only when all three are exercised, so a present-but-
202
217
  // shallow flow can't claim a perfect score (floor 0.5 — assertion depth still dominates).
203
- const isUiFlow = /^flows\//.test(catalogScreenName);
204
- const flowDepth = isUiFlow ? flowRegressionDepth(scenarios) : { stateful: false, countProof: false, teardown: false, multiSource: false, ratio: 1, missing: [] } as FlowDepthResult;
218
+ const isUiFlow = isUiFlowUnit;
219
+ let flowDepth = isUiFlow ? flowRegressionDepth(scenarios) : { stateful: false, countProof: false, teardown: false, multiSource: false, ratio: 1, missing: [] } as FlowDepthResult;
220
+ // Contract-declared statefulness generalizes the cart-hardcoded vocabulary: a flow that
221
+ // mutates ANY named collection (order, application, submission …) gets the same three
222
+ // regression dims. The legacy cart detection stays for contract-less flows (snapshots).
223
+ if (isUiFlow && !flowDepth.stateful && flowQ.contract?.stateful) {
224
+ const d = statefulDepthFor(flowQ.contract.stateful, scenarios);
225
+ flowDepth = { stateful: true, countProof: d.countProof, teardown: d.teardown, multiSource: d.multiSource, ratio: d.ratio, missing: d.missing };
226
+ }
205
227
  const FLOW_DEPTH_FLOOR = 0.5;
206
228
  // H4 — oracle strength: a weak facet oracle (name-substring "proves" category/brand membership)
207
229
  // caps businessDepth the same way (floor 0.5). A suite with no facet claim, or a strong oracle, is
@@ -211,10 +233,14 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
211
233
 
212
234
  // Sub-scores
213
235
  const coverage = gate.coverageRatio;
236
+ // #569 — handoff integrity: a flow's depth is also whether each cross-screen transition
237
+ // is followed by an assertion on the carried state (same floor discipline as the other caps).
238
+ const HANDOFF_FLOOR = 0.5;
214
239
  const businessDepth = Math.min(
215
240
  depth.bcDepthRatio,
216
241
  flowDepth.stateful ? FLOW_DEPTH_FLOOR + (1 - FLOW_DEPTH_FLOOR) * flowDepth.ratio : 1,
217
242
  oracle.weak.length ? ORACLE_FLOOR + (1 - ORACLE_FLOOR) * oracle.ratio : 1,
243
+ flowQ.hasContract && flowQ.handoffs.total > 0 ? HANDOFF_FLOOR + (1 - HANDOFF_FLOOR) * flowQ.handoffs.ratio : 1,
218
244
  );
219
245
  // When the taxonomy drifted (most scenarios unclassified), the balance axis is unreliable — cap it
220
246
  // at 0.5 instead of awarding a vacuous 1.0 so a stale taxonomy fails loudly, not silently (H1).
@@ -246,8 +272,15 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
246
272
  // what a create-test run did, silently dropping the performance viewpoint on
247
273
  // the way. While the declaration is unconfirmed, neither axis is evidence.
248
274
  const viewpointMoved = viewpointBaseline.status === 'changed';
275
+ // 4. A flow with a contract is measured on ITS coverage: journey phases (HP/ER/EH
276
+ // automated) — not a screen's page-type themes. Same weight, same criticality,
277
+ // so a flow can finally EARN the coverage axis instead of always losing it to
278
+ // PAGE-TYPE-UNDETERMINED (or worse, being judged against a form checklist).
279
+ const flowScored = flowQ.hasContract;
249
280
  const axisDefs: Array<{ key: string; value: number; weight: number; applicable: boolean; critical: boolean }> = [
250
- { key: 'coverage', value: coverage, weight: 0.22, applicable: !!gate.pageType && gate.themesTotal > 0, critical: true },
281
+ flowScored
282
+ ? { key: 'flowCoverage', value: flowQ.phaseRatio, weight: 0.22, applicable: true, critical: true }
283
+ : { key: 'coverage', value: coverage, weight: 0.22, applicable: !!gate.pageType && gate.themesTotal > 0, critical: true },
251
284
  { key: 'specFR', value: specRatio, weight: 0.15, applicable: spec.hasSpec && spec.frTotal > 0, critical: true },
252
285
  { key: 'atomicLedger', value: ledger.ratio, weight: 0.13, applicable: ledger.hasViewpoint && ledger.total > 0 && !viewpointMoved, critical: true },
253
286
  { key: 'businessDepth', value: businessDepth, weight: 0.20, applicable: true, critical: true },
@@ -297,9 +330,40 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
297
330
  ].filter(Boolean).join(', ');
298
331
  findings.push(`VIEWPOINT-BASELINE-CHANGED: test-viewpoint.md no longer declares what it did when this unit was last accepted — ${moved || 'the declared ids were reordered or replaced'}. atomicLedger + traceability measure the suite AGAINST this file, so they are excluded from the score until the change is confirmed: a generator that rewrites the declaration scores both 100% by construction, and a viewpoint dropped from the file stops being missing from anything. Review the diff (a removed id means that coverage is now unclaimed), then run \`sungen audit --screen ${screenName} --accept-viewpoint\`.`);
299
332
  }
300
- if (gate.pageTypeSource === 'undetermined') {
333
+ // On a contract flow the page type is INTENTIONALLY not applied — the contract is the checklist.
334
+ if (gate.pageTypeSource === 'undetermined' && !flowScored) {
301
335
  findings.push(`PAGE-TYPE-UNDETERMINED: no page type fits this screen with enough confidence (best ${gate.pageTypeEvidence?.hits ?? 0} keyword hit(s)), so NO critical themes were demanded and the coverage axis is excluded from the score — declare it in test-viewpoint.md (\`page-type: <id>\`) to have the theme checklist applied.`);
302
336
  }
337
+ // ---- #569: flow-quality findings -----------------------------------------
338
+ if (isUiFlow && !flowQ.hasContract) {
339
+ const why = flowQ.errors.length ? ` (${flowQ.errors.join('; ')})` : '';
340
+ findings.push(`FLOW-CONTRACT-MISSING: this flow declares no contract${why} — without \`requirements/flow-contract.yaml\` (goal · trigger · outcome.screen · phases) the flow cannot be scored on ITS coverage (journey phases) and falls back to screen machinery. Author it with the boundary checklist: one business goal, a clear trigger, ONE observable outcome valuable to the actor, name = "Verb + outcome"; split anything that forms an independently valuable flow.`);
341
+ }
342
+ if (flowQ.hasContract) {
343
+ if (!flowQ.outcomeProven) {
344
+ findings.push(flowQ.outcomeManualOnly
345
+ ? `FLOW-OUTCOME-UNPROVEN: the goal "${flowQ.contract!.goal}" is covered only by @manual scenarios — the flow's own outcome (\`${flowQ.contract!.outcome.screen}\`) has no AUTOMATED data assertion, so a regression run cannot prove the journey still works. Automate the happy-path proof first; it is the reason this flow exists.`
346
+ : `FLOW-OUTCOME-UNPROVEN: no scenario asserts data on the outcome screen \`${flowQ.contract!.outcome.screen}\` — the flow never proves its own goal ("${flowQ.contract!.goal}"). Add the happy-path scenario that ends in the declared final assertion.`);
347
+ }
348
+ if (flowQ.offGoalRatio > 0.35) {
349
+ findings.push(`FLOW-SCOPE-CREEP: ${flowQ.offGoal.length}/${scenarios.length} scenarios never touch the outcome screen \`${flowQ.contract!.outcome.screen}\` and are not guards/error-recovery (categories: ${flowQ.offGoalCategories.join(', ')}) — evidence of a SECOND business goal in this flow. Split them into their own flow (one flow = one goal, one observable outcome).`);
350
+ }
351
+ for (const ph of flowQ.phases.filter((p) => !p.covered || !p.automated)) {
352
+ findings.push(`FLOW-PHASE-${ph.phase}-MISSING: journey phase ${ph.phase} (${ph.phase === 'HP' ? 'happy path proving the outcome' : ph.phase === 'ER' ? 'error recovery — validation must not trap the journey' : 'guards — direct access / back / refresh'}) is ${ph.covered ? 'covered only by @manual' : 'not covered'} → it does not count toward flowCoverage until automated.`);
353
+ }
354
+ if (flowQ.handoffs.total > 0 && flowQ.handoffs.ratio < 0.7) {
355
+ findings.push(`FLOW-HANDOFF-SHALLOW: only ${flowQ.handoffs.asserted}/${flowQ.handoffs.total} cross-screen transitions are followed by an assertion in the new screen — carried state (the thing a flow exists to verify) is unproven at the handoff. businessDepth is capped until asserted.`);
356
+ }
357
+ }
358
+ if (isUiFlow) {
359
+ // Flows defer more than screens, so an UNCODED @manual is costlier here: the capability
360
+ // planner cannot route it to the driver that would automate it (password-reset: 11/22
361
+ // manual, most of them M5-inbox — invisible while tagged bare).
362
+ const bareManual = (featureText.match(/@manual(?![:\w])/g) || []).length;
363
+ if (bareManual > 0) {
364
+ findings.push(`MANUAL-CODE-MISSING: ${bareManual} @manual tag(s) carry no reason code — tag @manual:M1..M9 (M5 = external dependency e.g. inbox, M3 = mock/network, M7 = environment) so the capability planner can route what a driver would automate.`);
365
+ }
366
+ }
303
367
  for (const c of flowCredits) {
304
368
  findings.push(`COVERED-VIA-FLOW: critical theme "${c.theme}" is not covered on this screen but is deeply covered by flow "${c.flow}" — cross-screen depth correctly owned by the flow, so it is credited (not a screen gap). Verify with \`sungen flow-check\`.`);
305
369
  }
@@ -485,6 +549,7 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
485
549
  scenarioCount: scenarios.length,
486
550
  gate, depth, claim, taxonomy, balance, duplicates, trace, spec,
487
551
  taxonomyMismatch, downstream, manualOracle: manualOracleResult, automatableManual: autoManual, flowDepth, oracle, ledger, viewpointBaseline, calibration,
552
+ ...(isUiFlow ? { flowQuality: flowQ } : {}),
488
553
  score: {
489
554
  overall: Math.round(overall * 10) / 10,
490
555
  coverage: Math.round(coverage * 100) / 100,
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Flow Contract — the declared boundary of a flow, and the sensors that verify
3
+ * the suite against it. (#569, docs/spec/sungen-flow-quality-spec.md)
4
+ *
5
+ * A flow is the SMALLEST complete business action chain: one clear trigger ending
6
+ * in ONE observable, valuable outcome. Nothing enforced that: `add-flow` asked only
7
+ * "which screens, in order?", so a real example mixed three business goals (cart,
8
+ * filter, product-detail) in one "flow", and the harness scored flows with screen
9
+ * machinery — a registration flow was judged against the `form` page-type checklist
10
+ * (coverage 0%), and every flow audit showed taxonomy=0% because flow phase ids
11
+ * (FL-HP-001) don't even parse as categories.
12
+ *
13
+ * The contract is a declaration the QA owns (AI proposes at add-flow; a filled
14
+ * contract is an INPUT to generation, never an output — same rule as
15
+ * test-viewpoint.md). These sensors are deterministic checks against it:
16
+ *
17
+ * - outcome proof — the flow proves its own goal with an automated data assertion
18
+ * - scope creep — scenarios that never touch the outcome are a second goal
19
+ * - phase coverage — HP / ER / EH journey phases, the flow's coverage axis
20
+ * - handoff — a cross-screen transition is followed by an assertion
21
+ * - stateful depth — generalizes the cart-hardcoded regression dims to any
22
+ * declared collection (order, application, submission …)
23
+ */
24
+ import * as fs from 'fs';
25
+ import * as path from 'path';
26
+ import { parse as parseYaml } from 'yaml';
27
+ import { ScenarioInfo } from './parse';
28
+ import { readTextFile } from './read-text';
29
+
30
+ export interface FlowContract {
31
+ goal: string;
32
+ actor?: string;
33
+ trigger?: string;
34
+ precondition?: string;
35
+ outcome: { screen: string; assertion?: string };
36
+ value?: string;
37
+ /** Journey phases this flow declares. Default [HP, ER, EH]; UI is allowed but
38
+ * never demanded (presentation is the balance axis's business, not coverage's). */
39
+ phases: string[];
40
+ /** The mutated collection (cart, order, application …) — enables regression dims. */
41
+ stateful?: string;
42
+ budgets?: Record<string, number>;
43
+ }
44
+
45
+ export interface FlowQualityResult {
46
+ hasContract: boolean;
47
+ contract?: FlowContract;
48
+ /** Parse/shape errors — a broken contract is reported, never silently ignored. */
49
+ errors: string[];
50
+ outcomeProven: boolean;
51
+ /** Manual-only proof: the goal is covered but not by automation. */
52
+ outcomeManualOnly: boolean;
53
+ /** Scenario names that never touch the outcome screen (guard/error phases excluded). */
54
+ offGoal: string[];
55
+ offGoalRatio: number;
56
+ /** Off-goal categories, for the split suggestion ("VP-FILTER-* looks like its own flow"). */
57
+ offGoalCategories: string[];
58
+ phases: { phase: string; covered: boolean; automated: boolean }[];
59
+ /** Covered-and-automated phases / declared phases (UI excluded) — the flow coverage axis. */
60
+ phaseRatio: number;
61
+ /** Cross-namespace transitions followed by an assertion / all transitions. */
62
+ handoffs: { total: number; asserted: number; ratio: number };
63
+ }
64
+
65
+ const DEFAULT_PHASES = ['HP', 'ER', 'EH'];
66
+
67
+ export function flowContractPath(unitDir: string): string {
68
+ return path.join(unitDir, 'requirements', 'flow-contract.yaml');
69
+ }
70
+
71
+ /** Load + validate. Returns null when absent; a present-but-broken file returns errors. */
72
+ export function loadFlowContract(unitDir: string): { contract: FlowContract | null; errors: string[] } {
73
+ const p = flowContractPath(unitDir);
74
+ if (!fs.existsSync(p)) return { contract: null, errors: [] };
75
+ let raw: Record<string, unknown>;
76
+ try {
77
+ raw = parseYaml(readTextFile(p)) as Record<string, unknown>;
78
+ } catch (e) {
79
+ return { contract: null, errors: [`flow-contract.yaml does not parse: ${(e as Error).message}`] };
80
+ }
81
+ if (!raw || typeof raw !== 'object') return { contract: null, errors: ['flow-contract.yaml is empty'] };
82
+ const errors: string[] = [];
83
+ if (!raw.goal || typeof raw.goal !== 'string') errors.push('missing `goal:` (Verb + outcome, e.g. "Place an order for a product added from home")');
84
+ const outcome = raw.outcome as { screen?: unknown; assertion?: unknown } | undefined;
85
+ if (!outcome || typeof outcome.screen !== 'string' || !outcome.screen.trim()) {
86
+ errors.push('missing `outcome.screen:` — the screen namespace that carries the final proof');
87
+ }
88
+ if (errors.length > 0) return { contract: null, errors };
89
+ const phases = Array.isArray(raw.phases) && raw.phases.length > 0
90
+ ? (raw.phases as unknown[]).map((x) => String(x).toUpperCase())
91
+ : DEFAULT_PHASES;
92
+ return {
93
+ contract: {
94
+ goal: String(raw.goal),
95
+ actor: raw.actor !== undefined ? String(raw.actor) : undefined,
96
+ trigger: raw.trigger !== undefined ? String(raw.trigger) : undefined,
97
+ precondition: raw.precondition !== undefined ? String(raw.precondition) : undefined,
98
+ outcome: { screen: String(outcome!.screen).toLowerCase(), assertion: outcome!.assertion !== undefined ? String(outcome!.assertion) : undefined },
99
+ value: raw.value !== undefined ? String(raw.value) : undefined,
100
+ phases,
101
+ stateful: raw.stateful !== undefined ? String(raw.stateful).toLowerCase() : undefined,
102
+ budgets: (raw.budgets && typeof raw.budgets === 'object') ? raw.budgets as Record<string, number> : undefined,
103
+ },
104
+ errors: [],
105
+ };
106
+ }
107
+
108
+ /** `[screen:element]` namespaces referenced by a scenario's steps, in step order. */
109
+ function namespacesInOrder(s: ScenarioInfo): string[] {
110
+ const out: string[] = [];
111
+ for (const m of s.stepsText.matchAll(/\[([a-z0-9_.-]+):/g)) out.push(m[1]);
112
+ return out;
113
+ }
114
+
115
+ function touchesOutcome(s: ScenarioInfo, outcomeScreen: string): boolean {
116
+ return namespacesInOrder(s).includes(outcomeScreen);
117
+ }
118
+
119
+ /** Phase of a scenario: its declared phase token (FL-HP-001 / VP-FLOW-ER-02 / MS-EH-005)
120
+ * when present, else vocabulary detection. */
121
+ export function phaseOf(s: ScenarioInfo, declared: string[]): string | null {
122
+ const id = (s.vpId ?? '').toUpperCase();
123
+ for (const ph of declared) {
124
+ if (new RegExp(`(^|-)${ph}(-|$)`).test(id)) return ph;
125
+ }
126
+ const hay = s.haystack;
127
+ 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';
128
+ if (declared.includes('ER') && /\b(invalid|error|required|validation|malformed|blocked|then correct|recover)\b/.test(hay)) return 'ER';
129
+ if (declared.includes('HP') && s.hasDataAssertion) return 'HP';
130
+ return null;
131
+ }
132
+
133
+ /**
134
+ * Verify the suite against the contract. Deterministic; a flow without a contract
135
+ * returns hasContract:false and neutral values (the audit reports the checklist).
136
+ */
137
+ export function flowQuality(unitDir: string, scenarios: ScenarioInfo[]): FlowQualityResult {
138
+ const { contract, errors } = loadFlowContract(unitDir);
139
+ const neutral: FlowQualityResult = {
140
+ hasContract: false, errors, outcomeProven: false, outcomeManualOnly: false,
141
+ offGoal: [], offGoalRatio: 0, offGoalCategories: [],
142
+ phases: [], phaseRatio: 1, handoffs: { total: 0, asserted: 0, ratio: 1 },
143
+ };
144
+ if (!contract) return neutral;
145
+
146
+ const outcomeScreen = contract.outcome.screen;
147
+
148
+ // --- Outcome proof: the flow proves its own goal, by automation -------------
149
+ const proofs = scenarios.filter((s) => touchesOutcome(s, outcomeScreen) && s.hasDataAssertion);
150
+ const outcomeProven = proofs.some((s) => !s.manual);
151
+ const outcomeManualOnly = !outcomeProven && proofs.length > 0;
152
+
153
+ // --- Scope creep: a scenario that never touches the outcome and is not a ----
154
+ // guard/error phase is evidence of a SECOND business goal in this flow.
155
+ const declaredPhases = contract.phases;
156
+ const offGoalScenarios = scenarios.filter((s) => {
157
+ if (touchesOutcome(s, outcomeScreen)) return false;
158
+ const ph = phaseOf(s, declaredPhases);
159
+ return ph !== 'EH' && ph !== 'ER'; // guards/error-recovery legitimately stop early
160
+ });
161
+ const offGoalRatio = scenarios.length ? offGoalScenarios.length / scenarios.length : 0;
162
+ const offGoalCategories = Array.from(new Set(
163
+ offGoalScenarios.map((s) => s.category ?? s.vpId?.replace(/-\d+.*$/, '') ?? '?')));
164
+
165
+ // --- Phase coverage: the flow's coverage axis (UI never demanded) -----------
166
+ const demanded = declaredPhases.filter((p) => p !== 'UI');
167
+ const phases = demanded.map((phase) => {
168
+ const inPhase = scenarios.filter((s) => phaseOf(s, declaredPhases) === phase);
169
+ // HP must additionally prove the outcome — a data assertion elsewhere is not the goal.
170
+ const relevant = phase === 'HP' ? inPhase.filter((s) => touchesOutcome(s, outcomeScreen)) : inPhase;
171
+ return {
172
+ phase,
173
+ covered: relevant.length > 0,
174
+ automated: relevant.some((s) => !s.manual),
175
+ };
176
+ });
177
+ const phaseRatio = demanded.length
178
+ ? phases.filter((p) => p.covered && p.automated).length / demanded.length
179
+ : 1;
180
+
181
+ // --- Handoff integrity: no blind tail after a cross-namespace transition. ---
182
+ // A transition counts as asserted when ANY assertion follows it — in the entered
183
+ // namespace or later. Demanding the assertion in the entered namespace itself
184
+ // flagged two legitimate shapes: a guard that asserts the REDIRECT target
185
+ // ("go to [Checkout] → see [Home] page"), and a passthrough click en route
186
+ // ("click [Cart:Checkout]" asserting on the next screen). What the sensor
187
+ // actually guards against is a flow that clicks through screens and ends blind.
188
+ let total = 0; let asserted = 0;
189
+ for (const s of scenarios) {
190
+ if (s.manual) continue;
191
+ const steps = s.steps ?? [];
192
+ let current: string | null = null;
193
+ for (let i = 0; i < steps.length; i++) {
194
+ const ns = (steps[i].text.match(/\[([A-Za-z0-9_.-]+):/) || [])[1]?.toLowerCase() ?? null;
195
+ if (!ns) continue;
196
+ if (current !== null && ns !== current) {
197
+ total++;
198
+ if (steps.slice(i).some((st) => st.bucket === 'then')) asserted++;
199
+ }
200
+ current = ns;
201
+ }
202
+ }
203
+ const handoffs = { total, asserted, ratio: total ? asserted / total : 1 };
204
+
205
+ return {
206
+ hasContract: true, contract, errors: [],
207
+ outcomeProven, outcomeManualOnly,
208
+ offGoal: offGoalScenarios.map((s) => s.name.slice(0, 80)),
209
+ offGoalRatio, offGoalCategories,
210
+ phases, phaseRatio, handoffs,
211
+ };
212
+ }
213
+
214
+ /**
215
+ * Generalized stateful regression depth: the contract names the mutated collection,
216
+ * so the three dims (count-proof · teardown · multi-source) stop being cart-only.
217
+ */
218
+ export function statefulDepthFor(collection: string, scenarios: ScenarioInfo[]): { countProof: boolean; teardown: boolean; multiSource: boolean; missing: string[]; ratio: number } {
219
+ const hay = scenarios.map((s) => s.haystack);
220
+ const any = (re: RegExp) => hay.some((h) => re.test(h));
221
+ const noun = collection.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
222
+ const countProof = any(new RegExp(`\\b(quantity|qty|row count|count|number of|two (rows|lines|items)|\\d+ (rows|lines|items))\\b`)) ;
223
+ 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}`));
224
+ 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));
225
+ const multiSource = any(/\b(recommended|related|you may also|another source|both sources|second (list|source))\b/) && adds.length > 0;
226
+ const dims: Array<[string, boolean]> = [['count-proof', countProof], ['teardown', teardown], ['multi-source', multiSource]];
227
+ const missing = dims.filter(([, v]) => !v).map(([k]) => k);
228
+ return { countProof, teardown, multiSource, missing, ratio: (dims.length - missing.length) / dims.length };
229
+ }