@sun-asterisk/sungen 3.2.24-beta.1 → 3.2.24-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.
- package/dist/cli/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/audit.js +20 -3
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/exporters/feature-parser.d.ts +4 -3
- package/dist/exporters/feature-parser.d.ts.map +1 -1
- package/dist/exporters/feature-parser.js +10 -4
- package/dist/exporters/feature-parser.js.map +1 -1
- package/dist/harness/audit.d.ts +9 -1
- package/dist/harness/audit.d.ts.map +1 -1
- package/dist/harness/audit.js +83 -5
- package/dist/harness/audit.js.map +1 -1
- package/dist/harness/flow-contract.d.ts +75 -0
- package/dist/harness/flow-contract.d.ts.map +1 -1
- package/dist/harness/flow-contract.js +122 -1
- package/dist/harness/flow-contract.js.map +1 -1
- package/dist/harness/spec-coverage.d.ts +20 -0
- package/dist/harness/spec-coverage.d.ts.map +1 -1
- package/dist/harness/spec-coverage.js +35 -0
- package/dist/harness/spec-coverage.js.map +1 -1
- package/dist/harness/viewpoint-ledger.d.ts +4 -0
- package/dist/harness/viewpoint-ledger.d.ts.map +1 -1
- package/dist/harness/viewpoint-ledger.js +42 -0
- package/dist/harness/viewpoint-ledger.js.map +1 -1
- package/dist/orchestrator/templates/ai-src/commands/create-test.md +13 -2
- package/dist/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +36 -0
- package/package.json +3 -3
- package/src/cli/commands/audit.ts +20 -3
- package/src/exporters/feature-parser.ts +10 -4
- package/src/harness/audit.ts +86 -10
- package/src/harness/flow-contract.ts +158 -1
- package/src/harness/spec-coverage.ts +38 -0
- package/src/harness/viewpoint-ledger.ts +42 -0
- package/src/orchestrator/templates/ai-src/commands/create-test.md +13 -2
- package/src/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +36 -0
|
@@ -690,6 +690,42 @@ core, AF→behaviour, EF→validation/security) and traces them to the viewpoint
|
|
|
690
690
|
`FL-EF` priority rows. Declare `phases: [BF, AF, EF]` in the contract; the FIRST phase is the
|
|
691
691
|
Basic Flow and is the one that must reach `outcome`.
|
|
692
692
|
|
|
693
|
+
**Declare the flow INVENTORY, not just the phases.** `phases: [BF, AF, EF]` says which
|
|
694
|
+
vocabulary the suite uses; it cannot say how many flows the use case has — a phase counts as
|
|
695
|
+
covered the moment ONE scenario carries it, so a fifteen-flow use case with three scenarios
|
|
696
|
+
reported full phase coverage while a reviewer counting flows read it as a third done. List every
|
|
697
|
+
flow in the contract, and give each one a status:
|
|
698
|
+
|
|
699
|
+
```yaml
|
|
700
|
+
flows:
|
|
701
|
+
- id: BF
|
|
702
|
+
outcome: "The successGuarantee — verified, recorded, signed in"
|
|
703
|
+
status: covered
|
|
704
|
+
- id: AF02
|
|
705
|
+
branchFrom: "BF step 4, before submitting"
|
|
706
|
+
outcome: "Re-enters BF at step 4 with the buffer restored"
|
|
707
|
+
status: covered
|
|
708
|
+
- id: EF08
|
|
709
|
+
branchFrom: "BF step 1, the control tapped twice"
|
|
710
|
+
outcome: "One request, one record"
|
|
711
|
+
status: pending-clarification # the spec is silent — ASK, never assume a guard
|
|
712
|
+
reason: "The guard lives in ST_AUTH_001's spec, which this project does not hold."
|
|
713
|
+
```
|
|
714
|
+
|
|
715
|
+
Four statuses, and **silence is not one of them**: `covered` · `deferred` · `pending-clarification`
|
|
716
|
+
(the behaviour is not agreed yet, so no scenario can be right) · `out-of-scope` (another suite owns
|
|
717
|
+
it). Anything but `covered` needs a `reason:` — a deferral nobody can audit is the same as a
|
|
718
|
+
missing flow. `sungen audit` then measures `flowCoverage` per DECLARED FLOW and reports
|
|
719
|
+
`FLOW-UNCOVERED` (declared, nobody wrote it), `FLOW-UNDECLARED` (a scenario claiming a flow id the
|
|
720
|
+
inventory never declares), `FLOW-STATUS-UNREASONED` and `FLOW-INVENTORY-MISSING`.
|
|
721
|
+
|
|
722
|
+
**One flow, one id — a viewpoint is not a flow.** Several scenarios may prove one flow: give them
|
|
723
|
+
the same flow id and different sequence numbers (`VP-VAL-EF01-001`, `VP-VAL-EF01-002`), never a
|
|
724
|
+
fresh flow id per assertion. Two shapes the audit reports as `FLOW-PHASE-MISFILED`: an `EF` that
|
|
725
|
+
reaches the outcome with nothing failing (that is a success-path postcondition, so it belongs to
|
|
726
|
+
the BF), and an `AF` with no branch point (it walks the basic path asserting extra content — also
|
|
727
|
+
the BF's). Both inflate the flow count while adding no branch coverage.
|
|
728
|
+
|
|
693
729
|
**Declare once, then declare only the differences.** Actor · Trigger · Goal · Precondition ·
|
|
694
730
|
`successGuarantee` · `minimalGuarantee` are use-case-level: they live in `flow-contract.yaml`
|
|
695
731
|
and are never repeated per flow. Each flow in `test-viewpoint.md` then states only three things:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sun-asterisk/sungen",
|
|
3
|
-
"version": "3.2.24-beta.
|
|
3
|
+
"version": "3.2.24-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.24-beta.
|
|
43
|
-
"@sungen/driver-ui": "3.2.24-beta.
|
|
42
|
+
"@sungen/driver-data-factory": "3.2.24-beta.2",
|
|
43
|
+
"@sungen/driver-ui": "3.2.24-beta.2",
|
|
44
44
|
"chalk": "^5.6.2",
|
|
45
45
|
"commander": "^14.0.2",
|
|
46
46
|
"dotenv": "^17.2.3",
|
|
@@ -39,7 +39,9 @@ function render(r: AuditReport): void {
|
|
|
39
39
|
const width = Math.max(...r.score.axes.map((a) => a.key.length));
|
|
40
40
|
for (const a of r.score.axes) {
|
|
41
41
|
const label = a.key.padEnd(width);
|
|
42
|
-
|
|
42
|
+
// Two different n/a: this unit supplied nothing, vs this axis does not apply to this KIND
|
|
43
|
+
// of unit. Only the first is something an author can act on.
|
|
44
|
+
if (!a.applicable) { L(` ${label} ${' '.repeat(20)} n/a — ${a.byDesign ? 'not applicable to a contract flow' : 'no evidence'}`); continue; }
|
|
43
45
|
L(` ${label} ${bar(a.value)} ${(a.value * 100).toFixed(0)}%${verdictOf[a.key] ?? ''}`);
|
|
44
46
|
}
|
|
45
47
|
L(` (${r.score.formula})`);
|
|
@@ -52,7 +54,15 @@ function render(r: AuditReport): void {
|
|
|
52
54
|
const fq = r.flowQuality;
|
|
53
55
|
L(` ①F Flow contract — goal: ${fq.contract!.goal}`);
|
|
54
56
|
L(` outcome [${fq.contract!.outcome.screen}]: ${fq.outcomeProven ? '✓ proven (automated data assertion)' : fq.outcomeManualOnly ? '⚠ manual-only' : '✗ UNPROVEN'}`);
|
|
55
|
-
L(` phases: ${fq.phases.map((p) => `${p.phase}=${p.covered ? (p.automated ? '✓' : 'manual') : '✗'}`).join(' ')} →
|
|
57
|
+
L(` phases: ${fq.phases.map((p) => `${p.phase}=${p.covered ? (p.automated ? '✓' : 'manual') : '✗'}`).join(' ')} → phase coverage ${(fq.phaseRatio * 100).toFixed(0)}%`);
|
|
58
|
+
// The inventory, when declared, is the coverage that counts — phase coverage is only
|
|
59
|
+
// "each of BF/AF/EF appears at least once" and printing it as `flowCoverage` next to a 89%
|
|
60
|
+
// axis read as a contradiction.
|
|
61
|
+
const inv = r.flowInventory;
|
|
62
|
+
if (inv) {
|
|
63
|
+
L(` flows: ${inv.covered.length}/${inv.covered.length + inv.uncovered.length} declared covered → flowCoverage ${(inv.ratio * 100).toFixed(0)}%`
|
|
64
|
+
+ (inv.accounted.length ? ` (${inv.accounted.map((f) => `${f.id}=${f.status}`).join(', ')})` : ''));
|
|
65
|
+
}
|
|
56
66
|
L(` handoffs asserted: ${fq.handoffs.asserted}/${fq.handoffs.total}${fq.offGoal.length ? ` ⚠ off-goal scenarios: ${fq.offGoal.length} (${fq.offGoalCategories.join(', ')})` : ''}`);
|
|
57
67
|
L('');
|
|
58
68
|
}
|
|
@@ -103,7 +113,14 @@ function render(r: AuditReport): void {
|
|
|
103
113
|
if (na.length) L(` not scored (no evidence in this unit): ${na.join(', ')}`);
|
|
104
114
|
L(` weakest: ${r.calibration.weakest.axis} ${(r.calibration.weakest.value * 100).toFixed(0)}%${r.calibration.inflated ? ' ⚠ SCORE-INFLATED-BY-BREADTH' : ''}`);
|
|
105
115
|
if (r.calibration.cappedAt !== undefined) {
|
|
106
|
-
|
|
116
|
+
// Only blame the weakest axis when it IS weak. A cap for missing evidence printed
|
|
117
|
+
// "held by the weakest axis — fix it" next to "weakest: flowCoverage 100%", which sent
|
|
118
|
+
// authors hunting for a defect in a maxed-out axis (#595). The SCORE-CAPPED finding
|
|
119
|
+
// carries the real reason; say which kind of cap this is.
|
|
120
|
+
const weak = r.calibration.weakest.value < 0.7;
|
|
121
|
+
L(weak
|
|
122
|
+
? ` ⚠ score held at ${r.calibration.cappedAt.toFixed(1)} by the weakest axis — fix it to lift the number`
|
|
123
|
+
: ` ⚠ score held at ${r.calibration.cappedAt.toFixed(1)} — no axis is weak; see the SCORE-CAPPED finding for what evidence is missing`);
|
|
107
124
|
}
|
|
108
125
|
L('');
|
|
109
126
|
}
|
|
@@ -189,13 +189,19 @@ export function splitVpAndName(scenarioName: string): { vpId?: string; category1
|
|
|
189
189
|
*
|
|
190
190
|
* Flow ids carry the use-case decomposition (#592) and group by it, so the deliverable shows
|
|
191
191
|
* the reviewer WHICH flow each case belongs to instead of collapsing a whole journey into one
|
|
192
|
-
* "Function" block: `
|
|
193
|
-
*
|
|
194
|
-
*
|
|
192
|
+
* "Function" block: `BF` → Basic Flow, `AF0n` → Alternate Flow, `EF0n` → Exception Flow. The
|
|
193
|
+
* flat scheme keeps its old mapping (`HP`/`ER` functional, `EH` guards → Accessing).
|
|
194
|
+
* The phase may sit anywhere in the id: a project whose viewpoint declares themes writes a
|
|
195
|
+
* COMPOUND id (`VP-SEC-EF02-001`) carrying theme AND phase (#595).
|
|
195
196
|
*/
|
|
196
197
|
export function mapVpToCategory2(vpId: string | undefined, scenarioName?: string): string {
|
|
197
198
|
if (!vpId) return 'Function';
|
|
198
|
-
|
|
199
|
+
// The phase segment, wherever it sits in the id. A project whose viewpoint declares themes
|
|
200
|
+
// uses a COMPOUND id — `VP-SEC-EF02-001` carries both the theme (traceability) and the phase
|
|
201
|
+
// (flow) — and anchoring on `FL-` missed every one of them, so a use-case suite still grouped
|
|
202
|
+
// as Accessing/Function (#595). The phase wins over the theme here: a flow deliverable is read
|
|
203
|
+
// flow by flow.
|
|
204
|
+
const flow = vpId.match(/(?:^|-)(?:FL-)?(BF|AF|EF|HP|ER|EH|UI)\d*(?=-|$)/i);
|
|
199
205
|
if (flow) {
|
|
200
206
|
const phase = flow[1].toUpperCase();
|
|
201
207
|
if (phase === 'BF') return 'Basic Flow';
|
package/src/harness/audit.ts
CHANGED
|
@@ -10,7 +10,7 @@ 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, continuityGaps, FlowQualityResult } from './flow-contract';
|
|
13
|
+
import { flowQuality, statefulDepthFor, continuityGaps, flowInventory, misfiledPhases, FlowQualityResult, InventoryResult } from './flow-contract';
|
|
14
14
|
import { featureFilesFor } from './unit-paths';
|
|
15
15
|
import {
|
|
16
16
|
loadCatalog, viewpointGate, assertionDepth, dataThemesFor, depthThresholdFor, coverageBalance, duplicateClusters, traceability, claimProof, taxonomyLint,
|
|
@@ -22,10 +22,10 @@ import { manualReasonMismatches, MANUAL_REASONS, buildPlan } from './capability-
|
|
|
22
22
|
import { readCapabilities, verificationScopeFindings } from './capability';
|
|
23
23
|
import { readIntent, projectRootFromScreenDir, IntentProfile } from './intent';
|
|
24
24
|
import { getProvenance, Provenance } from './provenance';
|
|
25
|
-
import { specCoverage, SpecCoverageResult, parseSpecClauses } from './spec-coverage';
|
|
25
|
+
import { specCoverage, SpecCoverageResult, parseSpecClauses, restatedRequirementSources } from './spec-coverage';
|
|
26
26
|
import { downstreamScope, manualOracle, readText, DownstreamResult, ManualOracleResult,
|
|
27
27
|
negativeSideEffect, sourceBacked, crossArtifactOwnership, isolationRisk, serialCascadeRisk } from './quality-gates';
|
|
28
|
-
import { viewpointLedger, parseViewpointItems, LedgerResult } from './viewpoint-ledger';
|
|
28
|
+
import { viewpointLedger, parseViewpointItems, browserGestureSubstitutions, LedgerResult } from './viewpoint-ledger';
|
|
29
29
|
import { capabilityRegistry } from '../capabilities/registry';
|
|
30
30
|
import { discoverAndRegisterCapabilities } from '../capabilities/discover';
|
|
31
31
|
import { contextRouter } from '../capabilities/context-router';
|
|
@@ -50,6 +50,8 @@ export interface AuditReport {
|
|
|
50
50
|
ledger: LedgerResult; // atomic viewpoint-item coverage (per-bullet status)
|
|
51
51
|
viewpointBaseline: ViewpointBaseline; // is the yardstick still the accepted one? (#557)
|
|
52
52
|
flowQuality?: FlowQualityResult; // #569 — flow contract verification (flows only)
|
|
53
|
+
/** #595 — coverage per DECLARED FLOW; absent when the contract declares no `flows:`. */
|
|
54
|
+
flowInventory?: InventoryResult;
|
|
53
55
|
calibration: { // #8 — multi-axis score so a high overall can't hide a weak axis
|
|
54
56
|
axes: Record<string, number>;
|
|
55
57
|
weakest: { axis: string; value: number };
|
|
@@ -73,7 +75,7 @@ export interface AuditReport {
|
|
|
73
75
|
* `specFR` and `atomicLedger` entirely — so a flow's real coverage axis was never printed
|
|
74
76
|
* while `balance`, which carried no weight, was.
|
|
75
77
|
*/
|
|
76
|
-
axes: Array<{ key: string; value: number; weight: number; applicable: boolean; critical: boolean }>;
|
|
78
|
+
axes: Array<{ key: string; value: number; weight: number; applicable: boolean; critical: boolean; byDesign?: boolean }>;
|
|
77
79
|
formula: string;
|
|
78
80
|
};
|
|
79
81
|
gateStatus: 'PASS' | 'FAIL';
|
|
@@ -83,6 +85,23 @@ export interface AuditReport {
|
|
|
83
85
|
spec: SpecCoverageResult; // G2 — spec-clause coverage (FR + validation-trigger matrix)
|
|
84
86
|
}
|
|
85
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Every unit name this project holds (screens + flows + api areas). Used to answer "does the
|
|
90
|
+
* project actually contain the spec this one says its requirements come from?".
|
|
91
|
+
*/
|
|
92
|
+
export function projectUnitDirs(projectRoot: string): string[] {
|
|
93
|
+
const out: string[] = [];
|
|
94
|
+
for (const kind of ['screens', 'flows', 'api']) {
|
|
95
|
+
const dir = path.join(projectRoot, 'qa', kind);
|
|
96
|
+
try {
|
|
97
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
98
|
+
if (e.isDirectory()) out.push(e.name);
|
|
99
|
+
}
|
|
100
|
+
} catch { /* the project may not use this unit kind */ }
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
86
105
|
/** The catalog-resolution id for a unit dir (relative to qa/): screen · flows/<flow> · api/<area> · api/flows/<flow>. */
|
|
87
106
|
export function catalogIdFromScreenDir(screenDir: string): string {
|
|
88
107
|
const parts = screenDir.split(path.sep);
|
|
@@ -223,6 +242,12 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
|
|
|
223
242
|
// businessDepth headroom: it can reach 1.0 only when all three are exercised, so a present-but-
|
|
224
243
|
// shallow flow can't claim a perfect score (floor 0.5 — assertion depth still dominates).
|
|
225
244
|
const isUiFlow = isUiFlowUnit;
|
|
245
|
+
// #595 — coverage per DECLARED FLOW, when the contract declares its inventory. Phase
|
|
246
|
+
// coverage answers "is there at least one exception scenario?"; a reviewer counting the use
|
|
247
|
+
// case's flows asks "are all eleven of them accounted for?". Those gave 100% and ~40% on the
|
|
248
|
+
// same suite, and the reviewer was measuring the right thing.
|
|
249
|
+
const inventory: InventoryResult | null = flowQ.contract ? flowInventory(flowQ.contract, scenarios) : null;
|
|
250
|
+
const misfiled = flowQ.contract ? misfiledPhases(flowQ.contract, scenarios, flowQ.contract.phases[0] ?? 'BF') : [];
|
|
226
251
|
let flowDepth = isUiFlow ? flowRegressionDepth(scenarios) : { stateful: false, countProof: false, teardown: false, multiSource: false, ratio: 1, missing: [] } as FlowDepthResult;
|
|
227
252
|
// Contract-declared statefulness generalizes the cart-hardcoded vocabulary: a flow that
|
|
228
253
|
// mutates ANY named collection (order, application, submission …) gets the same three
|
|
@@ -284,9 +309,13 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
|
|
|
284
309
|
// so a flow can finally EARN the coverage axis instead of always losing it to
|
|
285
310
|
// PAGE-TYPE-UNDETERMINED (or worse, being judged against a form checklist).
|
|
286
311
|
const flowScored = flowQ.hasContract;
|
|
287
|
-
|
|
312
|
+
// `byDesign` separates "this unit supplied no evidence" from "this axis does not apply to
|
|
313
|
+
// this KIND of unit". Only the first is a reason to cap the score: a contract flow can never
|
|
314
|
+
// supply balance evidence, so capping for it held every flow at 8.9 forever and printed
|
|
315
|
+
// "weakest: flowCoverage 100% — fix it to lift the number", which is not fixable advice (#595).
|
|
316
|
+
const axisDefs: Array<{ key: string; value: number; weight: number; applicable: boolean; critical: boolean; byDesign?: boolean }> = [
|
|
288
317
|
flowScored
|
|
289
|
-
? { key: 'flowCoverage', value: flowQ.phaseRatio, weight: 0.22, applicable: true, critical: true }
|
|
318
|
+
? { key: 'flowCoverage', value: inventory ? Math.min(inventory.ratio, flowQ.phaseRatio) : flowQ.phaseRatio, weight: 0.22, applicable: true, critical: true }
|
|
290
319
|
: { key: 'coverage', value: coverage, weight: 0.22, applicable: !!gate.pageType && gate.themesTotal > 0, critical: true },
|
|
291
320
|
{ key: 'specFR', value: specRatio, weight: 0.15, applicable: spec.hasSpec && spec.frTotal > 0, critical: true },
|
|
292
321
|
{ key: 'atomicLedger', value: ledger.ratio, weight: 0.13, applicable: ledger.hasViewpoint && ledger.total > 0 && !viewpointMoved, critical: true },
|
|
@@ -297,7 +326,7 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
|
|
|
297
326
|
// exceptions) — that is the shape of a well-decomposed use case, not neglected business
|
|
298
327
|
// core. `flowCoverage` already measures whether the journey's phases are covered, so
|
|
299
328
|
// balance carries no independent evidence for a contract flow (#592).
|
|
300
|
-
{ key: 'balance', value: balanceScore, weight: 0.06, applicable: !flowScored, critical: false },
|
|
329
|
+
{ key: 'balance', value: balanceScore, weight: 0.06, applicable: !flowScored, critical: false, byDesign: flowScored },
|
|
301
330
|
];
|
|
302
331
|
const scored = axisDefs.filter((a) => a.applicable);
|
|
303
332
|
const weightSum = scored.reduce((t, a) => t + a.weight, 0) || 1;
|
|
@@ -316,7 +345,22 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
|
|
|
316
345
|
let cap = 10;
|
|
317
346
|
if (weakestCritical && weakestCritical.value < 0.5) cap = CAP_SEVERE;
|
|
318
347
|
else if (weakestCritical && weakestCritical.value < 0.7) cap = CAP_WEAK;
|
|
319
|
-
const missingEvidence = axisDefs.filter((a) => !a.applicable).map((a) => a.key);
|
|
348
|
+
const missingEvidence = axisDefs.filter((a) => !a.applicable && !a.byDesign).map((a) => a.key);
|
|
349
|
+
// Phase coverage is WEAK evidence of flow coverage: a phase counts as covered as soon as
|
|
350
|
+
// one scenario carries it, so `flowCoverage 100%` on a fifteen-flow use case meant only
|
|
351
|
+
// "each of BF/AF/EF appears at least once". Until the contract declares its inventory, the
|
|
352
|
+
// axis cannot be certified complete — so it is partial evidence and holds the top mark down,
|
|
353
|
+
// instead of printing 100% next to a finding that says we cannot actually tell (#595).
|
|
354
|
+
if (flowScored && !inventory) missingEvidence.push('flowCoverage (phase-only — no `flows:` inventory)');
|
|
355
|
+
// A requirement list hand-restated from spec documents the project does not hold makes
|
|
356
|
+
// `specFR 100%` a certificate over an unverifiable universe: the clause that was never
|
|
357
|
+
// copied across can never be reported missing. That is partial evidence, so it holds the
|
|
358
|
+
// top mark down rather than reading as complete FR coverage (#595).
|
|
359
|
+
const restated = restatedRequirementSources(readText(specPath) ?? '', projectUnitDirs(projectRootFromScreenDir(screenDir)));
|
|
360
|
+
if (restated.sources.length > 0 && restated.missing.length > 0) {
|
|
361
|
+
missingEvidence.push(`specFR (restated from absent specs: ${restated.missing.join(', ')})`);
|
|
362
|
+
}
|
|
363
|
+
const notApplicableByDesign = axisDefs.filter((a) => !a.applicable && a.byDesign).map((a) => a.key);
|
|
320
364
|
if (missingEvidence.length > 0) cap = Math.min(cap, CAP_PARTIAL_EVIDENCE);
|
|
321
365
|
const rawOverall = Math.min(weighted * 10, cap);
|
|
322
366
|
const capped = weakestCritical ? weighted * 10 > cap : false;
|
|
@@ -372,6 +416,37 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
|
|
|
372
416
|
if (!flowQ.contract!.minimalGuarantee) {
|
|
373
417
|
findings.push('FLOW-GUARANTEE-MISSING: the contract declares no `minimalGuarantee:` — the postcondition that must hold in EVERY flow, including failure ("no second account for the same email", "no verification mail on a rejected submit"). Exception/guard scenarios have nothing to assert against without it; add it (and `successGuarantee:` for the goal-reached side).');
|
|
374
418
|
}
|
|
419
|
+
// #595 — a viewpoint item naming a browser-level gesture, answered with an in-app control.
|
|
420
|
+
for (const g of browserGestureSubstitutions(viewpointPath, scenarios).slice(0, 4)) {
|
|
421
|
+
findings.push(`VIEWPOINT-GESTURE-SUBSTITUTED: the viewpoint asks for "${g.gesture}" ("${g.item.slice(0, 80)}…") but the suite exercises an in-app control instead. They are different mechanisms — an app button runs the screen's own re-hydrate logic, browser history does not, and a defect that only shows on history navigation lives in exactly that gap. The step vocabulary has no reload/back step (#593), so defer it with \`@manual\` naming the gesture rather than substituting silently.`);
|
|
422
|
+
}
|
|
423
|
+
// #595 — a flow whose requirement list is a hand restatement of the screen specs it
|
|
424
|
+
// traverses. `specFR` read 100% over two restated FRs while the guard clause the flow most
|
|
425
|
+
// needed had never entered the system. Name the source documents so the completeness of the
|
|
426
|
+
// restatement becomes a question someone can answer.
|
|
427
|
+
if (restated.sources.length > 0 && restated.missing.length > 0) {
|
|
428
|
+
findings.push(`SPEC-RESTATED-UNVERIFIED: this flow's requirements are restated from ${restated.sources.length} source spec(s) the project does not hold [${restated.missing.join(', ')}]. specFR ${spec.frCovered}/${spec.frTotal} therefore certifies coverage of the RESTATEMENT, not of those documents — whatever was not copied across is invisible here, and a guard clause that never entered the flow spec can never be reported missing. Add the screen units (\`sungen add --screen …\`) so their FRs are checkable, or record in the flow spec which of their requirements are deliberately out of this flow's scope.`);
|
|
429
|
+
}
|
|
430
|
+
// #595 — the flow inventory. Without it, "the phases are covered" was allowed to stand in
|
|
431
|
+
// for "the use case's flows are covered", and a reviewer who counted flows read the same
|
|
432
|
+
// suite as roughly half done.
|
|
433
|
+
if (!inventory) {
|
|
434
|
+
findings.push('FLOW-INVENTORY-MISSING: the contract declares `phases:` but no `flows:` inventory, so coverage can only be measured per PHASE — and a phase counts as covered as soon as ONE scenario carries it. Declare each flow of the use case (`id`, `branchFrom`, `outcome`, `status`) so a flow nobody wrote is a named gap instead of a silent absence. Enumerate them with the step x risk matrix in the `sungen-tc-generation` skill.');
|
|
435
|
+
} else {
|
|
436
|
+
for (const f of inventory.uncovered) {
|
|
437
|
+
findings.push(`FLOW-UNCOVERED: declared flow ${f.id}${f.branchFrom ? ` (branches from ${f.branchFrom})` : ''} has status \`covered\` but no scenario carries its id — write it, or change its status to \`deferred\` / \`pending-clarification\` / \`out-of-scope\` WITH a reason. Silence is the one option the inventory removes.`);
|
|
438
|
+
}
|
|
439
|
+
for (const u of inventory.undeclared.slice(0, 6)) {
|
|
440
|
+
findings.push(`FLOW-UNDECLARED: "${u.scenario}" claims flow id ${u.id}, which the contract's inventory does not declare — a phase id invented for one scenario inflates the flow count without adding branch coverage. Either declare ${u.id} as a real flow (branch point + own outcome), or fold the scenario into the flow it actually belongs to.`);
|
|
441
|
+
}
|
|
442
|
+
for (const f of inventory.accounted.filter((x) => !x.reason)) {
|
|
443
|
+
findings.push(`FLOW-STATUS-UNREASONED: declared flow ${f.id} is \`${f.status}\` with no \`reason:\` — a deferral nobody can audit is the same as a missing flow. Say what blocks it (a capability, an open question for the BA, another suite that owns it).`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
// #595 — a phase id used for something that is not that kind of flow.
|
|
447
|
+
for (const m of misfiled.slice(0, 6)) {
|
|
448
|
+
findings.push(`FLOW-PHASE-MISFILED: "${m.scenario}" carries flow id ${m.id} but ${m.why}. Re-file it under the flow it belongs to; the phase count is not the flow count.`);
|
|
449
|
+
}
|
|
375
450
|
// #580 P9 — a release-critical flow must carry its Final-Inspection selection.
|
|
376
451
|
if (flowQ.contract!.golden && !/@golden\b/i.test(featureText)) {
|
|
377
452
|
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.`);
|
|
@@ -610,7 +685,7 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
|
|
|
610
685
|
scenarioCount: scenarios.length,
|
|
611
686
|
gate, depth, claim, taxonomy, balance, duplicates, trace, spec,
|
|
612
687
|
taxonomyMismatch, downstream, manualOracle: manualOracleResult, automatableManual: autoManual, flowDepth, oracle, ledger, viewpointBaseline, calibration,
|
|
613
|
-
...(isUiFlow ? { flowQuality: flowQ } : {}),
|
|
688
|
+
...(isUiFlow ? { flowQuality: flowQ, flowInventory: inventory ?? undefined } : {}),
|
|
614
689
|
score: {
|
|
615
690
|
overall: Math.round(overall * 10) / 10,
|
|
616
691
|
coverage: Math.round(coverage * 100) / 100,
|
|
@@ -622,7 +697,8 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
|
|
|
622
697
|
// flow) that carried no weight at all.
|
|
623
698
|
axes: axisDefs.map((a) => ({ ...a, weight: a.applicable ? a.weight / weightSum : 0 })),
|
|
624
699
|
formula: `overall = (${scored.map((a) => `${(a.weight / weightSum).toFixed(2)}*${a.key}`).join(' + ')}) * 10`
|
|
625
|
-
+ (missingEvidence.length ? ` — n/a (no evidence): ${missingEvidence.join(', ')}` : '')
|
|
700
|
+
+ (missingEvidence.length ? ` — n/a (no evidence): ${missingEvidence.join(', ')}` : '')
|
|
701
|
+
+ (notApplicableByDesign.length ? ` — n/a for this unit kind: ${notApplicableByDesign.join(', ')}` : ''),
|
|
626
702
|
},
|
|
627
703
|
gateStatus,
|
|
628
704
|
findings,
|
|
@@ -27,6 +27,21 @@ import { parse as parseYaml } from 'yaml';
|
|
|
27
27
|
import { ScenarioInfo } from './parse';
|
|
28
28
|
import { readTextFile } from './read-text';
|
|
29
29
|
|
|
30
|
+
/** One declared flow of the use case: a branch point, its own steps, its own outcome. */
|
|
31
|
+
export interface FlowDecl {
|
|
32
|
+
/** The flow id as the use-case document numbers it — `BF`, `AF01`, `EF03`. */
|
|
33
|
+
id: string;
|
|
34
|
+
/** Where it leaves the basic flow (`BF step 2`). Omitted for the basic flow itself. */
|
|
35
|
+
branchFrom?: string;
|
|
36
|
+
/** Its OWN outcome/postcondition — what is different about where this flow ends. */
|
|
37
|
+
outcome?: string;
|
|
38
|
+
status: 'covered' | 'deferred' | 'pending-clarification' | 'out-of-scope';
|
|
39
|
+
/** Why, for anything other than `covered` — a deferral with no reason is a silent gap. */
|
|
40
|
+
reason?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const FLOW_STATUSES = new Set(['covered', 'deferred', 'pending-clarification', 'out-of-scope']);
|
|
44
|
+
|
|
30
45
|
export interface FlowContract {
|
|
31
46
|
goal: string;
|
|
32
47
|
actor?: string;
|
|
@@ -43,6 +58,18 @@ export interface FlowContract {
|
|
|
43
58
|
/** Journey phases this flow declares. Default [HP, ER, EH]; UI is allowed but
|
|
44
59
|
* never demanded (presentation is the balance axis's business, not coverage's). */
|
|
45
60
|
phases: string[];
|
|
61
|
+
/**
|
|
62
|
+
* The use case's declared flow inventory — the answer to "how many flows does this use
|
|
63
|
+
* case HAVE?". `phases:` alone cannot answer it: a phase is present as soon as ONE
|
|
64
|
+
* scenario carries it, so a fifteen-flow use case with three scenarios reported full
|
|
65
|
+
* phase coverage. With an inventory, coverage is measured per DECLARED FLOW and a flow
|
|
66
|
+
* nobody wrote is a named gap instead of a silent absence (#595).
|
|
67
|
+
*
|
|
68
|
+
* `status` is what keeps a gap honest: every flow ends up `covered`, `deferred`,
|
|
69
|
+
* `pending-clarification` (an open question for the BA — the behaviour is not agreed yet,
|
|
70
|
+
* so no scenario can be right) or `out-of-scope`. Absent → `covered` is expected.
|
|
71
|
+
*/
|
|
72
|
+
flows?: FlowDecl[];
|
|
46
73
|
/** The mutated collection (cart, order, application …) — enables regression dims. */
|
|
47
74
|
stateful?: string;
|
|
48
75
|
budgets?: Record<string, number>;
|
|
@@ -117,6 +144,23 @@ export function loadFlowContract(unitDir: string): { contract: FlowContract | nu
|
|
|
117
144
|
successGuarantee: raw.successGuarantee !== undefined ? String(raw.successGuarantee) : (raw.success_guarantee !== undefined ? String(raw.success_guarantee) : undefined),
|
|
118
145
|
minimalGuarantee: raw.minimalGuarantee !== undefined ? String(raw.minimalGuarantee) : (raw.minimal_guarantee !== undefined ? String(raw.minimal_guarantee) : undefined),
|
|
119
146
|
phases,
|
|
147
|
+
flows: Array.isArray(raw.flows)
|
|
148
|
+
? (raw.flows as Array<Record<string, unknown>>)
|
|
149
|
+
.filter((f) => f && typeof f === 'object' && f.id)
|
|
150
|
+
.map((f) => {
|
|
151
|
+
const status = String(f.status ?? 'covered').toLowerCase();
|
|
152
|
+
if (!FLOW_STATUSES.has(status)) {
|
|
153
|
+
errors.push(`flows[${String(f.id)}].status "${status}" is not one of covered|deferred|pending-clarification|out-of-scope`);
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
id: String(f.id).toUpperCase(),
|
|
157
|
+
branchFrom: f.branchFrom !== undefined ? String(f.branchFrom) : (f.branch_from !== undefined ? String(f.branch_from) : undefined),
|
|
158
|
+
outcome: f.outcome !== undefined ? String(f.outcome) : undefined,
|
|
159
|
+
status: (FLOW_STATUSES.has(status) ? status : 'covered') as FlowDecl['status'],
|
|
160
|
+
reason: f.reason !== undefined ? String(f.reason) : undefined,
|
|
161
|
+
};
|
|
162
|
+
})
|
|
163
|
+
: undefined,
|
|
120
164
|
stateful: raw.stateful !== undefined ? String(raw.stateful).toLowerCase() : undefined,
|
|
121
165
|
budgets: (raw.budgets && typeof raw.budgets === 'object') ? raw.budgets as Record<string, number> : undefined,
|
|
122
166
|
external: Array.isArray(raw.external)
|
|
@@ -130,7 +174,9 @@ export function loadFlowContract(unitDir: string): { contract: FlowContract | nu
|
|
|
130
174
|
: undefined,
|
|
131
175
|
golden: raw.golden === true,
|
|
132
176
|
},
|
|
133
|
-
errors
|
|
177
|
+
// Shape errors found while reading the inventory (a bad `status:`) are REPORTED with the
|
|
178
|
+
// contract, not swallowed — the contract is still usable, the typo is not silently ignored.
|
|
179
|
+
errors,
|
|
134
180
|
};
|
|
135
181
|
}
|
|
136
182
|
|
|
@@ -145,6 +191,22 @@ function touchesOutcome(s: ScenarioInfo, outcomeScreen: string): boolean {
|
|
|
145
191
|
return namespacesInOrder(s).includes(outcomeScreen);
|
|
146
192
|
}
|
|
147
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Does the scenario ARRIVE at the outcome, as opposed to merely naming it?
|
|
196
|
+
*
|
|
197
|
+
* A guard scenario asserts the outcome screen is *absent* (`[Complete:Title] header is hidden`)
|
|
198
|
+
* — the strongest thing it can say — so "the outcome namespace appears somewhere in the steps"
|
|
199
|
+
* counts it as reaching a screen it exists to prove unreachable.
|
|
200
|
+
*/
|
|
201
|
+
function reachesOutcome(s: ScenarioInfo, outcomeScreen: string): boolean {
|
|
202
|
+
// Per STEP, from the structured list — `stepsText` is one space-joined blob, so any negation
|
|
203
|
+
// anywhere in the scenario would suppress every positive assertion in it.
|
|
204
|
+
const steps = (s.steps ?? []).map((st) => st.text.toLowerCase());
|
|
205
|
+
if (steps.length === 0) return touchesOutcome(s, outcomeScreen);
|
|
206
|
+
return steps.some((t) => t.includes(`[${outcomeScreen}`)
|
|
207
|
+
&& !/\b(is hidden|is not visible|does not exist|is absent|no longer)\b/.test(t));
|
|
208
|
+
}
|
|
209
|
+
|
|
148
210
|
/** Phase of a scenario: its declared phase token (FL-HP-001 / VP-FLOW-ER-02 / MS-EH-005)
|
|
149
211
|
* when present, else vocabulary detection. */
|
|
150
212
|
export function phaseOf(s: ScenarioInfo, declared: string[]): string | null {
|
|
@@ -163,6 +225,101 @@ export function phaseOf(s: ScenarioInfo, declared: string[]): string | null {
|
|
|
163
225
|
return null;
|
|
164
226
|
}
|
|
165
227
|
|
|
228
|
+
/** The flow id a scenario claims: the `AF02`/`EF11`/`BF` segment of its viewpoint id. */
|
|
229
|
+
export function flowIdOf(s: ScenarioInfo, declaredPhases: string[]): string | null {
|
|
230
|
+
const tokens = new Set(declaredPhases.map(phaseToken));
|
|
231
|
+
for (const seg of (s.vpId ?? '').toUpperCase().split('-')) {
|
|
232
|
+
if (tokens.has(phaseToken(seg))) return seg;
|
|
233
|
+
}
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export interface InventoryResult {
|
|
238
|
+
/** Declared → the scenarios claiming it. A declared flow with none is a NAMED gap. */
|
|
239
|
+
covered: Array<{ id: string; scenarios: string[] }>;
|
|
240
|
+
/** Declared `covered` but nothing written — the silent-absence case, now named. */
|
|
241
|
+
uncovered: FlowDecl[];
|
|
242
|
+
/** Declared with a non-covered status, carried into the report so it stays visible. */
|
|
243
|
+
accounted: FlowDecl[];
|
|
244
|
+
/** A scenario's flow id that the inventory never declares — an id invented for one
|
|
245
|
+
* assertion (an "EF" that is really a success postcondition, an "AF" that is really a
|
|
246
|
+
* content check on the basic path). */
|
|
247
|
+
undeclared: Array<{ id: string; scenario: string }>;
|
|
248
|
+
/** Covered declared flows / declared flows that OUGHT to be covered. */
|
|
249
|
+
ratio: number;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Coverage per DECLARED FLOW, which is not the same question as coverage per phase.
|
|
254
|
+
*
|
|
255
|
+
* A phase is "covered" the moment one scenario carries it, so a use case decomposed into
|
|
256
|
+
* fifteen flows reported `BF=✓ AF=✓ EF=✓ → 100%` on three scenarios. Reviewers reading the
|
|
257
|
+
* suite counted the flows instead and got a very different number (#595). When the contract
|
|
258
|
+
* declares its inventory, this measures the thing the reviewer measures — and every gap is
|
|
259
|
+
* named, which is what makes "no silent missing flow" checkable rather than aspirational.
|
|
260
|
+
*/
|
|
261
|
+
export function flowInventory(contract: FlowContract, scenarios: ScenarioInfo[]): InventoryResult | null {
|
|
262
|
+
if (!contract.flows || contract.flows.length === 0) return null;
|
|
263
|
+
const byId = new Map<string, string[]>();
|
|
264
|
+
const undeclared: Array<{ id: string; scenario: string }> = [];
|
|
265
|
+
const declaredIds = new Set(contract.flows.map((f) => f.id));
|
|
266
|
+
for (const s of scenarios) {
|
|
267
|
+
const id = flowIdOf(s, contract.phases);
|
|
268
|
+
if (!id) continue;
|
|
269
|
+
if (!declaredIds.has(id)) { undeclared.push({ id, scenario: s.name }); continue; }
|
|
270
|
+
byId.set(id, [...(byId.get(id) ?? []), s.name]);
|
|
271
|
+
}
|
|
272
|
+
const expected = contract.flows.filter((f) => f.status === 'covered');
|
|
273
|
+
const covered = expected.filter((f) => (byId.get(f.id) ?? []).length > 0)
|
|
274
|
+
.map((f) => ({ id: f.id, scenarios: byId.get(f.id)! }));
|
|
275
|
+
return {
|
|
276
|
+
covered,
|
|
277
|
+
uncovered: expected.filter((f) => (byId.get(f.id) ?? []).length === 0),
|
|
278
|
+
accounted: contract.flows.filter((f) => f.status !== 'covered'),
|
|
279
|
+
undeclared,
|
|
280
|
+
ratio: expected.length ? covered.length / expected.length : 1,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* A phase id used for something that is not that kind of flow.
|
|
286
|
+
*
|
|
287
|
+
* Two shapes, both seen on a real suite that the phase check scored 100%:
|
|
288
|
+
* - an `EF` scenario that REACHES the contract outcome. An exception flow is blocked by
|
|
289
|
+
* definition; one that completes the journey is a success-path postcondition wearing an
|
|
290
|
+
* exception's id ("the buffer does not survive a finalized registration").
|
|
291
|
+
* - an `AF` scenario with no branch: it walks the basic path and asserts extra content on
|
|
292
|
+
* it. An alternate flow needs a point where the actor does something else; without one it
|
|
293
|
+
* is an assertion belonging to the basic flow ("the shared component renders the new-email
|
|
294
|
+
* copy").
|
|
295
|
+
* Both inflate the flow count while adding no branch coverage, which is exactly what makes a
|
|
296
|
+
* suite look complete to the harness and thin to a reviewer.
|
|
297
|
+
*/
|
|
298
|
+
export function misfiledPhases(
|
|
299
|
+
contract: FlowContract, scenarios: ScenarioInfo[], basicPhase: string,
|
|
300
|
+
): Array<{ scenario: string; id: string; why: string }> {
|
|
301
|
+
const out: Array<{ scenario: string; id: string; why: string }> = [];
|
|
302
|
+
const outcome = contract.outcome.screen;
|
|
303
|
+
for (const s of scenarios) {
|
|
304
|
+
const id = flowIdOf(s, contract.phases);
|
|
305
|
+
if (!id || phaseToken(id) === phaseToken(basicPhase)) continue;
|
|
306
|
+
const ph = phaseToken(id);
|
|
307
|
+
if (ph === 'EF' && reachesOutcome(s, outcome)) {
|
|
308
|
+
// An error-then-recover flow legitimately ends at the outcome — it is the recovery that
|
|
309
|
+
// is being proven, and a guard flow is blocked rather than "failed". Only a scenario with
|
|
310
|
+
// no failure and no block at all is misfiled.
|
|
311
|
+
const failed = /\b(error|invalid|duplicate|reject|fail|denied|blocked|expired|refuse|unauthenticat|unauthoriz|redirect|guard|without (?:a |an |the )?\w+|not skippable|forces? \w+ to restart)\w*/i.test(s.haystack);
|
|
312
|
+
if (!failed) {
|
|
313
|
+
out.push({ scenario: s.name, id, why: `reaches the outcome screen [${outcome}] and nothing in it fails — this is a success-path postcondition, not an exception flow` });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (ph === 'AF' && !/\b(back|cancel|return|instead|abandon|second tab|another tab|skip|leave|exit|retry|edit)\w*/i.test(s.haystack)) {
|
|
317
|
+
out.push({ scenario: s.name, id, why: 'no branch point — it walks the basic path and asserts extra content on it, so the assertion belongs to the basic flow' });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return out;
|
|
321
|
+
}
|
|
322
|
+
|
|
166
323
|
/**
|
|
167
324
|
* Verify the suite against the contract. Deterministic; a flow without a contract
|
|
168
325
|
* returns hasContract:false and neutral values (the audit reports the checklist).
|
|
@@ -202,3 +202,41 @@ export function specCoverage(specPath: string, scenarios: ScenarioInfo[], featur
|
|
|
202
202
|
|
|
203
203
|
return { hasSpec: true, frTotal: frs.length, frCovered, uncoveredMust, inferredOnly, triggerGaps, verdict };
|
|
204
204
|
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* A flow's requirement list that is a HAND RESTATEMENT of the screen specs it traverses.
|
|
208
|
+
*
|
|
209
|
+
* A flow spec typically cites its requirements as belonging elsewhere — "ST_AUTH_002 FR-001",
|
|
210
|
+
* "restated here in flow terms". That restatement is lossy by construction, and nothing checked
|
|
211
|
+
* it: on a real run the flow spec restated two of the screens' FRs, `specFR` read **2/2 = 100%**,
|
|
212
|
+
* and the guard clause the flow most needed (a double-submit rule in one of those screen specs)
|
|
213
|
+
* had never entered the system at all. The axis was certifying complete coverage of a
|
|
214
|
+
* hand-truncated universe — a worse failure than a missing scenario, because the number said the
|
|
215
|
+
* opposite.
|
|
216
|
+
*
|
|
217
|
+
* So: name the source documents the flow says its requirements come from, and say plainly which
|
|
218
|
+
* of them the project does not hold. Whether the restatement is complete is then a question
|
|
219
|
+
* someone can answer, instead of one nothing was asking.
|
|
220
|
+
*/
|
|
221
|
+
export function restatedRequirementSources(specText: string, availableUnits: string[]): {
|
|
222
|
+
restated: boolean; sources: string[]; missing: string[];
|
|
223
|
+
} {
|
|
224
|
+
// "these ... originate in the SCREEN specs", "restated here", "per ST_AUTH_002 FR-001".
|
|
225
|
+
const restated = /\brestate[sd]?\b|\boriginate[sd]? in\b|\bderived from the (?:screen|per-screen) spec/i.test(specText);
|
|
226
|
+
// External document ids carrying their own requirement number: `ST_AUTH_002 FR-001`,
|
|
227
|
+
// `SCR-1-SYS-0001.FR-3`. Two+ segments and an uppercase head, so a bare `FR-001` (the flow's
|
|
228
|
+
// own) never matches.
|
|
229
|
+
// A citation may name SEVERAL documents at once — "ST_AUTH_002/ST_AUTH_004 FR-001" — so match
|
|
230
|
+
// the whole slash/comma-joined run and split it. Capturing only the token adjacent to the
|
|
231
|
+
// requirement number silently dropped every sibling.
|
|
232
|
+
const DOC = '[A-Z][A-Z0-9]*(?:[_-][A-Z0-9]+)+';
|
|
233
|
+
const cite = new RegExp(`\\b((?:${DOC})(?:\\s*[/,]\\s*(?:${DOC}))*)[\\s.]+(?:FR|NFR|BR)-\\d+`, 'g');
|
|
234
|
+
const sources = [...new Set(
|
|
235
|
+
[...specText.matchAll(cite)].flatMap((m) => m[1].split(/\s*[/,]\s*/)).map((x) => x.trim()).filter(Boolean),
|
|
236
|
+
)];
|
|
237
|
+
if (!restated && sources.length === 0) return { restated: false, sources: [], missing: [] };
|
|
238
|
+
const norm = (x: string): string => x.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
239
|
+
const have = availableUnits.map(norm);
|
|
240
|
+
const missing = sources.filter((src) => !have.some((u) => u.includes(norm(src)) || norm(src).includes(u)));
|
|
241
|
+
return { restated, sources, missing };
|
|
242
|
+
}
|
|
@@ -94,3 +94,45 @@ export function viewpointLedger(viewpointPath: string, scenarios: ScenarioInfo[]
|
|
|
94
94
|
|
|
95
95
|
return { hasViewpoint: true, total: items.length, covered, ratio: items.length ? covered / items.length : 1, missing };
|
|
96
96
|
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A viewpoint item that names a BROWSER-LEVEL gesture, met by an in-app control instead.
|
|
100
|
+
*
|
|
101
|
+
* "Browser back button from Basic Info Confirmation to Basic Info — values must be restored"
|
|
102
|
+
* was answered with `User click [Confirmation:Back] button`. Those are different mechanisms: an
|
|
103
|
+
* app button runs the screen's own re-hydrate logic, browser history does not, and a defect
|
|
104
|
+
* that only appears on history navigation lives exactly in the gap. The ledger counted the item
|
|
105
|
+
* covered because the words matched, so the substitution was invisible (#595).
|
|
106
|
+
*
|
|
107
|
+
* The language has no reload/back step at all (#593), so the honest outcome is a `@manual`
|
|
108
|
+
* deferral naming the gesture — not a silent swap.
|
|
109
|
+
*/
|
|
110
|
+
const BROWSER_GESTURES: Array<{ gesture: string; re: RegExp; appSubstitute: RegExp }> = [
|
|
111
|
+
{ gesture: 'browser back', re: /\bbrowser(?:'s)?[ -]?(?:back|history)\b|\bhistory[ -]back\b/i,
|
|
112
|
+
appSubstitute: /\bclick \[[^\]]*back[^\]]*\]/i },
|
|
113
|
+
{ gesture: 'browser reload/refresh', re: /\b(?:page |browser )?(?:reload|refresh)(?:ing|ed|es)?\b/i,
|
|
114
|
+
appSubstitute: /\bis on \[[^\]]+\] page\b/i },
|
|
115
|
+
{ gesture: 'closing and reopening the tab', re: /\bclos(?:e|ing) (?:and reopen\w*\s*)?the tab\b|\breopen\w* the tab\b/i,
|
|
116
|
+
appSubstitute: /\bis on \[[^\]]+\] page\b/i },
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
export function browserGestureSubstitutions(
|
|
120
|
+
viewpointPath: string, scenarios: ScenarioInfo[],
|
|
121
|
+
): Array<{ gesture: string; item: string }> {
|
|
122
|
+
const out: Array<{ gesture: string; item: string }> = [];
|
|
123
|
+
// Judged PER SCENARIO, not over the whole file: a @manual scenario elsewhere that merely
|
|
124
|
+
// mentions the gesture in passing (explaining a tooling limit) silenced the check for an
|
|
125
|
+
// item that an automated scenario had quietly substituted.
|
|
126
|
+
const deferred = scenarios.filter((s) => s.manual).map((s) => s.haystack);
|
|
127
|
+
const automated = scenarios.filter((s) => !s.manual).map((s) => s.haystack);
|
|
128
|
+
for (const item of parseViewpointItems(viewpointPath)) {
|
|
129
|
+
for (const g of BROWSER_GESTURES) {
|
|
130
|
+
if (!g.re.test(item.text)) continue;
|
|
131
|
+
// A @manual scenario whose own subject IS the gesture is the honest answer.
|
|
132
|
+
if (deferred.some((h) => g.re.test(h))) break;
|
|
133
|
+
if (automated.some((h) => g.appSubstitute.test(h))) out.push({ gesture: g.gesture, item: item.text });
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
@@ -42,8 +42,19 @@ plus **both** postconditions: `successGuarantee` (true when the goal is reached)
|
|
|
42
42
|
`minimalGuarantee` (what must still hold when the journey FAILS). Exception flows assert against
|
|
43
43
|
the second one; with no `minimalGuarantee` they can only prove "an error appeared"
|
|
44
44
|
(`FLOW-GUARANTEE-MISSING`). Then declare `phases:` — `[BF, AF, EF]` for a use-case decomposition
|
|
45
|
-
(the first phase is the Basic Flow)
|
|
46
|
-
`sungen-tc-generation` skill
|
|
45
|
+
(the first phase is the Basic Flow). Then enumerate the use case's flows with the step × risk
|
|
46
|
+
matrix in the `sungen-tc-generation` skill and **write them into the contract's `flows:`
|
|
47
|
+
inventory**, each with a `status:` (`covered` / `deferred` / `pending-clarification` /
|
|
48
|
+
`out-of-scope`) and a `reason:` for anything but `covered`. Without the inventory, coverage can
|
|
49
|
+
only be measured per phase — and a phase counts as covered as soon as ONE scenario carries it, so
|
|
50
|
+
a fifteen-flow use case scores 100% on three scenarios (`FLOW-INVENTORY-MISSING`, and the top mark
|
|
51
|
+
is withheld).
|
|
52
|
+
|
|
53
|
+
A risk the specs do NOT settle is `pending-clarification` with the question written down — never a
|
|
54
|
+
scenario asserting invented behaviour, and never a silent omission. If the flow's requirements are
|
|
55
|
+
restated from screen specs the project does not hold, say so: the audit reports
|
|
56
|
+
`SPEC-RESTATED-UNVERIFIED` because `specFR 100%` over a hand-copied list certifies the copy, not
|
|
57
|
+
the source.
|
|
47
58
|
|
|
48
59
|
---
|
|
49
60
|
|