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

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 (46) hide show
  1. package/dist/cli/commands/delivery.d.ts.map +1 -1
  2. package/dist/cli/commands/delivery.js +1 -0
  3. package/dist/cli/commands/delivery.js.map +1 -1
  4. package/dist/exporters/matrix/build.d.ts +8 -1
  5. package/dist/exporters/matrix/build.d.ts.map +1 -1
  6. package/dist/exporters/matrix/build.js +158 -22
  7. package/dist/exporters/matrix/build.js.map +1 -1
  8. package/dist/exporters/matrix/export.d.ts +2 -0
  9. package/dist/exporters/matrix/export.d.ts.map +1 -1
  10. package/dist/exporters/matrix/export.js +7 -0
  11. package/dist/exporters/matrix/export.js.map +1 -1
  12. package/dist/exporters/matrix/gates.d.ts.map +1 -1
  13. package/dist/exporters/matrix/gates.js +37 -0
  14. package/dist/exporters/matrix/gates.js.map +1 -1
  15. package/dist/exporters/matrix/map-loader.d.ts.map +1 -1
  16. package/dist/exporters/matrix/map-loader.js +18 -0
  17. package/dist/exporters/matrix/map-loader.js.map +1 -1
  18. package/dist/exporters/matrix/render-csv.d.ts +3 -2
  19. package/dist/exporters/matrix/render-csv.d.ts.map +1 -1
  20. package/dist/exporters/matrix/render-csv.js +48 -28
  21. package/dist/exporters/matrix/render-csv.js.map +1 -1
  22. package/dist/exporters/matrix/render-xlsx.d.ts +16 -8
  23. package/dist/exporters/matrix/render-xlsx.d.ts.map +1 -1
  24. package/dist/exporters/matrix/render-xlsx.js +116 -52
  25. package/dist/exporters/matrix/render-xlsx.js.map +1 -1
  26. package/dist/exporters/matrix/types.d.ts +23 -1
  27. package/dist/exporters/matrix/types.d.ts.map +1 -1
  28. package/dist/exporters/matrix/types.js.map +1 -1
  29. package/dist/exporters/matrix/wording.d.ts +45 -0
  30. package/dist/exporters/matrix/wording.d.ts.map +1 -0
  31. package/dist/exporters/matrix/wording.js +150 -0
  32. package/dist/exporters/matrix/wording.js.map +1 -0
  33. package/dist/orchestrator/templates/ai-src/commands/delivery.md +27 -1
  34. package/dist/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +31 -8
  35. package/package.json +3 -3
  36. package/src/cli/commands/delivery.ts +1 -0
  37. package/src/exporters/matrix/build.ts +160 -22
  38. package/src/exporters/matrix/export.ts +10 -0
  39. package/src/exporters/matrix/gates.ts +41 -0
  40. package/src/exporters/matrix/map-loader.ts +20 -1
  41. package/src/exporters/matrix/render-csv.ts +49 -29
  42. package/src/exporters/matrix/render-xlsx.ts +122 -55
  43. package/src/exporters/matrix/types.ts +27 -1
  44. package/src/exporters/matrix/wording.ts +157 -0
  45. package/src/orchestrator/templates/ai-src/commands/delivery.md +27 -1
  46. package/src/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +31 -8
@@ -74,11 +74,37 @@ dispositions: # scenarios intentionally NOT delivered as te
74
74
  locales.
75
75
  - MUST split: different target, intent, oracle family, category, execution mode (`@manual` vs
76
76
  auto), test layer (`@api`/`@query`), or priority tag; sequence-sensitive flows (re-Given/When
77
- after a Then) stay solo.
77
+ after a Then) stay solo. **Different risk classes never merge** — XSS and SQL injection are
78
+ separate items even though both are "injection on the same field" (different risk, action,
79
+ and oracle family); a component's visibility rule and its action/revalidation rule are two
80
+ intents, not one.
78
81
  - Every scenario must land in exactly one group **or** one disposition (Gate B enforces 100%
79
82
  disposition). Data-setup blocks (`@manual:data-setup`) → `excluded`; SPEC-GAP placeholders →
80
83
  `blocked`.
81
84
 
85
+ **Wording rules for `intent`/`oracle` (customer-facing — Gate W lints these):**
86
+ - Plain product language, present simple, ~10–20 words, one behavior:
87
+ "A user can sign in with valid credentials and is redirected to the Jobs page."
88
+ - Oracle = the observable outcome as a definite assertion ("The Jobs page is displayed and the
89
+ Logout link is visible.") — no `should`, no tester actions.
90
+ - NEVER: `{{tokens}}`, `[Selector]` references, DSL phrasing (`User fill/click/see`), generator
91
+ labels (`Setup:`/`Observable:`/`Oracle:`), or vague verbs (`handles`, `surfaces`) when a precise
92
+ behavior exists. Use the visible UI label (the Login button, the Email field).
93
+ - **Preserve the source meaning exactly** — never strengthen, weaken, or reinterpret an oracle
94
+ (a security assertion especially: if the source says "the password appears ONLY in the HTTPS
95
+ POST body", do not write "no plaintext password on the network").
96
+
97
+ **Requirement coverage (`requirements:` section, optional):** `sungen delivery` scans
98
+ `requirements/spec.md` for FR-/TR-/NFR- ids; ids traced by `@spec:` tags are `covered`, the rest
99
+ are `gap` (Gate R warning). Record the reviewed status for genuine non-gaps:
100
+
101
+ ```yaml
102
+ requirements:
103
+ TR-007: { status: planned, note: Performance needs Lighthouse-style tooling }
104
+ TR-004: { status: partially_covered, note: client-side covered by VP-SEC-003; hashing needs DB verify }
105
+ # status: covered | partially_covered | covered_elsewhere | planned | gap | not_applicable
106
+ ```
107
+
82
108
  Then validate and fix any ERROR findings:
83
109
 
84
110
  ```bash
@@ -32,16 +32,39 @@ spec is `docs/spec/delivery-coverage-matrix-spec.md`.
32
32
  B mapping (every scenario in exactly one group XOR one disposition) · C aggregation (mode/layer/
33
33
  priority recomputed and equal within a group — heuristic oracle-shape/precondition mismatches are
34
34
  review-level, silenced once approved and unchanged) · D executability (precondition · condition+
35
- data · trigger · oracle all renderable; every `{{var}}` resolves) · E drift (fingerprint mismatch
36
- back to review) · G review state (proposed groups block the official render; `--preview` renders
37
- a DRAFT watermark).
38
-
39
- **Workbook**: `Testcases` sheet (parent rows + outline-level-1 variant sub-rows collapse for the
40
- customer view, expand to execute; result roll-up failedblocked→not_run→partial→passed, counts like
41
- `7/8 Passed · 1 Failed`) + `Coverage` sheet (target × category grid with explicit `—` gaps,
42
- dispositions, generation manifest). CSV is flat with a `Level` column (`item`/`variant`).
35
+ data · trigger · oracle all renderable; every `{{var}}` resolves; **no template token may survive
36
+ into a rendered cell** test-data cross-references are resolved for display) · E drift
37
+ (fingerprint mismatch → back to review) · G review state (proposed groups block the official
38
+ render; `--preview` renders a DRAFT watermark) · R requirement coverage (spec FR/TR/NFR ids with
39
+ no trace and no `requirements:` status warning) · W wording lint (map intent/oracle containing
40
+ tokens, `[Selector]` refs, DSL phrasing, or generator labelswarning).
41
+
42
+ **Wording normalization (deterministic, after semantic normalization):** DSL steps render as
43
+ controlled manual-test English without changing meaning — actions in the imperative (`User fill
44
+ [Email] field with X` → `Enter X in the Email field.`), expected results as observable assertions
45
+ (`User see [Jobs] page` → `The Jobs page is displayed.`), preconditions as states (`The user is
46
+ signed out.`). Manual `# Tester verifies:` labels become structured fields: `Setup:` →
47
+ Precondition, `Action:` → Action, `Observable:` → Expected Result, `Oracle:` → a separate
48
+ `Verification method:` line. Sequence-sensitive flows keep event order: actions numbered with
49
+ mid-flow assertions inline as `Verify: …`; only the final Then block is the Expected Result.
50
+ Empty test values render as `(empty)`.
51
+
52
+ **Workbook**: `Testcases` sheet — parent rows + outline-level-1 variant sub-rows for **every**
53
+ item (single-variant included: the sub-row carries the source VP-id, resolved data, and the
54
+ result/evidence entry). Collapse outline for the customer view, expand to execute. Variant Result
55
+ cells have a dropdown (Passed/Failed/Blocked/Pending/N/A) and the parent Result is a **live Excel
56
+ formula** over its children (failed→blocked→pending→partial→passed, e.g. `2/3 Passed · 1 Failed`)
57
+ — a parent can never contradict its variants, even after manual edits. ID + Target columns are
58
+ frozen; dates are ISO (`2026-08-04`). `Coverage` sheet — requirement coverage table (every FR/TR/
59
+ NFR id with an explicit status), target × category grid with explicit `—` gaps, dispositions,
60
+ manifest. CSV is flat with a `Level` column (`item`/`variant`) + a requirement-coverage appendix.
43
61
  `delivery_item_count` ≠ progress — variants are the execution metric.
44
62
 
63
+ **Authoring guidance the matrix rewards** (create-test side): payload/provider matrices (SQLi
64
+ payload lists, OAuth provider sets) belong in `@cases` datasets so each case is an atomic,
65
+ independently-reportable variant; keep dataset `case:` labels short and stable (`CHK-EMAIL-I1`),
66
+ with descriptions in other columns — the label is part of the variant's identity.
67
+
45
68
  ---
46
69
 
47
70
  ## Legacy mode (--legacy / --full)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sun-asterisk/sungen",
3
- "version": "3.2.16-beta.1",
3
+ "version": "3.2.16-beta.2",
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.1",
43
- "@sungen/driver-ui": "3.2.16-beta.1",
42
+ "@sungen/driver-data-factory": "3.2.16-beta.2",
43
+ "@sungen/driver-ui": "3.2.16-beta.2",
44
44
  "chalk": "^5.6.2",
45
45
  "commander": "^14.0.2",
46
46
  "dotenv": "^17.2.3",
@@ -621,6 +621,7 @@ function matrixPathsFor(cwd: string, target: DeliveryTarget): MatrixTargetPaths
621
621
  featureFile: path.join(base, 'features', `${target.featureBaseName}.feature`),
622
622
  testDataFile: resolveTestDataPathForTarget(cwd, target),
623
623
  specFile: path.join(genBase, `${target.featureBaseName}.spec.ts`),
624
+ specMdFile: path.join(base, 'requirements', 'spec.md'),
624
625
  resultsPath: resolveResultsPath(cwd, target),
625
626
  mapFile: mapFilePath(base, target.featureBaseName),
626
627
  };
@@ -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,7 +14,7 @@ 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 } from './wording';
18
18
  import { scenarioFingerprint, combinedFingerprint, mapContentFingerprint } from './fingerprint';
19
19
  import {
20
20
  CoverageVariant,
@@ -24,6 +24,7 @@ import {
24
24
  MatrixDisposition,
25
25
  MatrixLayer,
26
26
  MatrixModel,
27
+ RequirementCoverage,
27
28
  MAX_VARIANTS_PER_ITEM,
28
29
  } from './types';
29
30
  import { runGates } from './gates';
@@ -89,6 +90,8 @@ export interface BuildInputs {
89
90
  results: Map<string, PlaywrightResult> | null;
90
91
  map: DeliveryMap;
91
92
  transformerVersion: string;
93
+ /** requirements/spec.md content — source of the requirement-id inventory (FR/TR/NFR). */
94
+ specText?: string;
92
95
  }
93
96
 
94
97
  /**
@@ -97,7 +100,10 @@ export interface BuildInputs {
97
100
  * support tooling see the same universe the builder does.
98
101
  */
99
102
  export function deriveVariants(inputs: Pick<BuildInputs, 'feature' | 'merged' | 'testData' | 'results'>): CoverageVariant[] {
100
- const { merged, testData, results } = inputs;
103
+ const { merged, results } = inputs;
104
+ // Test-data values may cross-reference other keys (email_padded: " {{valid_email}} ") —
105
+ // resolve one level so display cells never leak a template token (review B-04).
106
+ const testData = resolveCrossRefs(inputs.testData);
101
107
  const variants: CoverageVariant[] = [];
102
108
 
103
109
  for (const m of merged) {
@@ -107,26 +113,36 @@ export function deriveVariants(inputs: Pick<BuildInputs, 'feature' | 'merged' |
107
113
  const mode = extractTestcaseType(tags) === 'Manual' ? 'manual' : 'auto';
108
114
  const authRole = extractAuthRole(tags);
109
115
  const vpCategory = vpId.replace(/^VP-/, '').replace(/-\d+[a-zA-Z]?$/, '');
116
+ const procedureProfile = deriveProcedureProfile(m);
110
117
 
111
- // Manual procedure comes from the parsed comment block; auto from Gherkin buckets.
118
+ // Manual scenarios: the `# Tester verifies:` block is classified into structured
119
+ // fields — Setup → precondition (review B-02), Action → trigger, Observable →
120
+ // expected, Oracle → verification method. Labels never remain inside prose.
112
121
  const manual = mode === 'manual' && m.feature.comments?.length
113
- ? parseManualComments(m.feature.comments)
122
+ ? classifyManualComments(m.feature.comments)
114
123
  : 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);
124
+
125
+ // Raw (pre-wording) step texts these feed shapes + Gate C, never the cells.
126
+ const rawTrigger = manual ? manual.actions : m.feature.rawWhenSteps;
127
+ const rawOracle = manual
128
+ ? (manual.expected.length > 0 ? manual.expected : m.feature.rawThenSteps)
129
+ : m.resolvedExpected.filter((s) => s.bucket === 'then').map((s) => s.text);
120
130
 
121
131
  const preconditionProfile = [
122
132
  authRole ?? '-',
123
133
  m.feature.extendsName ?? '-',
124
134
  ...m.feature.rawGivenSteps.map(normalizeShape),
135
+ ...(manual?.preconditions ?? []).map(normalizeShape),
125
136
  ].join(' | ');
126
- const precondition = [
127
- ...(authRole ? [authRole === 'no-auth' ? 'Not authenticated' : `Authenticated as ${authRole}`] : []),
128
- ...m.feature.rawGivenSteps,
129
- ];
137
+ // Display precondition = auth state + Background Given (shared start state) +
138
+ // the scenario's own Given steps + manual Setup lines deduplicated (a manual
139
+ // scenario often repeats the Background navigation as its own Given).
140
+ const precondition = Array.from(new Set([
141
+ ...(authRole ? [authRole === 'no-auth' ? 'The user is signed out.' : `The user is signed in as ${authRole}.`] : []),
142
+ ...inputs.feature.backgroundGivenSteps.map(renderPrecondition),
143
+ ...m.feature.rawGivenSteps.map(renderPrecondition),
144
+ ...(manual?.preconditions ?? []).map((t) => renderPrecondition(t)),
145
+ ]));
130
146
 
131
147
  const base = {
132
148
  vpId,
@@ -136,11 +152,46 @@ export function deriveVariants(inputs: Pick<BuildInputs, 'feature' | 'merged' |
136
152
  manualReason: manualReason(tags),
137
153
  layers: deriveLayers(tags),
138
154
  traces: tags.filter((t) => t.startsWith('@spec:')).map((t) => t.slice('@spec:'.length)),
139
- triggerShape: triggerSteps.map(normalizeShape),
140
- oracleShape: oracleSteps.map(normalizeShape),
155
+ triggerShape: rawTrigger.map(normalizeShape),
156
+ oracleShape: rawOracle.map(normalizeShape),
141
157
  preconditionProfile,
142
158
  precondition,
143
- procedureProfile: deriveProcedureProfile(m),
159
+ procedureProfile,
160
+ verification: manual?.verification.map((t) => sentenceOf(t)) ?? [],
161
+ };
162
+
163
+ /** Render the display cells for one data context (vars resolved first). */
164
+ const renderCells = (vars: Record<string, string>): { trigger: string[]; oracle: string[]; precondition: string[]; verification: string[] } => {
165
+ const sub = (s: string): string => substituteDisplayVars(s, vars);
166
+ const preconditionOut = precondition.map(sub);
167
+ const verificationOut = base.verification.map(sub);
168
+ if (procedureProfile === 'sequence' && !manual) {
169
+ // Ordered flow (review B-03): keep the event order — actions stay numbered in
170
+ // the trigger, mid-flow assertions render inline as "Verify:", and only the
171
+ // FINAL Then block becomes the expected result.
172
+ const steps = m.feature.orderedSteps;
173
+ let lastActionIdx = -1;
174
+ steps.forEach((s, i) => { if (s.bucket !== 'then') lastActionIdx = i; });
175
+ const trigger: string[] = [];
176
+ const oracle: string[] = [];
177
+ steps.forEach((s, i) => {
178
+ if (i > lastActionIdx) oracle.push(renderExpected(sub(s.text))); // trailing Then block
179
+ else if (s.bucket === 'then') trigger.push(`Verify: ${renderExpected(sub(s.text))}`);
180
+ else if (s.bucket === 'when') trigger.push(renderAction(sub(s.text)));
181
+ // leading given steps already live in the precondition
182
+ });
183
+ return { trigger, oracle, precondition: preconditionOut, verification: verificationOut };
184
+ }
185
+ const trigger = rawTrigger.map((s) => renderAction(sub(s)));
186
+ const oracle = rawOracle.map((s) => renderExpected(sub(s)));
187
+ return {
188
+ // A pure-render check (Given + Then only) is checked on page load — say so
189
+ // in words instead of a placeholder token (review §10.3).
190
+ trigger: trigger.length > 0 ? trigger : ['No action — the state is checked on page load.'],
191
+ oracle,
192
+ precondition: preconditionOut,
193
+ verification: verificationOut,
194
+ };
144
195
  };
145
196
 
146
197
  const rows = getCasesDatasetRows(m, testData ?? undefined);
@@ -150,6 +201,7 @@ export function deriveVariants(inputs: Pick<BuildInputs, 'feature' | 'merged' |
150
201
  const resultByLabel = new Map(resultVariants.map((rv) => [rv.nameSuffix.replace(/^ — /, ''), rv.result]));
151
202
  rows.forEach((row, i) => {
152
203
  const label = String(row.case ?? row.name ?? row.label ?? `row ${i + 1}`);
204
+ const cells = renderCells({ ...(testData ?? {}), ...rowAsStrings(row) });
153
205
  variants.push({
154
206
  ...base,
155
207
  ref: `${vpId}#${label}`,
@@ -157,21 +209,26 @@ export function deriveVariants(inputs: Pick<BuildInputs, 'feature' | 'merged' |
157
209
  title: `${category1} — ${label}`,
158
210
  condition: label,
159
211
  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) })),
212
+ trigger: cells.trigger,
213
+ oracle: cells.oracle,
214
+ precondition: cells.precondition,
215
+ verification: cells.verification,
162
216
  fingerprint: scenarioFingerprint(m.feature, row),
163
217
  result: resultByLabel.get(label),
164
218
  });
165
219
  });
166
220
  } else {
221
+ const cells = renderCells(testData ?? {});
167
222
  variants.push({
168
223
  ...base,
169
224
  ref: vpId,
170
225
  title: category1,
171
226
  condition: category1,
172
227
  data: resolveDataPairs(m.feature.referencedVars, testData),
173
- trigger: triggerSteps.map((s) => substituteTestDataVars(s, testData ?? {})),
174
- oracle: oracleSteps.map((s) => substituteTestDataVars(s, testData ?? {})),
228
+ trigger: cells.trigger,
229
+ oracle: cells.oracle,
230
+ precondition: cells.precondition,
231
+ verification: cells.verification,
175
232
  fingerprint: scenarioFingerprint(m.feature),
176
233
  result: resolveResultVariants(m, results ?? null, testData ?? undefined)[0]?.result,
177
234
  });
@@ -180,9 +237,77 @@ export function deriveVariants(inputs: Pick<BuildInputs, 'feature' | 'merged' |
180
237
  return variants;
181
238
  }
182
239
 
240
+ /**
241
+ * Display substitution for matrix cells. Unlike the legacy exporter's
242
+ * substituteTestDataVars (which keeps EMPTY values literal for the Steps
243
+ * column), an empty value is a legitimate test input here (an intentionally
244
+ * empty field) — it renders as `(empty)` so the tester sees the intent and no
245
+ * `{{token}}` survives into the deliverable (Gate D would reject it).
246
+ */
247
+ function substituteDisplayVars(text: string, vars: Record<string, string>): string {
248
+ return text.replace(/\{\{\s*([^}\s]+)\s*\}\}/g, (m, key: string) => {
249
+ if (!(key in vars)) return m; // unknown stays literal → Gate D flags it
250
+ const v = vars[key];
251
+ return v === '' ? '(empty)' : v;
252
+ });
253
+ }
254
+
255
+ function sentenceOf(text: string): string {
256
+ const s = text.trim();
257
+ if (!s) return s;
258
+ const cap = s.charAt(0).toUpperCase() + s.slice(1);
259
+ return /[.!?…]$/.test(cap) ? cap : `${cap}.`;
260
+ }
261
+
262
+ /**
263
+ * Requirement inventory ∪ traces → coverage table. Ids are scanned from the spec
264
+ * text (FR-/TR-/NFR-<n>); map overrides win over the derived covered/gap status.
265
+ */
266
+ export function computeRequirements(
267
+ specText: string,
268
+ map: DeliveryMap,
269
+ items: DeliveryItem[],
270
+ ): RequirementCoverage[] {
271
+ const ids: string[] = [];
272
+ const seen = new Set<string>();
273
+ for (const m of specText.matchAll(/\b(?:FR|TR|NFR)-\d+\b/g)) {
274
+ if (!seen.has(m[0])) { seen.add(m[0]); ids.push(m[0]); }
275
+ }
276
+ // Overrides may reference ids the spec text doesn't list (e.g. a shared catalog).
277
+ for (const id of Object.keys(map.requirements ?? {})) {
278
+ if (!seen.has(id)) { seen.add(id); ids.push(id); }
279
+ }
280
+ if (ids.length === 0) return [];
281
+
282
+ return ids.map((id) => {
283
+ const traced = items.filter((it) => it.variants.some((v) => v.traces.includes(id)));
284
+ const override = (map.requirements ?? {})[id];
285
+ return {
286
+ id,
287
+ status: override?.status ?? (traced.length > 0 ? 'covered' : 'gap'),
288
+ items: traced.map((it) => it.id),
289
+ variantCount: traced.reduce((a, it) => a + it.variants.filter((v) => v.traces.includes(id)).length, 0),
290
+ note: override?.note ?? '',
291
+ };
292
+ });
293
+ }
294
+
295
+ /** Resolve one level of {{key}} cross-references inside test-data VALUES. */
296
+ function resolveCrossRefs(testData: Record<string, string> | null): Record<string, string> | null {
297
+ if (!testData) return null;
298
+ const out: Record<string, string> = {};
299
+ for (const [k, v] of Object.entries(testData)) {
300
+ out[k] = typeof v === 'string' && v.includes('{{') ? substituteDisplayVars(v, testData) : v;
301
+ }
302
+ return out;
303
+ }
304
+
183
305
  function rowAsStrings(row: Record<string, unknown>): Record<string, string> {
184
306
  const out: Record<string, string> = {};
185
- for (const [k, v] of Object.entries(row)) out[k] = String(v);
307
+ for (const [k, v] of Object.entries(row)) {
308
+ out[k] = String(v);
309
+ out[`row.${k}`] = String(v); // steps reference dataset columns as {{row.<col>}}
310
+ }
186
311
  return out;
187
312
  }
188
313
 
@@ -270,6 +395,18 @@ export function buildMatrix(inputs: BuildInputs): MatrixModel {
270
395
  reason: d.reason ?? '',
271
396
  }));
272
397
 
398
+ // Requirement coverage (review §6): the requirement-id inventory comes from
399
+ // requirements/spec.md; @spec: traces mark `covered`; the map's `requirements:`
400
+ // section carries the reviewed overrides (partially_covered / not_applicable / …).
401
+ const requirements = computeRequirements(inputs.specText ?? '', map, items);
402
+ const gaps = requirements.filter((r) => r.status === 'gap');
403
+ if (gaps.length > 0) {
404
+ findings.push({
405
+ gate: 'R', severity: 'warning',
406
+ 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`,
407
+ });
408
+ }
409
+
273
410
  const variantCount = items.reduce((a, i) => a + i.variants.length, 0);
274
411
  const approved = items.every((i) => i.review === 'approved')
275
412
  && !findings.some((f) => f.severity === 'error' || f.severity === 'review');
@@ -279,6 +416,7 @@ export function buildMatrix(inputs: BuildInputs): MatrixModel {
279
416
  formNo: map.formNo ?? 'BM-2-901-13',
280
417
  items,
281
418
  dispositions,
419
+ requirements,
282
420
  findings,
283
421
  manifest: {
284
422
  unit,
@@ -31,10 +31,18 @@ export interface MatrixTargetPaths {
31
31
  featureFile: string;
32
32
  testDataFile: string;
33
33
  specFile: string;
34
+ /** requirements/spec.md — the requirement-id inventory for coverage (optional). */
35
+ specMdFile?: string;
34
36
  resultsPath: string | null;
35
37
  mapFile: string;
36
38
  }
37
39
 
40
+ function readSpecText(paths: MatrixTargetPaths): string {
41
+ return paths.specMdFile && fs.existsSync(paths.specMdFile)
42
+ ? fs.readFileSync(paths.specMdFile, 'utf-8')
43
+ : '';
44
+ }
45
+
38
46
  export interface MatrixLoadResult {
39
47
  model?: MatrixModel;
40
48
  map?: DeliveryMap;
@@ -64,6 +72,7 @@ export function loadMatrixModel(paths: MatrixTargetPaths): MatrixLoadResult {
64
72
  results,
65
73
  map,
66
74
  transformerVersion: getPackageVersion(),
75
+ specText: readSpecText(paths),
67
76
  });
68
77
  return { model, map, mapErrors: [] };
69
78
  }
@@ -88,6 +97,7 @@ export function approveMatrix(paths: MatrixTargetPaths, groupIds?: string[]): {
88
97
  const model = buildMatrix({
89
98
  unit: paths.unit, feature, merged, testData, results: null, map,
90
99
  transformerVersion: getPackageVersion(),
100
+ specText: readSpecText(paths),
91
101
  });
92
102
  const blocking = model.findings.filter((f) => f.severity === 'error');
93
103
  if (blocking.length > 0) return { findings: blocking, approved: [] };
@@ -29,6 +29,7 @@ export function runGates(ctx: GateContext): MatrixFinding[] {
29
29
  gateDExecutability(ctx, findings);
30
30
  gateEDrift(ctx, findings);
31
31
  gateGReviewState(ctx, findings);
32
+ gateWWording(ctx, findings);
32
33
  return findings;
33
34
  }
34
35
 
@@ -241,6 +242,21 @@ function gateDExecutability(ctx: GateContext, findings: MatrixFinding[]): void {
241
242
  }
242
243
  }
243
244
  }
245
+
246
+ // No template token may survive into a RENDERED cell (review B-04) — this also
247
+ // catches cross-referencing test-data values the one-level resolver couldn't close.
248
+ for (const [, vs] of variantsByVp) {
249
+ for (const v of vs) {
250
+ const rendered = [...v.precondition, ...v.trigger, ...v.oracle, ...v.verification, ...v.data];
251
+ const token = rendered.map((t) => t.match(/\{\{[^}]*\}\}/)).find(Boolean);
252
+ if (token) {
253
+ findings.push({
254
+ gate: 'D', severity: 'error', ref: v.ref,
255
+ message: `${v.ref}: unresolved template token ${token[0]} remains in the rendered output — the export is not deterministic for a tester`,
256
+ });
257
+ }
258
+ }
259
+ }
244
260
  }
245
261
 
246
262
  // --- Gate E — drift (stale approval) ------------------------------------------
@@ -271,6 +287,31 @@ function gateEDrift(ctx: GateContext, findings: MatrixFinding[]): void {
271
287
  }
272
288
  }
273
289
 
290
+ // --- Gate W — controlled-language lint on the map's semantic fields --------------
291
+
292
+ /** Patterns that make customer-facing wording read machine-generated (review §10). */
293
+ const WORDING_SMELLS: Array<[RegExp, string]> = [
294
+ [/\{\{[^}]*\}\}/, 'an unresolved {{token}}'],
295
+ [/\b(?:Setup|Observable|Oracle):/, 'a generator label (Setup:/Observable:/Oracle:)'],
296
+ [/\bUser (?:fill|click|press|see|wait)\b/i, 'DSL phrasing ("User fill/click/see…") — write plain product language'],
297
+ [/\[[^\]]+\]/, 'a [selector-style] reference — use the visible UI label'],
298
+ ];
299
+
300
+ function gateWWording(ctx: GateContext, findings: MatrixFinding[]): void {
301
+ for (const g of ctx.inputs.map.groups) {
302
+ for (const [field, text] of [['intent', g.intent], ['oracle', g.oracle]] as const) {
303
+ for (const [re, what] of WORDING_SMELLS) {
304
+ if (re.test(text)) {
305
+ findings.push({
306
+ gate: 'W', severity: 'warning', ref: g.id,
307
+ message: `group ${g.id} ${field} contains ${what}`,
308
+ });
309
+ }
310
+ }
311
+ }
312
+ }
313
+ }
314
+
274
315
  // --- Gate G — review state -----------------------------------------------------
275
316
 
276
317
  function gateGReviewState(ctx: GateContext, findings: MatrixFinding[]): void {
@@ -9,11 +9,13 @@
9
9
  import * as fs from 'fs';
10
10
  import * as path from 'path';
11
11
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
12
- import { DeliveryMap, MapCategory, MapDisposition, MapGroup, ReviewState } from './types';
12
+ import { DeliveryMap, MapCategory, MapDisposition, MapGroup, RequirementOverride, RequirementStatus, ReviewState } from './types';
13
13
 
14
14
  const CATEGORIES: MapCategory[] = ['normal', 'abnormal', 'security', 'nfr'];
15
15
  const REVIEW_STATES: ReviewState[] = ['proposed', 'approved'];
16
16
  const DISPOSITIONS = ['excluded', 'blocked', 'covered_elsewhere', 'accepted_risk'];
17
+ const REQUIREMENT_STATUSES: RequirementStatus[] =
18
+ ['covered', 'partially_covered', 'covered_elsewhere', 'planned', 'gap', 'not_applicable'];
17
19
 
18
20
  export interface MapLoadResult {
19
21
  map: DeliveryMap | null;
@@ -98,6 +100,21 @@ export function loadDeliveryMap(file: string): MapLoadResult {
98
100
  }
99
101
  }
100
102
 
103
+ const requirements: Record<string, RequirementOverride> = {};
104
+ if (doc.requirements !== undefined) {
105
+ if (!doc.requirements || typeof doc.requirements !== 'object' || Array.isArray(doc.requirements)) {
106
+ errors.push('`requirements` must be a mapping of requirement-id → { status, note }');
107
+ } else {
108
+ for (const [reqId, r] of Object.entries(doc.requirements as Record<string, unknown>)) {
109
+ const rr = (r && typeof r === 'object' ? r : {}) as Record<string, unknown>;
110
+ if (!REQUIREMENT_STATUSES.includes(String(rr.status) as RequirementStatus)) {
111
+ errors.push(`requirements.${reqId}: \`status\` must be one of ${REQUIREMENT_STATUSES.join(' | ')}`);
112
+ }
113
+ requirements[reqId] = { status: rr.status as RequirementStatus, note: rr.note ? String(rr.note) : undefined };
114
+ }
115
+ }
116
+ }
117
+
101
118
  const fingerprints: Record<string, string> = {};
102
119
  if (doc.fingerprints && typeof doc.fingerprints === 'object' && !Array.isArray(doc.fingerprints)) {
103
120
  for (const [k, v] of Object.entries(doc.fingerprints as Record<string, unknown>)) {
@@ -111,6 +128,7 @@ export function loadDeliveryMap(file: string): MapLoadResult {
111
128
  formNo: doc.form_no !== undefined ? String(doc.form_no) : undefined,
112
129
  groups,
113
130
  dispositions,
131
+ requirements,
114
132
  fingerprints,
115
133
  };
116
134
  return { map: errors.length > 0 ? null : map, errors };
@@ -135,6 +153,7 @@ export function writeDeliveryMap(file: string, map: DeliveryMap): void {
135
153
  variants: g.variants,
136
154
  })),
137
155
  ...(Object.keys(map.dispositions).length > 0 ? { dispositions: map.dispositions } : {}),
156
+ ...(Object.keys(map.requirements).length > 0 ? { requirements: map.requirements } : {}),
138
157
  ...(Object.keys(map.fingerprints).length > 0 ? { fingerprints: map.fingerprints } : {}),
139
158
  };
140
159
  fs.mkdirSync(path.dirname(file), { recursive: true });