@sun-asterisk/sungen 3.2.16-beta.4 → 3.2.16-beta.6

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 (31) hide show
  1. package/dist/exporters/matrix/build.d.ts +14 -2
  2. package/dist/exporters/matrix/build.d.ts.map +1 -1
  3. package/dist/exporters/matrix/build.js +65 -20
  4. package/dist/exporters/matrix/build.js.map +1 -1
  5. package/dist/exporters/matrix/gates.d.ts.map +1 -1
  6. package/dist/exporters/matrix/gates.js +43 -1
  7. package/dist/exporters/matrix/gates.js.map +1 -1
  8. package/dist/exporters/matrix/map-loader.d.ts.map +1 -1
  9. package/dist/exporters/matrix/map-loader.js +2 -0
  10. package/dist/exporters/matrix/map-loader.js.map +1 -1
  11. package/dist/exporters/matrix/render-csv.d.ts.map +1 -1
  12. package/dist/exporters/matrix/render-csv.js +8 -10
  13. package/dist/exporters/matrix/render-csv.js.map +1 -1
  14. package/dist/exporters/matrix/render-xlsx.d.ts +14 -0
  15. package/dist/exporters/matrix/render-xlsx.d.ts.map +1 -1
  16. package/dist/exporters/matrix/render-xlsx.js +62 -42
  17. package/dist/exporters/matrix/render-xlsx.js.map +1 -1
  18. package/dist/exporters/matrix/types.d.ts +19 -3
  19. package/dist/exporters/matrix/types.d.ts.map +1 -1
  20. package/dist/exporters/matrix/types.js.map +1 -1
  21. package/dist/orchestrator/templates/ai-src/commands/delivery.md +34 -1
  22. package/dist/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +17 -8
  23. package/package.json +3 -3
  24. package/src/exporters/matrix/build.ts +66 -17
  25. package/src/exporters/matrix/gates.ts +46 -1
  26. package/src/exporters/matrix/map-loader.ts +2 -0
  27. package/src/exporters/matrix/render-csv.ts +9 -11
  28. package/src/exporters/matrix/render-xlsx.ts +59 -43
  29. package/src/exporters/matrix/types.ts +20 -4
  30. package/src/orchestrator/templates/ai-src/commands/delivery.md +34 -1
  31. package/src/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +17 -8
@@ -31,9 +31,39 @@ export function runGates(ctx: GateContext): MatrixFinding[] {
31
31
  gateEDrift(ctx, findings);
32
32
  gateGReviewState(ctx, findings);
33
33
  gateWWording(ctx, findings);
34
+ gateKCategory(ctx, findings);
34
35
  return findings;
35
36
  }
36
37
 
38
+ // --- Gate K — category vs the source viewpoint class -------------------------
39
+
40
+ /** VP prefixes whose category is not a judgement call — the source decides it. */
41
+ const VP_CATEGORY_RULE: Array<[string, string]> = [
42
+ ['SEC', 'security'],
43
+ ['NFR', 'nfr'],
44
+ ];
45
+
46
+ /**
47
+ * A `VP-SEC-*` scenario filed under `normal`/`abnormal` empties the Coverage
48
+ * sheet's `security` column — the grid then reports a gap the unit does not
49
+ * have while hiding the security work it does have. Same for `VP-NFR-*`.
50
+ * (normal vs abnormal stays a judgement call and is never checked.)
51
+ */
52
+ function gateKCategory(ctx: GateContext, findings: MatrixFinding[]): void {
53
+ for (const g of ctx.inputs.map.groups) {
54
+ const vs = g.variants.flatMap((ref) => expandMapRef(ref, ctx.variantsByVp));
55
+ for (const [prefix, expected] of VP_CATEGORY_RULE) {
56
+ const hits = vs.filter((v) => v.vpCategory === prefix);
57
+ if (hits.length > 0 && g.category !== expected) {
58
+ findings.push({
59
+ gate: 'K', severity: 'warning', ref: g.id,
60
+ message: `group ${g.id} is \`category: ${g.category}\` but ${hits.length}/${vs.length} of its variants are VP-${prefix}-* (${hits.slice(0, 3).map((v) => v.vpId).join(', ')}) — use \`category: ${expected}\` or the Coverage grid's ${expected} column stays empty`,
61
+ });
62
+ }
63
+ }
64
+ }
65
+ }
66
+
37
67
  // --- Gate A — source quality ------------------------------------------------
38
68
 
39
69
  function gateASource(ctx: GateContext, findings: MatrixFinding[]): void {
@@ -320,7 +350,22 @@ const WORDING_SMELLS: Array<[RegExp, string]> = [
320
350
 
321
351
  function gateWWording(ctx: GateContext, findings: MatrixFinding[]): void {
322
352
  for (const g of ctx.inputs.map.groups) {
323
- for (const [field, text] of [['intent', g.intent], ['oracle', g.oracle]] as const) {
353
+ const variantCount = g.variants.flatMap((ref) => expandMapRef(ref, ctx.variantsByVp)).length;
354
+ // Without a digest the parent row can only list variant refs — a reviewer
355
+ // then has to expand the item to learn which dimensions it covers.
356
+ if (variantCount > 3 && !g.dimensions) {
357
+ findings.push({
358
+ gate: 'W', severity: 'warning', ref: g.id,
359
+ message: `group ${g.id} has ${variantCount} variants and no \`dimensions:\` digest — add a short one (e.g. "required ×3 · format ×9") so the collapsed view stays informative`,
360
+ });
361
+ }
362
+ if (g.dimensions && g.dimensions.length > 120) {
363
+ findings.push({
364
+ gate: 'W', severity: 'warning', ref: g.id,
365
+ message: `group ${g.id} \`dimensions:\` is ${g.dimensions.length} chars — keep the digest short (≤120)`,
366
+ });
367
+ }
368
+ for (const [field, text] of ([['intent', g.intent], ['oracle', g.oracle], ...(g.dimensions ? [['dimensions', g.dimensions] as const] : [])] as const)) {
324
369
  for (const [re, what] of WORDING_SMELLS) {
325
370
  if (re.test(text)) {
326
371
  findings.push({
@@ -78,6 +78,7 @@ export function loadDeliveryMap(file: string): MapLoadResult {
78
78
  target: String(grp.target ?? ''),
79
79
  intent: String(grp.intent ?? ''),
80
80
  oracle: String(grp.oracle ?? ''),
81
+ ...(grp.dimensions !== undefined ? { dimensions: String(grp.dimensions) } : {}),
81
82
  category: grp.category as MapCategory,
82
83
  review,
83
84
  variants: Array.isArray(variants) ? variants.map(String) : [],
@@ -148,6 +149,7 @@ export function writeDeliveryMap(file: string, map: DeliveryMap): void {
148
149
  target: g.target,
149
150
  intent: g.intent,
150
151
  oracle: g.oracle,
152
+ ...(g.dimensions !== undefined ? { dimensions: g.dimensions } : {}),
151
153
  category: g.category,
152
154
  review: g.review,
153
155
  variants: g.variants,
@@ -5,8 +5,8 @@
5
5
  * and result), so downstream pipelines can re-derive both views from one file.
6
6
  */
7
7
 
8
- import { statusToTestResult } from '../playwright-report-parser';
9
- import { itemModeLabel, itemResultLabel } from './render-xlsx';
8
+ import { variantState } from './build';
9
+ import { coverageSummary, itemModeLabel, itemResultLabel } from './render-xlsx';
10
10
  import { MatrixModel } from './types';
11
11
 
12
12
  const HEADERS = [
@@ -35,9 +35,6 @@ export function renderMatrixCsv(model: MatrixModel): string {
35
35
  lines.push(HEADERS.map(esc).join(','));
36
36
 
37
37
  for (const item of model.items) {
38
- const triggerTexts = new Set(item.variants.map((v) => v.trigger.join('\n')));
39
- const triggerIsCommon = triggerTexts.size === 1;
40
- const commonPre = new Set(item.precondition);
41
38
  lines.push([
42
39
  'item',
43
40
  item.id,
@@ -46,9 +43,9 @@ export function renderMatrixCsv(model: MatrixModel): string {
46
43
  item.category,
47
44
  item.priority,
48
45
  item.precondition.join('\n'),
49
- `${item.variants.length} variant(s): ${item.variants.map((v) => v.condition).join(' · ')}`,
50
- // No placeholder text: when triggers differ the variant rows carry them (GAP-01).
51
- triggerIsCommon ? item.variants[0].trigger.join('\n') : '',
46
+ coverageSummary(item),
47
+ // Shared leading steps only; each variant row carries its remaining steps.
48
+ item.trigger.join('\n'),
52
49
  item.oracle,
53
50
  itemModeLabel(item),
54
51
  item.traces.join(' '),
@@ -69,13 +66,14 @@ export function renderMatrixCsv(model: MatrixModel): string {
69
66
  v.condition,
70
67
  '', '',
71
68
  // Precondition DELTA — variant-specific setup only (review GAP-03).
72
- v.precondition.filter((line) => !commonPre.has(line)).join('\n'),
69
+ (item.preconditionDeltas[v.ref] ?? []).join('\n'),
73
70
  v.data.join('\n'),
74
- triggerIsCommon ? '' : v.trigger.join('\n'),
71
+ // Steps after the parent's shared prefix only.
72
+ (item.triggerDeltas[v.ref] ?? []).join('\n'),
75
73
  expected,
76
74
  v.mode === 'manual' ? 'Manual' : 'Auto',
77
75
  v.traces.join(' '),
78
- v.result ? statusToTestResult(v.result.status) : 'Pending',
76
+ variantState(v),
79
77
  isoDate(v.result?.startTime),
80
78
  v.result?.error ? String(v.result.error).slice(0, 200) : '',
81
79
  '',
@@ -27,8 +27,8 @@ import {
27
27
  BLACK,
28
28
  LAVENDER,
29
29
  } from '../xlsx-report-builder';
30
- import { statusToTestResult } from '../playwright-report-parser';
31
- import { CoverageVariant, DeliveryItem, ItemResult, MatrixModel, MapCategory } from './types';
30
+ import { variantState } from './build';
31
+ import { DeliveryItem, MatrixModel, MapCategory } from './types';
32
32
 
33
33
  const CATEGORY_ORDER: MapCategory[] = ['normal', 'abnormal', 'security', 'nfr'];
34
34
  const SOFT_BLUE = { argb: 'FFDCE6F1' };
@@ -63,22 +63,21 @@ export function itemModeLabel(item: DeliveryItem): string {
63
63
  return item.mode === 'manual' ? 'Manual' : 'Auto';
64
64
  }
65
65
 
66
+ /**
67
+ * Parent roll-up label. It must NEVER be exactly one of the variant state words
68
+ * (`Passed`/`Failed`/`Blocked`/`Pending`/`N/A`) — the summary band counts the
69
+ * Result column with exact-match COUNTIF over parent AND child rows, so a parent
70
+ * reading plain "Pending" was counted as an extra pending variant (109 instead
71
+ * of 77 on st-login).
72
+ */
66
73
  export function itemResultLabel(item: DeliveryItem): string {
67
- const { passed, failed, blocked, notRun } = item.resultCounts;
68
- const total = item.variants.length;
69
- const map: Record<ItemResult, string> = {
70
- passed: `${passed}/${total} Passed`,
71
- failed: `${passed}/${total} Passed · ${failed} Failed`,
72
- blocked: `${blocked} Blocked`,
73
- partial: `${passed}/${total} Passed · ${notRun} not run`,
74
- not_run: 'Pending',
75
- };
76
- return map[item.result];
77
- }
78
-
79
- function variantResultLabel(v: CoverageVariant): string {
80
- if (!v.result) return 'Pending';
81
- return statusToTestResult(v.result.status);
74
+ const { passed, failed, blocked, na } = item.resultCounts;
75
+ const eff = item.variants.length - na;
76
+ const suffix = na > 0 ? ` · ${na} N/A` : '';
77
+ if (item.result === 'na') return 'All N/A';
78
+ if (item.result === 'failed') return `${passed}/${eff} Passed · ${failed} Failed${suffix}`;
79
+ if (item.result === 'blocked') return `${passed}/${eff} Passed · ${blocked} Blocked${suffix}`;
80
+ return `${passed}/${eff} Passed${suffix}`;
82
81
  }
83
82
 
84
83
  /** ISO date (2026-08-04) — unambiguous across locales (review §9). */
@@ -89,10 +88,24 @@ function isoDate(startTime: string | undefined): string {
89
88
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
90
89
  }
91
90
 
92
- function numbered(lines: string[]): string {
91
+ function numbered(lines: string[], start = 1): string {
93
92
  if (lines.length === 0) return '';
94
- if (lines.length === 1) return lines[0];
95
- return lines.map((l, i) => `${i + 1}. ${l}`).join('\n');
93
+ if (lines.length === 1 && start === 1) return lines[0];
94
+ return lines.map((l, i) => `${start + i}. ${l}`).join('\n');
95
+ }
96
+
97
+ /**
98
+ * Compact coverage summary for the parent row. A declared `dimensions:` digest
99
+ * wins ("15 variants — required ×3 · format ×9 …"); otherwise a single variant
100
+ * shows its condition and a group falls back to its variant REFS. Concatenating
101
+ * every variant's full title turned this cell into an 800-character paragraph.
102
+ */
103
+ export function coverageSummary(item: DeliveryItem): string {
104
+ const n = item.variants.length;
105
+ const head = `${n} variant${n === 1 ? '' : 's'}:`;
106
+ if (item.dimensions) return `${head} ${item.dimensions}`;
107
+ if (n === 1) return `${head} ${item.variants[0].condition}`;
108
+ return `${head} ${item.variants.map((v) => v.ref).join(' · ')}`;
96
109
  }
97
110
 
98
111
  /** Expected cell = observable outcomes; the HOW moves under a separate heading. */
@@ -154,11 +167,16 @@ function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?
154
167
  // Summary band (row 6) — items vs variants stay separate metrics by design (I8).
155
168
  // The execution counters are LIVE formulas over the variant Result cells, so
156
169
  // hand-entered results update the totals (review GAP-06). Variant cells hold the
157
- // exact state words; parent cells hold composed labels exact-match COUNTIF
158
- // therefore counts variants only.
159
- const passed = model.items.reduce((a, i) => a + i.resultCounts.passed, 0);
160
- const failed = model.items.reduce((a, i) => a + i.resultCounts.failed, 0);
161
- const notRun = model.items.reduce((a, i) => a + i.resultCounts.notRun + i.resultCounts.blocked, 0);
170
+ // exact state words and parent cells NEVER do (itemResultLabel + the roll-up
171
+ // formula always compose a "n/m Passed …" label), so exact-match COUNTIF over
172
+ // the whole column counts variants only.
173
+ const sum = (k: 'passed' | 'failed' | 'blocked' | 'notRun' | 'na'): number =>
174
+ model.items.reduce((a, i) => a + i.resultCounts[k], 0);
175
+ const passed = sum('passed');
176
+ const failed = sum('failed');
177
+ const blocked = sum('blocked');
178
+ const notRun = sum('notRun');
179
+ const na = sum('na');
162
180
  const RANGE = 'M9:M10000';
163
181
  const band = ws.getRow(6);
164
182
  const bandCells: Array<[string, ExcelJS.CellValue]> = [
@@ -166,9 +184,9 @@ function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?
166
184
  ['variants', `Coverage variants: ${model.manifest.variantCount}`],
167
185
  ['passed', { formula: `"Passed: "&COUNTIF(${RANGE},"Passed")`, result: `Passed: ${passed}` } as ExcelJS.CellValue],
168
186
  ['failed', { formula: `"Failed: "&COUNTIF(${RANGE},"Failed")`, result: `Failed: ${failed}` } as ExcelJS.CellValue],
169
- ['blocked', { formula: `"Blocked: "&COUNTIF(${RANGE},"Blocked")`, result: 'Blocked: 0' } as ExcelJS.CellValue],
187
+ ['blocked', { formula: `"Blocked: "&COUNTIF(${RANGE},"Blocked")`, result: `Blocked: ${blocked}` } as ExcelJS.CellValue],
170
188
  ['pending', { formula: `"Pending: "&COUNTIF(${RANGE},"Pending")`, result: `Pending: ${notRun}` } as ExcelJS.CellValue],
171
- ['na', { formula: `"N/A: "&COUNTIF(${RANGE},"N/A")`, result: 'N/A: 0' } as ExcelJS.CellValue],
189
+ ['na', { formula: `"N/A: "&COUNTIF(${RANGE},"N/A")`, result: `N/A: ${na}` } as ExcelJS.CellValue],
172
190
  ];
173
191
  bandCells.forEach(([, v], i) => {
174
192
  const c = band.getCell(2 + i);
@@ -192,25 +210,23 @@ function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?
192
210
  // item lives ONCE on the parent — a child repeats only what distinguishes it.
193
211
  // The child's Expected Result is always kept: it is the precise source
194
212
  // oracle (the semantic anchor), not a repetition of the parent's summary.
195
- const commonPre = new Set(item.precondition);
196
- const triggerTextsAll = new Set(item.variants.map((v) => v.trigger.join('\n')));
197
- const triggerIsCommon = triggerTextsAll.size === 1;
198
213
  for (const v of item.variants) {
199
214
  const vr = ws.getRow(rowIdx++);
200
215
  vr.outlineLevel = 1;
201
- const preDelta = v.precondition.filter((line) => !commonPre.has(line));
202
216
  const vValues: ExcelJS.CellValue[] = [
203
217
  v.ref,
204
218
  '',
205
219
  v.condition,
206
220
  '', '',
207
- numbered(preDelta),
221
+ numbered(item.preconditionDeltas[v.ref] ?? []),
208
222
  numbered(v.data),
209
- triggerIsCommon ? '' : numbered(v.trigger),
223
+ // Only the steps after the parent's shared prefix, numbered so the
224
+ // parent's steps + these read as one continuous procedure.
225
+ numbered(item.triggerDeltas[v.ref] ?? [], item.trigger.length + 1),
210
226
  expectedWithVerification(v.oracle, v.verification),
211
227
  v.mode === 'manual' ? `Manual${v.manualReason ? ` (${v.manualReason})` : ''}` : 'Auto',
212
228
  v.traces.join(', '),
213
- variantResultLabel(v),
229
+ variantState(v),
214
230
  isoDate(v.result?.startTime),
215
231
  '',
216
232
  v.result?.error ? String(v.result.error).slice(0, 200) : '',
@@ -227,8 +243,7 @@ function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?
227
243
  // --- Parent row: only values that are genuinely COMMON to the whole item
228
244
  // (review M-04); everything variant-specific stays on the sub-rows.
229
245
  const pr = ws.getRow(parentRowIdx);
230
- const commonTrigger = triggerIsCommon ? item.variants[0].trigger : null;
231
- const coverage = `${item.variants.length} variant(s): ${item.variants.map((v) => v.condition).join(' · ')}`;
246
+ const coverage = coverageSummary(item);
232
247
 
233
248
  // Live roll-up derived from the child Result cells — precedence
234
249
  // failed → blocked → pending → partial → passed. N/A variants leave the
@@ -236,12 +251,13 @@ function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?
236
251
  const rng = `M${firstChild}:M${lastChild}`;
237
252
  const n = item.variants.length;
238
253
  const EFF = `(${n}-COUNTIF(${rng},"N/A"))`;
254
+ const NA = `IF(COUNTIF(${rng},"N/A")>0," · "&COUNTIF(${rng},"N/A")&" N/A","")`;
255
+ // Mirrors itemResultLabel exactly — and never yields a bare state word.
239
256
  const rollUpFormula =
240
- `IF(COUNTIF(${rng},"Failed")>0,COUNTIF(${rng},"Passed")&"/"&${EFF}&" Passed · "&COUNTIF(${rng},"Failed")&" Failed",` +
241
- `IF(COUNTIF(${rng},"Blocked")>0,COUNTIF(${rng},"Passed")&"/"&${EFF}&" Passed · "&COUNTIF(${rng},"Blocked")&" Blocked",` +
242
- `IF(${EFF}=0,"N/A",` +
243
- `IF(COUNTIF(${rng},"Passed")=${EFF},${EFF}&"/"&${EFF}&" Passed",` +
244
- `IF(COUNTIF(${rng},"Passed")=0,"Pending",COUNTIF(${rng},"Passed")&"/"&${EFF}&" Passed · pending")))))`;
257
+ `IF(COUNTIF(${rng},"Failed")>0,COUNTIF(${rng},"Passed")&"/"&${EFF}&" Passed · "&COUNTIF(${rng},"Failed")&" Failed"&${NA},` +
258
+ `IF(COUNTIF(${rng},"Blocked")>0,COUNTIF(${rng},"Passed")&"/"&${EFF}&" Passed · "&COUNTIF(${rng},"Blocked")&" Blocked"&${NA},` +
259
+ `IF(${EFF}=0,"All N/A",` +
260
+ `COUNTIF(${rng},"Passed")&"/"&${EFF}&" Passed"&${NA})))`;
245
261
 
246
262
  const values: ExcelJS.CellValue[] = [
247
263
  item.id,
@@ -251,8 +267,8 @@ function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?
251
267
  item.priority,
252
268
  numbered(item.precondition),
253
269
  coverage,
254
- // No placeholder text: when triggers differ the sub-rows carry them (GAP-01).
255
- commonTrigger ? numbered(commonTrigger) : '',
270
+ // The shared leading steps; each variant's remaining steps sit on its sub-row.
271
+ numbered(item.trigger),
256
272
  item.oracle,
257
273
  itemModeLabel(item),
258
274
  item.traces.join(', '),
@@ -29,6 +29,10 @@ export interface MapGroup {
29
29
  oracle: string;
30
30
  category: MapCategory;
31
31
  review: ReviewState;
32
+ /** Optional short digest of the coverage dimensions this item varies over
33
+ * (e.g. "required ×3 · format ×9 · full-width ×2"). Rendered on the parent row
34
+ * instead of concatenating every variant's title — the compact review view. */
35
+ dimensions?: string;
32
36
  /** Variant refs: `VP-ID` (all @cases rows when the scenario has a dataset) or `VP-ID#label`. */
33
37
  variants: string[];
34
38
  }
@@ -118,7 +122,11 @@ export interface CoverageVariant {
118
122
  result?: PlaywrightResult;
119
123
  }
120
124
 
121
- export type ItemResult = 'passed' | 'failed' | 'blocked' | 'partial' | 'not_run';
125
+ export type ItemResult = 'passed' | 'failed' | 'blocked' | 'partial' | 'not_run' | 'na';
126
+
127
+ /** The exact word rendered in a variant's Result cell — the single vocabulary the
128
+ * parent's roll-up formula counts (COUNTIF is exact-match). */
129
+ export type VariantState = 'Passed' | 'Failed' | 'Blocked' | 'Pending' | 'N/A';
122
130
 
123
131
  /** One summarized row in the matrix — a group of variants sharing the signature. */
124
132
  export interface DeliveryItem {
@@ -126,6 +134,8 @@ export interface DeliveryItem {
126
134
  target: string;
127
135
  intent: string;
128
136
  oracle: string;
137
+ /** Short coverage-dimension digest from the map (compact parent view). */
138
+ dimensions?: string;
129
139
  category: MapCategory;
130
140
  review: ReviewState;
131
141
  /** Highest priority among the variants (per-variant priorities stay on the sub-rows). */
@@ -136,13 +146,19 @@ export interface DeliveryItem {
136
146
  layers: MatrixLayer[];
137
147
  /** Union of variant traces (exact per-variant traces stay on the variants). */
138
148
  traces: string[];
149
+ /** Preconditions shared by EVERY variant (the intersection) — written once here. */
139
150
  precondition: string[];
140
- /** Common trigger when all variants share one shape, else '(per variant)'. */
151
+ /** Leading trigger steps shared by every variant written once here; each
152
+ * variant renders only its remaining steps (`triggerDeltas`). */
141
153
  trigger: string[];
142
154
  variants: CoverageVariant[];
155
+ /** variant ref → its precondition lines that are not on the parent. */
156
+ preconditionDeltas: Record<string, string[]>;
157
+ /** variant ref → its trigger steps after the shared prefix. */
158
+ triggerDeltas: Record<string, string[]>;
143
159
  /** Derived roll-up — never entered independently (Gate F). */
144
160
  result: ItemResult;
145
- resultCounts: { passed: number; failed: number; blocked: number; notRun: number };
161
+ resultCounts: { passed: number; failed: number; blocked: number; notRun: number; na: number };
146
162
  }
147
163
 
148
164
  export interface MatrixDisposition {
@@ -166,7 +182,7 @@ export interface RequirementCoverage {
166
182
  // Gate findings
167
183
  // ---------------------------------------------------------------------------
168
184
 
169
- export type GateId = 'A' | 'B' | 'C' | 'D' | 'E' | 'G' | 'R' | 'W';
185
+ export type GateId = 'A' | 'B' | 'C' | 'D' | 'E' | 'G' | 'K' | 'R' | 'W';
170
186
  export type FindingSeverity = 'error' | 'review' | 'warning';
171
187
 
172
188
  export interface MatrixFinding {
@@ -60,7 +60,8 @@ groups:
60
60
  target: login.email # ONE target: field/component dot-path, flow phrase, or METHOD /path
61
61
  intent: <one behavior/rule this item verifies>
62
62
  oracle: <the shared observable Pass/Fail statement>
63
- category: normal | abnormal | security | nfr
63
+ dimensions: violated rule required ×3 · format ×10 · full-width ×2 # see below
64
+ category: normal | abnormal | security | nfr # see the rule below
64
65
  review: proposed # ALWAYS proposed — only QA approval flips it
65
66
  variants: [VP-VAL-001-B, VP-VAL-001-S] # VP-ids; bare id on a @cases scenario = all its rows
66
67
  dispositions: # scenarios intentionally NOT delivered as test cases
@@ -89,11 +90,36 @@ determined** (its oracle *family*, not its exact message).
89
90
  sequence-sensitive flows (re-Given/When after a Then) stay solo. **Different risk classes never
90
91
  merge**: XSS and SQL injection are separate items (different risk and determination), even on
91
92
  the same field.
93
+ - **Look for these families before settling on a grouping** — they are where under-merging happens:
94
+ | Family | Merge into one item |
95
+ |---|---|
96
+ | Static render | every "element X is visible/has its content on load" scenario of the screen — title, instructions, progress step, buttons present, header/footer |
97
+ | Field validation branches | all rules of ONE field (required · format · length · character class · full-width) |
98
+ | Account/entity states | wrong-credentials · locked · deleted · unverified for the same rejection oracle |
99
+ | Provider / surface sets | the 3 OAuth providers, header+footer, the a11y surfaces of one behaviour |
100
+ | Lifetime / mode pairs | checked vs unchecked, mobile vs desktop, when the oracle is one rule with two branches |
101
+ On a 36-scenario screen, four separate one-variant "renders on load" items should have been one.
92
102
  - When unsure, keep items separate — the gates and QA decide, never guess-merge.
93
103
  - Every scenario must land in exactly one group **or** one disposition (Gate B enforces 100%
94
104
  disposition). Data-setup blocks (`@manual:data-setup`) → `excluded`; SPEC-GAP placeholders →
95
105
  `blocked`.
96
106
 
107
+ **`dimensions:` — the compact coverage digest (required for items with >3 variants).**
108
+ This one short line is what the collapsed parent row shows instead of listing every variant, so a
109
+ reviewer sees *which dimensions* the item covers without expanding it. Name the dimension, then the
110
+ branches with counts:
111
+ - `violated rule — required ×3 · format ×10 · full-width ×2`
112
+ - `account state — wrong password · unregistered · locked · soft-deleted`
113
+ - `submission method — Login button · Enter in Password · Enter in Email`
114
+
115
+ Keep it ≤120 chars (Gate W warns). **YAML caveat:** a bare `: ` inside the value breaks the parse —
116
+ use ` — ` as the label separator (as above) or quote the whole string.
117
+
118
+ **`category` is not free choice for two classes (Gate K checks it):** a group whose variants are
119
+ `VP-SEC-*` MUST be `category: security`, and `VP-NFR-*` MUST be `nfr` — otherwise the Coverage
120
+ sheet's security/nfr column renders empty and the grid reports a gap the unit does not have while
121
+ hiding the work it does have. `normal` vs `abnormal` stays your judgement.
122
+
97
123
  **Wording rules for `intent`/`oracle` (customer-facing — Gate W lints these):**
98
124
  - Plain product language, present simple, ~10–20 words, one behavior:
99
125
  "A user can sign in with valid credentials and is redirected to the Jobs page."
@@ -117,6 +143,13 @@ requirements:
117
143
  # status: covered | partially_covered | covered_elsewhere | planned | gap | not_applicable
118
144
  ```
119
145
 
146
+ **Never write `status: covered` for a requirement no variant traces to** (Gate R flags it). A note
147
+ saying "proven by DI-SEC-CSRF" is prose — nothing detects it when that scenario later changes. If a
148
+ scenario in THIS feature proves the requirement, **add `@spec:<id>` to that scenario** so the trace
149
+ is real, then drop the override (it derives as `covered` on its own). Use `covered_elsewhere` only
150
+ when another suite proves it, and name that suite; `not_applicable` when the spec itself excludes
151
+ the requirement.
152
+
120
153
  Then validate and fix any ERROR findings:
121
154
 
122
155
  ```bash
@@ -38,8 +38,11 @@ data · trigger · oracle all renderable; every `{{var}}` resolves; **no templat
38
38
  into a rendered cell** — test-data cross-references are resolved for display) · E drift
39
39
  (scenario fingerprint mismatch → back to review; the map's OWN reviewed
40
40
  wording/grouping is fingerprinted as `__map__` too, so post-approval edits re-open review) · G review state (proposed groups block the official
41
- render; `--preview` renders a DRAFT watermark) · R requirement coverage (spec FR/TR/NFR ids with
42
- no trace and no `requirements:` status → warning) · W wording lint (map intent/oracle containing
41
+ render; `--preview` renders a DRAFT watermark) · K category class (a `VP-SEC-*` variant outside
42
+ `category: security`, or `VP-NFR-*` outside `nfr` → warning: the Coverage grid's column would render
43
+ empty and report a false gap) · R requirement coverage (spec FR/TR/NFR ids with no trace and no
44
+ `requirements:` status → warning; **and a `status: covered` override that no variant traces to** →
45
+ warning: tag the proving scenario `@spec:<id>` instead of asserting it in prose) · W wording lint (map intent/oracle containing
43
46
  tokens, `[Selector]` refs, DSL phrasing, or generator labels → warning).
44
47
 
45
48
  **Wording normalization (deterministic, after semantic normalization):** DSL steps render as
@@ -63,12 +66,18 @@ result/evidence entry). Sub-rows are **delta-only**: knowledge common to the who
63
66
  execution fields. The parent never carries placeholder text — when triggers differ the cell is
64
67
  simply empty and the sub-rows carry them.
65
68
 
66
- Parent preconditions are the **intersection** of the variants' variant-specific setup stays on
67
- the variant, so no variant inherits a wrong start state. Variant Result cells have a dropdown
68
- (Passed/Failed/Blocked/Pending/N/A) and the parent Result is a **live Excel formula** over its
69
- children (failed→blocked→pending→partial→passed; **`N/A` leaves the denominator**, all-`N/A`
70
- `N/A`). The summary band counts results with live formulas too. Evidence and Defect ID are
71
- separate columns; ID + Target frozen; ISO dates; landscape print with repeated header rows.
69
+ Parent preconditions are the **intersection** of the variants' and the parent's Action holds the
70
+ **shared leading steps**; each variant renders only its remaining steps, numbered to continue the
71
+ parent's list so a shared prefix is written once, not repeated on every child. The parent's
72
+ Coverage cell is the map's short `dimensions:` digest (or the variant refs when none is declared),
73
+ never a concatenation of variant titles.
74
+
75
+ Variant Result cells hold exactly one of `Passed`/`Failed`/`Blocked`/`Pending`/`N/A` (dropdown) —
76
+ that vocabulary is the contract: the parent Result and the summary band are **live Excel formulas**
77
+ counting those words, so a parent label is always a composed `n/m Passed …` string (`All N/A` when
78
+ every variant is N/A) and never a bare state word. **`N/A` leaves the denominator.** Evidence and
79
+ Defect ID are separate columns; ID + Target frozen; ISO dates; landscape print with repeated
80
+ header rows.
72
81
 
73
82
  `Coverage` sheet — requirement coverage table (every FR/TR/NFR id with an explicit status),
74
83
  target × category grid with explicit `—` gaps, dispositions, manifest. CSV mirrors the same model