@sun-asterisk/sungen 3.2.25 → 3.2.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/audit.js +23 -4
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/cli/commands/next.js +2 -2
- package/dist/cli/commands/next.js.map +1 -1
- package/dist/exporters/matrix/build.d.ts.map +1 -1
- package/dist/exporters/matrix/build.js +4 -1
- package/dist/exporters/matrix/build.js.map +1 -1
- package/dist/exporters/matrix/map-loader.d.ts.map +1 -1
- package/dist/exporters/matrix/map-loader.js +5 -0
- package/dist/exporters/matrix/map-loader.js.map +1 -1
- package/dist/exporters/matrix/types.d.ts +11 -0
- package/dist/exporters/matrix/types.d.ts.map +1 -1
- package/dist/exporters/matrix/types.js.map +1 -1
- package/dist/generators/test-generator/utils/selector-resolver.d.ts.map +1 -1
- package/dist/generators/test-generator/utils/selector-resolver.js +2 -1
- package/dist/generators/test-generator/utils/selector-resolver.js.map +1 -1
- package/dist/harness/audit.d.ts +7 -0
- package/dist/harness/audit.d.ts.map +1 -1
- package/dist/harness/audit.js +77 -9
- package/dist/harness/audit.js.map +1 -1
- package/dist/harness/flow-contract.d.ts +18 -15
- package/dist/harness/flow-contract.d.ts.map +1 -1
- package/dist/harness/flow-contract.js +116 -18
- package/dist/harness/flow-contract.js.map +1 -1
- package/dist/harness/next-step.d.ts +8 -2
- package/dist/harness/next-step.d.ts.map +1 -1
- package/dist/harness/next-step.js +2 -2
- package/dist/harness/next-step.js.map +1 -1
- package/dist/harness/quality-gates.d.ts +12 -1
- package/dist/harness/quality-gates.d.ts.map +1 -1
- package/dist/harness/quality-gates.js +62 -7
- package/dist/harness/quality-gates.js.map +1 -1
- package/dist/harness/sensors.d.ts.map +1 -1
- package/dist/harness/sensors.js +9 -1
- package/dist/harness/sensors.js.map +1 -1
- package/dist/harness/spec-branches.d.ts +88 -0
- package/dist/harness/spec-branches.d.ts.map +1 -0
- package/dist/harness/spec-branches.js +280 -0
- package/dist/harness/spec-branches.js.map +1 -0
- package/dist/harness/spec-coverage.d.ts +1 -1
- package/dist/harness/spec-coverage.js +4 -4
- package/dist/harness/spec-coverage.js.map +1 -1
- package/dist/harness/viewpoint-baseline.d.ts +9 -0
- package/dist/harness/viewpoint-baseline.d.ts.map +1 -1
- package/dist/harness/viewpoint-baseline.js +33 -3
- package/dist/harness/viewpoint-baseline.js.map +1 -1
- package/dist/harness/viewpoint-ledger.d.ts.map +1 -1
- package/dist/harness/viewpoint-ledger.js +63 -5
- package/dist/harness/viewpoint-ledger.js.map +1 -1
- package/dist/orchestrator/templates/ai-src/commands/add-flow.md +16 -0
- package/dist/orchestrator/templates/ai-src/commands/create-test.md +10 -1
- package/dist/orchestrator/templates/ai-src/commands/delivery.md +9 -2
- package/dist/orchestrator/templates/ai-src/skills/sungen-harness-audit/SKILL.md +17 -0
- package/dist/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +42 -2
- package/dist/utils/ref-key.d.ts +30 -0
- package/dist/utils/ref-key.d.ts.map +1 -0
- package/dist/utils/ref-key.js +48 -0
- package/dist/utils/ref-key.js.map +1 -0
- package/package.json +3 -3
- package/src/cli/commands/audit.ts +22 -3
- package/src/cli/commands/next.ts +2 -2
- package/src/exporters/matrix/build.ts +4 -1
- package/src/exporters/matrix/map-loader.ts +5 -0
- package/src/exporters/matrix/types.ts +11 -0
- package/src/generators/test-generator/utils/selector-resolver.ts +2 -1
- package/src/harness/audit.ts +81 -11
- package/src/harness/flow-contract.ts +128 -17
- package/src/harness/next-step.ts +10 -4
- package/src/harness/quality-gates.ts +64 -6
- package/src/harness/sensors.ts +9 -1
- package/src/harness/spec-branches.ts +346 -0
- package/src/harness/spec-coverage.ts +4 -4
- package/src/harness/viewpoint-baseline.ts +41 -6
- package/src/harness/viewpoint-ledger.ts +56 -4
- package/src/orchestrator/templates/ai-src/commands/add-flow.md +16 -0
- package/src/orchestrator/templates/ai-src/commands/create-test.md +10 -1
- package/src/orchestrator/templates/ai-src/commands/delivery.md +9 -2
- package/src/orchestrator/templates/ai-src/skills/sungen-harness-audit/SKILL.md +17 -0
- package/src/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +42 -2
- package/src/utils/ref-key.ts +43 -0
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* declared collection (order, application, submission …)
|
|
23
23
|
*/
|
|
24
24
|
import * as fs from 'fs';
|
|
25
|
+
import { refNamespaces, screenKey } from '../utils/ref-key';
|
|
25
26
|
import * as path from 'path';
|
|
26
27
|
import { parse as parseYaml } from 'yaml';
|
|
27
28
|
import { ScenarioInfo } from './parse';
|
|
@@ -40,6 +41,13 @@ export interface FlowDecl {
|
|
|
40
41
|
reason?: string;
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/** One declared journey phase. `scenarios:` is the author naming its evidence directly. */
|
|
45
|
+
export interface FlowPhaseDecl {
|
|
46
|
+
id: string;
|
|
47
|
+
description?: string;
|
|
48
|
+
scenarios?: string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
43
51
|
const FLOW_STATUSES = new Set(['covered', 'deferred', 'pending-clarification', 'out-of-scope']);
|
|
44
52
|
|
|
45
53
|
export interface FlowContract {
|
|
@@ -58,6 +66,12 @@ export interface FlowContract {
|
|
|
58
66
|
/** Journey phases this flow declares. Default [HP, ER, EH]; UI is allowed but
|
|
59
67
|
* never demanded (presentation is the balance axis's business, not coverage's). */
|
|
60
68
|
phases: string[];
|
|
69
|
+
/**
|
|
70
|
+
* The phases as DECLARED, when the contract uses the object form. A phase may name the
|
|
71
|
+
* scenarios that carry it — direct evidence, rather than the harness inferring coverage from
|
|
72
|
+
* id segments — and that is what a project which writes `phases:` this way is telling us.
|
|
73
|
+
*/
|
|
74
|
+
phaseDetails?: FlowPhaseDecl[];
|
|
61
75
|
/**
|
|
62
76
|
* The use case's declared flow inventory — the answer to "how many flows does this use
|
|
63
77
|
* case HAVE?". `phases:` alone cannot answer it: a phase is present as soon as ONE
|
|
@@ -94,6 +108,8 @@ export interface FlowQualityResult {
|
|
|
94
108
|
/** Off-goal categories, for the split suggestion ("VP-FILTER-* looks like its own flow"). */
|
|
95
109
|
offGoalCategories: string[];
|
|
96
110
|
phases: { phase: string; covered: boolean; automated: boolean }[];
|
|
111
|
+
/** Phase-declared scenario refs that match no scenario — a phase whose evidence does not exist. */
|
|
112
|
+
danglingPhaseRefs: Array<{ phase: string; ref: string }>;
|
|
97
113
|
/** Covered-and-automated phases / declared phases (UI excluded) — the flow coverage axis. */
|
|
98
114
|
phaseRatio: number;
|
|
99
115
|
/** Cross-namespace transitions followed by an assertion / all transitions. */
|
|
@@ -130,20 +146,46 @@ export function loadFlowContract(unitDir: string): { contract: FlowContract | nu
|
|
|
130
146
|
errors.push('missing `outcome.screen:` — the screen namespace that carries the final proof');
|
|
131
147
|
}
|
|
132
148
|
if (errors.length > 0) return { contract: null, errors };
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
149
|
+
// `phases:` comes in two shapes, and the loader used to assume the first. A real project
|
|
150
|
+
// declared the RICHER one — each phase an object with `name`, `description` and an explicit
|
|
151
|
+
// `scenarios:` list — and `String(x).toUpperCase()` turned every one of them into
|
|
152
|
+
// `[OBJECT OBJECT]`. Nothing matched, phase coverage read 0%, the score fell to 6.9, and the
|
|
153
|
+
// report said only that the phases were uncovered. Garbage in place of an error is the worst
|
|
154
|
+
// of both: the author sees a broken number with no cause (#657).
|
|
155
|
+
//
|
|
156
|
+
// The object form is the better declaration — it names the scenarios that carry the phase
|
|
157
|
+
// instead of leaving the harness to infer them from ids — so it is read, not merely tolerated.
|
|
158
|
+
const phaseDetails: FlowPhaseDecl[] = Array.isArray(raw.phases)
|
|
159
|
+
? (raw.phases as unknown[]).map((x, i) => {
|
|
160
|
+
if (x !== null && typeof x === 'object') {
|
|
161
|
+
const o = x as Record<string, unknown>;
|
|
162
|
+
const name = o.name ?? o.id ?? o.phase;
|
|
163
|
+
if (name === undefined) {
|
|
164
|
+
errors.push(`phases[${i}] is an object with no \`name:\` (or \`id:\`) — a phase needs a label`);
|
|
165
|
+
return { id: `PHASE${i + 1}` };
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
id: String(name).toUpperCase(),
|
|
169
|
+
description: o.description !== undefined ? String(o.description) : undefined,
|
|
170
|
+
scenarios: Array.isArray(o.scenarios) ? (o.scenarios as unknown[]).map((v) => String(v)) : undefined,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
return { id: String(x).toUpperCase() };
|
|
174
|
+
})
|
|
175
|
+
: [];
|
|
176
|
+
const phases = phaseDetails.length > 0 ? phaseDetails.map((p) => p.id) : DEFAULT_PHASES;
|
|
136
177
|
return {
|
|
137
178
|
contract: {
|
|
138
179
|
goal: String(raw.goal),
|
|
139
180
|
actor: raw.actor !== undefined ? String(raw.actor) : undefined,
|
|
140
181
|
trigger: raw.trigger !== undefined ? String(raw.trigger) : undefined,
|
|
141
182
|
precondition: raw.precondition !== undefined ? String(raw.precondition) : undefined,
|
|
142
|
-
outcome: { screen: String(outcome!.screen)
|
|
183
|
+
outcome: { screen: screenKey(String(outcome!.screen)), assertion: outcome!.assertion !== undefined ? String(outcome!.assertion) : undefined },
|
|
143
184
|
value: raw.value !== undefined ? String(raw.value) : undefined,
|
|
144
185
|
successGuarantee: raw.successGuarantee !== undefined ? String(raw.successGuarantee) : (raw.success_guarantee !== undefined ? String(raw.success_guarantee) : undefined),
|
|
145
186
|
minimalGuarantee: raw.minimalGuarantee !== undefined ? String(raw.minimalGuarantee) : (raw.minimal_guarantee !== undefined ? String(raw.minimal_guarantee) : undefined),
|
|
146
187
|
phases,
|
|
188
|
+
...(phaseDetails.some((p) => p.scenarios || p.description) ? { phaseDetails } : {}),
|
|
147
189
|
flows: Array.isArray(raw.flows)
|
|
148
190
|
? (raw.flows as Array<Record<string, unknown>>)
|
|
149
191
|
.filter((f) => f && typeof f === 'object' && f.id)
|
|
@@ -169,7 +211,7 @@ export function loadFlowContract(unitDir: string): { contract: FlowContract | nu
|
|
|
169
211
|
.map((e) => ({
|
|
170
212
|
name: String(e.name),
|
|
171
213
|
owner: e.owner !== undefined ? String(e.owner) : undefined,
|
|
172
|
-
screens: Array.isArray(e.screens) ? (e.screens as unknown[]).map((x) => String(x)
|
|
214
|
+
screens: Array.isArray(e.screens) ? (e.screens as unknown[]).map((x) => screenKey(String(x))) : [],
|
|
173
215
|
}))
|
|
174
216
|
: undefined,
|
|
175
217
|
golden: raw.golden === true,
|
|
@@ -182,13 +224,12 @@ export function loadFlowContract(unitDir: string): { contract: FlowContract | nu
|
|
|
182
224
|
|
|
183
225
|
/** `[screen:element]` namespaces referenced by a scenario's steps, in step order. */
|
|
184
226
|
function namespacesInOrder(s: ScenarioInfo): string[] {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
return out;
|
|
227
|
+
// Through the shared vocabulary — a private regex here once dropped every multi-word screen (#660).
|
|
228
|
+
return refNamespaces(s.stepsText);
|
|
188
229
|
}
|
|
189
230
|
|
|
190
231
|
function touchesOutcome(s: ScenarioInfo, outcomeScreen: string): boolean {
|
|
191
|
-
return namespacesInOrder(s).includes(outcomeScreen);
|
|
232
|
+
return namespacesInOrder(s).includes(screenKey(outcomeScreen));
|
|
192
233
|
}
|
|
193
234
|
|
|
194
235
|
/**
|
|
@@ -203,14 +244,24 @@ function reachesOutcome(s: ScenarioInfo, outcomeScreen: string): boolean {
|
|
|
203
244
|
// anywhere in the scenario would suppress every positive assertion in it.
|
|
204
245
|
const steps = (s.steps ?? []).map((st) => st.text.toLowerCase());
|
|
205
246
|
if (steps.length === 0) return touchesOutcome(s, outcomeScreen);
|
|
206
|
-
|
|
247
|
+
const key = screenKey(outcomeScreen);
|
|
248
|
+
return steps.some((t) => refNamespaces(t, { bare: true }).includes(key)
|
|
207
249
|
&& !/\b(is hidden|is not visible|does not exist|is absent|no longer)\b/.test(t));
|
|
208
250
|
}
|
|
209
251
|
|
|
210
252
|
/** Phase of a scenario: its declared phase token (FL-HP-001 / VP-FLOW-ER-02 / MS-EH-005)
|
|
211
253
|
* when present, else vocabulary detection. */
|
|
212
|
-
export function phaseOf(s: ScenarioInfo, declared: string[]): string | null {
|
|
254
|
+
export function phaseOf(s: ScenarioInfo, declared: string[], details?: FlowPhaseDecl[]): string | null {
|
|
213
255
|
const id = (s.vpId ?? '').toUpperCase();
|
|
256
|
+
// When a phase NAMES its scenarios, that list is the mapping — the author has already done
|
|
257
|
+
// the assignment, and inferring it from id segments instead would ignore them. A phase called
|
|
258
|
+
// "Cross-screen data" shares no token with `VP-LOGIC-002`; only the declaration connects them.
|
|
259
|
+
for (const ph of details ?? []) {
|
|
260
|
+
if (ph.scenarios?.some((ref) => {
|
|
261
|
+
const r = ref.trim().toUpperCase();
|
|
262
|
+
return r === id || (r.length > 0 && s.name.toUpperCase().includes(r));
|
|
263
|
+
})) return ph.id;
|
|
264
|
+
}
|
|
214
265
|
// A DECLARED phase wins, matched on the id's segments with the branch number removed —
|
|
215
266
|
// so the use-case vocabulary (VP-EF01-01, VP-AF02-01: one id per Exception/Alternate
|
|
216
267
|
// Flow) resolves to its phase exactly like the flat HP/ER/EH form does.
|
|
@@ -316,15 +367,39 @@ export function flowInventory(contract: FlowContract, scenarios: ScenarioInfo[])
|
|
|
316
367
|
* Both inflate the flow count while adding no branch coverage, which is exactly what makes a
|
|
317
368
|
* suite look complete to the harness and thin to a reviewer.
|
|
318
369
|
*/
|
|
370
|
+
/** An interaction, whichever keyword the author put in front of it — `Then User click` exists. */
|
|
371
|
+
const INTERACTION_VERB = /\buser\s+(?:click|clicks|tap|taps|fill|fills|type|types|select|selects|press|presses|check|checks|uncheck|unchecks|upload|uploads|submit|submits|enter|enters|choose|chooses|toggle|toggles|drag|drags|swipe|swipes|scroll|scrolls|hover|hovers|clear|clears)\b/i;
|
|
372
|
+
|
|
319
373
|
export function misfiledPhases(
|
|
320
374
|
contract: FlowContract, scenarios: ScenarioInfo[], basicPhase: string,
|
|
321
375
|
): Array<{ scenario: string; id: string; why: string }> {
|
|
322
376
|
const out: Array<{ scenario: string; id: string; why: string }> = [];
|
|
323
377
|
const outcome = contract.outcome.screen;
|
|
378
|
+
// A branch point is a STRUCTURAL fact before it is a vocabulary one. The check used to demand a
|
|
379
|
+
// branch WORD (back / cancel / retry…), so a precondition guard — `Given` already authenticated,
|
|
380
|
+
// or no originating session → `Then` redirected — had no way to satisfy it: such a scenario has
|
|
381
|
+
// no interaction step at all, by construction, and its branch is in the `Given`, not in a verb.
|
|
382
|
+
// Reported under AF and EF alike on a real project (#660). Two structural signals now count:
|
|
383
|
+
// no `When` at all (a guard by construction), or a `Given` the basic flow never establishes
|
|
384
|
+
// (a precondition branch). Vocabulary remains as the fallback for scenarios without step data.
|
|
385
|
+
const basicGivens = new Set(scenarios
|
|
386
|
+
.filter((sc) => phaseToken(flowIdOf(sc, contract.phases) ?? '') === phaseToken(basicPhase))
|
|
387
|
+
.flatMap((sc) => (sc.steps ?? []).filter((st) => st.bucket === 'given').map((st) => st.text.toLowerCase().trim())));
|
|
388
|
+
const branchesStructurally = (sc: ScenarioInfo): boolean => {
|
|
389
|
+
const steps = sc.steps ?? [];
|
|
390
|
+
if (steps.length === 0) return false;
|
|
391
|
+
// No interaction AND the journey does not complete: a guard by construction (the redirect or
|
|
392
|
+
// the blocked screen IS the branch). No interaction but the outcome IS reached is a different
|
|
393
|
+
// animal — a postcondition on the basic path — and stays subject to the checks below.
|
|
394
|
+
const interacts = steps.some((st) => st.bucket === 'when' || INTERACTION_VERB.test(st.text));
|
|
395
|
+
if (!interacts && !reachesOutcome(sc, outcome)) return true;
|
|
396
|
+
return steps.some((st) => st.bucket === 'given' && !basicGivens.has(st.text.toLowerCase().trim()));
|
|
397
|
+
};
|
|
324
398
|
for (const s of scenarios) {
|
|
325
399
|
const id = flowIdOf(s, contract.phases);
|
|
326
400
|
if (!id || phaseToken(id) === phaseToken(basicPhase)) continue;
|
|
327
401
|
const ph = phaseToken(id);
|
|
402
|
+
if (branchesStructurally(s)) continue;
|
|
328
403
|
if (ph === 'EF' && reachesOutcome(s, outcome)) {
|
|
329
404
|
// An error-then-recover flow legitimately ends at the outcome — it is the recovery that
|
|
330
405
|
// is being proven, and a guard flow is blocked rather than "failed". Only a scenario with
|
|
@@ -350,7 +425,7 @@ export function flowQuality(unitDir: string, scenarios: ScenarioInfo[]): FlowQua
|
|
|
350
425
|
const neutral: FlowQualityResult = {
|
|
351
426
|
hasContract: false, errors, outcomeProven: false, outcomeManualOnly: false,
|
|
352
427
|
offGoal: [], offGoalRatio: 0, offGoalCategories: [],
|
|
353
|
-
phases: [], phaseRatio: 1, handoffs: { total: 0, asserted: 0, ratio: 1 }, sit: [],
|
|
428
|
+
phases: [], phaseRatio: 1, danglingPhaseRefs: [], handoffs: { total: 0, asserted: 0, ratio: 1 }, sit: [],
|
|
354
429
|
};
|
|
355
430
|
if (!contract) return neutral;
|
|
356
431
|
|
|
@@ -365,9 +440,17 @@ export function flowQuality(unitDir: string, scenarios: ScenarioInfo[]): FlowQua
|
|
|
365
440
|
// guard/error phase is evidence of a SECOND business goal in this flow.
|
|
366
441
|
const declaredPhases = contract.phases;
|
|
367
442
|
const basic = declaredPhases.filter((p) => p !== 'UI')[0];
|
|
443
|
+
// A flow the inventory declares WITH ITS OWN outcome is measured against that outcome, never
|
|
444
|
+
// against the use case's. A spec that keeps an entry variant deliberately light ("prove the
|
|
445
|
+
// entry reaches the action bar; do not repeat the full assertion") could only satisfy the old
|
|
446
|
+
// check by widening the scenario — which the human reviewer then called over-testing. The gate
|
|
447
|
+
// and the reviewer were optimising different things; the declaration is where they agree (#660).
|
|
448
|
+
const ownOutcome = new Set((contract.flows ?? []).filter((f) => f.outcome).map((f) => f.id));
|
|
368
449
|
const offGoalScenarios = scenarios.filter((s) => {
|
|
369
450
|
if (touchesOutcome(s, outcomeScreen)) return false;
|
|
370
|
-
const
|
|
451
|
+
const fid = flowIdOf(s, declaredPhases);
|
|
452
|
+
if (fid && ownOutcome.has(fid)) return false;
|
|
453
|
+
const ph = phaseOf(s, declaredPhases, contract.phaseDetails);
|
|
371
454
|
// Any NON-basic declared phase (guards, error recovery, alternate branches) legitimately
|
|
372
455
|
// stops before the outcome — that is what a branch IS. Only an unclassified scenario that
|
|
373
456
|
// never reaches the outcome is evidence of a second business goal.
|
|
@@ -383,7 +466,7 @@ export function flowQuality(unitDir: string, scenarios: ScenarioInfo[]): FlowQua
|
|
|
383
466
|
// phase that must reach the declared outcome, whether the project spells it HP or BF.
|
|
384
467
|
const basicPhase = demanded[0];
|
|
385
468
|
const phases = demanded.map((phase) => {
|
|
386
|
-
const inPhase = scenarios.filter((s) => phaseOf(s, declaredPhases) === phase);
|
|
469
|
+
const inPhase = scenarios.filter((s) => phaseOf(s, declaredPhases, contract.phaseDetails) === phase);
|
|
387
470
|
// The basic flow must additionally prove the outcome — a data assertion elsewhere is not the goal.
|
|
388
471
|
const relevant = phase === basicPhase ? inPhase.filter((s) => touchesOutcome(s, outcomeScreen)) : inPhase;
|
|
389
472
|
return {
|
|
@@ -396,6 +479,17 @@ export function flowQuality(unitDir: string, scenarios: ScenarioInfo[]): FlowQua
|
|
|
396
479
|
? phases.filter((p) => p.covered && p.automated).length / demanded.length
|
|
397
480
|
: 1;
|
|
398
481
|
|
|
482
|
+
// A phase that names a scenario which does not exist is a declaration pointing at nothing:
|
|
483
|
+
// the phase reads covered in the contract and is empty in the suite. Same class as a dangling
|
|
484
|
+
// traceability ref — the map, not the territory.
|
|
485
|
+
const known = new Set(scenarios.flatMap((s) => [s.vpId?.toUpperCase(), s.name.toUpperCase()].filter(Boolean) as string[]));
|
|
486
|
+
const danglingPhaseRefs = (contract.phaseDetails ?? []).flatMap((ph) => (ph.scenarios ?? [])
|
|
487
|
+
.filter((ref) => {
|
|
488
|
+
const r = ref.trim().toUpperCase();
|
|
489
|
+
return !known.has(r) && !scenarios.some((s) => s.name.toUpperCase().includes(r));
|
|
490
|
+
})
|
|
491
|
+
.map((ref) => ({ phase: ph.id, ref })));
|
|
492
|
+
|
|
399
493
|
// --- Handoff integrity: no blind tail after a cross-namespace transition. ---
|
|
400
494
|
// A transition counts as asserted when ANY assertion follows it — in the entered
|
|
401
495
|
// namespace or later. Demanding the assertion in the entered namespace itself
|
|
@@ -409,7 +503,7 @@ export function flowQuality(unitDir: string, scenarios: ScenarioInfo[]): FlowQua
|
|
|
409
503
|
const steps = s.steps ?? [];
|
|
410
504
|
let current: string | null = null;
|
|
411
505
|
for (let i = 0; i < steps.length; i++) {
|
|
412
|
-
const ns = (steps[i].text
|
|
506
|
+
const ns = refNamespaces(steps[i].text)[0] ?? null;
|
|
413
507
|
if (!ns) continue;
|
|
414
508
|
if (current !== null && ns !== current) {
|
|
415
509
|
total++;
|
|
@@ -439,7 +533,7 @@ export function flowQuality(unitDir: string, scenarios: ScenarioInfo[]): FlowQua
|
|
|
439
533
|
outcomeProven, outcomeManualOnly,
|
|
440
534
|
offGoal: offGoalScenarios.map((s) => s.name.slice(0, 80)),
|
|
441
535
|
offGoalRatio, offGoalCategories,
|
|
442
|
-
phases, phaseRatio, handoffs, sit,
|
|
536
|
+
phases, phaseRatio, danglingPhaseRefs, handoffs, sit,
|
|
443
537
|
};
|
|
444
538
|
}
|
|
445
539
|
|
|
@@ -460,7 +554,24 @@ export interface ContinuityGap { claim: string; missing: 'loss' | 'persistence'
|
|
|
460
554
|
|
|
461
555
|
const CONTINUITY_CLAIM = /\b(buffer(?:s|ed|ing)?|persist(?:s|ed|ence)?|round[- ]?trips?|restor(?:e|es|ed|ation)|carr(?:y|ied|ies)|retain(?:s|ed)?|session[- ]?storage|local[- ]?storage)\b/i;
|
|
462
556
|
const SIDE_PERSIST = /\b(restor(?:e|es|ed)|persist(?:s|ed)?|unchanged|same value|round[- ]?trips?|re-?hydrat\w*|still (?:shows|holds)|carr(?:y|ied|ies)|retain(?:s|ed)?|prefilled|pre-?filled)\b/i;
|
|
463
|
-
|
|
557
|
+
// A LOSS CONSTRUCTION, not a word list. `reset` and `expire` are domain NOUNS — every claim in a
|
|
558
|
+
// password-*reset* flow contains "reset", and every token flow contains "expire", so a bare-word
|
|
559
|
+
// list marked all of them as promising that state is lost and then demanded the missing half.
|
|
560
|
+
// A field report hit exactly that: the two sides of its mechanism were both written (in two
|
|
561
|
+
// different scenarios, which this already handles) and CONTINUITY-ONE-SIDED fired anyway (#630).
|
|
562
|
+
// The state has to be the thing that goes: `is cleared`, `loses the values`, `no longer holds`.
|
|
563
|
+
const SIDE_LOSS = new RegExp([
|
|
564
|
+
'\\b(?:lose|loses|losing|lost)\\b',
|
|
565
|
+
'\\b(?:is|are|gets?|got|becomes?|will be|was|were)\\s+(?:lost|cleared|emptied|discarded|wiped|reset|blanked)\\b',
|
|
566
|
+
'\\b(?:clears|clearing|discards|discarding|wipes|wiping)\\b',
|
|
567
|
+
'\\b(?:does|do|did)\\s+not\\s+(?:survive|persist|remain|carry)\\b',
|
|
568
|
+
'\\bnot\\s+(?:restored|retained|preserved|kept)\\b',
|
|
569
|
+
'\\bno longer\\b',
|
|
570
|
+
'\\bcomes? up (?:empty|blank)\\b',
|
|
571
|
+
// The triggers that CAUSE the loss still count when the claim names them as such.
|
|
572
|
+
'\\b(?:on|after) (?:a )?(?:reload|refresh)\\b',
|
|
573
|
+
'\\breload(?:ing|s|ed)? (?:the )?(?:page|screen|form|tab)\\b',
|
|
574
|
+
].join('|'), 'i');
|
|
464
575
|
|
|
465
576
|
/**
|
|
466
577
|
* Continuity claims in the viewpoint file whose feature proves only ONE side.
|
package/src/harness/next-step.ts
CHANGED
|
@@ -25,8 +25,14 @@ export interface NextStep {
|
|
|
25
25
|
command: string;
|
|
26
26
|
/** Why it is next — stated so an agent can weigh it, not just obey it. */
|
|
27
27
|
because: string;
|
|
28
|
-
/**
|
|
29
|
-
|
|
28
|
+
/**
|
|
29
|
+
* `blocked` steps are not runnable yet; they name what to resolve first. `decide` steps are a
|
|
30
|
+
* HUMAN'S to run: an agent presents them and stops. Accepting the viewpoint baseline is the
|
|
31
|
+
* case — offered as a plain `do`, an agent that had just rewritten `test-viewpoint.md` from the
|
|
32
|
+
* spec ran it, and `atomicLedger`/`traceability` read 100% against a checklist it wrote itself
|
|
33
|
+
* in the same session (#660).
|
|
34
|
+
*/
|
|
35
|
+
kind: 'do' | 'blocked' | 'optional' | 'decide';
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
export interface UnitState {
|
|
@@ -228,9 +234,9 @@ export function deriveSteps(s: UnitState): NextStep[] {
|
|
|
228
234
|
}
|
|
229
235
|
if (s.audit.viewpointBaselineStatus === 'changed') {
|
|
230
236
|
out.push({
|
|
231
|
-
kind: '
|
|
237
|
+
kind: 'decide',
|
|
232
238
|
command: `sungen audit --screen ${s.unit} --accept-viewpoint`,
|
|
233
|
-
because: 'test-viewpoint.md changed since the accepted baseline, so the ledger and traceability axes are not evidence until
|
|
239
|
+
because: 'test-viewpoint.md changed since the accepted baseline, so the ledger and traceability axes are not evidence until a QA confirms the new declaration. A decision, not a step: an agent presents it and does not run it — accepting the yardstick you just wrote makes both axes read 100% by construction',
|
|
234
240
|
});
|
|
235
241
|
}
|
|
236
242
|
const repair = s.audit.findings
|
|
@@ -14,24 +14,46 @@ import { readTextFile } from './read-text';
|
|
|
14
14
|
export interface DownstreamResult {
|
|
15
15
|
downstreamRoutes: string[]; // success/navigation targets ≠ own route
|
|
16
16
|
underCovered: { route: string; slug: string }[]; // referenced only by a bare page-nav
|
|
17
|
+
/**
|
|
18
|
+
* Declared navigation targets NO scenario mentions at all. These used to be skipped as "out of
|
|
19
|
+
* this screen's scope", which is right for a screen and wrong for a FLOW: a field report found a
|
|
20
|
+
* dashboard whose spec declared four click-through destinations and whose suite tested only the
|
|
21
|
+
* render conditions of the regions containing them — the navigation itself had no case, and
|
|
22
|
+
* nothing said so (#630).
|
|
23
|
+
*/
|
|
24
|
+
absent: { route: string; slug: string }[];
|
|
17
25
|
}
|
|
18
26
|
|
|
19
27
|
/** Routes the spec hands off to (Navigation Flow / success), other than the screen's own route. */
|
|
20
28
|
function downstreamRoutes(specText: string): string[] {
|
|
21
29
|
const ownRoute = (specText.match(/\*\*Route\*\*\s*:\s*`?(\/[^\s`]+)/) || [])[1] || '';
|
|
22
30
|
const routes = new Set<string>();
|
|
31
|
+
const negated = new Set<string>();
|
|
23
32
|
for (const line of specText.split('\n')) {
|
|
24
33
|
if (!/success|navigat|to \(|→/i.test(line)) continue;
|
|
25
34
|
// A real route's leading `/` sits at a path boundary (start, whitespace, backtick, quote, paren),
|
|
26
35
|
// NOT after a letter/digit. The lookbehind rejects prose slashes like "text/icon" or
|
|
27
36
|
// "category/brand" that aren't routes at all (H2 — they produced /icon, /button, /brand).
|
|
37
|
+
let negRun = false; // inside a "…, not `/a`/`/b`" list
|
|
38
|
+
let prevEnd = 0;
|
|
28
39
|
for (const m of line.matchAll(/(?<![A-Za-z0-9])(\/[a-z][a-z0-9/_-]+)`?/gi)) {
|
|
29
40
|
const r = m[1];
|
|
41
|
+
// "the destination is `/a/b` (singular, not `/a/bs` as this spec assumed)" declares ONE
|
|
42
|
+
// target and CORRECTS another. A spec that says where a route is NOT is doing the reader a
|
|
43
|
+
// service; extracting the disowned one and demanding a test for it punishes the correction.
|
|
44
|
+
// The negation carries across a LIST — "not `/x`/`/y`" disowns both, not just the first.
|
|
45
|
+
const before = line.slice(Math.max(0, m.index - 24), m.index);
|
|
46
|
+
const gap = line.slice(prevEnd, m.index);
|
|
47
|
+
prevEnd = m.index + m[0].length;
|
|
48
|
+
if (/\b(?:not|instead of|rather than|no longer|never|không)\s*[`'"(\[]*\s*$/i.test(before)) negRun = true;
|
|
49
|
+
else if (negRun && !/^[`'"\s)\]]*[/,]?\s*(?:or|hoặc)?\s*[`'"(\[]*$/i.test(gap)) negRun = false;
|
|
50
|
+
if (negRun) { negated.add(r); continue; }
|
|
30
51
|
if (r !== ownRoute && r.split('/').length > ownRoute.split('/').length - 0) routes.add(r);
|
|
31
52
|
}
|
|
32
53
|
}
|
|
54
|
+
for (const n of negated) if (!routes.has(n)) routes.delete(n);
|
|
33
55
|
// keep only routes that extend beyond the own route (a distinct downstream surface)
|
|
34
|
-
return [...routes].filter((r) => r !== ownRoute && (!ownRoute || r.startsWith(ownRoute + '/') || r.split('/').length >= 3));
|
|
56
|
+
return [...routes].filter((r) => !negated.has(r) && r !== ownRoute && (!ownRoute || r.startsWith(ownRoute + '/') || r.split('/').length >= 3));
|
|
35
57
|
}
|
|
36
58
|
|
|
37
59
|
/**
|
|
@@ -100,9 +122,27 @@ export function sameScreenName(slug: string, label: string): boolean {
|
|
|
100
122
|
return i >= 5 && i / Math.min(a.length, b.length) >= 0.6;
|
|
101
123
|
}
|
|
102
124
|
|
|
103
|
-
export function downstreamScope(
|
|
104
|
-
|
|
125
|
+
export function downstreamScope(
|
|
126
|
+
specText: string, scenarios: ScenarioInfo[], featureText = '',
|
|
127
|
+
): DownstreamResult {
|
|
128
|
+
const all = downstreamRoutes(specText);
|
|
129
|
+
// A one-segment route that other declared routes are BUILT ON is a prefix, not a destination:
|
|
130
|
+
// a spec noting that "every route is served under a `/xx/` locale prefix (e.g. `/xx/register`)"
|
|
131
|
+
// was read as declaring `/xx/` a navigation target, and no suite will ever "navigate to" it.
|
|
132
|
+
// Structural, so it catches any mount point — locale, tenant, api version — without a list.
|
|
133
|
+
const mounts = all.filter((r) => {
|
|
134
|
+
const segs = r.split('/').filter(Boolean);
|
|
135
|
+
return segs.length === 1 && all.some((o) => o !== r && o.startsWith(r.endsWith('/') ? r : `${r}/`));
|
|
136
|
+
});
|
|
137
|
+
// The same destination written twice — once bare, once under the mount the spec documents —
|
|
138
|
+
// is one navigation target, and reporting both doubles the work it asks for.
|
|
139
|
+
const routes = all.filter((r) => !mounts.includes(r)
|
|
140
|
+
&& !mounts.some((m) => {
|
|
141
|
+
const bare = r.slice(m.replace(/\/$/, '').length);
|
|
142
|
+
return bare.startsWith('/') && all.includes(bare);
|
|
143
|
+
}));
|
|
105
144
|
const underCovered: { route: string; slug: string }[] = [];
|
|
145
|
+
const absent: { route: string; slug: string }[] = [];
|
|
106
146
|
for (const route of routes) {
|
|
107
147
|
const slug = (route.split('/').filter(Boolean).pop() || route).toLowerCase();
|
|
108
148
|
// Referenced when the route/slug appears literally, OR when any `[Ref]` in the suite
|
|
@@ -112,16 +152,34 @@ export function downstreamScope(specText: string, scenarios: ScenarioInfo[]): Do
|
|
|
112
152
|
const refs = scenarios.filter((s) =>
|
|
113
153
|
s.haystack.includes(slug) || s.haystack.includes(route.toLowerCase()) ||
|
|
114
154
|
labelsOf(s).some((l) => sameScreenName(slug, l)));
|
|
115
|
-
if (!refs.length)
|
|
155
|
+
if (!refs.length) {
|
|
156
|
+
// A scenario's haystack is its name + steps, so a route documented in a @manual scenario's
|
|
157
|
+
// tester procedure — a COMMENT — looks like nothing at all. That route is accounted for: a
|
|
158
|
+
// person has been told to check it. Reporting "no scenario mentions it, add one" over a
|
|
159
|
+
// deliberate manual deferral is the false positive this whole round is about, so absence is
|
|
160
|
+
// measured against the feature TEXT, comments included (#651 follow-up).
|
|
161
|
+
if (!featureText.toLowerCase().includes(route.toLowerCase())) absent.push({ route, slug });
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
116
164
|
// Substantively covered only if some scenario OPERATES on the downstream — i.e. it
|
|
117
165
|
// starts there (`is on [<downstream>]`) — not merely navigates to it as a terminal
|
|
118
166
|
// `see [<downstream>] page` assertion. The latter just proves the transition.
|
|
167
|
+
// Two shapes prove the screen's CONTENT was checked, not just its route:
|
|
168
|
+
// - the suite OPENS ON it (`is on [Registration] page`), and
|
|
169
|
+
// - it asserts an element NAMESPACED to it (`see [Registration:Submit] button`) — the
|
|
170
|
+
// namespace names the screen, so the assertion is about that screen by construction.
|
|
171
|
+
// Only the first counted, so a scenario that navigated there and then asserted the screen's
|
|
172
|
+
// own elements was still reported "covered only by a page-nav assertion" (#630). The field
|
|
173
|
+
// report's hypothesis — that the parser reads one line after the nav and stops — is still not
|
|
174
|
+
// what happens: there is no line window, and both shapes are matched over the whole scenario.
|
|
119
175
|
const contentCovered = refs.some((s) =>
|
|
120
176
|
[...s.haystack.matchAll(/\bis on \[([^\]]+)\]/g)]
|
|
121
|
-
.some((m) => sameScreenName(slug, m[1].split(':')[0]))
|
|
177
|
+
.some((m) => sameScreenName(slug, m[1].split(':')[0]))
|
|
178
|
+
|| [...s.haystack.matchAll(/\bsee \[([^\]:]+):[^\]]+\]/g)]
|
|
179
|
+
.some((m) => sameScreenName(slug, m[1])));
|
|
122
180
|
if (!contentCovered) underCovered.push({ route, slug });
|
|
123
181
|
}
|
|
124
|
-
return { downstreamRoutes: routes, underCovered };
|
|
182
|
+
return { downstreamRoutes: routes, underCovered, absent };
|
|
125
183
|
}
|
|
126
184
|
|
|
127
185
|
// ---------- #4 Manual-oracle ----------
|
package/src/harness/sensors.ts
CHANGED
|
@@ -659,7 +659,15 @@ const CLAIM_RULES: ClaimRule[] = [
|
|
|
659
659
|
// category behind "browser back does not re-submit", "does not re-charge the card",
|
|
660
660
|
// "double-click does not create two orders" — not a per-feature keyword.
|
|
661
661
|
claim: 'no-side-effect/no-duplicate',
|
|
662
|
-
|
|
662
|
+
// The negation must attach to an EFFECT — something created, sent, stored, charged, fired
|
|
663
|
+
// twice — not to the INPUT being refused. "duplicate address is rejected" is an observable
|
|
664
|
+
// rejection, proven by the error assertion in front of the user; "no duplicate record is
|
|
665
|
+
// created" is an absence nothing on the page can show. The rule used to fire on any action
|
|
666
|
+
// word beside any negation word, so ordinary rejection titles were told to go find a DB count,
|
|
667
|
+
// and renaming around the trigger words became the workflow (#660). Two shapes now qualify:
|
|
668
|
+
// (a) a negation followed by an effect token, (b) a repeat token (twice / again / double- or
|
|
669
|
+
// re-submit / second …) beside a negation, "only once" or a disabled control.
|
|
670
|
+
title: /(?:\b(?:no|not|n['’]t|never|without|cannot|không|chưa)\b[^.;]{0,40}?\b(?:creat(?:e|es|ed|ion)|record(?:s|ed)?|rows?|entr(?:y|ies)|sen[dt]|resen[dt]|charg(?:e|es|ed)|order(?:s|ed)?|payment|paid|insert(?:s|ed)?|submit(?:s|ted)?|submission|stor(?:e|es|ed)|sav(?:e|es|ed)|persist(?:s|ed)?|fir(?:e|es|ed)|issu(?:e|es|ed)|post(?:s|ed)?|email(?:s|ed)?|request(?:s|ed)?|\botp\b|twice|again|second|double|duplicate[sd]?|re-?submit(?:s|ted)?|tạo|gửi|lưu)\b)|(?=.*\b(?:double[- ]?(?:submit|click|tap|activation)|re-?submi(?:t|ts|tted|ssion)|twice|again|second (?:submit|submission|request|click|tap|order|charge|email|record))\b)(?=.*\b(?:no|not|n['’]t|never|without|cannot|prevent(?:s|ed)?|block(?:s|ed)?|avoid(?:s|ed)?|only once|exactly one|single|idempotent|disabled|không|chưa)\b)/i,
|
|
663
671
|
// `is disabled` counts: when the spec's own mechanism against a repeat is "the control
|
|
664
672
|
// is disabled immediately" (FR-014-style), asserting the disabled state IS the contrast —
|
|
665
673
|
// the second activation cannot occur. Without it the canonical double-submit proof shape
|