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

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 (40) hide show
  1. package/dist/cli/commands/delivery.d.ts.map +1 -1
  2. package/dist/cli/commands/delivery.js +16 -5
  3. package/dist/cli/commands/delivery.js.map +1 -1
  4. package/dist/exporters/matrix/build.d.ts +6 -0
  5. package/dist/exporters/matrix/build.d.ts.map +1 -1
  6. package/dist/exporters/matrix/build.js +24 -6
  7. package/dist/exporters/matrix/build.js.map +1 -1
  8. package/dist/exporters/matrix/export.d.ts +5 -4
  9. package/dist/exporters/matrix/export.d.ts.map +1 -1
  10. package/dist/exporters/matrix/export.js +15 -6
  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 +20 -0
  14. package/dist/exporters/matrix/gates.js.map +1 -1
  15. package/dist/exporters/matrix/render-csv.d.ts.map +1 -1
  16. package/dist/exporters/matrix/render-csv.js +10 -5
  17. package/dist/exporters/matrix/render-csv.js.map +1 -1
  18. package/dist/exporters/matrix/render-xlsx.d.ts.map +1 -1
  19. package/dist/exporters/matrix/render-xlsx.js +63 -24
  20. package/dist/exporters/matrix/render-xlsx.js.map +1 -1
  21. package/dist/exporters/matrix/types.d.ts +3 -1
  22. package/dist/exporters/matrix/types.d.ts.map +1 -1
  23. package/dist/exporters/matrix/types.js.map +1 -1
  24. package/dist/exporters/matrix/wording.d.ts +6 -0
  25. package/dist/exporters/matrix/wording.d.ts.map +1 -1
  26. package/dist/exporters/matrix/wording.js +22 -3
  27. package/dist/exporters/matrix/wording.js.map +1 -1
  28. package/dist/orchestrator/templates/ai-src/commands/delivery.md +11 -6
  29. package/dist/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +25 -8
  30. package/package.json +3 -3
  31. package/src/cli/commands/delivery.ts +19 -6
  32. package/src/exporters/matrix/build.ts +23 -7
  33. package/src/exporters/matrix/export.ts +21 -6
  34. package/src/exporters/matrix/gates.ts +20 -0
  35. package/src/exporters/matrix/render-csv.ts +10 -5
  36. package/src/exporters/matrix/render-xlsx.ts +63 -24
  37. package/src/exporters/matrix/types.ts +3 -1
  38. package/src/exporters/matrix/wording.ts +20 -3
  39. package/src/orchestrator/templates/ai-src/commands/delivery.md +11 -6
  40. package/src/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +25 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sun-asterisk/sungen",
3
- "version": "3.2.16-beta.3",
3
+ "version": "3.2.16-beta.4",
4
4
  "description": "Deterministic E2E Test Compiler - Gherkin + Selectors → Playwright tests",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -39,8 +39,8 @@
39
39
  "@babel/types": "^7.28.5",
40
40
  "@cucumber/gherkin": "^37.0.0",
41
41
  "@cucumber/messages": "^31.0.0",
42
- "@sungen/driver-data-factory": "3.2.16-beta.3",
43
- "@sungen/driver-ui": "3.2.16-beta.3",
42
+ "@sungen/driver-data-factory": "3.2.16-beta.4",
43
+ "@sungen/driver-ui": "3.2.16-beta.4",
44
44
  "chalk": "^5.6.2",
45
45
  "commander": "^14.0.2",
46
46
  "dotenv": "^17.2.3",
@@ -31,6 +31,7 @@ import {
31
31
  loadMatrixModel,
32
32
  approveMatrix,
33
33
  writeMatrixDeliverables,
34
+ MatrixFormat,
34
35
  hasErrors,
35
36
  hasReview,
36
37
  } from '../../exporters/matrix/export';
@@ -651,7 +652,7 @@ interface MatrixRunSummary {
651
652
  async function exportMatrixTarget(
652
653
  cwd: string,
653
654
  target: DeliveryTarget,
654
- opts: { check?: boolean; preview?: boolean },
655
+ opts: { check?: boolean; preview?: boolean; format?: MatrixFormat },
655
656
  ): Promise<MatrixRunSummary | null> {
656
657
  const paths = matrixPathsFor(cwd, target);
657
658
  const { model, mapMissing, mapErrors } = loadMatrixModel(paths);
@@ -689,24 +690,25 @@ async function exportMatrixTarget(
689
690
  return null;
690
691
  }
691
692
 
692
- const { csvPath, xlsxPath } = await writeMatrixDeliverables(paths, model);
693
+ const { csvPath, xlsxPath } = await writeMatrixDeliverables(paths, model, opts.format ?? 'xlsx');
693
694
  const passed = model.items.reduce((a, i) => a + i.resultCounts.passed, 0);
694
695
  const failed = model.items.reduce((a, i) => a + i.resultCounts.failed, 0);
695
- log(` ${COLOR.green}→ ${path.relative(cwd, xlsxPath)}${COLOR.reset} ${COLOR.gray}(+ ${path.relative(cwd, csvPath)})${COLOR.reset}`);
696
+ const written = [xlsxPath, csvPath].filter((p): p is string => !!p).map((p) => path.relative(cwd, p));
697
+ for (const rel of written) log(` ${COLOR.green}→ ${rel}${COLOR.reset}`);
696
698
  return {
697
699
  label: paths.label,
698
700
  items: model.manifest.itemCount,
699
701
  variants: model.manifest.variantCount,
700
702
  passed,
701
703
  failed,
702
- file: path.relative(cwd, xlsxPath),
704
+ file: written.join(' + '),
703
705
  };
704
706
  }
705
707
 
706
708
  function printMatrixSummaryTable(summaries: MatrixRunSummary[]): void {
707
709
  log(`\n${COLOR.bold}Delivery matrix export complete${COLOR.reset}\n`);
708
710
  const colWidth = Math.max(20, ...summaries.map((s) => s.label.length)) + 1;
709
- log(` ${'Feature'.padEnd(colWidth)}Items Variants Passed Failed File`);
711
+ log(` ${'Feature'.padEnd(colWidth)}Items Variants Passed Failed File(s)`);
710
712
  log(' ' + '-'.repeat(colWidth + 50));
711
713
  for (const s of summaries) {
712
714
  log(
@@ -796,9 +798,11 @@ export function registerDeliveryCommand(program: Command): void {
796
798
  .option('--check', 'Matrix gates only — validate the delivery map, write nothing')
797
799
  .option('--approve [ids]', 'Approve proposed groups (all, or a comma-separated id list) + stamp fingerprints')
798
800
  .option('--preview', 'Render the matrix despite review-required findings (DRAFT watermark)')
801
+ .option('--format <format>', 'Matrix output format: xlsx (default) | csv | both', 'xlsx')
799
802
  .action(async (names: string[], options: {
800
803
  skipPreflight?: boolean; continueOnMissing?: boolean; env?: string;
801
804
  legacy?: boolean; full?: boolean; check?: boolean; approve?: boolean | string; preview?: boolean;
805
+ format?: string;
802
806
  }) => {
803
807
  try {
804
808
  // Same effect as running with SUNGEN_ENV=<env> — result-file resolution,
@@ -806,6 +810,13 @@ export function registerDeliveryCommand(program: Command): void {
806
810
  if (options.env) process.env.SUNGEN_ENV = options.env;
807
811
  const cwd = process.cwd();
808
812
 
813
+ // Matrix output format — one artifact by default (xlsx); csv/both on request.
814
+ const format = (options.format ?? 'xlsx').toLowerCase();
815
+ if (!['xlsx', 'csv', 'both'].includes(format)) {
816
+ console.error(`${COLOR.red}Invalid --format "${options.format}"${COLOR.reset} — use xlsx | csv | both`);
817
+ process.exit(1);
818
+ }
819
+
809
820
  // 1. Scope detection — each positional name expands into one target
810
821
  // per `.feature` file inside that screen/flow. Passing a specific
811
822
  // feature basename (e.g. `home-modal`) narrows to that one file.
@@ -886,7 +897,9 @@ export function registerDeliveryCommand(program: Command): void {
886
897
  const matrixSummaries: MatrixRunSummary[] = [];
887
898
  let failedTargets = 0;
888
899
  for (const target of toExport) {
889
- const s = await exportMatrixTarget(cwd, target, { check: options.check, preview: options.preview });
900
+ const s = await exportMatrixTarget(cwd, target, {
901
+ check: options.check, preview: options.preview, format: format as MatrixFormat,
902
+ });
890
903
  if (s) matrixSummaries.push(s);
891
904
  else failedTargets++;
892
905
  }
@@ -14,7 +14,7 @@ import {
14
14
  splitVpAndName,
15
15
  } from '../feature-parser';
16
16
  import { getCasesDatasetRows, resolveResultVariants } from '../result-variants';
17
- import { classifyManualComments, renderAction, renderExpected, renderPrecondition } from './wording';
17
+ import { classifyManualComments, renderAction, renderExpected, renderPrecondition, renderSetupInstruction } from './wording';
18
18
  import { scenarioFingerprint, combinedFingerprint, mapContentFingerprint } from './fingerprint';
19
19
  import {
20
20
  CoverageVariant,
@@ -72,12 +72,12 @@ function resolveDataPairs(
72
72
  if (row) {
73
73
  for (const [k, v] of Object.entries(row)) {
74
74
  if (k.startsWith('__') || k === 'case' || k === 'name' || k === 'label') continue;
75
- pairs.push(`${k}: ${String(v)}`);
75
+ pairs.push(`${k}: ${displayValue(String(v))}`);
76
76
  }
77
77
  }
78
78
  for (const v of vars) {
79
79
  const val = row && v in row ? undefined : testData?.[v]; // row columns already listed
80
- if (val !== undefined) pairs.push(`${v}: ${val}`);
80
+ if (val !== undefined) pairs.push(`${v}: ${displayValue(val)}`);
81
81
  }
82
82
  return pairs;
83
83
  }
@@ -141,7 +141,7 @@ export function deriveVariants(inputs: Pick<BuildInputs, 'feature' | 'merged' |
141
141
  ...(authRole ? [authRole === 'no-auth' ? 'The user is signed out.' : `The user is signed in as ${authRole}.`] : []),
142
142
  ...inputs.feature.backgroundGivenSteps.map(renderPrecondition),
143
143
  ...m.feature.rawGivenSteps.map(renderPrecondition),
144
- ...(manual?.preconditions ?? []).map((t) => renderPrecondition(t)),
144
+ ...(manual?.preconditions ?? []).map((t) => renderSetupInstruction(t)),
145
145
  ]));
146
146
 
147
147
  const base = {
@@ -247,11 +247,22 @@ export function deriveVariants(inputs: Pick<BuildInputs, 'feature' | 'merged' |
247
247
  function substituteDisplayVars(text: string, vars: Record<string, string>): string {
248
248
  return text.replace(/\{\{\s*([^}\s]+)\s*\}\}/g, (m, key: string) => {
249
249
  if (!(key in vars)) return m; // unknown stays literal → Gate D flags it
250
- const v = vars[key];
251
- return v === '' ? '(empty)' : v;
250
+ return displayValue(vars[key]);
252
251
  });
253
252
  }
254
253
 
254
+ /**
255
+ * Make invisible test data VISIBLE instead of losing it (review GAP-04):
256
+ * '' → (empty) · whitespace-only → (N spaces) · padded → quoted verbatim.
257
+ * The canonical value is never changed — only its presentation.
258
+ */
259
+ export function displayValue(v: string): string {
260
+ if (v === '') return '(empty)';
261
+ if (/^\s+$/.test(v)) return `(${v.length} space${v.length > 1 ? 's' : ''})`;
262
+ if (v !== v.trim()) return `"${v}"`;
263
+ return v;
264
+ }
265
+
255
266
  function sentenceOf(text: string): string {
256
267
  const s = text.trim();
257
268
  if (!s) return s;
@@ -386,7 +397,12 @@ export function buildMatrix(inputs: BuildInputs): MatrixModel {
386
397
  mode: modes.size > 1 ? 'mixed' : (first?.mode ?? 'auto'),
387
398
  layers: Array.from(new Set(groupVariants.flatMap((v) => v.layers))),
388
399
  traces: Array.from(new Set(groupVariants.flatMap((v) => v.traces))),
389
- precondition: first?.precondition ?? [],
400
+ // Only the preconditions COMMON to every variant belong on the parent —
401
+ // copying the first variant's setup mis-states the start state of the
402
+ // others (review GAP-03). Variant-specific lines render as the sub-row's
403
+ // precondition delta.
404
+ precondition: (first?.precondition ?? []).filter((line) =>
405
+ groupVariants.every((v) => v.precondition.includes(line))),
390
406
  trigger: triggerShapes.size === 1 ? (first?.trigger ?? []) : ['(per variant)'],
391
407
  variants: groupVariants,
392
408
  result,
@@ -17,6 +17,7 @@ import { getPackageVersion } from '../package-info';
17
17
  import { writeCsv } from '../csv-exporter';
18
18
  import { writeXlsx } from '../xlsx-exporter';
19
19
  import { loadDeliveryMap, writeDeliveryMap } from './map-loader';
20
+ import { mapContentFingerprint } from './fingerprint';
20
21
  import { buildMatrix, deriveVariants } from './build';
21
22
  import { renderMatrixXlsx } from './render-xlsx';
22
23
  import { renderMatrixCsv } from './render-csv';
@@ -121,14 +122,28 @@ export function approveMatrix(paths: MatrixTargetPaths, groupIds?: string[]): {
121
122
  g.review = 'approved';
122
123
  approved.push(g.id);
123
124
  }
125
+ // Freeze the reviewed map semantics as well (GAP-09) — computed AFTER the
126
+ // review flips so re-running approve on an unchanged map is idempotent.
127
+ map.fingerprints.__map__ = mapContentFingerprint(map.groups, map.dispositions);
124
128
  writeDeliveryMap(paths.mapFile, map);
125
129
  return { findings: model.findings.filter((f) => f.severity !== 'error'), approved };
126
130
  }
127
131
 
128
- /** Render + write both artifacts. Caller has already enforced the gate policy. */
129
- export async function writeMatrixDeliverables(paths: MatrixTargetPaths, model: MatrixModel): Promise<{ csvPath: string; xlsxPath: string }> {
130
- const csvPath = writeCsv(paths.cwd, paths.unit, renderMatrixCsv(model));
131
- const wb = renderMatrixXlsx(model, getPackageVersion());
132
- const xlsxPath = await writeXlsx(paths.cwd, paths.unit, wb);
133
- return { csvPath, xlsxPath };
132
+ export type MatrixFormat = 'xlsx' | 'csv' | 'both';
133
+
134
+ /** Render + write the requested format(s). Caller has already enforced the gate policy. */
135
+ export async function writeMatrixDeliverables(
136
+ paths: MatrixTargetPaths,
137
+ model: MatrixModel,
138
+ format: MatrixFormat = 'xlsx',
139
+ ): Promise<{ csvPath?: string; xlsxPath?: string }> {
140
+ const out: { csvPath?: string; xlsxPath?: string } = {};
141
+ if (format === 'csv' || format === 'both') {
142
+ out.csvPath = writeCsv(paths.cwd, paths.unit, renderMatrixCsv(model));
143
+ }
144
+ if (format === 'xlsx' || format === 'both') {
145
+ const wb = renderMatrixXlsx(model, getPackageVersion());
146
+ out.xlsxPath = await writeXlsx(paths.cwd, paths.unit, wb);
147
+ }
148
+ return out;
134
149
  }
@@ -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
 
@@ -266,6 +267,25 @@ function gateEDrift(ctx: GateContext, findings: MatrixFinding[]): void {
266
267
  const { map } = ctx.inputs;
267
268
  const { variantsByVp } = ctx;
268
269
 
270
+ // The map's own semantics (targets/intents/oracles/grouping) are part of what
271
+ // was approved — an edit after approval must be re-reviewed, not silently
272
+ // published (review GAP-09).
273
+ const stampedMap = map.fingerprints.__map__;
274
+ const liveMap = mapContentFingerprint(map.groups, map.dispositions);
275
+ if (map.groups.some((g) => g.review === 'approved')) {
276
+ if (!stampedMap) {
277
+ findings.push({
278
+ gate: 'E', severity: 'review',
279
+ message: 'the delivery map has no stamped content fingerprint — run `sungen delivery --approve` to record the reviewed wording',
280
+ });
281
+ } else if (stampedMap !== liveMap) {
282
+ findings.push({
283
+ gate: 'E', severity: 'review',
284
+ message: 'the delivery map wording/grouping changed since approval — re-review, then `sungen delivery --approve`',
285
+ });
286
+ }
287
+ }
288
+
269
289
  for (const g of map.groups) {
270
290
  if (g.review !== 'approved') continue; // proposed groups are already under review
271
291
  for (const ref of g.variants) {
@@ -12,7 +12,7 @@ import { MatrixModel } from './types';
12
12
  const HEADERS = [
13
13
  'Level', 'ID', 'Target', 'Test Intent / Condition', 'Category', 'Priority',
14
14
  'Precondition', 'Coverage / Test Data', 'Action / Trigger', 'Expected Result',
15
- 'Mode', 'Trace', 'Result', 'Executed Date', 'Note',
15
+ 'Mode', 'Trace', 'Result', 'Executed Date', 'Note / Evidence', 'Defect ID',
16
16
  ];
17
17
 
18
18
  function esc(value: string): string {
@@ -36,7 +36,8 @@ export function renderMatrixCsv(model: MatrixModel): string {
36
36
 
37
37
  for (const item of model.items) {
38
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)';
39
+ const triggerIsCommon = triggerTexts.size === 1;
40
+ const commonPre = new Set(item.precondition);
40
41
  lines.push([
41
42
  'item',
42
43
  item.id,
@@ -46,13 +47,15 @@ export function renderMatrixCsv(model: MatrixModel): string {
46
47
  item.priority,
47
48
  item.precondition.join('\n'),
48
49
  `${item.variants.length} variant(s): ${item.variants.map((v) => v.condition).join(' · ')}`,
49
- commonTrigger,
50
+ // No placeholder text: when triggers differ the variant rows carry them (GAP-01).
51
+ triggerIsCommon ? item.variants[0].trigger.join('\n') : '',
50
52
  item.oracle,
51
53
  itemModeLabel(item),
52
54
  item.traces.join(' '),
53
55
  itemResultLabel(item),
54
56
  '',
55
57
  item.review === 'proposed' ? 'REVIEW REQUIRED' : '',
58
+ '',
56
59
  ].map(esc).join(','));
57
60
 
58
61
  for (const v of item.variants) {
@@ -65,15 +68,17 @@ export function renderMatrixCsv(model: MatrixModel): string {
65
68
  '',
66
69
  v.condition,
67
70
  '', '',
68
- '',
71
+ // Precondition DELTA — variant-specific setup only (review GAP-03).
72
+ v.precondition.filter((line) => !commonPre.has(line)).join('\n'),
69
73
  v.data.join('\n'),
70
- v.trigger.join('\n'),
74
+ triggerIsCommon ? '' : v.trigger.join('\n'),
71
75
  expected,
72
76
  v.mode === 'manual' ? 'Manual' : 'Auto',
73
77
  v.traces.join(' '),
74
78
  v.result ? statusToTestResult(v.result.status) : 'Pending',
75
79
  isoDate(v.result?.startTime),
76
80
  v.result?.error ? String(v.result.error).slice(0, 200) : '',
81
+ '',
77
82
  ].map(esc).join(','));
78
83
  }
79
84
  }
@@ -120,7 +120,7 @@ function draftBanner(ws: ExcelJS.Worksheet, model: MatrixModel): void {
120
120
  const MATRIX_HEADERS = [
121
121
  'ID', 'Target', 'Test Intent / Condition', 'Category', 'Priority', 'Precondition',
122
122
  'Coverage / Test Data', 'Action / Trigger', 'Expected Result', 'Mode', 'Trace',
123
- 'Result', 'Executed Date', 'Executor', 'Note\n(Evidence, DefectID)',
123
+ 'Result', 'Executed Date', 'Executor', 'Note / Evidence', 'Defect ID',
124
124
  ];
125
125
  const RESULT_COL = 13; // column M
126
126
 
@@ -144,25 +144,35 @@ function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?
144
144
  { width: 22 }, // M Result
145
145
  { width: 12 }, // N Executed
146
146
  { width: 13 }, // O Executor
147
- { width: 24 }, // P Note
147
+ { width: 22 }, // P Note / Evidence
148
+ { width: 12 }, // Q Defect ID
148
149
  ];
149
150
 
150
151
  renderReportHeaderBand(wb, ws, `${model.unit.toUpperCase()} TEST CASE & COVERAGE MATRIX`, sungenVersion, model.formNo);
151
152
  draftBanner(ws, model);
152
153
 
153
- // Summary band (row 6) — items vs variants stay separate metrics by design (I8):
154
- // the ITEM count is reviewer workload, the VARIANT count is execution workload.
154
+ // Summary band (row 6) — items vs variants stay separate metrics by design (I8).
155
+ // The execution counters are LIVE formulas over the variant Result cells, so
156
+ // 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.
155
159
  const passed = model.items.reduce((a, i) => a + i.resultCounts.passed, 0);
156
160
  const failed = model.items.reduce((a, i) => a + i.resultCounts.failed, 0);
157
161
  const notRun = model.items.reduce((a, i) => a + i.resultCounts.notRun + i.resultCounts.blocked, 0);
162
+ const RANGE = 'M9:M10000';
158
163
  const band = ws.getRow(6);
159
- [
160
- `Delivery items: ${model.manifest.itemCount}`,
161
- `Coverage variants: ${model.manifest.variantCount}`,
162
- `Passed: ${passed}`, `Failed: ${failed}`, `Pending: ${notRun}`,
163
- ].forEach((label, i) => {
164
+ const bandCells: Array<[string, ExcelJS.CellValue]> = [
165
+ ['items', `Delivery items: ${model.manifest.itemCount}`],
166
+ ['variants', `Coverage variants: ${model.manifest.variantCount}`],
167
+ ['passed', { formula: `"Passed: "&COUNTIF(${RANGE},"Passed")`, result: `Passed: ${passed}` } as ExcelJS.CellValue],
168
+ ['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],
170
+ ['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],
172
+ ];
173
+ bandCells.forEach(([, v], i) => {
164
174
  const c = band.getCell(2 + i);
165
- dataCell(c, label, { bold: true, fill: SOFT_BLUE, center: true });
175
+ dataCell(c, v, { bold: true, fill: SOFT_BLUE, center: true });
166
176
  });
167
177
 
168
178
  const HEADER_ROW = 8;
@@ -178,16 +188,25 @@ function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?
178
188
  // Every item gets sub-rows — the sub-row is where the source scenario id, the
179
189
  // resolved data, and the manual result/evidence entry live (review B-05).
180
190
  const firstChild = rowIdx;
191
+ // Delta-only sub-rows (review GAP-01): content identical across the whole
192
+ // item lives ONCE on the parent — a child repeats only what distinguishes it.
193
+ // The child's Expected Result is always kept: it is the precise source
194
+ // 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;
181
198
  for (const v of item.variants) {
182
199
  const vr = ws.getRow(rowIdx++);
183
200
  vr.outlineLevel = 1;
201
+ const preDelta = v.precondition.filter((line) => !commonPre.has(line));
184
202
  const vValues: ExcelJS.CellValue[] = [
185
203
  v.ref,
186
204
  '',
187
205
  v.condition,
188
- '', '', '',
206
+ '', '',
207
+ numbered(preDelta),
189
208
  numbered(v.data),
190
- numbered(v.trigger),
209
+ triggerIsCommon ? '' : numbered(v.trigger),
191
210
  expectedWithVerification(v.oracle, v.verification),
192
211
  v.mode === 'manual' ? `Manual${v.manualReason ? ` (${v.manualReason})` : ''}` : 'Auto',
193
212
  v.traces.join(', '),
@@ -195,6 +214,7 @@ function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?
195
214
  isoDate(v.result?.startTime),
196
215
  '',
197
216
  v.result?.error ? String(v.result.error).slice(0, 200) : '',
217
+ '',
198
218
  ];
199
219
  vValues.forEach((val, i) => dataCell(vr.getCell(2 + i), val, { center: i === 9 }));
200
220
  // Manual-execution entry: constrain the Result cell to the known states.
@@ -207,19 +227,21 @@ function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?
207
227
  // --- Parent row: only values that are genuinely COMMON to the whole item
208
228
  // (review M-04); everything variant-specific stays on the sub-rows.
209
229
  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;
230
+ const commonTrigger = triggerIsCommon ? item.variants[0].trigger : null;
212
231
  const coverage = `${item.variants.length} variant(s): ${item.variants.map((v) => v.condition).join(' · ')}`;
213
232
 
214
- // Live roll-up: derived from the child Result cells so hand-entered results
215
- // recompute the parent — precedence failed → blocked → pending → partial → passed.
233
+ // Live roll-up derived from the child Result cells precedence
234
+ // failed → blocked → pending → partial → passed. N/A variants leave the
235
+ // denominator (review GAP-06): eff = n − COUNTIF(N/A); all-N/A → "N/A".
216
236
  const rng = `M${firstChild}:M${lastChild}`;
217
237
  const n = item.variants.length;
238
+ const EFF = `(${n}-COUNTIF(${rng},"N/A"))`;
218
239
  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"))))`;
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")))))`;
223
245
 
224
246
  const values: ExcelJS.CellValue[] = [
225
247
  item.id,
@@ -229,7 +251,8 @@ function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?
229
251
  item.priority,
230
252
  numbered(item.precondition),
231
253
  coverage,
232
- commonTrigger ? numbered(commonTrigger) : '(differs per variant see sub-rows)',
254
+ // No placeholder text: when triggers differ the sub-rows carry them (GAP-01).
255
+ commonTrigger ? numbered(commonTrigger) : '',
233
256
  item.oracle,
234
257
  itemModeLabel(item),
235
258
  item.traces.join(', '),
@@ -237,6 +260,7 @@ function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?
237
260
  '',
238
261
  '',
239
262
  item.review === 'proposed' ? 'REVIEW REQUIRED' : '',
263
+ '',
240
264
  ];
241
265
  values.forEach((v, i) => dataCell(pr.getCell(2 + i), v, {
242
266
  bold: true,
@@ -246,9 +270,18 @@ function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?
246
270
  }));
247
271
  }
248
272
 
249
- ws.autoFilter = { from: { row: HEADER_ROW, column: 2 }, to: { row: rowIdx - 1, column: 16 } };
273
+ ws.autoFilter = { from: { row: HEADER_ROW, column: 2 }, to: { row: rowIdx - 1, column: 17 } };
250
274
  // Freeze the header band AND the ID + Target columns (review §9).
251
275
  ws.views = [{ state: 'frozen', xSplit: 3, ySplit: HEADER_ROW }];
276
+ // Print: landscape, fit width only (never squash all rows into one page),
277
+ // repeat the masthead + header rows on every page (review GAP-08).
278
+ ws.pageSetup = {
279
+ orientation: 'landscape',
280
+ fitToPage: true,
281
+ fitToWidth: 1,
282
+ fitToHeight: 0,
283
+ printTitlesRow: '1:8',
284
+ };
252
285
  }
253
286
 
254
287
  // ---------------------------------------------------------------------------
@@ -270,8 +303,14 @@ function addCoverageSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersio
270
303
  if (model.requirements.length > 0) {
271
304
  dataCell(ws.getRow(rowIdx).getCell(2), 'Requirement coverage — every id has an explicit status', { bold: true });
272
305
  rowIdx += 1;
273
- const rh = ws.getRow(rowIdx++);
274
- ['Requirement', 'Status', 'Delivery items', 'Variants', 'Note'].forEach((l, i) => headerCell(rh.getCell(2 + i), l));
306
+ const rh = ws.getRow(rowIdx);
307
+ ws.mergeCells(rowIdx, 4, rowIdx, 6);
308
+ headerCell(rh.getCell(2), 'Requirement');
309
+ headerCell(rh.getCell(3), 'Status');
310
+ headerCell(rh.getCell(4), 'Delivery items');
311
+ headerCell(rh.getCell(7), 'Variants');
312
+ headerCell(rh.getCell(8), 'Note');
313
+ rowIdx += 1;
275
314
  for (const r of model.requirements) {
276
315
  const row = ws.getRow(rowIdx++);
277
316
  dataCell(row.getCell(2), r.id, { bold: true });
@@ -57,7 +57,9 @@ export interface DeliveryMap {
57
57
  /** Requirement-id → reviewed status override (partially_covered / not_applicable / …).
58
58
  * Without an override a requirement is `covered` when a variant traces to it, else `gap`. */
59
59
  requirements: Record<string, RequirementOverride>;
60
- /** VP-id → scenario fingerprint stamped at approval time (drift detector input). */
60
+ /** VP-id → scenario fingerprint stamped at approval time (drift detector input).
61
+ * The reserved key `__map__` holds the fingerprint of the map's own semantic
62
+ * content (groups + dispositions) so edited wording re-opens review (GAP-09). */
61
63
  fingerprints: Record<string, string>;
62
64
  }
63
65
 
@@ -19,10 +19,13 @@ function deRef(text: string): string {
19
19
  }
20
20
 
21
21
  function sentence(text: string): string {
22
- let s = text.trim().replace(/\s+/g, ' ');
22
+ // Trim the ENDS only — internal whitespace may be the test data itself
23
+ // (a padded email, a spaces-only value); collapsing it would silently
24
+ // change what the tester types (review GAP-04).
25
+ let s = text.trim();
23
26
  if (!s) return s;
24
27
  s = s.charAt(0).toUpperCase() + s.slice(1);
25
- if (!/[.!?…]$/.test(s)) s += '.';
28
+ if (!/[.!?…"]$/.test(s)) s += '.';
26
29
  return s;
27
30
  }
28
31
 
@@ -42,7 +45,7 @@ export function renderAction(raw: string): string {
42
45
  // click [X] <type>
43
46
  [/^clicks? \[([^\]]+)\]\s*(\w+)?$/i, (m) => `Click the ${m[1]}${m[2] ? ` ${m[2]}` : ''}`],
44
47
  // press <Key> at/in [X] field
45
- [/^press(?:es)? (.+?) (?:at|in|inside) \[([^\]]+)\](?: field)?$/i, (m) => `Press ${m[1]} in the ${m[2]} field`],
48
+ [/^press(?:es)? (.+?) (?:at|in|on|inside) \[([^\]]+)\](?: field)?$/i, (m) => `Press ${m[1]} in the ${m[2]} field`],
46
49
  [/^press(?:es)? (.+)$/i, (m) => `Press ${m[1]}`],
47
50
  // select V in/from [X] dropdown
48
51
  [/^selects? (.+?) (?:in|from) \[([^\]]+)\]\s*(\w+)?$/i, (m) => `Select ${m[1]} in the ${m[2]}${m[3] ? ` ${m[3]}` : ''}`],
@@ -103,9 +106,23 @@ export function renderPrecondition(raw: string): string {
103
106
  const s = raw.trim().replace(/^(User|The user)\s+/i, '');
104
107
  const m = s.match(/^is on \[([^\]]+)\] page(.*)$/i);
105
108
  if (m) return sentence(`The user is on the ${m[1]} page${m[2] ?? ''}`);
109
+ // "wait for [X] <type> (is) visible" is a STATE once established, not an action.
110
+ const w = s.match(/^waits? for \[([^\]]+)\]\s*(\w+)?(?: is)?(?: visible)?$/i);
111
+ if (w) return sentence(`The ${w[1]}${w[2] ? ` ${w[2]}` : ''} is visible`);
112
+ const auth = s.match(/^is (signed|logged) (in|out)(.*)$/i);
113
+ if (auth) return sentence(`The user is ${auth[1]} ${auth[2]}${auth[3] ?? ''}`);
106
114
  return sentence(deRef(`The user ${s.charAt(0).toLowerCase()}${s.slice(1)}`));
107
115
  }
108
116
 
117
+ /**
118
+ * A manual `Setup:` line is an INSTRUCTION the tester performs to establish the
119
+ * state — imperative reads correctly ("Seed the locked account."), while
120
+ * "The user seed …" is broken grammar (review GAP-08).
121
+ */
122
+ export function renderSetupInstruction(raw: string): string {
123
+ return renderAction(raw);
124
+ }
125
+
109
126
  // ---------------------------------------------------------------------------
110
127
  // Manual `# Tester verifies:` comment classification (structured, label-free)
111
128
  // ---------------------------------------------------------------------------
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: delivery
3
3
  description: "Export the Test Case & Coverage Matrix (review/manual/customer deliverable) from Gherkin + Playwright results. --legacy exports the classic per-scenario CSV/XLSX."
4
- argument-hint: "[name...] [--env <locale>] [--legacy] (omit names for all; --env for locale-specific export)"
4
+ argument-hint: "[name...] [--format csv] [--env <locale>] [--legacy] (omit names for all)"
5
5
  order: 50
6
6
  claude-tools: "Bash, Read, Write, AskUserQuestion"
7
7
  copilot-tools: "[read, execute, edit, vscode/askQuestions]"
@@ -27,6 +27,8 @@ review → approve → official render.
27
27
 
28
28
  Parse from `$ARGUMENTS`:
29
29
  - **names** — zero or more screen/flow/api names. Empty → all targets.
30
+ - **`--format <xlsx|csv|both>`** — output format. **Default `xlsx` — one artifact only.** Pass
31
+ `--format csv` when the user wants the flat CSV (pipelines/diffing), `--format both` for both.
30
32
  - **`--env <locale>`** — sets `SUNGEN_ENV=<locale>` for the run (accept `--locale` as alias).
31
33
  - **`--legacy` / `--full`** — pass through to the CLI and skip the map flow entirely.
32
34
 
@@ -37,8 +39,8 @@ Parse from `$ARGUMENTS`:
37
39
  ```bash
38
40
  [ -x ./bin/sungen.js ] && ./bin/sungen.js delivery <names> || npx sungen delivery <names>
39
41
  ```
40
- (prepend `SUNGEN_ENV=<locale>` when `--env` was given; append `--legacy` when requested then skip
41
- to step 5.)
42
+ (prepend `SUNGEN_ENV=<locale>` when `--env` was given; append `--format <fmt>` when the user asked
43
+ for a non-default format; append `--legacy` when requested — then skip to step 5.)
42
44
 
43
45
  Three outcomes per target:
44
46
  - **Rendered** → done, go to step 5.
@@ -151,6 +153,7 @@ counted in variants, never items**). Then `AskUserQuestion`:
151
153
  - **Open the workbook** — inspect `qa/deliverables/<unit>-testcases.xlsx` (Testcases sheet:
152
154
  collapse outline level 1 for the customer view; Coverage sheet: target × category grid + gaps).
153
155
  - **Run tests to refresh results** — `/sungen:run-test <unit>`, then re-run delivery.
156
+ - **Also export CSV** — `sungen delivery <unit> --format csv` (flat `item`/`variant` rows for pipelines).
154
157
  - **Export the legacy workbook too** — `sungen delivery <unit> --legacy`.
155
158
  - **Done**
156
159
 
@@ -168,7 +171,8 @@ counted in variants, never items**). Then `AskUserQuestion`:
168
171
  ## CLI reference
169
172
 
170
173
  ```
171
- sungen delivery [names...] # matrix (default; needs the map)
174
+ sungen delivery [names...] # matrix (default; needs the map) → XLSX only
175
+ --format <xlsx|csv|both> # output format; default xlsx (one artifact)
172
176
  --check # gates only — validate the map, write nothing
173
177
  --approve [DI-a,DI-b] # flip proposed→approved (+ stamp fingerprints); all groups when bare
174
178
  --preview # render despite review findings (DRAFT watermark)
@@ -176,5 +180,6 @@ sungen delivery [names...] # matrix (default; needs the map)
176
180
  --skip-preflight | --continue-on-missing | --env <env> # as before
177
181
  ```
178
182
 
179
- Outputs: `qa/deliverables/<unit>-testcases.xlsx` (Testcases + Coverage sheets) + `.csv`
180
- (flat, `Level` column `item|variant`). Legacy mode writes the classic files instead.
183
+ Outputs: `qa/deliverables/<unit>-testcases.xlsx` (Testcases + Coverage sheets) by default;
184
+ `--format csv` writes `<unit>-testcases.csv` instead (flat, `Level` column `item|variant`),
185
+ `--format both` writes both. Legacy mode writes the classic files instead.