@sun-asterisk/sungen 3.2.16-beta.1 → 3.2.16-beta.3

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 (47) hide show
  1. package/dist/cli/commands/delivery.d.ts.map +1 -1
  2. package/dist/cli/commands/delivery.js +1 -0
  3. package/dist/cli/commands/delivery.js.map +1 -1
  4. package/dist/exporters/matrix/build.d.ts +8 -1
  5. package/dist/exporters/matrix/build.d.ts.map +1 -1
  6. package/dist/exporters/matrix/build.js +166 -24
  7. package/dist/exporters/matrix/build.js.map +1 -1
  8. package/dist/exporters/matrix/export.d.ts +2 -0
  9. package/dist/exporters/matrix/export.d.ts.map +1 -1
  10. package/dist/exporters/matrix/export.js +7 -0
  11. package/dist/exporters/matrix/export.js.map +1 -1
  12. package/dist/exporters/matrix/gates.d.ts.map +1 -1
  13. package/dist/exporters/matrix/gates.js +40 -2
  14. package/dist/exporters/matrix/gates.js.map +1 -1
  15. package/dist/exporters/matrix/map-loader.d.ts.map +1 -1
  16. package/dist/exporters/matrix/map-loader.js +18 -0
  17. package/dist/exporters/matrix/map-loader.js.map +1 -1
  18. package/dist/exporters/matrix/render-csv.d.ts +3 -2
  19. package/dist/exporters/matrix/render-csv.d.ts.map +1 -1
  20. package/dist/exporters/matrix/render-csv.js +49 -29
  21. package/dist/exporters/matrix/render-csv.js.map +1 -1
  22. package/dist/exporters/matrix/render-xlsx.d.ts +18 -8
  23. package/dist/exporters/matrix/render-xlsx.d.ts.map +1 -1
  24. package/dist/exporters/matrix/render-xlsx.js +125 -52
  25. package/dist/exporters/matrix/render-xlsx.js.map +1 -1
  26. package/dist/exporters/matrix/types.d.ts +29 -4
  27. package/dist/exporters/matrix/types.d.ts.map +1 -1
  28. package/dist/exporters/matrix/types.js +2 -2
  29. package/dist/exporters/matrix/types.js.map +1 -1
  30. package/dist/exporters/matrix/wording.d.ts +45 -0
  31. package/dist/exporters/matrix/wording.d.ts.map +1 -0
  32. package/dist/exporters/matrix/wording.js +150 -0
  33. package/dist/exporters/matrix/wording.js.map +1 -0
  34. package/dist/orchestrator/templates/ai-src/commands/delivery.md +45 -9
  35. package/dist/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +36 -11
  36. package/package.json +3 -3
  37. package/src/cli/commands/delivery.ts +1 -0
  38. package/src/exporters/matrix/build.ts +168 -24
  39. package/src/exporters/matrix/export.ts +10 -0
  40. package/src/exporters/matrix/gates.ts +44 -2
  41. package/src/exporters/matrix/map-loader.ts +20 -1
  42. package/src/exporters/matrix/render-csv.ts +50 -30
  43. package/src/exporters/matrix/render-xlsx.ts +131 -55
  44. package/src/exporters/matrix/types.ts +33 -4
  45. package/src/exporters/matrix/wording.ts +157 -0
  46. package/src/orchestrator/templates/ai-src/commands/delivery.md +45 -9
  47. package/src/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +36 -11
@@ -31,10 +31,18 @@ export interface MatrixTargetPaths {
31
31
  featureFile: string;
32
32
  testDataFile: string;
33
33
  specFile: string;
34
+ /** requirements/spec.md — the requirement-id inventory for coverage (optional). */
35
+ specMdFile?: string;
34
36
  resultsPath: string | null;
35
37
  mapFile: string;
36
38
  }
37
39
 
40
+ function readSpecText(paths: MatrixTargetPaths): string {
41
+ return paths.specMdFile && fs.existsSync(paths.specMdFile)
42
+ ? fs.readFileSync(paths.specMdFile, 'utf-8')
43
+ : '';
44
+ }
45
+
38
46
  export interface MatrixLoadResult {
39
47
  model?: MatrixModel;
40
48
  map?: DeliveryMap;
@@ -64,6 +72,7 @@ export function loadMatrixModel(paths: MatrixTargetPaths): MatrixLoadResult {
64
72
  results,
65
73
  map,
66
74
  transformerVersion: getPackageVersion(),
75
+ specText: readSpecText(paths),
67
76
  });
68
77
  return { model, map, mapErrors: [] };
69
78
  }
@@ -88,6 +97,7 @@ export function approveMatrix(paths: MatrixTargetPaths, groupIds?: string[]): {
88
97
  const model = buildMatrix({
89
98
  unit: paths.unit, feature, merged, testData, results: null, map,
90
99
  transformerVersion: getPackageVersion(),
100
+ specText: readSpecText(paths),
91
101
  });
92
102
  const blocking = model.findings.filter((f) => f.severity === 'error');
93
103
  if (blocking.length > 0) return { findings: blocking, approved: [] };
@@ -29,6 +29,7 @@ export function runGates(ctx: GateContext): MatrixFinding[] {
29
29
  gateDExecutability(ctx, findings);
30
30
  gateEDrift(ctx, findings);
31
31
  gateGReviewState(ctx, findings);
32
+ gateWWording(ctx, findings);
32
33
  return findings;
33
34
  }
34
35
 
@@ -155,10 +156,11 @@ function gateCAggregation(ctx: GateContext, findings: MatrixFinding[]): void {
155
156
  const heuristicsConfirmed = g.review === 'approved' && !stale;
156
157
 
157
158
  // Hard signature parts — recomputed, never trusted from the map. ERROR on mismatch.
159
+ // Execution mode and priority are deliberately NOT here: they are coverage
160
+ // dimensions (account states may need seeded manual variants next to auto ones;
161
+ // an item takes the highest variant priority) — shown per variant, never split on.
158
162
  const hard: Array<[string, (v: CoverageVariant) => string]> = [
159
- ['execution mode', (v) => v.mode],
160
163
  ['test layer', (v) => [...v.layers].sort().join('+')],
161
- ['priority', (v) => v.priority],
162
164
  ];
163
165
  for (const [label, keyOf] of hard) {
164
166
  const distinct = new Set(vs.map(keyOf));
@@ -241,6 +243,21 @@ function gateDExecutability(ctx: GateContext, findings: MatrixFinding[]): void {
241
243
  }
242
244
  }
243
245
  }
246
+
247
+ // No template token may survive into a RENDERED cell (review B-04) — this also
248
+ // catches cross-referencing test-data values the one-level resolver couldn't close.
249
+ for (const [, vs] of variantsByVp) {
250
+ for (const v of vs) {
251
+ const rendered = [...v.precondition, ...v.trigger, ...v.oracle, ...v.verification, ...v.data];
252
+ const token = rendered.map((t) => t.match(/\{\{[^}]*\}\}/)).find(Boolean);
253
+ if (token) {
254
+ findings.push({
255
+ gate: 'D', severity: 'error', ref: v.ref,
256
+ message: `${v.ref}: unresolved template token ${token[0]} remains in the rendered output — the export is not deterministic for a tester`,
257
+ });
258
+ }
259
+ }
260
+ }
244
261
  }
245
262
 
246
263
  // --- Gate E — drift (stale approval) ------------------------------------------
@@ -271,6 +288,31 @@ function gateEDrift(ctx: GateContext, findings: MatrixFinding[]): void {
271
288
  }
272
289
  }
273
290
 
291
+ // --- Gate W — controlled-language lint on the map's semantic fields --------------
292
+
293
+ /** Patterns that make customer-facing wording read machine-generated (review §10). */
294
+ const WORDING_SMELLS: Array<[RegExp, string]> = [
295
+ [/\{\{[^}]*\}\}/, 'an unresolved {{token}}'],
296
+ [/\b(?:Setup|Observable|Oracle):/, 'a generator label (Setup:/Observable:/Oracle:)'],
297
+ [/\bUser (?:fill|click|press|see|wait)\b/i, 'DSL phrasing ("User fill/click/see…") — write plain product language'],
298
+ [/\[[^\]]+\]/, 'a [selector-style] reference — use the visible UI label'],
299
+ ];
300
+
301
+ function gateWWording(ctx: GateContext, findings: MatrixFinding[]): void {
302
+ for (const g of ctx.inputs.map.groups) {
303
+ for (const [field, text] of [['intent', g.intent], ['oracle', g.oracle]] as const) {
304
+ for (const [re, what] of WORDING_SMELLS) {
305
+ if (re.test(text)) {
306
+ findings.push({
307
+ gate: 'W', severity: 'warning', ref: g.id,
308
+ message: `group ${g.id} ${field} contains ${what}`,
309
+ });
310
+ }
311
+ }
312
+ }
313
+ }
314
+ }
315
+
274
316
  // --- Gate G — review state -----------------------------------------------------
275
317
 
276
318
  function gateGReviewState(ctx: GateContext, findings: MatrixFinding[]): void {
@@ -9,11 +9,13 @@
9
9
  import * as fs from 'fs';
10
10
  import * as path from 'path';
11
11
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
12
- import { DeliveryMap, MapCategory, MapDisposition, MapGroup, ReviewState } from './types';
12
+ import { DeliveryMap, MapCategory, MapDisposition, MapGroup, RequirementOverride, RequirementStatus, ReviewState } from './types';
13
13
 
14
14
  const CATEGORIES: MapCategory[] = ['normal', 'abnormal', 'security', 'nfr'];
15
15
  const REVIEW_STATES: ReviewState[] = ['proposed', 'approved'];
16
16
  const DISPOSITIONS = ['excluded', 'blocked', 'covered_elsewhere', 'accepted_risk'];
17
+ const REQUIREMENT_STATUSES: RequirementStatus[] =
18
+ ['covered', 'partially_covered', 'covered_elsewhere', 'planned', 'gap', 'not_applicable'];
17
19
 
18
20
  export interface MapLoadResult {
19
21
  map: DeliveryMap | null;
@@ -98,6 +100,21 @@ export function loadDeliveryMap(file: string): MapLoadResult {
98
100
  }
99
101
  }
100
102
 
103
+ const requirements: Record<string, RequirementOverride> = {};
104
+ if (doc.requirements !== undefined) {
105
+ if (!doc.requirements || typeof doc.requirements !== 'object' || Array.isArray(doc.requirements)) {
106
+ errors.push('`requirements` must be a mapping of requirement-id → { status, note }');
107
+ } else {
108
+ for (const [reqId, r] of Object.entries(doc.requirements as Record<string, unknown>)) {
109
+ const rr = (r && typeof r === 'object' ? r : {}) as Record<string, unknown>;
110
+ if (!REQUIREMENT_STATUSES.includes(String(rr.status) as RequirementStatus)) {
111
+ errors.push(`requirements.${reqId}: \`status\` must be one of ${REQUIREMENT_STATUSES.join(' | ')}`);
112
+ }
113
+ requirements[reqId] = { status: rr.status as RequirementStatus, note: rr.note ? String(rr.note) : undefined };
114
+ }
115
+ }
116
+ }
117
+
101
118
  const fingerprints: Record<string, string> = {};
102
119
  if (doc.fingerprints && typeof doc.fingerprints === 'object' && !Array.isArray(doc.fingerprints)) {
103
120
  for (const [k, v] of Object.entries(doc.fingerprints as Record<string, unknown>)) {
@@ -111,6 +128,7 @@ export function loadDeliveryMap(file: string): MapLoadResult {
111
128
  formNo: doc.form_no !== undefined ? String(doc.form_no) : undefined,
112
129
  groups,
113
130
  dispositions,
131
+ requirements,
114
132
  fingerprints,
115
133
  };
116
134
  return { map: errors.length > 0 ? null : map, errors };
@@ -135,6 +153,7 @@ export function writeDeliveryMap(file: string, map: DeliveryMap): void {
135
153
  variants: g.variants,
136
154
  })),
137
155
  ...(Object.keys(map.dispositions).length > 0 ? { dispositions: map.dispositions } : {}),
156
+ ...(Object.keys(map.requirements).length > 0 ? { requirements: map.requirements } : {}),
138
157
  ...(Object.keys(map.fingerprints).length > 0 ? { fingerprints: map.fingerprints } : {}),
139
158
  };
140
159
  fs.mkdirSync(path.dirname(file), { recursive: true });
@@ -1,15 +1,17 @@
1
1
  /**
2
2
  * Flat CSV rendering of the matrix — same model as the workbook, one row per
3
- * delivery item (`Level: item`) followed by its variant rows (`Level: variant`),
4
- * so downstream pipelines can re-derive both views from one file.
3
+ * delivery item (`Level: item`) followed by its variant rows (`Level: variant`;
4
+ * every item has them the variant row carries the source scenario id, data,
5
+ * and result), so downstream pipelines can re-derive both views from one file.
5
6
  */
6
7
 
7
- import { statusToTestResult, formatExecutedDate } from '../playwright-report-parser';
8
+ import { statusToTestResult } from '../playwright-report-parser';
9
+ import { itemModeLabel, itemResultLabel } from './render-xlsx';
8
10
  import { MatrixModel } from './types';
9
11
 
10
12
  const HEADERS = [
11
13
  'Level', 'ID', 'Target', 'Test Intent / Condition', 'Category', 'Priority',
12
- 'Precondition', 'Coverage / Test Data', 'Action / Trigger', 'Expected (Oracle)',
14
+ 'Precondition', 'Coverage / Test Data', 'Action / Trigger', 'Expected Result',
13
15
  'Mode', 'Trace', 'Result', 'Executed Date', 'Note',
14
16
  ];
15
17
 
@@ -18,6 +20,13 @@ function esc(value: string): string {
18
20
  return value;
19
21
  }
20
22
 
23
+ function isoDate(startTime: string | undefined): string {
24
+ if (!startTime) return '';
25
+ const d = new Date(startTime);
26
+ if (isNaN(d.getTime())) return '';
27
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
28
+ }
29
+
21
30
  export function renderMatrixCsv(model: MatrixModel): string {
22
31
  const BOM = '\ufeff'; // Excel compatibility (Vietnamese/Japanese content)
23
32
  const lines: string[] = [];
@@ -26,8 +35,8 @@ export function renderMatrixCsv(model: MatrixModel): string {
26
35
  lines.push(HEADERS.map(esc).join(','));
27
36
 
28
37
  for (const item of model.items) {
29
- const single = item.variants.length === 1;
30
- const only = single ? item.variants[0] : undefined;
38
+ const triggerTexts = new Set(item.variants.map((v) => v.trigger.join('\n')));
39
+ const commonTrigger = triggerTexts.size === 1 ? item.variants[0].trigger.join('\n') : '(differs per variant — see variant rows)';
31
40
  lines.push([
32
41
  'item',
33
42
  item.id,
@@ -36,35 +45,46 @@ export function renderMatrixCsv(model: MatrixModel): string {
36
45
  item.category,
37
46
  item.priority,
38
47
  item.precondition.join('\n'),
39
- single ? only!.data.join('\n') : `${item.variants.length} variants`,
40
- item.trigger.join('\n'),
48
+ `${item.variants.length} variant(s): ${item.variants.map((v) => v.condition).join(' · ')}`,
49
+ commonTrigger,
41
50
  item.oracle,
42
- item.mode === 'manual' ? 'Manual' : 'Auto',
51
+ itemModeLabel(item),
43
52
  item.traces.join(' '),
44
- item.result,
45
- single ? formatExecutedDate(only!.result?.startTime) : '',
53
+ itemResultLabel(item),
54
+ '',
46
55
  item.review === 'proposed' ? 'REVIEW REQUIRED' : '',
47
56
  ].map(esc).join(','));
48
57
 
49
- if (!single) {
50
- for (const v of item.variants) {
51
- lines.push([
52
- 'variant',
53
- v.ref,
54
- '',
55
- v.condition,
56
- '', '',
57
- '',
58
- v.data.join('\n'),
59
- v.trigger.join('\n'),
60
- v.oracle.join('\n'),
61
- v.mode === 'manual' ? 'Manual' : 'Auto',
62
- v.traces.join(' '),
63
- v.result ? statusToTestResult(v.result.status) : 'Pending',
64
- formatExecutedDate(v.result?.startTime),
65
- v.result?.error ? String(v.result.error).slice(0, 200) : '',
66
- ].map(esc).join(','));
67
- }
58
+ for (const v of item.variants) {
59
+ const expected = v.verification.length > 0
60
+ ? `${v.oracle.join('\n')}\nVerification method: ${v.verification.join(' ')}`
61
+ : v.oracle.join('\n');
62
+ lines.push([
63
+ 'variant',
64
+ v.ref,
65
+ '',
66
+ v.condition,
67
+ '', '',
68
+ '',
69
+ v.data.join('\n'),
70
+ v.trigger.join('\n'),
71
+ expected,
72
+ v.mode === 'manual' ? 'Manual' : 'Auto',
73
+ v.traces.join(' '),
74
+ v.result ? statusToTestResult(v.result.status) : 'Pending',
75
+ isoDate(v.result?.startTime),
76
+ v.result?.error ? String(v.result.error).slice(0, 200) : '',
77
+ ].map(esc).join(','));
78
+ }
79
+ }
80
+
81
+ // Requirement coverage appendix — keeps the CSV self-contained for pipelines.
82
+ if (model.requirements.length > 0) {
83
+ lines.push('');
84
+ lines.push('# Requirement coverage');
85
+ lines.push(['Requirement', 'Status', 'Delivery items', 'Variants', 'Note'].join(','));
86
+ for (const r of model.requirements) {
87
+ lines.push([r.id, r.status, r.items.join(' '), String(r.variantCount), r.note].map(esc).join(','));
68
88
  }
69
89
  }
70
90
  return BOM + lines.join('\n') + '\n';
@@ -2,14 +2,21 @@
2
2
  * Render the Test Case & Coverage Matrix workbook.
3
3
  *
4
4
  * Sheets:
5
- * 1. Testcases — one parent row per delivery item, variant sub-rows grouped with
6
- * Excel outline (expanded for execution; the level-1 outline button collapses
7
- * to the customer/review view same sheet, one model, two views).
8
- * 2. Coverage target × category grid (items + variants, `—` marks the gaps),
9
- * dispositions, and the generation manifest.
5
+ * 1. Testcases — one parent row per delivery item; EVERY item (including
6
+ * single-variant ones) carries variant sub-rows on Excel outline level 1 —
7
+ * the sub-row is where source traceability (VP-id), test data, and the
8
+ * manual result/evidence entry live (review B-05/B-06). Collapse outline
9
+ * level 1 for the customer/review view; expand to execute.
10
+ * 2. Coverage — requirement coverage (id ↔ items ↔ status), the target ×
11
+ * category grid (explicit `—` gaps), dispositions, and the manifest.
10
12
  *
11
- * Every sheet keeps the company ISO masthead (renderReportHeaderBand). A draft
12
- * matrix (unapproved groups / review findings) carries a red DRAFT banner.
13
+ * Manual-execution support: variant Result cells carry a dropdown
14
+ * (Passed/Failed/Blocked/Pending/N/A) and the parent Result is a live Excel
15
+ * formula derived from its children — a parent can never contradict its
16
+ * variants, even after QA edits results by hand (review B-06).
17
+ *
18
+ * Every sheet keeps the company ISO masthead. A draft matrix (unapproved
19
+ * groups / review findings) carries a red DRAFT banner.
13
20
  */
14
21
 
15
22
  import * as ExcelJS from 'exceljs';
@@ -20,13 +27,14 @@ import {
20
27
  BLACK,
21
28
  LAVENDER,
22
29
  } from '../xlsx-report-builder';
23
- import { statusToTestResult, formatExecutedDate } from '../playwright-report-parser';
30
+ import { statusToTestResult } from '../playwright-report-parser';
24
31
  import { CoverageVariant, DeliveryItem, ItemResult, MatrixModel, MapCategory } from './types';
25
32
 
26
33
  const CATEGORY_ORDER: MapCategory[] = ['normal', 'abnormal', 'security', 'nfr'];
27
34
  const SOFT_BLUE = { argb: 'FFDCE6F1' };
28
35
  const DRAFT_RED = { argb: 'FFC00000' };
29
36
  const GAP_FILL = { argb: 'FFFCE4EC' };
37
+ const RESULT_STATES = 'Passed,Failed,Blocked,Pending,N/A';
30
38
 
31
39
  function headerCell(c: ExcelJS.Cell, label: string): void {
32
40
  c.value = label;
@@ -37,14 +45,25 @@ function headerCell(c: ExcelJS.Cell, label: string): void {
37
45
  }
38
46
 
39
47
  function dataCell(c: ExcelJS.Cell, value: ExcelJS.CellValue, opts?: { bold?: boolean; fill?: { argb: string }; center?: boolean; color?: { argb: string } }): void {
40
- c.value = value;
48
+ // An empty value stays a genuinely blank cell — never an empty shared string,
49
+ // which some renderers display as its string-table index (review M-03).
50
+ if (value !== '' && value !== undefined && value !== null) c.value = value;
41
51
  c.font = { bold: opts?.bold ?? false, size: 10, name: ARIAL_FONT, ...(opts?.color ? { color: opts.color } : {}) };
42
52
  c.alignment = { horizontal: opts?.center ? 'center' : 'left', vertical: 'top', wrapText: true };
43
53
  c.border = allBordersBlack;
44
54
  if (opts?.fill) c.fill = { type: 'pattern', pattern: 'solid', fgColor: opts.fill };
45
55
  }
46
56
 
47
- function itemResultLabel(item: DeliveryItem): string {
57
+ /** Mode is a coverage dimension: mixed items show both counts. */
58
+ export function itemModeLabel(item: DeliveryItem): string {
59
+ if (item.mode === 'mixed') {
60
+ const auto = item.variants.filter((v) => v.mode === 'auto').length;
61
+ return `Auto ${auto} · Manual ${item.variants.length - auto}`;
62
+ }
63
+ return item.mode === 'manual' ? 'Manual' : 'Auto';
64
+ }
65
+
66
+ export function itemResultLabel(item: DeliveryItem): string {
48
67
  const { passed, failed, blocked, notRun } = item.resultCounts;
49
68
  const total = item.variants.length;
50
69
  const map: Record<ItemResult, string> = {
@@ -62,12 +81,27 @@ function variantResultLabel(v: CoverageVariant): string {
62
81
  return statusToTestResult(v.result.status);
63
82
  }
64
83
 
84
+ /** ISO date (2026-08-04) — unambiguous across locales (review §9). */
85
+ function isoDate(startTime: string | undefined): string {
86
+ if (!startTime) return '';
87
+ const d = new Date(startTime);
88
+ if (isNaN(d.getTime())) return '';
89
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
90
+ }
91
+
65
92
  function numbered(lines: string[]): string {
66
93
  if (lines.length === 0) return '';
67
94
  if (lines.length === 1) return lines[0];
68
95
  return lines.map((l, i) => `${i + 1}. ${l}`).join('\n');
69
96
  }
70
97
 
98
+ /** Expected cell = observable outcomes; the HOW moves under a separate heading. */
99
+ function expectedWithVerification(oracle: string[], verification: string[]): string {
100
+ const expected = numbered(oracle);
101
+ if (verification.length === 0) return expected;
102
+ return `${expected}\nVerification method: ${verification.join(' ')}`;
103
+ }
104
+
71
105
  /** Red DRAFT banner on row 5 when the matrix is not approved for official delivery. */
72
106
  function draftBanner(ws: ExcelJS.Worksheet, model: MatrixModel): void {
73
107
  if (model.manifest.approvalState !== 'draft') return;
@@ -85,9 +119,10 @@ function draftBanner(ws: ExcelJS.Worksheet, model: MatrixModel): void {
85
119
 
86
120
  const MATRIX_HEADERS = [
87
121
  'ID', 'Target', 'Test Intent / Condition', 'Category', 'Priority', 'Precondition',
88
- 'Coverage / Test Data', 'Action / Trigger', 'Expected (Oracle)', 'Mode', 'Trace',
122
+ 'Coverage / Test Data', 'Action / Trigger', 'Expected Result', 'Mode', 'Trace',
89
123
  'Result', 'Executed Date', 'Executor', 'Note\n(Evidence, DefectID)',
90
124
  ];
125
+ const RESULT_COL = 13; // column M
91
126
 
92
127
  function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?: string): void {
93
128
  const ws = wb.addWorksheet('Testcases');
@@ -95,7 +130,7 @@ function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?
95
130
 
96
131
  ws.columns = [
97
132
  { width: 3 }, // A margin
98
- { width: 20 }, // B ID
133
+ { width: 22 }, // B ID
99
134
  { width: 18 }, // C Target
100
135
  { width: 42 }, // D Intent / Condition
101
136
  { width: 11 }, // E Category
@@ -106,8 +141,8 @@ function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?
106
141
  { width: 42 }, // J Expected
107
142
  { width: 11 }, // K Mode
108
143
  { width: 13 }, // L Trace
109
- { width: 17 }, // M Result
110
- { width: 13 }, // N Executed
144
+ { width: 22 }, // M Result
145
+ { width: 12 }, // N Executed
111
146
  { width: 13 }, // O Executor
112
147
  { width: 24 }, // P Note
113
148
  ];
@@ -137,13 +172,55 @@ function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?
137
172
 
138
173
  let rowIdx = HEADER_ROW + 1;
139
174
  for (const item of model.items) {
140
- // Parent row — the delivery item (the review unit).
141
- const pr = ws.getRow(rowIdx++);
142
- const single = item.variants.length === 1;
143
- const only = single ? item.variants[0] : undefined;
144
- const coverage = single
145
- ? numbered(only!.data)
146
- : `${item.variants.length} variants: ${item.variants.map((v) => v.condition).join(' · ')}`;
175
+ const parentRowIdx = rowIdx++;
176
+
177
+ // --- Variant sub-rows FIRST (to know their cell range for the parent formula).
178
+ // Every item gets sub-rows the sub-row is where the source scenario id, the
179
+ // resolved data, and the manual result/evidence entry live (review B-05).
180
+ const firstChild = rowIdx;
181
+ for (const v of item.variants) {
182
+ const vr = ws.getRow(rowIdx++);
183
+ vr.outlineLevel = 1;
184
+ const vValues: ExcelJS.CellValue[] = [
185
+ v.ref,
186
+ '',
187
+ v.condition,
188
+ '', '', '',
189
+ numbered(v.data),
190
+ numbered(v.trigger),
191
+ expectedWithVerification(v.oracle, v.verification),
192
+ v.mode === 'manual' ? `Manual${v.manualReason ? ` (${v.manualReason})` : ''}` : 'Auto',
193
+ v.traces.join(', '),
194
+ variantResultLabel(v),
195
+ isoDate(v.result?.startTime),
196
+ '',
197
+ v.result?.error ? String(v.result.error).slice(0, 200) : '',
198
+ ];
199
+ vValues.forEach((val, i) => dataCell(vr.getCell(2 + i), val, { center: i === 9 }));
200
+ // Manual-execution entry: constrain the Result cell to the known states.
201
+ vr.getCell(2 + 11).dataValidation = {
202
+ type: 'list', allowBlank: true, formulae: [`"${RESULT_STATES}"`],
203
+ };
204
+ }
205
+ const lastChild = rowIdx - 1;
206
+
207
+ // --- Parent row: only values that are genuinely COMMON to the whole item
208
+ // (review M-04); everything variant-specific stays on the sub-rows.
209
+ const pr = ws.getRow(parentRowIdx);
210
+ const triggerTexts = new Set(item.variants.map((v) => v.trigger.join('\n')));
211
+ const commonTrigger = triggerTexts.size === 1 ? item.variants[0].trigger : null;
212
+ const coverage = `${item.variants.length} variant(s): ${item.variants.map((v) => v.condition).join(' · ')}`;
213
+
214
+ // Live roll-up: derived from the child Result cells so hand-entered results
215
+ // recompute the parent — precedence failed → blocked → pending → partial → passed.
216
+ const rng = `M${firstChild}:M${lastChild}`;
217
+ const n = item.variants.length;
218
+ const rollUpFormula =
219
+ `IF(COUNTIF(${rng},"Failed")>0,COUNTIF(${rng},"Passed")&"/${n} Passed · "&COUNTIF(${rng},"Failed")&" Failed",` +
220
+ `IF(COUNTIF(${rng},"Blocked")>0,"Blocked",` +
221
+ `IF(COUNTIF(${rng},"Passed")=${n},"${n}/${n} Passed",` +
222
+ `IF(COUNTIF(${rng},"Passed")=0,"Pending",COUNTIF(${rng},"Passed")&"/${n} Passed · pending"))))`;
223
+
147
224
  const values: ExcelJS.CellValue[] = [
148
225
  item.id,
149
226
  item.target,
@@ -152,12 +229,12 @@ function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?
152
229
  item.priority,
153
230
  numbered(item.precondition),
154
231
  coverage,
155
- numbered(item.trigger),
232
+ commonTrigger ? numbered(commonTrigger) : '(differs per variant — see sub-rows)',
156
233
  item.oracle,
157
- item.mode === 'manual' ? `Manual${only?.manualReason ? ` (${only.manualReason})` : ''}` : 'Auto',
234
+ itemModeLabel(item),
158
235
  item.traces.join(', '),
159
- itemResultLabel(item),
160
- single ? formatExecutedDate(only!.result?.startTime) : '',
236
+ { formula: rollUpFormula, result: itemResultLabel(item) } as ExcelJS.CellValue,
237
+ '',
161
238
  '',
162
239
  item.review === 'proposed' ? 'REVIEW REQUIRED' : '',
163
240
  ];
@@ -165,53 +242,52 @@ function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?
165
242
  bold: true,
166
243
  fill: SOFT_BLUE,
167
244
  center: i === 3 || i === 4 || i === 9,
168
- ...(String(values[14]).startsWith('REVIEW') && i === 14 ? { color: DRAFT_RED } : {}),
245
+ ...(i === 14 && item.review === 'proposed' ? { color: DRAFT_RED } : {}),
169
246
  }));
170
-
171
- // Variant sub-rows — the execution units (outline level 1; collapse for review).
172
- if (!single) {
173
- for (const v of item.variants) {
174
- const vr = ws.getRow(rowIdx++);
175
- vr.outlineLevel = 1;
176
- const vValues: ExcelJS.CellValue[] = [
177
- v.ref,
178
- '',
179
- v.condition,
180
- '', '', '',
181
- numbered(v.data),
182
- numbered(v.trigger),
183
- numbered(v.oracle),
184
- v.mode === 'manual' ? `Manual${v.manualReason ? ` (${v.manualReason})` : ''}` : 'Auto',
185
- v.traces.join(', '),
186
- variantResultLabel(v),
187
- formatExecutedDate(v.result?.startTime),
188
- '',
189
- v.result?.error ? String(v.result.error).slice(0, 200) : '',
190
- ];
191
- vValues.forEach((val, i) => dataCell(vr.getCell(2 + i), val, { center: i === 9 }));
192
- }
193
- }
194
247
  }
195
248
 
196
249
  ws.autoFilter = { from: { row: HEADER_ROW, column: 2 }, to: { row: rowIdx - 1, column: 16 } };
197
- ws.views = [{ state: 'frozen', ySplit: HEADER_ROW }];
250
+ // Freeze the header band AND the ID + Target columns (review §9).
251
+ ws.views = [{ state: 'frozen', xSplit: 3, ySplit: HEADER_ROW }];
198
252
  }
199
253
 
200
254
  // ---------------------------------------------------------------------------
201
- // Sheet 2 — Coverage (overview grid + dispositions + manifest)
255
+ // Sheet 2 — Coverage (requirements + overview grid + dispositions + manifest)
202
256
  // ---------------------------------------------------------------------------
203
257
 
204
258
  function addCoverageSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?: string): void {
205
259
  const ws = wb.addWorksheet('Coverage');
206
260
  ws.columns = [
207
- { width: 3 }, { width: 30 }, { width: 16 }, { width: 16 }, { width: 16 }, { width: 16 },
208
- { width: 16 }, { width: 40 },
261
+ { width: 3 }, { width: 22 }, { width: 18 }, { width: 16 }, { width: 16 }, { width: 16 },
262
+ { width: 16 }, { width: 44 },
209
263
  ];
210
264
  renderReportHeaderBand(wb, ws, `${model.unit.toUpperCase()} COVERAGE OVERVIEW`, sungenVersion, model.formNo);
211
265
  draftBanner(ws, model);
212
266
 
213
- // --- Target × category grid ---
214
267
  let rowIdx = 7;
268
+
269
+ // --- Requirement coverage (review §6): every requirement id gets ONE explicit status.
270
+ if (model.requirements.length > 0) {
271
+ dataCell(ws.getRow(rowIdx).getCell(2), 'Requirement coverage — every id has an explicit status', { bold: true });
272
+ rowIdx += 1;
273
+ const rh = ws.getRow(rowIdx++);
274
+ ['Requirement', 'Status', 'Delivery items', 'Variants', 'Note'].forEach((l, i) => headerCell(rh.getCell(2 + i), l));
275
+ for (const r of model.requirements) {
276
+ const row = ws.getRow(rowIdx++);
277
+ dataCell(row.getCell(2), r.id, { bold: true });
278
+ dataCell(row.getCell(3), r.status, {
279
+ center: true,
280
+ ...(r.status === 'gap' ? { fill: GAP_FILL, bold: true } : {}),
281
+ });
282
+ ws.mergeCells(rowIdx - 1, 4, rowIdx - 1, 6);
283
+ dataCell(row.getCell(4), r.items.join(', '));
284
+ dataCell(row.getCell(7), r.variantCount > 0 ? String(r.variantCount) : '', { center: true });
285
+ dataCell(row.getCell(8), r.note);
286
+ }
287
+ rowIdx += 1;
288
+ }
289
+
290
+ // --- Target × category grid ---
215
291
  dataCell(ws.getRow(rowIdx).getCell(2), 'Coverage matrix — delivery items (coverage variants) per target × category', { bold: true });
216
292
  rowIdx += 1;
217
293
  const gridHeader = ws.getRow(rowIdx++);
@@ -38,6 +38,14 @@ export interface MapDisposition {
38
38
  reason?: string;
39
39
  }
40
40
 
41
+ export type RequirementStatus =
42
+ | 'covered' | 'partially_covered' | 'covered_elsewhere' | 'planned' | 'gap' | 'not_applicable';
43
+
44
+ export interface RequirementOverride {
45
+ status: RequirementStatus;
46
+ note?: string;
47
+ }
48
+
41
49
  export interface DeliveryMap {
42
50
  version: number;
43
51
  unit: string;
@@ -46,6 +54,9 @@ export interface DeliveryMap {
46
54
  groups: MapGroup[];
47
55
  /** VP-id → intentionally-not-grouped disposition. */
48
56
  dispositions: Record<string, MapDisposition>;
57
+ /** Requirement-id → reviewed status override (partially_covered / not_applicable / …).
58
+ * Without an override a requirement is `covered` when a variant traces to it, else `gap`. */
59
+ requirements: Record<string, RequirementOverride>;
49
60
  /** VP-id → scenario fingerprint stamped at approval time (drift detector input). */
50
61
  fingerprints: Record<string, string>;
51
62
  }
@@ -90,6 +101,9 @@ export interface CoverageVariant {
90
101
  oracleShape: string[];
91
102
  /** Oracle for display. */
92
103
  oracle: string[];
104
+ /** HOW to check (manual `Oracle:` lines — tools, panes, queries). Rendered as
105
+ * "Verification method" under the expected result, never inside it. */
106
+ verification: string[];
93
107
  /** Precondition profile: auth role + scenario-level Given shapes — signature input. */
94
108
  preconditionProfile: string;
95
109
  /** Human preconditions (auth + own Given steps). */
@@ -112,8 +126,11 @@ export interface DeliveryItem {
112
126
  oracle: string;
113
127
  category: MapCategory;
114
128
  review: ReviewState;
129
+ /** Highest priority among the variants (per-variant priorities stay on the sub-rows). */
115
130
  priority: string;
116
- mode: 'auto' | 'manual';
131
+ /** Execution mode is a coverage dimension, not a split — 'mixed' when auto and
132
+ * manual variants share one intent (e.g. account states needing seeded data). */
133
+ mode: 'auto' | 'manual' | 'mixed';
117
134
  layers: MatrixLayer[];
118
135
  /** Union of variant traces (exact per-variant traces stay on the variants). */
119
136
  traces: string[];
@@ -133,11 +150,21 @@ export interface MatrixDisposition {
133
150
  reason: string;
134
151
  }
135
152
 
153
+ /** One row of the requirement-coverage table (Coverage sheet). */
154
+ export interface RequirementCoverage {
155
+ id: string; // FR-003 / TR-001 / NFR-…
156
+ status: RequirementStatus;
157
+ /** Delivery items whose variants trace to this requirement. */
158
+ items: string[];
159
+ variantCount: number;
160
+ note: string;
161
+ }
162
+
136
163
  // ---------------------------------------------------------------------------
137
164
  // Gate findings
138
165
  // ---------------------------------------------------------------------------
139
166
 
140
- export type GateId = 'A' | 'B' | 'C' | 'D' | 'E' | 'G';
167
+ export type GateId = 'A' | 'B' | 'C' | 'D' | 'E' | 'G' | 'R' | 'W';
141
168
  export type FindingSeverity = 'error' | 'review' | 'warning';
142
169
 
143
170
  export interface MatrixFinding {
@@ -168,9 +195,11 @@ export interface MatrixModel {
168
195
  formNo: string;
169
196
  items: DeliveryItem[];
170
197
  dispositions: MatrixDisposition[];
198
+ /** Requirement coverage — empty when the unit has no requirements/spec.md ids. */
199
+ requirements: RequirementCoverage[];
171
200
  findings: MatrixFinding[];
172
201
  manifest: MatrixManifest;
173
202
  }
174
203
 
175
- /** Complexity warning threshold (rules draft Gate I) — echoed in the manifest. */
176
- export const MAX_VARIANTS_PER_ITEM = 12;
204
+ /** Complexity warning threshold (rules draft Gate I; raised for compact grouping) — echoed in the manifest. */
205
+ export const MAX_VARIANTS_PER_ITEM = 20;