@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.
- package/dist/cli/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/audit.js +8 -0
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/cli/commands/delivery.d.ts +3 -0
- package/dist/cli/commands/delivery.d.ts.map +1 -1
- package/dist/cli/commands/delivery.js +15 -0
- package/dist/cli/commands/delivery.js.map +1 -1
- package/dist/cli/commands/inspect.d.ts +41 -0
- package/dist/cli/commands/inspect.d.ts.map +1 -0
- package/dist/cli/commands/inspect.js +134 -0
- package/dist/cli/commands/inspect.js.map +1 -0
- package/dist/cli/commands/trace.d.ts.map +1 -1
- package/dist/cli/commands/trace.js +9 -0
- package/dist/cli/commands/trace.js.map +1 -1
- package/dist/cli/index.js +2 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/exporters/matrix/build.d.ts +10 -0
- package/dist/exporters/matrix/build.d.ts.map +1 -1
- package/dist/exporters/matrix/build.js +38 -0
- package/dist/exporters/matrix/build.js.map +1 -1
- package/dist/exporters/matrix/export.d.ts.map +1 -1
- package/dist/exporters/matrix/export.js +11 -0
- package/dist/exporters/matrix/export.js.map +1 -1
- package/dist/exporters/matrix/render-xlsx.d.ts.map +1 -1
- package/dist/exporters/matrix/render-xlsx.js +44 -1
- package/dist/exporters/matrix/render-xlsx.js.map +1 -1
- package/dist/exporters/matrix/types.d.ts +10 -0
- package/dist/exporters/matrix/types.d.ts.map +1 -1
- package/dist/exporters/matrix/types.js.map +1 -1
- package/dist/exporters/playwright-report-parser.d.ts.map +1 -1
- package/dist/exporters/playwright-report-parser.js +1 -0
- package/dist/exporters/playwright-report-parser.js.map +1 -1
- package/dist/exporters/types.d.ts +2 -0
- package/dist/exporters/types.d.ts.map +1 -1
- package/dist/generators/test-generator/diagnostics.d.ts +6 -1
- package/dist/generators/test-generator/diagnostics.d.ts.map +1 -1
- package/dist/generators/test-generator/diagnostics.js +5 -0
- package/dist/generators/test-generator/diagnostics.js.map +1 -1
- package/dist/generators/test-generator/patterns/index.d.ts.map +1 -1
- package/dist/generators/test-generator/patterns/index.js +27 -19
- package/dist/generators/test-generator/patterns/index.js.map +1 -1
- package/dist/generators/test-generator/step-mapper.d.ts.map +1 -1
- package/dist/generators/test-generator/step-mapper.js +22 -0
- package/dist/generators/test-generator/step-mapper.js.map +1 -1
- package/dist/harness/audit.d.ts +2 -0
- package/dist/harness/audit.d.ts.map +1 -1
- package/dist/harness/audit.js +101 -10
- package/dist/harness/audit.js.map +1 -1
- package/dist/harness/flow-contract.d.ts +87 -0
- package/dist/harness/flow-contract.d.ts.map +1 -0
- package/dist/harness/flow-contract.js +259 -0
- package/dist/harness/flow-contract.js.map +1 -0
- package/dist/harness/flow-plan.d.ts +3 -0
- package/dist/harness/flow-plan.d.ts.map +1 -1
- package/dist/harness/flow-plan.js +6 -2
- package/dist/harness/flow-plan.js.map +1 -1
- package/dist/harness/parse.d.ts +5 -0
- package/dist/harness/parse.d.ts.map +1 -1
- package/dist/harness/parse.js +29 -1
- package/dist/harness/parse.js.map +1 -1
- package/dist/harness/perf.d.ts +40 -0
- package/dist/harness/perf.d.ts.map +1 -0
- package/dist/harness/perf.js +136 -0
- package/dist/harness/perf.js.map +1 -0
- package/dist/harness/sensors.d.ts.map +1 -1
- package/dist/harness/sensors.js +13 -1
- package/dist/harness/sensors.js.map +1 -1
- package/dist/harness/spec-coverage.d.ts +8 -0
- package/dist/harness/spec-coverage.d.ts.map +1 -1
- package/dist/harness/spec-coverage.js +60 -6
- package/dist/harness/spec-coverage.js.map +1 -1
- package/dist/orchestrator/templates/ai-src/commands/add-flow.md +51 -3
- package/dist/orchestrator/templates/ai-src/commands/create-test.md +10 -0
- package/dist/orchestrator/templates/ai-src/commands/run-test.md +23 -0
- package/dist/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +60 -16
- package/dist/orchestrator/templates/qa-context.md +14 -1
- package/package.json +3 -3
- package/src/cli/commands/audit.ts +8 -0
- package/src/cli/commands/delivery.ts +14 -2
- package/src/cli/commands/inspect.ts +128 -0
- package/src/cli/commands/trace.ts +9 -0
- package/src/cli/index.ts +2 -0
- package/src/exporters/matrix/build.ts +40 -0
- package/src/exporters/matrix/export.ts +11 -0
- package/src/exporters/matrix/render-xlsx.ts +45 -1
- package/src/exporters/matrix/types.ts +10 -0
- package/src/exporters/playwright-report-parser.ts +2 -0
- package/src/exporters/types.ts +2 -0
- package/src/generators/test-generator/diagnostics.ts +6 -1
- package/src/generators/test-generator/patterns/index.ts +30 -24
- package/src/generators/test-generator/step-mapper.ts +22 -0
- package/src/harness/audit.ts +104 -11
- package/src/harness/flow-contract.ts +261 -0
- package/src/harness/flow-plan.ts +10 -3
- package/src/harness/parse.ts +31 -1
- package/src/harness/perf.ts +112 -0
- package/src/harness/sensors.ts +13 -1
- package/src/harness/spec-coverage.ts +55 -5
- package/src/orchestrator/templates/ai-src/commands/add-flow.md +51 -3
- package/src/orchestrator/templates/ai-src/commands/create-test.md +10 -0
- package/src/orchestrator/templates/ai-src/commands/run-test.md +23 -0
- package/src/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +60 -16
- package/src/orchestrator/templates/qa-context.md +14 -1
|
@@ -73,34 +73,40 @@ export class PatternRegistry {
|
|
|
73
73
|
* Prefers resolver (returns template data) over generator (renders directly).
|
|
74
74
|
*/
|
|
75
75
|
generateStep(step: ParsedStep, context: PatternContext): MappedStep | null {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
76
|
+
// Matchers see only the STEP; some decisions need context (a resolver reading the
|
|
77
|
+
// test-data value, e.g. row-count's numeric check). A resolver may therefore return
|
|
78
|
+
// null to say "not mine after all" — the search continues with the next matching
|
|
79
|
+
// pattern in priority order instead of crashing or silently dropping the step.
|
|
80
|
+
const matchStep: ParsedStep = { ...step, text: maskRefsForMatching(step.text) };
|
|
81
|
+
for (const pattern of this.patterns) {
|
|
82
|
+
if (!this.matchesPattern(matchStep, pattern.matcher)) continue;
|
|
83
|
+
|
|
84
|
+
// Prefer resolver (framework-agnostic) over generator (legacy)
|
|
85
|
+
if (pattern.resolver) {
|
|
86
|
+
const resolved = pattern.resolver(step, context);
|
|
87
|
+
if (!resolved) continue; // resolver declined — fall through
|
|
88
|
+
|
|
89
|
+
// Auto-inject parent scoping if step has parentRef
|
|
90
|
+
// Skip for table-* patterns — they resolve the table name internally from step text
|
|
91
|
+
if (step.parentRef && step.parentType && !pattern.name.startsWith('table-')) {
|
|
92
|
+
resolved.data.parentLocator = PatternRegistry.resolveParentLocator(
|
|
93
|
+
step.parentRef, step.parentType, context
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const code = context.templateEngine.renderStep(resolved.templateName, resolved.data);
|
|
98
|
+
return {
|
|
99
|
+
code,
|
|
100
|
+
comment: resolved.comment,
|
|
101
|
+
};
|
|
91
102
|
}
|
|
92
103
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
comment: resolved.comment,
|
|
97
|
-
};
|
|
98
|
-
}
|
|
104
|
+
if (pattern.generator) {
|
|
105
|
+
return pattern.generator(step, context);
|
|
106
|
+
}
|
|
99
107
|
|
|
100
|
-
|
|
101
|
-
return pattern.generator(step, context);
|
|
108
|
+
return null;
|
|
102
109
|
}
|
|
103
|
-
|
|
104
110
|
return null;
|
|
105
111
|
}
|
|
106
112
|
|
|
@@ -175,6 +175,28 @@ export class StepMapper {
|
|
|
175
175
|
this.lastPrimaryKeyword = keyword;
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
+
// SG-W015 — `see [X] card with {{n}}` where n is a bare number compiles to a TEXT
|
|
179
|
+
// assertion (toHaveText('3')), not a count. An author reaching for "3 cards are shown"
|
|
180
|
+
// gets an assertion that can only pass if the card's TEXT is the digit — a wrong test
|
|
181
|
+
// with no error. Count semantics live on row/table ("see [T] table with {{n}}",
|
|
182
|
+
// "see [Ref] row with {{n}}") and lists ("should have count"); on the container types
|
|
183
|
+
// below, a bare-number text expectation is almost never what the author meant. A text
|
|
184
|
+
// element asserting a numeric readout ("[Row Quantity] text with 4") stays silent.
|
|
185
|
+
const COUNT_MISREAD_TYPES = new Set(['card', 'item', 'items', 'cards', 'option', 'options']);
|
|
186
|
+
if (this.diagnostics && step.dataRef && step.selectorRef &&
|
|
187
|
+
COUNT_MISREAD_TYPES.has((step.elementType || '').toLowerCase()) &&
|
|
188
|
+
/\bsees?\s+\[/.test(step.text) && /\bwith\b/.test(step.text)) {
|
|
189
|
+
// peekBaseValue reads the literal from test-data even in runtime-data mode,
|
|
190
|
+
// where resolveData returns an opaque marker (never numeric-looking).
|
|
191
|
+
let resolvedValue: string | undefined;
|
|
192
|
+
try { resolvedValue = this.dataResolver.peekBaseValue(step.dataRef, this.featureName); } catch { /* unresolved at compile time → cannot judge */ }
|
|
193
|
+
if (resolvedValue !== undefined && /^\d+$/.test(resolvedValue.trim())) {
|
|
194
|
+
this.diagnostics.add('SG-W015',
|
|
195
|
+
`\`see [${step.selectorRef}] ${step.elementType} with {{${step.dataRef}}}\` compiles to a TEXT assertion toHaveText('${resolvedValue.trim()}'), NOT a count of ${step.elementType}s. For a count use \`see [Ref] row with {{n}}\` / \`see [T] table with {{n}}\` (or a list "should have count"); if '${resolvedValue.trim()}' really is the ${step.elementType}'s text, assert it on a text/label element instead`,
|
|
196
|
+
{ feature: this.featureName, step: step.text });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
178
200
|
// SG-W007 — a two-step-form browser-alert handler (`click [OK] alert`) written AFTER
|
|
179
201
|
// an action step registers its page.once('dialog') listener too late: a dialog
|
|
180
202
|
// triggered by the earlier action auto-dismisses with no error (issue #472).
|
package/src/harness/audit.ts
CHANGED
|
@@ -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
|
-
|
|
165
|
-
|
|
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 }, sit: [] };
|
|
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 =
|
|
204
|
-
|
|
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
|
-
|
|
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,49 @@ 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
|
-
|
|
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
|
+
// #580 P10 — the QA matrix keeps System INTEGRATION cases (external-system legs)
|
|
355
|
+
// separate, and holds their exit criteria open while they are mock-verified only.
|
|
356
|
+
for (const x of flowQ.sit.filter((v) => v.mockOnly).slice(0, 4)) {
|
|
357
|
+
findings.push(`SIT-MOCK-ONLY: "${x.name}" touches the external system "${x.external}" but is verified only against mocks — Pass (Mocked) says nothing about the real vendor behaviour. Re-verify on the real dependency before calling it Done (the delivery's External Dependency Tracking table holds the checklist).`);
|
|
358
|
+
}
|
|
359
|
+
// #580 P9 — a release-critical flow must carry its Final-Inspection selection.
|
|
360
|
+
if (flowQ.contract!.golden && !/@golden\b/i.test(featureText)) {
|
|
361
|
+
findings.push(`GOLDEN-MISSING: the contract declares \`golden: true\` (release-critical) but no scenario is tagged @golden — Final Inspection (\`sungen inspect\`) will have nothing to run for this flow. Tag the happy-path scenario(s) that prove the outcome.`);
|
|
362
|
+
}
|
|
363
|
+
if (flowQ.handoffs.total > 0 && flowQ.handoffs.ratio < 0.7) {
|
|
364
|
+
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.`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (isUiFlow) {
|
|
368
|
+
// Flows defer more than screens, so an UNCODED @manual is costlier here: the capability
|
|
369
|
+
// planner cannot route it to the driver that would automate it (password-reset: 11/22
|
|
370
|
+
// manual, most of them M5-inbox — invisible while tagged bare).
|
|
371
|
+
const bareManual = (featureText.match(/@manual(?![:\w])/g) || []).length;
|
|
372
|
+
if (bareManual > 0) {
|
|
373
|
+
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.`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
303
376
|
for (const c of flowCredits) {
|
|
304
377
|
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
378
|
}
|
|
@@ -318,7 +391,7 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
|
|
|
318
391
|
const tag = depth.verdict === 'fail' ? 'DEPTH-FAIL' : depth.verdict === 'warn' ? 'DEPTH-WARN' : 'DEPTH';
|
|
319
392
|
findings.push(
|
|
320
393
|
`${tag}: ${depth.businessCriticalShallow}/${depth.businessCriticalTotal} data-correctness scenarios assert only visibility ` +
|
|
321
|
-
`(ratio ${depth.bcDepthRatio.toFixed(2)} < threshold ${depth.threshold.toFixed(2)} for focus "${depth.focus}") → ` +
|
|
394
|
+
`(ratio ${depth.bcDepthRatio.toFixed(2)} ${depth.bcDepthRatio < depth.threshold ? '<' : '≥'} threshold ${depth.threshold.toFixed(2)} for focus "${depth.focus}"${depth.bcDepthRatio < depth.threshold ? '' : ' — the gate holds; these are the remaining deepen-targets'}) → ` +
|
|
322
395
|
`add data assertions (\`... with {{value}}\`, \`see all ... contain {{v}}\`) or, if cross-screen, defer to a flow with @manual + reason.`,
|
|
323
396
|
);
|
|
324
397
|
}
|
|
@@ -376,6 +449,13 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
|
|
|
376
449
|
const shown = spec.inferredOnly.slice(0, 8).join(', ');
|
|
377
450
|
findings.push(`SPEC-TRACE-IMPLICIT: ${spec.inferredOnly.length} requirement(s) are covered by keyword inference only (${shown}${spec.inferredOnly.length > 8 ? ', …' : ''}) — add \`@spec:<id>\` to the scenario that proves each so the trace survives edits and delivery can report it.`);
|
|
378
451
|
}
|
|
452
|
+
// Safety net for the parser (#572): the feature CITES requirement ids the spec parser
|
|
453
|
+
// could not find a declaration for. Silence here would repeat the original failure —
|
|
454
|
+
// "nothing found" quietly excluding the specFR axis while the evidence sits in the file.
|
|
455
|
+
if (spec.hasSpec && spec.frTotal === 0 && /@spec:/i.test(featureText)) {
|
|
456
|
+
const cited = [...new Set([...featureText.matchAll(/@spec:([A-Za-z0-9.-]+)/g)].map((m) => m[1].toUpperCase()))];
|
|
457
|
+
findings.push(`SPEC-IDS-UNPARSED: the feature cites ${cited.length} requirement id(s) via @spec: (${cited.slice(0, 5).join(', ')}${cited.length > 5 ? ', …' : ''}) but requirements/spec.md yielded ZERO parseable requirement declarations — the spec likely declares ids in a scheme the parser cannot read. Declare each requirement on its own line as \`<ID>: <clause>\` (bullet/bold/backtick all fine; the id must end in a number) so the MUST-coverage gate and the specFR axis can run.`);
|
|
458
|
+
}
|
|
379
459
|
if (taxonomyMismatch) {
|
|
380
460
|
findings.push(`VP-TAXONOMY-MISMATCH: only ${(trace.mappedRatio * 100).toFixed(0)}% of scenarios use the viewpoint IDs declared in test-viewpoint.md — scenarios invented a generic VP-<CAT> scheme. Re-tag to the project's viewpoint IDs so the coverage matrix is accurate.`);
|
|
381
461
|
}
|
|
@@ -429,6 +509,18 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
|
|
|
429
509
|
for (const d of ownership.duplicates.slice(0, 6)) {
|
|
430
510
|
findings.push(`DUPLICATE-FLOW-OWNERSHIP: "${d.scenario}" has the same shape as a scenario in flow "${d.flow}" — keep one owner (screen-local vs flow); the other should only reference/set up.`);
|
|
431
511
|
}
|
|
512
|
+
// #580 — the flow→screen half of the ownership rule (ISTQB system-test design:
|
|
513
|
+
// representative, not exhaustive). A flow's cases take ONE representative input per
|
|
514
|
+
// branch — one valid value on the main flow, one error value that triggers the
|
|
515
|
+
// exception flow. An EP/data-family cluster (≥3 same-shape scenarios differing only
|
|
516
|
+
// in data) inside a FLOW is the screen's validation matrix being re-run where it
|
|
517
|
+
// does not belong: the screen already proved the field rejects each class; the flow
|
|
518
|
+
// only has to prove the journey survives one of them. Advisory — never a gate.
|
|
519
|
+
if (isUiFlow) {
|
|
520
|
+
for (const c of duplicates.clusters.filter((x) => !x.sameDataLikely && x.scenarios.length >= 3).slice(0, 4)) {
|
|
521
|
+
findings.push(`FLOW-EXHAUSTIVE-INPUTS: ${c.scenarios.length} same-shape scenarios differ only in data (${c.scenarios.slice(0, 3).join(' | ')}${c.scenarios.length > 3 ? ' …' : ''}) — a system-test flow uses REPRESENTATIVE inputs (one valid + one that triggers the exception branch); the full EP/boundary matrix belongs to the owning screen (or a @cases dataset there). Keep one representative per branch here.`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
432
524
|
for (const u of unsourced.slice(0, 6)) {
|
|
433
525
|
findings.push(`UNSOURCEABLE-SCENARIO: "${u}" doesn't trace to any FR / viewpoint item — link it to a source, or tag it @exploration (not part of the official suite).`);
|
|
434
526
|
}
|
|
@@ -485,6 +577,7 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
|
|
|
485
577
|
scenarioCount: scenarios.length,
|
|
486
578
|
gate, depth, claim, taxonomy, balance, duplicates, trace, spec,
|
|
487
579
|
taxonomyMismatch, downstream, manualOracle: manualOracleResult, automatableManual: autoManual, flowDepth, oracle, ledger, viewpointBaseline, calibration,
|
|
580
|
+
...(isUiFlow ? { flowQuality: flowQ } : {}),
|
|
488
581
|
score: {
|
|
489
582
|
overall: Math.round(overall * 10) / 10,
|
|
490
583
|
coverage: Math.round(coverage * 100) / 100,
|
|
@@ -0,0 +1,261 @@
|
|
|
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
|
+
/** External-system legs (owned by another team/vendor) — ISTQB System INTEGRATION Testing
|
|
44
|
+
* scope (#580 P10). Scenarios touching these namespaces are classified SIT. */
|
|
45
|
+
external?: Array<{ name: string; owner?: string; screens: string[] }>;
|
|
46
|
+
/** This flow is release-critical: Final Inspection expects a @golden scenario here (P9). */
|
|
47
|
+
golden?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface FlowQualityResult {
|
|
51
|
+
hasContract: boolean;
|
|
52
|
+
contract?: FlowContract;
|
|
53
|
+
/** Parse/shape errors — a broken contract is reported, never silently ignored. */
|
|
54
|
+
errors: string[];
|
|
55
|
+
outcomeProven: boolean;
|
|
56
|
+
/** Manual-only proof: the goal is covered but not by automation. */
|
|
57
|
+
outcomeManualOnly: boolean;
|
|
58
|
+
/** Scenario names that never touch the outcome screen (guard/error phases excluded). */
|
|
59
|
+
offGoal: string[];
|
|
60
|
+
offGoalRatio: number;
|
|
61
|
+
/** Off-goal categories, for the split suggestion ("VP-FILTER-* looks like its own flow"). */
|
|
62
|
+
offGoalCategories: string[];
|
|
63
|
+
phases: { phase: string; covered: boolean; automated: boolean }[];
|
|
64
|
+
/** Covered-and-automated phases / declared phases (UI excluded) — the flow coverage axis. */
|
|
65
|
+
phaseRatio: number;
|
|
66
|
+
/** Cross-namespace transitions followed by an assertion / all transitions. */
|
|
67
|
+
handoffs: { total: number; asserted: number; ratio: number };
|
|
68
|
+
/** ISTQB SIT classification (#580 P10): scenarios whose steps touch an external-system
|
|
69
|
+
* namespace declared in the contract's `external:` block. */
|
|
70
|
+
sit: Array<{ name: string; external: string; mockOnly: boolean }>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const DEFAULT_PHASES = ['HP', 'ER', 'EH'];
|
|
74
|
+
|
|
75
|
+
export function flowContractPath(unitDir: string): string {
|
|
76
|
+
return path.join(unitDir, 'requirements', 'flow-contract.yaml');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Load + validate. Returns null when absent; a present-but-broken file returns errors. */
|
|
80
|
+
export function loadFlowContract(unitDir: string): { contract: FlowContract | null; errors: string[] } {
|
|
81
|
+
const p = flowContractPath(unitDir);
|
|
82
|
+
if (!fs.existsSync(p)) return { contract: null, errors: [] };
|
|
83
|
+
let raw: Record<string, unknown>;
|
|
84
|
+
try {
|
|
85
|
+
raw = parseYaml(readTextFile(p)) as Record<string, unknown>;
|
|
86
|
+
} catch (e) {
|
|
87
|
+
return { contract: null, errors: [`flow-contract.yaml does not parse: ${(e as Error).message}`] };
|
|
88
|
+
}
|
|
89
|
+
if (!raw || typeof raw !== 'object') return { contract: null, errors: ['flow-contract.yaml is empty'] };
|
|
90
|
+
const errors: string[] = [];
|
|
91
|
+
if (!raw.goal || typeof raw.goal !== 'string') errors.push('missing `goal:` (Verb + outcome, e.g. "Place an order for a product added from home")');
|
|
92
|
+
const outcome = raw.outcome as { screen?: unknown; assertion?: unknown } | undefined;
|
|
93
|
+
if (!outcome || typeof outcome.screen !== 'string' || !outcome.screen.trim()) {
|
|
94
|
+
errors.push('missing `outcome.screen:` — the screen namespace that carries the final proof');
|
|
95
|
+
}
|
|
96
|
+
if (errors.length > 0) return { contract: null, errors };
|
|
97
|
+
const phases = Array.isArray(raw.phases) && raw.phases.length > 0
|
|
98
|
+
? (raw.phases as unknown[]).map((x) => String(x).toUpperCase())
|
|
99
|
+
: DEFAULT_PHASES;
|
|
100
|
+
return {
|
|
101
|
+
contract: {
|
|
102
|
+
goal: String(raw.goal),
|
|
103
|
+
actor: raw.actor !== undefined ? String(raw.actor) : undefined,
|
|
104
|
+
trigger: raw.trigger !== undefined ? String(raw.trigger) : undefined,
|
|
105
|
+
precondition: raw.precondition !== undefined ? String(raw.precondition) : undefined,
|
|
106
|
+
outcome: { screen: String(outcome!.screen).toLowerCase(), assertion: outcome!.assertion !== undefined ? String(outcome!.assertion) : undefined },
|
|
107
|
+
value: raw.value !== undefined ? String(raw.value) : undefined,
|
|
108
|
+
phases,
|
|
109
|
+
stateful: raw.stateful !== undefined ? String(raw.stateful).toLowerCase() : undefined,
|
|
110
|
+
budgets: (raw.budgets && typeof raw.budgets === 'object') ? raw.budgets as Record<string, number> : undefined,
|
|
111
|
+
external: Array.isArray(raw.external)
|
|
112
|
+
? (raw.external as Array<Record<string, unknown>>)
|
|
113
|
+
.filter((e) => e && typeof e === 'object' && e.name)
|
|
114
|
+
.map((e) => ({
|
|
115
|
+
name: String(e.name),
|
|
116
|
+
owner: e.owner !== undefined ? String(e.owner) : undefined,
|
|
117
|
+
screens: Array.isArray(e.screens) ? (e.screens as unknown[]).map((x) => String(x).toLowerCase()) : [],
|
|
118
|
+
}))
|
|
119
|
+
: undefined,
|
|
120
|
+
golden: raw.golden === true,
|
|
121
|
+
},
|
|
122
|
+
errors: [],
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** `[screen:element]` namespaces referenced by a scenario's steps, in step order. */
|
|
127
|
+
function namespacesInOrder(s: ScenarioInfo): string[] {
|
|
128
|
+
const out: string[] = [];
|
|
129
|
+
for (const m of s.stepsText.matchAll(/\[([a-z0-9_.-]+):/g)) out.push(m[1]);
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function touchesOutcome(s: ScenarioInfo, outcomeScreen: string): boolean {
|
|
134
|
+
return namespacesInOrder(s).includes(outcomeScreen);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Phase of a scenario: its declared phase token (FL-HP-001 / VP-FLOW-ER-02 / MS-EH-005)
|
|
138
|
+
* when present, else vocabulary detection. */
|
|
139
|
+
export function phaseOf(s: ScenarioInfo, declared: string[]): string | null {
|
|
140
|
+
const id = (s.vpId ?? '').toUpperCase();
|
|
141
|
+
for (const ph of declared) {
|
|
142
|
+
if (new RegExp(`(^|-)${ph}(-|$)`).test(id)) return ph;
|
|
143
|
+
}
|
|
144
|
+
const hay = s.haystack;
|
|
145
|
+
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';
|
|
146
|
+
if (declared.includes('ER') && /\b(invalid|error|required|validation|malformed|blocked|then correct|recover)\b/.test(hay)) return 'ER';
|
|
147
|
+
if (declared.includes('HP') && s.hasDataAssertion) return 'HP';
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Verify the suite against the contract. Deterministic; a flow without a contract
|
|
153
|
+
* returns hasContract:false and neutral values (the audit reports the checklist).
|
|
154
|
+
*/
|
|
155
|
+
export function flowQuality(unitDir: string, scenarios: ScenarioInfo[]): FlowQualityResult {
|
|
156
|
+
const { contract, errors } = loadFlowContract(unitDir);
|
|
157
|
+
const neutral: FlowQualityResult = {
|
|
158
|
+
hasContract: false, errors, outcomeProven: false, outcomeManualOnly: false,
|
|
159
|
+
offGoal: [], offGoalRatio: 0, offGoalCategories: [],
|
|
160
|
+
phases: [], phaseRatio: 1, handoffs: { total: 0, asserted: 0, ratio: 1 }, sit: [],
|
|
161
|
+
};
|
|
162
|
+
if (!contract) return neutral;
|
|
163
|
+
|
|
164
|
+
const outcomeScreen = contract.outcome.screen;
|
|
165
|
+
|
|
166
|
+
// --- Outcome proof: the flow proves its own goal, by automation -------------
|
|
167
|
+
const proofs = scenarios.filter((s) => touchesOutcome(s, outcomeScreen) && s.hasDataAssertion);
|
|
168
|
+
const outcomeProven = proofs.some((s) => !s.manual);
|
|
169
|
+
const outcomeManualOnly = !outcomeProven && proofs.length > 0;
|
|
170
|
+
|
|
171
|
+
// --- Scope creep: a scenario that never touches the outcome and is not a ----
|
|
172
|
+
// guard/error phase is evidence of a SECOND business goal in this flow.
|
|
173
|
+
const declaredPhases = contract.phases;
|
|
174
|
+
const offGoalScenarios = scenarios.filter((s) => {
|
|
175
|
+
if (touchesOutcome(s, outcomeScreen)) return false;
|
|
176
|
+
const ph = phaseOf(s, declaredPhases);
|
|
177
|
+
return ph !== 'EH' && ph !== 'ER'; // guards/error-recovery legitimately stop early
|
|
178
|
+
});
|
|
179
|
+
const offGoalRatio = scenarios.length ? offGoalScenarios.length / scenarios.length : 0;
|
|
180
|
+
const offGoalCategories = Array.from(new Set(
|
|
181
|
+
offGoalScenarios.map((s) => s.category ?? s.vpId?.replace(/-\d+.*$/, '') ?? '?')));
|
|
182
|
+
|
|
183
|
+
// --- Phase coverage: the flow's coverage axis (UI never demanded) -----------
|
|
184
|
+
const demanded = declaredPhases.filter((p) => p !== 'UI');
|
|
185
|
+
const phases = demanded.map((phase) => {
|
|
186
|
+
const inPhase = scenarios.filter((s) => phaseOf(s, declaredPhases) === phase);
|
|
187
|
+
// HP must additionally prove the outcome — a data assertion elsewhere is not the goal.
|
|
188
|
+
const relevant = phase === 'HP' ? inPhase.filter((s) => touchesOutcome(s, outcomeScreen)) : inPhase;
|
|
189
|
+
return {
|
|
190
|
+
phase,
|
|
191
|
+
covered: relevant.length > 0,
|
|
192
|
+
automated: relevant.some((s) => !s.manual),
|
|
193
|
+
};
|
|
194
|
+
});
|
|
195
|
+
const phaseRatio = demanded.length
|
|
196
|
+
? phases.filter((p) => p.covered && p.automated).length / demanded.length
|
|
197
|
+
: 1;
|
|
198
|
+
|
|
199
|
+
// --- Handoff integrity: no blind tail after a cross-namespace transition. ---
|
|
200
|
+
// A transition counts as asserted when ANY assertion follows it — in the entered
|
|
201
|
+
// namespace or later. Demanding the assertion in the entered namespace itself
|
|
202
|
+
// flagged two legitimate shapes: a guard that asserts the REDIRECT target
|
|
203
|
+
// ("go to [Checkout] → see [Home] page"), and a passthrough click en route
|
|
204
|
+
// ("click [Cart:Checkout]" asserting on the next screen). What the sensor
|
|
205
|
+
// actually guards against is a flow that clicks through screens and ends blind.
|
|
206
|
+
let total = 0; let asserted = 0;
|
|
207
|
+
for (const s of scenarios) {
|
|
208
|
+
if (s.manual) continue;
|
|
209
|
+
const steps = s.steps ?? [];
|
|
210
|
+
let current: string | null = null;
|
|
211
|
+
for (let i = 0; i < steps.length; i++) {
|
|
212
|
+
const ns = (steps[i].text.match(/\[([A-Za-z0-9_.-]+):/) || [])[1]?.toLowerCase() ?? null;
|
|
213
|
+
if (!ns) continue;
|
|
214
|
+
if (current !== null && ns !== current) {
|
|
215
|
+
total++;
|
|
216
|
+
if (steps.slice(i).some((st) => st.bucket === 'then')) asserted++;
|
|
217
|
+
}
|
|
218
|
+
current = ns;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const handoffs = { total, asserted, ratio: total ? asserted / total : 1 };
|
|
222
|
+
|
|
223
|
+
// --- SIT classification (#580 P10): a scenario whose steps touch an external-system
|
|
224
|
+
// namespace belongs to the System INTEGRATION group the QA matrix keeps separate. When
|
|
225
|
+
// that scenario is also verified only against mocks, its pass says nothing about the
|
|
226
|
+
// real vendor behaviour — the exit criteria the QA doc holds open (Pass (Mocked)).
|
|
227
|
+
const MOCK_STEP = /\[[^\]]+\]\s+mock\s+(?:is\s+active|called)\b/i;
|
|
228
|
+
const sit: FlowQualityResult['sit'] = [];
|
|
229
|
+
for (const leg of contract.external ?? []) {
|
|
230
|
+
const legScreens = new Set(leg.screens);
|
|
231
|
+
for (const s of scenarios) {
|
|
232
|
+
if (!namespacesInOrder(s).some((ns) => legScreens.has(ns))) continue;
|
|
233
|
+
sit.push({ name: s.name.slice(0, 80), external: leg.name, mockOnly: MOCK_STEP.test(s.stepsText) });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
hasContract: true, contract, errors: [],
|
|
239
|
+
outcomeProven, outcomeManualOnly,
|
|
240
|
+
offGoal: offGoalScenarios.map((s) => s.name.slice(0, 80)),
|
|
241
|
+
offGoalRatio, offGoalCategories,
|
|
242
|
+
phases, phaseRatio, handoffs, sit,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Generalized stateful regression depth: the contract names the mutated collection,
|
|
248
|
+
* so the three dims (count-proof · teardown · multi-source) stop being cart-only.
|
|
249
|
+
*/
|
|
250
|
+
export function statefulDepthFor(collection: string, scenarios: ScenarioInfo[]): { countProof: boolean; teardown: boolean; multiSource: boolean; missing: string[]; ratio: number } {
|
|
251
|
+
const hay = scenarios.map((s) => s.haystack);
|
|
252
|
+
const any = (re: RegExp) => hay.some((h) => re.test(h));
|
|
253
|
+
const noun = collection.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
254
|
+
const countProof = any(new RegExp(`\\b(quantity|qty|row count|count|number of|two (rows|lines|items)|\\d+ (rows|lines|items))\\b`)) ;
|
|
255
|
+
const teardown = any(/\b(remove|delete|clear|cancel|withdraw)(?:s|d|ed|ing|n)?\b/) && any(new RegExp(`\\b(empty|emptied|no items|zero|removed|cleared|cancelled|withdrawn|0 items)\\b|empty[- ]${noun}`));
|
|
256
|
+
const adds = hay.filter((h) => new RegExp(`\\b(add|submit|create|place).{0,40}${noun}|${noun}.{0,40}\\b(add|submit|create|place)`).test(h));
|
|
257
|
+
const multiSource = any(/\b(recommended|related|you may also|another source|both sources|second (list|source))\b/) && adds.length > 0;
|
|
258
|
+
const dims: Array<[string, boolean]> = [['count-proof', countProof], ['teardown', teardown], ['multi-source', multiSource]];
|
|
259
|
+
const missing = dims.filter(([, v]) => !v).map(([k]) => k);
|
|
260
|
+
return { countProof, teardown, multiSource, missing, ratio: (dims.length - missing.length) / dims.length };
|
|
261
|
+
}
|
package/src/harness/flow-plan.ts
CHANGED
|
@@ -71,6 +71,9 @@ export interface FlowPlan {
|
|
|
71
71
|
legs: LegPlan[];
|
|
72
72
|
byReason: Record<string, number>;
|
|
73
73
|
capabilityManual: number;
|
|
74
|
+
/** @manual whose reason is "cross-screen → automate via flow" (class XS) — inside a flow this
|
|
75
|
+
* usually means the scenario should simply BE automated here. (#569) */
|
|
76
|
+
crossScreenManual: number;
|
|
74
77
|
judgmentManual: number;
|
|
75
78
|
contracts: Contract[];
|
|
76
79
|
readiness: 'ready' | 'not-ready';
|
|
@@ -87,14 +90,18 @@ export function buildFlowPlan(cwd: string, flow: string): FlowPlan {
|
|
|
87
90
|
// Legs = distinct screen namespaces.
|
|
88
91
|
const legMap = new Map<string, { scenarios: Set<string>; refs: Set<string>; automated: boolean }>();
|
|
89
92
|
const byReason: Record<string, number> = {};
|
|
90
|
-
let capabilityManual = 0, judgmentManual = 0;
|
|
93
|
+
let capabilityManual = 0, judgmentManual = 0, crossScreenManual = 0;
|
|
91
94
|
|
|
92
95
|
for (const sc of scenarios) {
|
|
93
96
|
if (sc.manual) {
|
|
94
97
|
const { code } = inferReasonCode(sc.tags, sc.reason);
|
|
95
98
|
byReason[code] = (byReason[code] || 0) + 1;
|
|
96
99
|
const cls = MANUAL_REASONS[code]?.cls;
|
|
97
|
-
|
|
100
|
+
// XS ("cross-screen → automate via flow") is a THIRD class; it used to be silently
|
|
101
|
+
// dropped from both counters, understating the plan's manual load. (#569)
|
|
102
|
+
if (cls === 'capability') capabilityManual++;
|
|
103
|
+
else if (cls === 'keep') judgmentManual++;
|
|
104
|
+
else if (cls === 'flow') crossScreenManual++;
|
|
98
105
|
}
|
|
99
106
|
for (const r of sc.refs) {
|
|
100
107
|
const leg = r.screen.toLowerCase();
|
|
@@ -133,5 +140,5 @@ export function buildFlowPlan(cwd: string, flow: string): FlowPlan {
|
|
|
133
140
|
}
|
|
134
141
|
if (readiness === 'ready') plan.unshift('Selectors present for every automated leg — ready to compile + run.');
|
|
135
142
|
|
|
136
|
-
return { flow, total: scenarios.length, legs, byReason, capabilityManual, judgmentManual, contracts, readiness, missingLegs, plan };
|
|
143
|
+
return { flow, total: scenarios.length, legs, byReason, capabilityManual, judgmentManual, crossScreenManual, contracts, readiness, missingLegs, plan };
|
|
137
144
|
}
|