@sun-asterisk/sungen 3.2.23 → 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.
Files changed (63) hide show
  1. package/dist/cli/commands/audit.d.ts.map +1 -1
  2. package/dist/cli/commands/audit.js +40 -8
  3. package/dist/cli/commands/audit.js.map +1 -1
  4. package/dist/dashboard/templates/index.html +1 -1
  5. package/dist/exporters/csv-exporter.d.ts.map +1 -1
  6. package/dist/exporters/csv-exporter.js +3 -1
  7. package/dist/exporters/csv-exporter.js.map +1 -1
  8. package/dist/exporters/feature-parser.d.ts +7 -0
  9. package/dist/exporters/feature-parser.d.ts.map +1 -1
  10. package/dist/exporters/feature-parser.js +27 -0
  11. package/dist/exporters/feature-parser.js.map +1 -1
  12. package/dist/exporters/xlsx-report-builder.d.ts.map +1 -1
  13. package/dist/exporters/xlsx-report-builder.js +3 -1
  14. package/dist/exporters/xlsx-report-builder.js.map +1 -1
  15. package/dist/harness/audit.d.ts +22 -1
  16. package/dist/harness/audit.d.ts.map +1 -1
  17. package/dist/harness/audit.js +122 -13
  18. package/dist/harness/audit.js.map +1 -1
  19. package/dist/harness/flow-contract.d.ts +104 -0
  20. package/dist/harness/flow-contract.d.ts.map +1 -1
  21. package/dist/harness/flow-contract.js +199 -12
  22. package/dist/harness/flow-contract.js.map +1 -1
  23. package/dist/harness/parse.d.ts.map +1 -1
  24. package/dist/harness/parse.js +22 -3
  25. package/dist/harness/parse.js.map +1 -1
  26. package/dist/harness/quality-gates.d.ts +2 -1
  27. package/dist/harness/quality-gates.d.ts.map +1 -1
  28. package/dist/harness/quality-gates.js +47 -5
  29. package/dist/harness/quality-gates.js.map +1 -1
  30. package/dist/harness/sensors.d.ts.map +1 -1
  31. package/dist/harness/sensors.js +48 -5
  32. package/dist/harness/sensors.js.map +1 -1
  33. package/dist/harness/spec-coverage.d.ts +20 -0
  34. package/dist/harness/spec-coverage.d.ts.map +1 -1
  35. package/dist/harness/spec-coverage.js +35 -0
  36. package/dist/harness/spec-coverage.js.map +1 -1
  37. package/dist/harness/viewpoint-ledger.d.ts +4 -0
  38. package/dist/harness/viewpoint-ledger.d.ts.map +1 -1
  39. package/dist/harness/viewpoint-ledger.js +59 -1
  40. package/dist/harness/viewpoint-ledger.js.map +1 -1
  41. package/dist/orchestrator/templates/ai-src/commands/create-test.md +28 -0
  42. package/dist/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +98 -5
  43. package/dist/orchestrator/templates/ai-src/skills/sungen-viewpoint/group-e-identity.md +1 -1
  44. package/dist/orchestrator/test-data-guide.d.ts.map +1 -1
  45. package/dist/orchestrator/test-data-guide.js +8 -0
  46. package/dist/orchestrator/test-data-guide.js.map +1 -1
  47. package/package.json +3 -3
  48. package/src/cli/commands/audit.ts +37 -8
  49. package/src/dashboard/templates/index.html +1 -1
  50. package/src/exporters/csv-exporter.ts +3 -1
  51. package/src/exporters/feature-parser.ts +22 -0
  52. package/src/exporters/xlsx-report-builder.ts +3 -1
  53. package/src/harness/audit.ts +131 -17
  54. package/src/harness/flow-contract.ts +258 -12
  55. package/src/harness/parse.ts +22 -3
  56. package/src/harness/quality-gates.ts +48 -5
  57. package/src/harness/sensors.ts +47 -4
  58. package/src/harness/spec-coverage.ts +38 -0
  59. package/src/harness/viewpoint-ledger.ts +58 -1
  60. package/src/orchestrator/templates/ai-src/commands/create-test.md +28 -0
  61. package/src/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +98 -5
  62. package/src/orchestrator/templates/ai-src/skills/sungen-viewpoint/group-e-identity.md +1 -1
  63. package/src/orchestrator/test-data-guide.ts +8 -0
@@ -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, 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 };
@@ -67,6 +69,13 @@ export interface AuditReport {
67
69
  businessDepth: number; // 0..1
68
70
  balance: number; // 0..1
69
71
  traceability: number; // 0..1
72
+ /**
73
+ * Every axis considered, in weight order, with the renormalised weight it actually
74
+ * carried. The five legacy fields above are a fixed subset that omitted `flowCoverage`,
75
+ * `specFR` and `atomicLedger` entirely — so a flow's real coverage axis was never printed
76
+ * while `balance`, which carried no weight, was.
77
+ */
78
+ axes: Array<{ key: string; value: number; weight: number; applicable: boolean; critical: boolean; byDesign?: boolean }>;
70
79
  formula: string;
71
80
  };
72
81
  gateStatus: 'PASS' | 'FAIL';
@@ -76,6 +85,23 @@ export interface AuditReport {
76
85
  spec: SpecCoverageResult; // G2 — spec-clause coverage (FR + validation-trigger matrix)
77
86
  }
78
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
+
79
105
  /** The catalog-resolution id for a unit dir (relative to qa/): screen · flows/<flow> · api/<area> · api/flows/<flow>. */
80
106
  export function catalogIdFromScreenDir(screenDir: string): string {
81
107
  const parts = screenDir.split(path.sep);
@@ -216,6 +242,12 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
216
242
  // businessDepth headroom: it can reach 1.0 only when all three are exercised, so a present-but-
217
243
  // shallow flow can't claim a perfect score (floor 0.5 — assertion depth still dominates).
218
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') : [];
219
251
  let flowDepth = isUiFlow ? flowRegressionDepth(scenarios) : { stateful: false, countProof: false, teardown: false, multiSource: false, ratio: 1, missing: [] } as FlowDepthResult;
220
252
  // Contract-declared statefulness generalizes the cart-hardcoded vocabulary: a flow that
221
253
  // mutates ANY named collection (order, application, submission …) gets the same three
@@ -277,16 +309,24 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
277
309
  // so a flow can finally EARN the coverage axis instead of always losing it to
278
310
  // PAGE-TYPE-UNDETERMINED (or worse, being judged against a form checklist).
279
311
  const flowScored = flowQ.hasContract;
280
- const axisDefs: Array<{ key: string; value: number; weight: number; applicable: boolean; critical: boolean }> = [
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 }> = [
281
317
  flowScored
282
- ? { 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 }
283
319
  : { key: 'coverage', value: coverage, weight: 0.22, applicable: !!gate.pageType && gate.themesTotal > 0, critical: true },
284
320
  { key: 'specFR', value: specRatio, weight: 0.15, applicable: spec.hasSpec && spec.frTotal > 0, critical: true },
285
321
  { key: 'atomicLedger', value: ledger.ratio, weight: 0.13, applicable: ledger.hasViewpoint && ledger.total > 0 && !viewpointMoved, critical: true },
286
322
  { key: 'businessDepth', value: businessDepth, weight: 0.20, applicable: true, critical: true },
287
323
  { key: 'claimProof', value: claim.ratio, weight: 0.15, applicable: claim.withClaims > 0, critical: true },
288
324
  { key: 'traceability', value: traceScore, weight: 0.09, applicable: viewpoints.length > 0 && !viewpointMoved, critical: false },
289
- { key: 'balance', value: balanceScore, weight: 0.06, applicable: true, critical: false },
325
+ // A use-case decomposition is EF-heavy by construction (1 basic flow, N alternates, N
326
+ // exceptions) — that is the shape of a well-decomposed use case, not neglected business
327
+ // core. `flowCoverage` already measures whether the journey's phases are covered, so
328
+ // balance carries no independent evidence for a contract flow (#592).
329
+ { key: 'balance', value: balanceScore, weight: 0.06, applicable: !flowScored, critical: false, byDesign: flowScored },
290
330
  ];
291
331
  const scored = axisDefs.filter((a) => a.applicable);
292
332
  const weightSum = scored.reduce((t, a) => t + a.weight, 0) || 1;
@@ -305,7 +345,22 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
305
345
  let cap = 10;
306
346
  if (weakestCritical && weakestCritical.value < 0.5) cap = CAP_SEVERE;
307
347
  else if (weakestCritical && weakestCritical.value < 0.7) cap = CAP_WEAK;
308
- 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);
309
364
  if (missingEvidence.length > 0) cap = Math.min(cap, CAP_PARTIAL_EVIDENCE);
310
365
  const rawOverall = Math.min(weighted * 10, cap);
311
366
  const capped = weakestCritical ? weighted * 10 > cap : false;
@@ -313,7 +368,7 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
313
368
  // axes — a stateful flow with FULL regression depth (count + teardown + multi-source), no weak
314
369
  // facet oracle, and no parallel-cart isolation risk. Otherwise the score is held just below 9, so
315
370
  // "≥9" means senior-grade, not merely "themes covered". Neutral for screens/api (no signals).
316
- const isoRisk = isolationRisk(featureText, flowDepth.stateful);
371
+ const isoRisk = isolationRisk(featureText, flowDepth.stateful, flowQ.contract?.stateful);
317
372
  const overall = seniorBandedOverall(rawOverall, { flowStateful: flowDepth.stateful, flowRatio: flowDepth.ratio, oracleWeak: oracle.weak.length, isolationRisk: isoRisk });
318
373
 
319
374
  const findings: string[] = [];
@@ -356,6 +411,42 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
356
411
  for (const x of flowQ.sit.filter((v) => v.mockOnly).slice(0, 4)) {
357
412
  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
413
  }
414
+ // #592 W1 — an Exception Flow asserts against the Minimal Guarantee; without the
415
+ // declaration there is no anchor and its assertions are guesswork.
416
+ if (!flowQ.contract!.minimalGuarantee) {
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).');
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
+ }
359
450
  // #580 P9 — a release-critical flow must carry its Final-Inspection selection.
360
451
  if (flowQ.contract!.golden && !/@golden\b/i.test(featureText)) {
361
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.`);
@@ -364,6 +455,13 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
364
455
  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
456
  }
366
457
  }
458
+ // #592 W5 — a continuity mechanism proved on one side only.
459
+ if (isUiFlow) {
460
+ const scenarioTexts = scenarios.map((s) => `${s.name} ${s.stepsText}`);
461
+ for (const g of continuityGaps(parseViewpointItems(viewpointPath).map((i) => i.text), scenarioTexts).slice(0, 4)) {
462
+ findings.push(`CONTINUITY-ONE-SIDED: the viewpoint claims "${g.claim}" but the suite proves only the ${g.missing === 'loss' ? 'SURVIVING' : 'LOSING'} side — a persistence mechanism has two sides and testing one is testing half the mechanism. Add the ${g.missing === 'loss' ? 'loss' : 'persistence'} scenario (e.g. ${g.missing === 'loss' ? 'reload / clear storage between the steps and assert the values are gone' : 'return to the step and assert the values are restored'}).`);
463
+ }
464
+ }
367
465
  if (isUiFlow) {
368
466
  // Flows defer more than screens, so an UNCODED @manual is costlier here: the capability
369
467
  // planner cannot route it to the driver that would automate it (password-reset: 11/22
@@ -399,7 +497,8 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
399
497
  // isolation, else count/quantity asserts go flaky. Warn (advisory) when no mitigation is present
400
498
  // (@cleanup:cart / @isolate / a "Given … empty cart" background / fresh context).
401
499
  if (isoRisk) {
402
- findings.push('ISOLATION-RISK: this @parallel flow mutates the cart but has no per-scenario isolation → cart/count/quantity asserts can go flaky when scenarios share state. Add `@cleanup:cart` (or `@isolate`, or a `Given User has an empty cart` background) so each scenario starts clean.');
500
+ const what = flowQ.contract?.stateful ?? 'cart';
501
+ findings.push(`ISOLATION-RISK: this @parallel flow mutates ${what} but has no per-scenario isolation → count/quantity/state asserts can go flaky when scenarios share it. Add \`@cleanup:${what}\` (or \`@isolate\`, a fresh context, or \`{{$timestamp}}\` data) so each scenario starts clean.`);
403
502
  }
404
503
  // #414 — serial-cascade masking: an @serial suite of independent (re-navigating) scenarios hides
405
504
  // failures, because Playwright's describe.serial SKIPS everything after the first red. Isolation is
@@ -409,12 +508,19 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
409
508
  findings.push(`SERIAL-CASCADE-RISK: this \`@serial\` suite has ${cascadeN} INDEPENDENT scenarios (each re-navigates via Background) that don't need a shared-page sequence. \`describe.serial\` SKIPS every test after the first failure → one early red MASKS the rest as never-run. **Drop \`@serial\`** — the default per-scenario isolation runs every scenario and isolates failures. Keep \`@serial\` only for a genuinely stateful ordered sequence (where scenario N depends on N-1's state).`);
410
509
  }
411
510
  if (flowDepth.stateful && flowDepth.missing.length) {
511
+ // The advice names the collection the CONTRACT declared. It used to say "the cart" whatever
512
+ // the flow mutated, so a registration journey was told to "add to the cart from EVERY
513
+ // source" — advice its author could not act on (#592).
514
+ const what = flowQ.contract?.stateful ?? 'cart';
412
515
  const how: Record<string, string> = {
413
- 'count-proof': 'assert the cart ROW COUNT / item QUANTITY (e.g. `... table with {{two_rows}}`, `Quantity column with {{qty}}`), not just the row presence',
414
- 'teardown': 'add a REMOVE/clear scenario that returns the cart to its empty state (the inverse operation)',
415
- 'multi-source': 'add to the cart from EVERY source on the page (the main list AND the recommended/related rail), not just one',
516
+ 'count-proof': `assert the ${what} ROW COUNT / item QUANTITY (e.g. \`... table with {{two_rows}}\`, \`Quantity column with {{qty}}\`), not just the row presence`,
517
+ 'teardown': `prove the INVERSE operation the ${what} returning to its empty/absent state (a remove or clear for a collection; a lapse, expiry, reset or loss for a state machine)`,
518
+ 'multi-source': `exercise EVERY source that feeds the ${what} (the main list AND the recommended/related rail), not just one`,
416
519
  };
417
- findings.push(`FLOW-DEPTH: this stateful flow exercises ${3 - flowDepth.missing.length}/3 regression dimensions missing [${flowDepth.missing.join(', ')}] ${flowDepth.missing.map((m) => how[m]).join('; ')}. (businessDepth is capped until covered.)`);
520
+ // How many dimensions this flow can HAVEthe ratio is renormalised over the applicable
521
+ // ones, so "2/3" would misreport a flow that only has two to begin with.
522
+ const dims = flowDepth.ratio < 1 ? Math.round(flowDepth.missing.length / (1 - flowDepth.ratio)) : 3;
523
+ findings.push(`FLOW-DEPTH: this stateful flow (${what}) exercises ${dims - flowDepth.missing.length}/${dims} applicable regression dimensions — missing [${flowDepth.missing.join(', ')}] → ${flowDepth.missing.map((m) => how[m]).join('; ')}. (businessDepth is capped until covered.)`);
418
524
  }
419
525
  for (const w of oracle.weak) {
420
526
  findings.push(`ORACLE-WEAK: "${w.name}" — ${w.hint}`);
@@ -428,7 +534,7 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
428
534
  }
429
535
  if (balance.unclassifiedRatio > 0.4) {
430
536
  findings.push(`TAXONOMY-UNCLASSIFIED: ${balance.note} → align the VP-<CATEGORY> codes with the catalog (or extend the bucket keywords) so coverage-balance is meaningful.`);
431
- } else if (balance.imbalanced) {
537
+ } else if (balance.imbalanced && !flowScored) {
432
538
  findings.push(`BALANCE: ${balance.note} Stop expanding secondary viewpoints until business-core gaps are filled.`);
433
539
  }
434
540
  if (trace.mappedRatio < 0.5) {
@@ -454,7 +560,9 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
454
560
  // "nothing found" quietly excluding the specFR axis while the evidence sits in the file.
455
561
  if (spec.hasSpec && spec.frTotal === 0 && /@spec:/i.test(featureText)) {
456
562
  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.`);
563
+ findings.push(isUiFlow
564
+ ? `SPEC-IDS-UNPARSED: the feature cites ${cited.length} requirement id(s) via @spec: (${cited.slice(0, 5).join(', ')}${cited.length > 5 ? ', …' : ''}) that this FLOW's requirements/spec.md does not declare. That is normal when the ids belong to the SCREEN specs the flow traverses — but it means nothing here can verify them: the specFR axis and the MUST-coverage gate stay off for this unit. To gate the flow on them, restate each as \`<ID>: <clause>\` in the flow's own spec (a one-line restatement per id is enough).`
565
+ : `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
566
  }
459
567
  if (taxonomyMismatch) {
460
568
  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.`);
@@ -577,14 +685,20 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
577
685
  scenarioCount: scenarios.length,
578
686
  gate, depth, claim, taxonomy, balance, duplicates, trace, spec,
579
687
  taxonomyMismatch, downstream, manualOracle: manualOracleResult, automatableManual: autoManual, flowDepth, oracle, ledger, viewpointBaseline, calibration,
580
- ...(isUiFlow ? { flowQuality: flowQ } : {}),
688
+ ...(isUiFlow ? { flowQuality: flowQ, flowInventory: inventory ?? undefined } : {}),
581
689
  score: {
582
690
  overall: Math.round(overall * 10) / 10,
583
691
  coverage: Math.round(coverage * 100) / 100,
584
692
  businessDepth: Math.round(businessDepth * 100) / 100,
585
693
  balance: Math.round(balanceScore * 100) / 100,
586
694
  traceability: Math.round(traceScore * 100) / 100,
587
- formula: 'overall = (0.4*coverage + 0.3*businessDepth + 0.15*balance + 0.15*traceability) * 10',
695
+ // Built from the axes ACTUALLY scored, with the renormalised weights — a hardcoded
696
+ // string drifted from the real calculation and printed axes (`balance` on a contract
697
+ // flow) that carried no weight at all.
698
+ axes: axisDefs.map((a) => ({ ...a, weight: a.applicable ? a.weight / weightSum : 0 })),
699
+ formula: `overall = (${scored.map((a) => `${(a.weight / weightSum).toFixed(2)}*${a.key}`).join(' + ')}) * 10`
700
+ + (missingEvidence.length ? ` — n/a (no evidence): ${missingEvidence.join(', ')}` : '')
701
+ + (notApplicableByDesign.length ? ` — n/a for this unit kind: ${notApplicableByDesign.join(', ')}` : ''),
588
702
  },
589
703
  gateStatus,
590
704
  findings,