@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
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { FeatureMetadata, PlaywrightResult } from '../types';
9
- import { MergedScenario, parseManualComments } from '../scenario-merger';
9
+ import { MergedScenario } from '../scenario-merger';
10
10
  import {
11
11
  extractAuthRole,
12
12
  extractPriority,
@@ -14,16 +14,18 @@ import {
14
14
  splitVpAndName,
15
15
  } from '../feature-parser';
16
16
  import { getCasesDatasetRows, resolveResultVariants } from '../result-variants';
17
- import { substituteTestDataVars } from '../test-data-resolver';
17
+ import { classifyManualComments, renderAction, renderExpected, renderPrecondition, renderSetupInstruction, runtimeVarsOf } from './wording';
18
18
  import { scenarioFingerprint, combinedFingerprint, mapContentFingerprint } from './fingerprint';
19
19
  import {
20
20
  CoverageVariant,
21
21
  DeliveryItem,
22
22
  DeliveryMap,
23
23
  ItemResult,
24
+ VariantState,
24
25
  MatrixDisposition,
25
26
  MatrixLayer,
26
27
  MatrixModel,
28
+ RequirementCoverage,
27
29
  MAX_VARIANTS_PER_ITEM,
28
30
  } from './types';
29
31
  import { runGates } from './gates';
@@ -71,12 +73,12 @@ function resolveDataPairs(
71
73
  if (row) {
72
74
  for (const [k, v] of Object.entries(row)) {
73
75
  if (k.startsWith('__') || k === 'case' || k === 'name' || k === 'label') continue;
74
- pairs.push(`${k}: ${String(v)}`);
76
+ pairs.push(`${k}: ${displayValue(String(v))}`);
75
77
  }
76
78
  }
77
79
  for (const v of vars) {
78
80
  const val = row && v in row ? undefined : testData?.[v]; // row columns already listed
79
- if (val !== undefined) pairs.push(`${v}: ${val}`);
81
+ if (val !== undefined) pairs.push(`${v}: ${displayValue(val)}`);
80
82
  }
81
83
  return pairs;
82
84
  }
@@ -89,6 +91,8 @@ export interface BuildInputs {
89
91
  results: Map<string, PlaywrightResult> | null;
90
92
  map: DeliveryMap;
91
93
  transformerVersion: string;
94
+ /** requirements/spec.md content — source of the requirement-id inventory (FR/TR/NFR). */
95
+ specText?: string;
92
96
  }
93
97
 
94
98
  /**
@@ -97,7 +101,10 @@ export interface BuildInputs {
97
101
  * support tooling see the same universe the builder does.
98
102
  */
99
103
  export function deriveVariants(inputs: Pick<BuildInputs, 'feature' | 'merged' | 'testData' | 'results'>): CoverageVariant[] {
100
- const { merged, testData, results } = inputs;
104
+ const { merged, results } = inputs;
105
+ // Test-data values may cross-reference other keys (email_padded: " {{valid_email}} ") —
106
+ // resolve one level so display cells never leak a template token (review B-04).
107
+ const testData = resolveCrossRefs(inputs.testData);
101
108
  const variants: CoverageVariant[] = [];
102
109
 
103
110
  for (const m of merged) {
@@ -106,27 +113,47 @@ export function deriveVariants(inputs: Pick<BuildInputs, 'feature' | 'merged' |
106
113
  const tags = m.feature.tags;
107
114
  const mode = extractTestcaseType(tags) === 'Manual' ? 'manual' : 'auto';
108
115
  const authRole = extractAuthRole(tags);
109
- const vpCategory = vpId.replace(/^VP-/, '').replace(/-\d+[a-zA-Z]?$/, '');
116
+ // Category segment of the id, whatever the project's scheme:
117
+ // VP-SEC-001 → SEC · SEC-123 → SEC · MS-HP-001 → MS-HP · PER-345 → PER.
118
+ const vpCategory = vpId.replace(/^VP-/, '').replace(/-\d+(?:[a-zA-Z]|-[A-Z0-9]+)?$/, '');
119
+ const procedureProfile = deriveProcedureProfile(m);
110
120
 
111
- // Manual procedure comes from the parsed comment block; auto from Gherkin buckets.
121
+ // Manual scenarios: the `# Tester verifies:` block is classified into structured
122
+ // fields — Setup → precondition (review B-02), Action → trigger, Observable →
123
+ // expected, Oracle → verification method. Labels never remain inside prose.
112
124
  const manual = mode === 'manual' && m.feature.comments?.length
113
- ? parseManualComments(m.feature.comments)
125
+ ? classifyManualComments(m.feature.comments)
114
126
  : null;
115
- // A pure-render check (Given + Then only) is triggered by the page load itself —
116
- // that IS its action, not a Gate-D executability gap.
117
- const rawTrigger = manual ? manual.steps.map((s) => s.text) : m.feature.rawWhenSteps;
118
- const triggerSteps = rawTrigger.length > 0 ? rawTrigger : ['(on page load)'];
119
- const oracleSteps = m.resolvedExpected.map((s) => s.text);
127
+
128
+ // Raw (pre-wording) step texts — these feed shapes + Gate C, never the cells.
129
+ const rawTrigger = manual ? manual.actions : m.feature.rawWhenSteps;
130
+ const rawOracle = manual
131
+ ? (manual.expected.length > 0 ? manual.expected : m.feature.rawThenSteps)
132
+ : m.resolvedExpected.filter((s) => s.bucket === 'then').map((s) => s.text);
133
+
134
+ // Values the scenario itself produces during the run — captured from the page
135
+ // or bound by a capability precondition. The compiler registers these as
136
+ // "captured" and skips YAML validation; the matrix does the same.
137
+ const runtimeVars = runtimeVarsOf(
138
+ [...m.resolvedSteps.map((st) => st.text), ...m.resolvedExpected.map((st) => st.text)],
139
+ tags,
140
+ );
120
141
 
121
142
  const preconditionProfile = [
122
143
  authRole ?? '-',
123
144
  m.feature.extendsName ?? '-',
124
145
  ...m.feature.rawGivenSteps.map(normalizeShape),
146
+ ...(manual?.preconditions ?? []).map(normalizeShape),
125
147
  ].join(' | ');
126
- const precondition = [
127
- ...(authRole ? [authRole === 'no-auth' ? 'Not authenticated' : `Authenticated as ${authRole}`] : []),
128
- ...m.feature.rawGivenSteps,
129
- ];
148
+ // Display precondition = auth state + Background Given (shared start state) +
149
+ // the scenario's own Given steps + manual Setup lines — deduplicated (a manual
150
+ // scenario often repeats the Background navigation as its own Given).
151
+ const precondition = Array.from(new Set([
152
+ ...(authRole ? [authRole === 'no-auth' ? 'The user is signed out.' : `The user is signed in as ${authRole}.`] : []),
153
+ ...inputs.feature.backgroundGivenSteps.map(renderPrecondition),
154
+ ...m.feature.rawGivenSteps.map(renderPrecondition),
155
+ ...(manual?.preconditions ?? []).map((t) => renderSetupInstruction(t)),
156
+ ]));
130
157
 
131
158
  const base = {
132
159
  vpId,
@@ -136,11 +163,47 @@ export function deriveVariants(inputs: Pick<BuildInputs, 'feature' | 'merged' |
136
163
  manualReason: manualReason(tags),
137
164
  layers: deriveLayers(tags),
138
165
  traces: tags.filter((t) => t.startsWith('@spec:')).map((t) => t.slice('@spec:'.length)),
139
- triggerShape: triggerSteps.map(normalizeShape),
140
- oracleShape: oracleSteps.map(normalizeShape),
166
+ runtimeVars: [...runtimeVars],
167
+ triggerShape: rawTrigger.map(normalizeShape),
168
+ oracleShape: rawOracle.map(normalizeShape),
141
169
  preconditionProfile,
142
170
  precondition,
143
- procedureProfile: deriveProcedureProfile(m),
171
+ procedureProfile,
172
+ verification: manual?.verification.map((t) => sentenceOf(t)) ?? [],
173
+ };
174
+
175
+ /** Render the display cells for one data context (vars resolved first). */
176
+ const renderCells = (vars: Record<string, string>): { trigger: string[]; oracle: string[]; precondition: string[]; verification: string[] } => {
177
+ const sub = (s: string): string => substituteDisplayVars(s, vars, runtimeVars);
178
+ const preconditionOut = precondition.map(sub);
179
+ const verificationOut = base.verification.map(sub);
180
+ if (procedureProfile === 'sequence' && !manual) {
181
+ // Ordered flow (review B-03): keep the event order — actions stay numbered in
182
+ // the trigger, mid-flow assertions render inline as "Verify:", and only the
183
+ // FINAL Then block becomes the expected result.
184
+ const steps = m.feature.orderedSteps;
185
+ let lastActionIdx = -1;
186
+ steps.forEach((s, i) => { if (s.bucket !== 'then') lastActionIdx = i; });
187
+ const trigger: string[] = [];
188
+ const oracle: string[] = [];
189
+ steps.forEach((s, i) => {
190
+ if (i > lastActionIdx) oracle.push(renderExpected(sub(s.text))); // trailing Then block
191
+ else if (s.bucket === 'then') trigger.push(`Verify: ${renderExpected(sub(s.text))}`);
192
+ else if (s.bucket === 'when') trigger.push(renderAction(sub(s.text)));
193
+ // leading given steps already live in the precondition
194
+ });
195
+ return { trigger, oracle, precondition: preconditionOut, verification: verificationOut };
196
+ }
197
+ const trigger = rawTrigger.map((s) => renderAction(sub(s)));
198
+ const oracle = rawOracle.map((s) => renderExpected(sub(s)));
199
+ return {
200
+ // A pure-render check (Given + Then only) is checked on page load — say so
201
+ // in words instead of a placeholder token (review §10.3).
202
+ trigger: trigger.length > 0 ? trigger : ['No action — the state is checked on page load.'],
203
+ oracle,
204
+ precondition: preconditionOut,
205
+ verification: verificationOut,
206
+ };
144
207
  };
145
208
 
146
209
  const rows = getCasesDatasetRows(m, testData ?? undefined);
@@ -150,6 +213,7 @@ export function deriveVariants(inputs: Pick<BuildInputs, 'feature' | 'merged' |
150
213
  const resultByLabel = new Map(resultVariants.map((rv) => [rv.nameSuffix.replace(/^ — /, ''), rv.result]));
151
214
  rows.forEach((row, i) => {
152
215
  const label = String(row.case ?? row.name ?? row.label ?? `row ${i + 1}`);
216
+ const cells = renderCells({ ...(testData ?? {}), ...rowAsStrings(row) });
153
217
  variants.push({
154
218
  ...base,
155
219
  ref: `${vpId}#${label}`,
@@ -157,21 +221,26 @@ export function deriveVariants(inputs: Pick<BuildInputs, 'feature' | 'merged' |
157
221
  title: `${category1} — ${label}`,
158
222
  condition: label,
159
223
  data: resolveDataPairs(m.feature.referencedVars, testData, row),
160
- trigger: triggerSteps.map((s) => substituteTestDataVars(s, { ...(testData ?? {}), ...rowAsStrings(row) })),
161
- oracle: oracleSteps.map((s) => substituteTestDataVars(s, { ...(testData ?? {}), ...rowAsStrings(row) })),
224
+ trigger: cells.trigger,
225
+ oracle: cells.oracle,
226
+ precondition: cells.precondition,
227
+ verification: cells.verification,
162
228
  fingerprint: scenarioFingerprint(m.feature, row),
163
229
  result: resultByLabel.get(label),
164
230
  });
165
231
  });
166
232
  } else {
233
+ const cells = renderCells(testData ?? {});
167
234
  variants.push({
168
235
  ...base,
169
236
  ref: vpId,
170
237
  title: category1,
171
238
  condition: category1,
172
239
  data: resolveDataPairs(m.feature.referencedVars, testData),
173
- trigger: triggerSteps.map((s) => substituteTestDataVars(s, testData ?? {})),
174
- oracle: oracleSteps.map((s) => substituteTestDataVars(s, testData ?? {})),
240
+ trigger: cells.trigger,
241
+ oracle: cells.oracle,
242
+ precondition: cells.precondition,
243
+ verification: cells.verification,
175
244
  fingerprint: scenarioFingerprint(m.feature),
176
245
  result: resolveResultVariants(m, results ?? null, testData ?? undefined)[0]?.result,
177
246
  });
@@ -180,9 +249,106 @@ export function deriveVariants(inputs: Pick<BuildInputs, 'feature' | 'merged' |
180
249
  return variants;
181
250
  }
182
251
 
252
+ /**
253
+ * Display substitution for matrix cells. Unlike the legacy exporter's
254
+ * substituteTestDataVars (which keeps EMPTY values literal for the Steps
255
+ * column), an empty value is a legitimate test input here (an intentionally
256
+ * empty field) — it renders as `(empty)` so the tester sees the intent and no
257
+ * `{{token}}` survives into the deliverable (Gate D would reject it).
258
+ */
259
+ function substituteDisplayVars(
260
+ text: string,
261
+ vars: Record<string, string>,
262
+ runtimeVars: Set<string> = new Set(),
263
+ ): string {
264
+ // `remember … as {{v}}` DECLARES v — leave that token for renderAction, which
265
+ // turns it into a quoted name ("note it as …"), not a value to look up.
266
+ const isCapture = /\bremember\b/i.test(text);
267
+ return text.replace(/\{\{\s*([^}\s]+)\s*\}\}/g, (m, key: string, offset: number) => {
268
+ if (isCapture && /\bas\s+$/i.test(text.slice(0, offset))) return m;
269
+ // A value the test produces at run time has no static form — name it so the
270
+ // tester knows to compare against what the earlier step captured.
271
+ if (runtimeVars.has(key) || runtimeVars.has(key.split(/[.[]/)[0])) return `the captured ${key}`;
272
+ if (!(key in vars)) return m; // unknown stays literal → Gate D flags it
273
+ return displayValue(vars[key]);
274
+ });
275
+ }
276
+
277
+ /**
278
+ * Make invisible test data VISIBLE instead of losing it (review GAP-04):
279
+ * '' → (empty) · whitespace-only → (N spaces) · padded → quoted verbatim.
280
+ * The canonical value is never changed — only its presentation.
281
+ */
282
+ export function displayValue(v: string): string {
283
+ if (v === '') return '(empty)';
284
+ if (/^\s+$/.test(v)) return `(${v.length} space${v.length > 1 ? 's' : ''})`;
285
+ if (v !== v.trim()) return `"${v}"`;
286
+ return v;
287
+ }
288
+
289
+ function sentenceOf(text: string): string {
290
+ const s = text.trim();
291
+ if (!s) return s;
292
+ const cap = s.charAt(0).toUpperCase() + s.slice(1);
293
+ return /[.!?…]$/.test(cap) ? cap : `${cap}.`;
294
+ }
295
+
296
+ /**
297
+ * Requirement inventory ∪ traces → coverage table. Ids are scanned from the spec
298
+ * text (FR-/TR-/NFR-<n>); map overrides win over the derived covered/gap status.
299
+ */
300
+ export function computeRequirements(
301
+ specText: string,
302
+ map: DeliveryMap,
303
+ items: DeliveryItem[],
304
+ ): RequirementCoverage[] {
305
+ const ids: string[] = [];
306
+ const seen = new Set<string>();
307
+ const add = (id: string): void => { if (!seen.has(id)) { seen.add(id); ids.push(id); } };
308
+ // A spec declares its requirements in bold — `- **FR-001**: …`, `- **REQ-12**: …`.
309
+ // Reading the DECLARATION (rather than any `PREFIX-123` token in the prose) keeps
310
+ // a project's own scheme working without matching ISO numbers, dates, or the form
311
+ // number in a header.
312
+ for (const m of specText.matchAll(/\*\*([A-Z][A-Z0-9]{1,7}-\d+)\*\*/g)) add(m[1]);
313
+ // Specs that do not bold their ids still work for the conventional prefixes.
314
+ if (ids.length === 0) {
315
+ for (const m of specText.matchAll(/\b(?:FR|TR|NFR)-\d+\b/g)) add(m[0]);
316
+ }
317
+ // Overrides may reference ids the spec text doesn't list (e.g. a shared catalog).
318
+ for (const id of Object.keys(map.requirements ?? {})) {
319
+ if (!seen.has(id)) { seen.add(id); ids.push(id); }
320
+ }
321
+ if (ids.length === 0) return [];
322
+
323
+ return ids.map((id) => {
324
+ const traced = items.filter((it) => it.variants.some((v) => v.traces.includes(id)));
325
+ const override = (map.requirements ?? {})[id];
326
+ return {
327
+ id,
328
+ status: override?.status ?? (traced.length > 0 ? 'covered' : 'gap'),
329
+ items: traced.map((it) => it.id),
330
+ variantCount: traced.reduce((a, it) => a + it.variants.filter((v) => v.traces.includes(id)).length, 0),
331
+ note: override?.note ?? '',
332
+ };
333
+ });
334
+ }
335
+
336
+ /** Resolve one level of {{key}} cross-references inside test-data VALUES. */
337
+ function resolveCrossRefs(testData: Record<string, string> | null): Record<string, string> | null {
338
+ if (!testData) return null;
339
+ const out: Record<string, string> = {};
340
+ for (const [k, v] of Object.entries(testData)) {
341
+ out[k] = typeof v === 'string' && v.includes('{{') ? substituteDisplayVars(v, testData) : v;
342
+ }
343
+ return out;
344
+ }
345
+
183
346
  function rowAsStrings(row: Record<string, unknown>): Record<string, string> {
184
347
  const out: Record<string, string> = {};
185
- for (const [k, v] of Object.entries(row)) out[k] = String(v);
348
+ for (const [k, v] of Object.entries(row)) {
349
+ out[k] = String(v);
350
+ out[`row.${k}`] = String(v); // steps reference dataset columns as {{row.<col>}}
351
+ }
186
352
  return out;
187
353
  }
188
354
 
@@ -190,28 +356,57 @@ function rowAsStrings(row: Record<string, unknown>): Record<string, string> {
190
356
  // Item assembly + roll-up
191
357
  // ---------------------------------------------------------------------------
192
358
 
193
- function classifyResult(r: PlaywrightResult | undefined): 'passed' | 'failed' | 'blocked' | 'notRun' {
194
- if (!r) return 'notRun';
195
- if (r.status === 'passed') return 'passed';
196
- if (r.status === 'failed' || r.status === 'timedOut') return 'failed';
197
- if (r.status === 'interrupted') return 'blocked';
198
- return 'notRun'; // skipped / unknown
359
+ /**
360
+ * The exact word rendered in a variant's Result cell. This is the SINGLE
361
+ * vocabulary both the renderers and the parent's COUNTIF roll-up use — the
362
+ * legacy exporter's statusToTestResult disagreed on two statuses (`skipped`
363
+ * showed N/A but counted as not-run; `interrupted` showed Pending but counted
364
+ * as blocked), which silently skewed the derived parent result.
365
+ */
366
+ export function variantState(v: CoverageVariant): VariantState {
367
+ const r = v.result;
368
+ if (!r) return 'Pending';
369
+ switch (r.status) {
370
+ case 'passed': return 'Passed';
371
+ case 'failed':
372
+ case 'timedOut': return 'Failed';
373
+ case 'interrupted': return 'Blocked';
374
+ case 'skipped': return 'N/A';
375
+ default: return 'Pending';
376
+ }
199
377
  }
200
378
 
201
- /** Derived parent result — precedence: failed → blocked → not_run → partial → passed. */
379
+ const STATE_TO_COUNT: Record<VariantState, keyof DeliveryItem['resultCounts']> = {
380
+ Passed: 'passed', Failed: 'failed', Blocked: 'blocked', Pending: 'notRun', 'N/A': 'na',
381
+ };
382
+
383
+ /**
384
+ * Derived parent result — precedence failed → blocked → all-N/A → not_run →
385
+ * partial → passed. `N/A` variants leave the denominator (an intentionally
386
+ * skipped case must not make the parent look incomplete).
387
+ */
202
388
  export function rollUp(variants: CoverageVariant[]): { result: ItemResult; counts: DeliveryItem['resultCounts'] } {
203
- const counts = { passed: 0, failed: 0, blocked: 0, notRun: 0 };
204
- for (const v of variants) counts[classifyResult(v.result)]++;
205
- const run = counts.passed + counts.failed + counts.blocked;
389
+ const counts = { passed: 0, failed: 0, blocked: 0, notRun: 0, na: 0 };
390
+ for (const v of variants) counts[STATE_TO_COUNT[variantState(v)]]++;
391
+ const effective = variants.length - counts.na;
206
392
  let result: ItemResult;
207
393
  if (counts.failed > 0) result = 'failed';
208
394
  else if (counts.blocked > 0) result = 'blocked';
209
- else if (run === 0) result = 'not_run';
210
- else if (counts.notRun > 0) result = 'partial';
211
- else result = 'passed';
395
+ else if (effective === 0) result = 'na';
396
+ else if (counts.passed === 0) result = 'not_run';
397
+ else if (counts.passed === effective) result = 'passed';
398
+ else result = 'partial';
212
399
  return { result, counts };
213
400
  }
214
401
 
402
+ /** Longest shared leading run of steps — hoisted onto the parent row. */
403
+ function commonPrefix(lists: string[][]): number {
404
+ if (lists.length === 0) return 0;
405
+ let n = 0;
406
+ while (lists.every((l) => l.length > n) && new Set(lists.map((l) => l[n])).size === 1) n++;
407
+ return n;
408
+ }
409
+
215
410
  /**
216
411
  * Expand one map variant ref to concrete variant refs:
217
412
  * a bare VP-id whose scenario has a dataset means ALL its rows.
@@ -242,7 +437,18 @@ export function buildMatrix(inputs: BuildInputs): MatrixModel {
242
437
  const groupVariants = g.variants.flatMap((ref) => expandMapRef(ref, variantsByVp));
243
438
  const { result, counts } = rollUp(groupVariants);
244
439
  const first = groupVariants[0];
245
- const triggerShapes = new Set(groupVariants.map((v) => v.triggerShape.join(' ; ')));
440
+ const modes = new Set(groupVariants.map((v) => v.mode));
441
+ const commonPre = (first?.precondition ?? []).filter((line) =>
442
+ groupVariants.every((v) => v.precondition.includes(line)));
443
+ // Steps every variant starts with belong on the parent once; each variant
444
+ // renders only its remaining steps (review: the shared prefix was repeated
445
+ // on every child while the parent cell sat empty).
446
+ const prefixLen = commonPrefix(groupVariants.map((v) => v.trigger));
447
+ // Item priority = highest variant priority (per-variant priorities stay visible).
448
+ const priorityRank: Record<string, number> = { High: 0, Normal: 1, Low: 2 };
449
+ const priority = groupVariants
450
+ .map((v) => v.priority)
451
+ .sort((a, b) => (priorityRank[a] ?? 1) - (priorityRank[b] ?? 1))[0] ?? 'Normal';
246
452
  return {
247
453
  id: g.id,
248
454
  target: g.target,
@@ -251,12 +457,21 @@ export function buildMatrix(inputs: BuildInputs): MatrixModel {
251
457
  category: g.category,
252
458
  // A stale (drifted) group presents as proposed regardless of its stored state.
253
459
  review: stale.has(g.id) ? 'proposed' : g.review,
254
- priority: first?.priority ?? 'Normal',
255
- mode: first?.mode ?? 'auto',
460
+ priority,
461
+ mode: modes.size > 1 ? 'mixed' : (first?.mode ?? 'auto'),
256
462
  layers: Array.from(new Set(groupVariants.flatMap((v) => v.layers))),
257
463
  traces: Array.from(new Set(groupVariants.flatMap((v) => v.traces))),
258
- precondition: first?.precondition ?? [],
259
- trigger: triggerShapes.size === 1 ? (first?.trigger ?? []) : ['(per variant)'],
464
+ dimensions: g.dimensions,
465
+ // Only the preconditions COMMON to every variant belong on the parent —
466
+ // copying the first variant's setup mis-states the start state of the
467
+ // others (review GAP-03). Variant-specific lines render as the sub-row's
468
+ // precondition delta.
469
+ precondition: commonPre,
470
+ preconditionDeltas: Object.fromEntries(groupVariants.map((v) =>
471
+ [v.ref, v.precondition.filter((line) => !commonPre.includes(line))])),
472
+ triggerDeltas: Object.fromEntries(groupVariants.map((v) =>
473
+ [v.ref, v.trigger.slice(prefixLen)])),
474
+ trigger: (first?.trigger ?? []).slice(0, prefixLen),
260
475
  variants: groupVariants,
261
476
  result,
262
477
  resultCounts: counts,
@@ -270,6 +485,28 @@ export function buildMatrix(inputs: BuildInputs): MatrixModel {
270
485
  reason: d.reason ?? '',
271
486
  }));
272
487
 
488
+ // Requirement coverage (review §6): the requirement-id inventory comes from
489
+ // requirements/spec.md; @spec: traces mark `covered`; the map's `requirements:`
490
+ // section carries the reviewed overrides (partially_covered / not_applicable / …).
491
+ const requirements = computeRequirements(inputs.specText ?? '', map, items);
492
+ const gaps = requirements.filter((r) => r.status === 'gap');
493
+ if (gaps.length > 0) {
494
+ findings.push({
495
+ gate: 'R', severity: 'warning',
496
+ message: `${gaps.length} requirement id(s) with no coverage or disposition: ${gaps.map((g) => g.id).join(', ')} — trace them, or record a status in the map \`requirements:\` section`,
497
+ });
498
+ }
499
+ // A `covered` claim with no traced variant is prose, not traceability: nothing
500
+ // detects it when the proving scenario is later changed or deleted. Either tag
501
+ // the scenario that proves it (`@spec:<id>`) or say where it IS covered.
502
+ const unbacked = requirements.filter((r) => r.status === 'covered' && r.items.length === 0);
503
+ if (unbacked.length > 0) {
504
+ findings.push({
505
+ gate: 'R', severity: 'warning',
506
+ message: `${unbacked.length} requirement id(s) declared \`covered\` with no variant tracing to them: ${unbacked.map((r) => r.id).join(', ')} — add \`@spec:<id>\` to the scenario that proves each, or use covered_elsewhere / partially_covered with a named reference`,
507
+ });
508
+ }
509
+
273
510
  const variantCount = items.reduce((a, i) => a + i.variants.length, 0);
274
511
  const approved = items.every((i) => i.review === 'approved')
275
512
  && !findings.some((f) => f.severity === 'error' || f.severity === 'review');
@@ -279,6 +516,7 @@ export function buildMatrix(inputs: BuildInputs): MatrixModel {
279
516
  formNo: map.formNo ?? 'BM-2-901-13',
280
517
  items,
281
518
  dispositions,
519
+ requirements,
282
520
  findings,
283
521
  manifest: {
284
522
  unit,
@@ -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';
@@ -31,10 +32,18 @@ export interface MatrixTargetPaths {
31
32
  featureFile: string;
32
33
  testDataFile: string;
33
34
  specFile: string;
35
+ /** requirements/spec.md — the requirement-id inventory for coverage (optional). */
36
+ specMdFile?: string;
34
37
  resultsPath: string | null;
35
38
  mapFile: string;
36
39
  }
37
40
 
41
+ function readSpecText(paths: MatrixTargetPaths): string {
42
+ return paths.specMdFile && fs.existsSync(paths.specMdFile)
43
+ ? fs.readFileSync(paths.specMdFile, 'utf-8')
44
+ : '';
45
+ }
46
+
38
47
  export interface MatrixLoadResult {
39
48
  model?: MatrixModel;
40
49
  map?: DeliveryMap;
@@ -64,6 +73,7 @@ export function loadMatrixModel(paths: MatrixTargetPaths): MatrixLoadResult {
64
73
  results,
65
74
  map,
66
75
  transformerVersion: getPackageVersion(),
76
+ specText: readSpecText(paths),
67
77
  });
68
78
  return { model, map, mapErrors: [] };
69
79
  }
@@ -88,6 +98,7 @@ export function approveMatrix(paths: MatrixTargetPaths, groupIds?: string[]): {
88
98
  const model = buildMatrix({
89
99
  unit: paths.unit, feature, merged, testData, results: null, map,
90
100
  transformerVersion: getPackageVersion(),
101
+ specText: readSpecText(paths),
91
102
  });
92
103
  const blocking = model.findings.filter((f) => f.severity === 'error');
93
104
  if (blocking.length > 0) return { findings: blocking, approved: [] };
@@ -111,14 +122,28 @@ export function approveMatrix(paths: MatrixTargetPaths, groupIds?: string[]): {
111
122
  g.review = 'approved';
112
123
  approved.push(g.id);
113
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);
114
128
  writeDeliveryMap(paths.mapFile, map);
115
129
  return { findings: model.findings.filter((f) => f.severity !== 'error'), approved };
116
130
  }
117
131
 
118
- /** Render + write both artifacts. Caller has already enforced the gate policy. */
119
- export async function writeMatrixDeliverables(paths: MatrixTargetPaths, model: MatrixModel): Promise<{ csvPath: string; xlsxPath: string }> {
120
- const csvPath = writeCsv(paths.cwd, paths.unit, renderMatrixCsv(model));
121
- const wb = renderMatrixXlsx(model, getPackageVersion());
122
- const xlsxPath = await writeXlsx(paths.cwd, paths.unit, wb);
123
- 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;
124
149
  }