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

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 (140) hide show
  1. package/dist/cli/commands/delivery.d.ts.map +1 -1
  2. package/dist/cli/commands/delivery.js +67 -20
  3. package/dist/cli/commands/delivery.js.map +1 -1
  4. package/dist/exporters/feature-parser.d.ts +16 -1
  5. package/dist/exporters/feature-parser.d.ts.map +1 -1
  6. package/dist/exporters/feature-parser.js +21 -2
  7. package/dist/exporters/feature-parser.js.map +1 -1
  8. package/dist/exporters/matrix/build.d.ts +27 -2
  9. package/dist/exporters/matrix/build.d.ts.map +1 -1
  10. package/dist/exporters/matrix/build.js +277 -47
  11. package/dist/exporters/matrix/build.js.map +1 -1
  12. package/dist/exporters/matrix/export.d.ts +7 -4
  13. package/dist/exporters/matrix/export.d.ts.map +1 -1
  14. package/dist/exporters/matrix/export.js +22 -6
  15. package/dist/exporters/matrix/export.js.map +1 -1
  16. package/dist/exporters/matrix/gates.d.ts.map +1 -1
  17. package/dist/exporters/matrix/gates.js +112 -3
  18. package/dist/exporters/matrix/gates.js.map +1 -1
  19. package/dist/exporters/matrix/map-loader.d.ts.map +1 -1
  20. package/dist/exporters/matrix/map-loader.js +20 -0
  21. package/dist/exporters/matrix/map-loader.js.map +1 -1
  22. package/dist/exporters/matrix/render-csv.d.ts +3 -2
  23. package/dist/exporters/matrix/render-csv.d.ts.map +1 -1
  24. package/dist/exporters/matrix/render-csv.js +53 -30
  25. package/dist/exporters/matrix/render-csv.js.map +1 -1
  26. package/dist/exporters/matrix/render-xlsx.d.ts +32 -8
  27. package/dist/exporters/matrix/render-xlsx.d.ts.map +1 -1
  28. package/dist/exporters/matrix/render-xlsx.js +215 -83
  29. package/dist/exporters/matrix/render-xlsx.js.map +1 -1
  30. package/dist/exporters/matrix/types.d.ts +53 -7
  31. package/dist/exporters/matrix/types.d.ts.map +1 -1
  32. package/dist/exporters/matrix/types.js +2 -2
  33. package/dist/exporters/matrix/types.js.map +1 -1
  34. package/dist/exporters/matrix/wording.d.ts +61 -0
  35. package/dist/exporters/matrix/wording.d.ts.map +1 -0
  36. package/dist/exporters/matrix/wording.js +221 -0
  37. package/dist/exporters/matrix/wording.js.map +1 -0
  38. package/dist/exporters/scenario-merger.d.ts.map +1 -1
  39. package/dist/exporters/scenario-merger.js +2 -1
  40. package/dist/exporters/scenario-merger.js.map +1 -1
  41. package/dist/exporters/spec-parser.d.ts.map +1 -1
  42. package/dist/exporters/spec-parser.js +2 -1
  43. package/dist/exporters/spec-parser.js.map +1 -1
  44. package/dist/harness/audit.d.ts.map +1 -1
  45. package/dist/harness/audit.js +11 -2
  46. package/dist/harness/audit.js.map +1 -1
  47. package/dist/harness/blindspot.d.ts.map +1 -1
  48. package/dist/harness/blindspot.js +2 -1
  49. package/dist/harness/blindspot.js.map +1 -1
  50. package/dist/harness/capability-plan.d.ts.map +1 -1
  51. package/dist/harness/capability-plan.js +3 -2
  52. package/dist/harness/capability-plan.js.map +1 -1
  53. package/dist/harness/feedback.d.ts.map +1 -1
  54. package/dist/harness/feedback.js +3 -2
  55. package/dist/harness/feedback.js.map +1 -1
  56. package/dist/harness/flow-check.d.ts.map +1 -1
  57. package/dist/harness/flow-check.js +2 -1
  58. package/dist/harness/flow-check.js.map +1 -1
  59. package/dist/harness/flow-plan.d.ts.map +1 -1
  60. package/dist/harness/flow-plan.js +3 -2
  61. package/dist/harness/flow-plan.js.map +1 -1
  62. package/dist/harness/intent.d.ts.map +1 -1
  63. package/dist/harness/intent.js +2 -1
  64. package/dist/harness/intent.js.map +1 -1
  65. package/dist/harness/journey.d.ts.map +1 -1
  66. package/dist/harness/journey.js +3 -2
  67. package/dist/harness/journey.js.map +1 -1
  68. package/dist/harness/ledger.d.ts.map +1 -1
  69. package/dist/harness/ledger.js +3 -2
  70. package/dist/harness/ledger.js.map +1 -1
  71. package/dist/harness/manifest.d.ts.map +1 -1
  72. package/dist/harness/manifest.js +4 -3
  73. package/dist/harness/manifest.js.map +1 -1
  74. package/dist/harness/parse.d.ts.map +1 -1
  75. package/dist/harness/parse.js +16 -3
  76. package/dist/harness/parse.js.map +1 -1
  77. package/dist/harness/quality-gates.d.ts.map +1 -1
  78. package/dist/harness/quality-gates.js +2 -1
  79. package/dist/harness/quality-gates.js.map +1 -1
  80. package/dist/harness/read-text.d.ts +22 -0
  81. package/dist/harness/read-text.d.ts.map +1 -0
  82. package/dist/harness/read-text.js +64 -0
  83. package/dist/harness/read-text.js.map +1 -0
  84. package/dist/harness/script-check.d.ts.map +1 -1
  85. package/dist/harness/script-check.js +3 -2
  86. package/dist/harness/script-check.js.map +1 -1
  87. package/dist/harness/sensors.d.ts.map +1 -1
  88. package/dist/harness/sensors.js +2 -10
  89. package/dist/harness/sensors.js.map +1 -1
  90. package/dist/harness/spec-coverage.d.ts +5 -0
  91. package/dist/harness/spec-coverage.d.ts.map +1 -1
  92. package/dist/harness/spec-coverage.js +17 -7
  93. package/dist/harness/spec-coverage.js.map +1 -1
  94. package/dist/harness/trace.d.ts.map +1 -1
  95. package/dist/harness/trace.js +4 -3
  96. package/dist/harness/trace.js.map +1 -1
  97. package/dist/harness/viewpoint-ledger.d.ts.map +1 -1
  98. package/dist/harness/viewpoint-ledger.js +2 -1
  99. package/dist/harness/viewpoint-ledger.js.map +1 -1
  100. package/dist/orchestrator/templates/ai-src/commands/create-test.md +9 -0
  101. package/dist/orchestrator/templates/ai-src/commands/delivery.md +103 -18
  102. package/dist/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +77 -11
  103. package/dist/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +1 -0
  104. package/dist/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +22 -0
  105. package/package.json +4 -4
  106. package/src/cli/commands/delivery.ts +68 -22
  107. package/src/exporters/feature-parser.ts +21 -2
  108. package/src/exporters/matrix/build.ts +281 -43
  109. package/src/exporters/matrix/export.ts +31 -6
  110. package/src/exporters/matrix/gates.ts +119 -3
  111. package/src/exporters/matrix/map-loader.ts +22 -1
  112. package/src/exporters/matrix/render-csv.ts +53 -30
  113. package/src/exporters/matrix/render-xlsx.ts +216 -85
  114. package/src/exporters/matrix/types.ts +58 -8
  115. package/src/exporters/matrix/wording.ts +221 -0
  116. package/src/exporters/scenario-merger.ts +2 -1
  117. package/src/exporters/spec-parser.ts +2 -1
  118. package/src/harness/audit.ts +11 -2
  119. package/src/harness/blindspot.ts +2 -1
  120. package/src/harness/capability-plan.ts +3 -2
  121. package/src/harness/feedback.ts +3 -2
  122. package/src/harness/flow-check.ts +2 -1
  123. package/src/harness/flow-plan.ts +3 -2
  124. package/src/harness/intent.ts +2 -1
  125. package/src/harness/journey.ts +3 -2
  126. package/src/harness/ledger.ts +3 -2
  127. package/src/harness/manifest.ts +4 -3
  128. package/src/harness/parse.ts +17 -3
  129. package/src/harness/quality-gates.ts +2 -1
  130. package/src/harness/read-text.ts +28 -0
  131. package/src/harness/script-check.ts +3 -2
  132. package/src/harness/sensors.ts +2 -1
  133. package/src/harness/spec-coverage.ts +22 -7
  134. package/src/harness/trace.ts +4 -3
  135. package/src/harness/viewpoint-ledger.ts +2 -1
  136. package/src/orchestrator/templates/ai-src/commands/create-test.md +9 -0
  137. package/src/orchestrator/templates/ai-src/commands/delivery.md +103 -18
  138. package/src/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +77 -11
  139. package/src/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +1 -0
  140. package/src/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +22 -0
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { splitVpAndName, extractTestcaseType } from '../feature-parser';
15
+ import { mapContentFingerprint } from './fingerprint';
15
16
  import { CoverageVariant, MatrixFinding, MAX_VARIANTS_PER_ITEM } from './types';
16
17
  import { expandMapRef, BuildInputs } from './build';
17
18
 
@@ -29,9 +30,44 @@ export function runGates(ctx: GateContext): MatrixFinding[] {
29
30
  gateDExecutability(ctx, findings);
30
31
  gateEDrift(ctx, findings);
31
32
  gateGReviewState(ctx, findings);
33
+ gateWWording(ctx, findings);
34
+ gateKCategory(ctx, findings);
32
35
  return findings;
33
36
  }
34
37
 
38
+ // --- Gate K — category vs the source viewpoint class -------------------------
39
+
40
+ /**
41
+ * Viewpoint classes whose delivery category is not a judgement call — the source
42
+ * decides it. Matched on the id's category segment, so a project scheme without
43
+ * the `VP-` prefix (`SEC-123`, `PER-345`) classifies the same as `VP-SEC-001`.
44
+ */
45
+ const VP_CATEGORY_RULE: Array<[RegExp, string, string]> = [
46
+ [/^(SEC|SECURITY)$/i, 'security', 'SEC'],
47
+ [/^(NFR|PERF|PER|PERFORMANCE|A11Y|ACCESSIBILITY)$/i, 'nfr', 'NFR/PERF/A11Y'],
48
+ ];
49
+
50
+ /**
51
+ * A `VP-SEC-*` scenario filed under `normal`/`abnormal` empties the Coverage
52
+ * sheet's `security` column — the grid then reports a gap the unit does not
53
+ * have while hiding the security work it does have. Same for `VP-NFR-*`.
54
+ * (normal vs abnormal stays a judgement call and is never checked.)
55
+ */
56
+ function gateKCategory(ctx: GateContext, findings: MatrixFinding[]): void {
57
+ for (const g of ctx.inputs.map.groups) {
58
+ const vs = g.variants.flatMap((ref) => expandMapRef(ref, ctx.variantsByVp));
59
+ for (const [re, expected, label] of VP_CATEGORY_RULE) {
60
+ const hits = vs.filter((v) => re.test(v.vpCategory));
61
+ if (hits.length > 0 && g.category !== expected) {
62
+ findings.push({
63
+ gate: 'K', severity: 'warning', ref: g.id,
64
+ message: `group ${g.id} is \`category: ${g.category}\` but ${hits.length}/${vs.length} of its variants are ${label} scenarios (${hits.slice(0, 3).map((v) => v.vpId).join(', ')}) — use \`category: ${expected}\` or the Coverage grid's ${expected} column stays empty`,
65
+ });
66
+ }
67
+ }
68
+ }
69
+ }
70
+
35
71
  // --- Gate A — source quality ------------------------------------------------
36
72
 
37
73
  function gateASource(ctx: GateContext, findings: MatrixFinding[]): void {
@@ -155,10 +191,11 @@ function gateCAggregation(ctx: GateContext, findings: MatrixFinding[]): void {
155
191
  const heuristicsConfirmed = g.review === 'approved' && !stale;
156
192
 
157
193
  // Hard signature parts — recomputed, never trusted from the map. ERROR on mismatch.
194
+ // Execution mode and priority are deliberately NOT here: they are coverage
195
+ // dimensions (account states may need seeded manual variants next to auto ones;
196
+ // an item takes the highest variant priority) — shown per variant, never split on.
158
197
  const hard: Array<[string, (v: CoverageVariant) => string]> = [
159
- ['execution mode', (v) => v.mode],
160
198
  ['test layer', (v) => [...v.layers].sort().join('+')],
161
- ['priority', (v) => v.priority],
162
199
  ];
163
200
  for (const [label, keyOf] of hard) {
164
201
  const distinct = new Set(vs.map(keyOf));
@@ -231,9 +268,14 @@ function gateDExecutability(ctx: GateContext, findings: MatrixFinding[]): void {
231
268
  for (const [vpId, vs] of variantsByVp) {
232
269
  const scenario = ctx.inputs.merged.find((m) => splitVpAndName(m.feature.name).vpId === vpId);
233
270
  if (!scenario) continue;
271
+ // Values produced during the run (`remember … as {{v}}`, capability bindings)
272
+ // are provided by the test itself — demanding a test-data entry for them
273
+ // blocked 14 scenarios of a real suite that compiles and runs fine.
274
+ const runtime = new Set(vs.flatMap((v) => v.runtimeVars));
234
275
  for (const varName of scenario.feature.referencedVars) {
235
276
  const inRows = vs.some((v) => v.caseLabel !== undefined); // dataset rows carry their own columns
236
- if (!inRows && (!testData || !(varName in testData))) {
277
+ const atRuntime = runtime.has(varName) || runtime.has(varName.split(/[.[]/)[0]);
278
+ if (!inRows && !atRuntime && (!testData || !(varName in testData))) {
237
279
  findings.push({
238
280
  gate: 'D', severity: 'error', ref: vpId,
239
281
  message: `${vpId}: {{${varName}}} does not resolve in test-data — the variant is not reproducible`,
@@ -241,6 +283,21 @@ function gateDExecutability(ctx: GateContext, findings: MatrixFinding[]): void {
241
283
  }
242
284
  }
243
285
  }
286
+
287
+ // No template token may survive into a RENDERED cell (review B-04) — this also
288
+ // catches cross-referencing test-data values the one-level resolver couldn't close.
289
+ for (const [, vs] of variantsByVp) {
290
+ for (const v of vs) {
291
+ const rendered = [...v.precondition, ...v.trigger, ...v.oracle, ...v.verification, ...v.data];
292
+ const token = rendered.map((t) => t.match(/\{\{[^}]*\}\}/)).find(Boolean);
293
+ if (token) {
294
+ findings.push({
295
+ gate: 'D', severity: 'error', ref: v.ref,
296
+ message: `${v.ref}: unresolved template token ${token[0]} remains in the rendered output — the export is not deterministic for a tester`,
297
+ });
298
+ }
299
+ }
300
+ }
244
301
  }
245
302
 
246
303
  // --- Gate E — drift (stale approval) ------------------------------------------
@@ -249,6 +306,25 @@ function gateEDrift(ctx: GateContext, findings: MatrixFinding[]): void {
249
306
  const { map } = ctx.inputs;
250
307
  const { variantsByVp } = ctx;
251
308
 
309
+ // The map's own semantics (targets/intents/oracles/grouping) are part of what
310
+ // was approved — an edit after approval must be re-reviewed, not silently
311
+ // published (review GAP-09).
312
+ const stampedMap = map.fingerprints.__map__;
313
+ const liveMap = mapContentFingerprint(map.groups, map.dispositions);
314
+ if (map.groups.some((g) => g.review === 'approved')) {
315
+ if (!stampedMap) {
316
+ findings.push({
317
+ gate: 'E', severity: 'review',
318
+ message: 'the delivery map has no stamped content fingerprint — run `sungen delivery --approve` to record the reviewed wording',
319
+ });
320
+ } else if (stampedMap !== liveMap) {
321
+ findings.push({
322
+ gate: 'E', severity: 'review',
323
+ message: 'the delivery map wording/grouping changed since approval — re-review, then `sungen delivery --approve`',
324
+ });
325
+ }
326
+ }
327
+
252
328
  for (const g of map.groups) {
253
329
  if (g.review !== 'approved') continue; // proposed groups are already under review
254
330
  for (const ref of g.variants) {
@@ -271,6 +347,46 @@ function gateEDrift(ctx: GateContext, findings: MatrixFinding[]): void {
271
347
  }
272
348
  }
273
349
 
350
+ // --- Gate W — controlled-language lint on the map's semantic fields --------------
351
+
352
+ /** Patterns that make customer-facing wording read machine-generated (review §10). */
353
+ const WORDING_SMELLS: Array<[RegExp, string]> = [
354
+ [/\{\{[^}]*\}\}/, 'an unresolved {{token}}'],
355
+ [/\b(?:Setup|Observable|Oracle):/, 'a generator label (Setup:/Observable:/Oracle:)'],
356
+ [/\bUser (?:fill|click|press|see|wait)\b/i, 'DSL phrasing ("User fill/click/see…") — write plain product language'],
357
+ [/\[[^\]]+\]/, 'a [selector-style] reference — use the visible UI label'],
358
+ ];
359
+
360
+ function gateWWording(ctx: GateContext, findings: MatrixFinding[]): void {
361
+ for (const g of ctx.inputs.map.groups) {
362
+ const variantCount = g.variants.flatMap((ref) => expandMapRef(ref, ctx.variantsByVp)).length;
363
+ // Without a digest the parent row can only list variant refs — a reviewer
364
+ // then has to expand the item to learn which dimensions it covers.
365
+ if (variantCount > 3 && !g.dimensions) {
366
+ findings.push({
367
+ gate: 'W', severity: 'warning', ref: g.id,
368
+ 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`,
369
+ });
370
+ }
371
+ if (g.dimensions && g.dimensions.length > 120) {
372
+ findings.push({
373
+ gate: 'W', severity: 'warning', ref: g.id,
374
+ message: `group ${g.id} \`dimensions:\` is ${g.dimensions.length} chars — keep the digest short (≤120)`,
375
+ });
376
+ }
377
+ for (const [field, text] of ([['intent', g.intent], ['oracle', g.oracle], ...(g.dimensions ? [['dimensions', g.dimensions] as const] : [])] as const)) {
378
+ for (const [re, what] of WORDING_SMELLS) {
379
+ if (re.test(text)) {
380
+ findings.push({
381
+ gate: 'W', severity: 'warning', ref: g.id,
382
+ message: `group ${g.id} ${field} contains ${what}`,
383
+ });
384
+ }
385
+ }
386
+ }
387
+ }
388
+ }
389
+
274
390
  // --- Gate G — review state -----------------------------------------------------
275
391
 
276
392
  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;
@@ -76,6 +78,7 @@ export function loadDeliveryMap(file: string): MapLoadResult {
76
78
  target: String(grp.target ?? ''),
77
79
  intent: String(grp.intent ?? ''),
78
80
  oracle: String(grp.oracle ?? ''),
81
+ ...(grp.dimensions !== undefined ? { dimensions: String(grp.dimensions) } : {}),
79
82
  category: grp.category as MapCategory,
80
83
  review,
81
84
  variants: Array.isArray(variants) ? variants.map(String) : [],
@@ -98,6 +101,21 @@ export function loadDeliveryMap(file: string): MapLoadResult {
98
101
  }
99
102
  }
100
103
 
104
+ const requirements: Record<string, RequirementOverride> = {};
105
+ if (doc.requirements !== undefined) {
106
+ if (!doc.requirements || typeof doc.requirements !== 'object' || Array.isArray(doc.requirements)) {
107
+ errors.push('`requirements` must be a mapping of requirement-id → { status, note }');
108
+ } else {
109
+ for (const [reqId, r] of Object.entries(doc.requirements as Record<string, unknown>)) {
110
+ const rr = (r && typeof r === 'object' ? r : {}) as Record<string, unknown>;
111
+ if (!REQUIREMENT_STATUSES.includes(String(rr.status) as RequirementStatus)) {
112
+ errors.push(`requirements.${reqId}: \`status\` must be one of ${REQUIREMENT_STATUSES.join(' | ')}`);
113
+ }
114
+ requirements[reqId] = { status: rr.status as RequirementStatus, note: rr.note ? String(rr.note) : undefined };
115
+ }
116
+ }
117
+ }
118
+
101
119
  const fingerprints: Record<string, string> = {};
102
120
  if (doc.fingerprints && typeof doc.fingerprints === 'object' && !Array.isArray(doc.fingerprints)) {
103
121
  for (const [k, v] of Object.entries(doc.fingerprints as Record<string, unknown>)) {
@@ -111,6 +129,7 @@ export function loadDeliveryMap(file: string): MapLoadResult {
111
129
  formNo: doc.form_no !== undefined ? String(doc.form_no) : undefined,
112
130
  groups,
113
131
  dispositions,
132
+ requirements,
114
133
  fingerprints,
115
134
  };
116
135
  return { map: errors.length > 0 ? null : map, errors };
@@ -130,11 +149,13 @@ export function writeDeliveryMap(file: string, map: DeliveryMap): void {
130
149
  target: g.target,
131
150
  intent: g.intent,
132
151
  oracle: g.oracle,
152
+ ...(g.dimensions !== undefined ? { dimensions: g.dimensions } : {}),
133
153
  category: g.category,
134
154
  review: g.review,
135
155
  variants: g.variants,
136
156
  })),
137
157
  ...(Object.keys(map.dispositions).length > 0 ? { dispositions: map.dispositions } : {}),
158
+ ...(Object.keys(map.requirements).length > 0 ? { requirements: map.requirements } : {}),
138
159
  ...(Object.keys(map.fingerprints).length > 0 ? { fingerprints: map.fingerprints } : {}),
139
160
  };
140
161
  fs.mkdirSync(path.dirname(file), { recursive: true });
@@ -1,16 +1,18 @@
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 { variantState } from './build';
9
+ import { coverageSummary, 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)',
13
- 'Mode', 'Trace', 'Result', 'Executed Date', 'Note',
14
+ 'Precondition', 'Coverage / Test Data', 'Action / Trigger', 'Expected Result',
15
+ 'Mode', 'Trace', 'Result', 'Executed Date', 'Note / Evidence', 'Defect ID',
14
16
  ];
15
17
 
16
18
  function esc(value: string): string {
@@ -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,6 @@ 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;
31
38
  lines.push([
32
39
  'item',
33
40
  item.id,
@@ -36,35 +43,51 @@ export function renderMatrixCsv(model: MatrixModel): string {
36
43
  item.category,
37
44
  item.priority,
38
45
  item.precondition.join('\n'),
39
- single ? only!.data.join('\n') : `${item.variants.length} variants`,
46
+ coverageSummary(item),
47
+ // Shared leading steps only; each variant row carries its remaining steps.
40
48
  item.trigger.join('\n'),
41
49
  item.oracle,
42
- item.mode === 'manual' ? 'Manual' : 'Auto',
50
+ itemModeLabel(item),
43
51
  item.traces.join(' '),
44
- item.result,
45
- single ? formatExecutedDate(only!.result?.startTime) : '',
52
+ itemResultLabel(item),
53
+ '',
46
54
  item.review === 'proposed' ? 'REVIEW REQUIRED' : '',
55
+ '',
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
+ // Precondition DELTA — variant-specific setup only (review GAP-03).
69
+ (item.preconditionDeltas[v.ref] ?? []).join('\n'),
70
+ v.data.join('\n'),
71
+ // Steps after the parent's shared prefix only.
72
+ (item.triggerDeltas[v.ref] ?? []).join('\n'),
73
+ expected,
74
+ v.mode === 'manual' ? 'Manual' : 'Auto',
75
+ v.traces.join(' '),
76
+ variantState(v),
77
+ isoDate(v.result?.startTime),
78
+ v.result?.error ? String(v.result.error).slice(0, 200) : '',
79
+ '',
80
+ ].map(esc).join(','));
81
+ }
82
+ }
83
+
84
+ // Requirement coverage appendix — keeps the CSV self-contained for pipelines.
85
+ if (model.requirements.length > 0) {
86
+ lines.push('');
87
+ lines.push('# Requirement coverage');
88
+ lines.push(['Requirement', 'Status', 'Delivery items', 'Variants', 'Note'].join(','));
89
+ for (const r of model.requirements) {
90
+ lines.push([r.id, r.status, r.items.join(' '), String(r.variantCount), r.note].map(esc).join(','));
68
91
  }
69
92
  }
70
93
  return BOM + lines.join('\n') + '\n';