@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.
- package/dist/cli/commands/delivery.d.ts.map +1 -1
- package/dist/cli/commands/delivery.js +67 -20
- package/dist/cli/commands/delivery.js.map +1 -1
- package/dist/exporters/feature-parser.d.ts +16 -1
- package/dist/exporters/feature-parser.d.ts.map +1 -1
- package/dist/exporters/feature-parser.js +21 -2
- package/dist/exporters/feature-parser.js.map +1 -1
- package/dist/exporters/matrix/build.d.ts +27 -2
- package/dist/exporters/matrix/build.d.ts.map +1 -1
- package/dist/exporters/matrix/build.js +277 -47
- package/dist/exporters/matrix/build.js.map +1 -1
- package/dist/exporters/matrix/export.d.ts +7 -4
- package/dist/exporters/matrix/export.d.ts.map +1 -1
- package/dist/exporters/matrix/export.js +22 -6
- package/dist/exporters/matrix/export.js.map +1 -1
- package/dist/exporters/matrix/gates.d.ts.map +1 -1
- package/dist/exporters/matrix/gates.js +112 -3
- package/dist/exporters/matrix/gates.js.map +1 -1
- package/dist/exporters/matrix/map-loader.d.ts.map +1 -1
- package/dist/exporters/matrix/map-loader.js +20 -0
- package/dist/exporters/matrix/map-loader.js.map +1 -1
- package/dist/exporters/matrix/render-csv.d.ts +3 -2
- package/dist/exporters/matrix/render-csv.d.ts.map +1 -1
- package/dist/exporters/matrix/render-csv.js +53 -30
- package/dist/exporters/matrix/render-csv.js.map +1 -1
- package/dist/exporters/matrix/render-xlsx.d.ts +32 -8
- package/dist/exporters/matrix/render-xlsx.d.ts.map +1 -1
- package/dist/exporters/matrix/render-xlsx.js +215 -83
- package/dist/exporters/matrix/render-xlsx.js.map +1 -1
- package/dist/exporters/matrix/types.d.ts +53 -7
- package/dist/exporters/matrix/types.d.ts.map +1 -1
- package/dist/exporters/matrix/types.js +2 -2
- package/dist/exporters/matrix/types.js.map +1 -1
- package/dist/exporters/matrix/wording.d.ts +61 -0
- package/dist/exporters/matrix/wording.d.ts.map +1 -0
- package/dist/exporters/matrix/wording.js +221 -0
- package/dist/exporters/matrix/wording.js.map +1 -0
- package/dist/exporters/scenario-merger.d.ts.map +1 -1
- package/dist/exporters/scenario-merger.js +2 -1
- package/dist/exporters/scenario-merger.js.map +1 -1
- package/dist/exporters/spec-parser.d.ts.map +1 -1
- package/dist/exporters/spec-parser.js +2 -1
- package/dist/exporters/spec-parser.js.map +1 -1
- package/dist/harness/audit.d.ts.map +1 -1
- package/dist/harness/audit.js +11 -2
- package/dist/harness/audit.js.map +1 -1
- package/dist/harness/blindspot.d.ts.map +1 -1
- package/dist/harness/blindspot.js +2 -1
- package/dist/harness/blindspot.js.map +1 -1
- package/dist/harness/capability-plan.d.ts.map +1 -1
- package/dist/harness/capability-plan.js +3 -2
- package/dist/harness/capability-plan.js.map +1 -1
- package/dist/harness/feedback.d.ts.map +1 -1
- package/dist/harness/feedback.js +3 -2
- package/dist/harness/feedback.js.map +1 -1
- package/dist/harness/flow-check.d.ts.map +1 -1
- package/dist/harness/flow-check.js +2 -1
- package/dist/harness/flow-check.js.map +1 -1
- package/dist/harness/flow-plan.d.ts.map +1 -1
- package/dist/harness/flow-plan.js +3 -2
- package/dist/harness/flow-plan.js.map +1 -1
- package/dist/harness/intent.d.ts.map +1 -1
- package/dist/harness/intent.js +2 -1
- package/dist/harness/intent.js.map +1 -1
- package/dist/harness/journey.d.ts.map +1 -1
- package/dist/harness/journey.js +3 -2
- package/dist/harness/journey.js.map +1 -1
- package/dist/harness/ledger.d.ts.map +1 -1
- package/dist/harness/ledger.js +3 -2
- package/dist/harness/ledger.js.map +1 -1
- package/dist/harness/manifest.d.ts.map +1 -1
- package/dist/harness/manifest.js +4 -3
- package/dist/harness/manifest.js.map +1 -1
- package/dist/harness/parse.d.ts.map +1 -1
- package/dist/harness/parse.js +16 -3
- package/dist/harness/parse.js.map +1 -1
- package/dist/harness/quality-gates.d.ts.map +1 -1
- package/dist/harness/quality-gates.js +2 -1
- package/dist/harness/quality-gates.js.map +1 -1
- package/dist/harness/read-text.d.ts +22 -0
- package/dist/harness/read-text.d.ts.map +1 -0
- package/dist/harness/read-text.js +64 -0
- package/dist/harness/read-text.js.map +1 -0
- package/dist/harness/script-check.d.ts.map +1 -1
- package/dist/harness/script-check.js +3 -2
- package/dist/harness/script-check.js.map +1 -1
- package/dist/harness/sensors.d.ts.map +1 -1
- package/dist/harness/sensors.js +2 -10
- package/dist/harness/sensors.js.map +1 -1
- package/dist/harness/spec-coverage.d.ts +5 -0
- package/dist/harness/spec-coverage.d.ts.map +1 -1
- package/dist/harness/spec-coverage.js +17 -7
- package/dist/harness/spec-coverage.js.map +1 -1
- package/dist/harness/trace.d.ts.map +1 -1
- package/dist/harness/trace.js +4 -3
- package/dist/harness/trace.js.map +1 -1
- package/dist/harness/viewpoint-ledger.d.ts.map +1 -1
- package/dist/harness/viewpoint-ledger.js +2 -1
- package/dist/harness/viewpoint-ledger.js.map +1 -1
- package/dist/orchestrator/templates/ai-src/commands/create-test.md +9 -0
- package/dist/orchestrator/templates/ai-src/commands/delivery.md +103 -18
- package/dist/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +77 -11
- package/dist/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +1 -0
- package/dist/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +22 -0
- package/package.json +4 -4
- package/src/cli/commands/delivery.ts +68 -22
- package/src/exporters/feature-parser.ts +21 -2
- package/src/exporters/matrix/build.ts +281 -43
- package/src/exporters/matrix/export.ts +31 -6
- package/src/exporters/matrix/gates.ts +119 -3
- package/src/exporters/matrix/map-loader.ts +22 -1
- package/src/exporters/matrix/render-csv.ts +53 -30
- package/src/exporters/matrix/render-xlsx.ts +216 -85
- package/src/exporters/matrix/types.ts +58 -8
- package/src/exporters/matrix/wording.ts +221 -0
- package/src/exporters/scenario-merger.ts +2 -1
- package/src/exporters/spec-parser.ts +2 -1
- package/src/harness/audit.ts +11 -2
- package/src/harness/blindspot.ts +2 -1
- package/src/harness/capability-plan.ts +3 -2
- package/src/harness/feedback.ts +3 -2
- package/src/harness/flow-check.ts +2 -1
- package/src/harness/flow-plan.ts +3 -2
- package/src/harness/intent.ts +2 -1
- package/src/harness/journey.ts +3 -2
- package/src/harness/ledger.ts +3 -2
- package/src/harness/manifest.ts +4 -3
- package/src/harness/parse.ts +17 -3
- package/src/harness/quality-gates.ts +2 -1
- package/src/harness/read-text.ts +28 -0
- package/src/harness/script-check.ts +3 -2
- package/src/harness/sensors.ts +2 -1
- package/src/harness/spec-coverage.ts +22 -7
- package/src/harness/trace.ts +4 -3
- package/src/harness/viewpoint-ledger.ts +2 -1
- package/src/orchestrator/templates/ai-src/commands/create-test.md +9 -0
- package/src/orchestrator/templates/ai-src/commands/delivery.md +103 -18
- package/src/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +77 -11
- package/src/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +1 -0
- 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
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
?
|
|
125
|
+
? classifyManualComments(m.feature.comments)
|
|
114
126
|
: null;
|
|
115
|
-
|
|
116
|
-
//
|
|
117
|
-
const rawTrigger = manual ? manual.
|
|
118
|
-
const
|
|
119
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
140
|
-
|
|
166
|
+
runtimeVars: [...runtimeVars],
|
|
167
|
+
triggerShape: rawTrigger.map(normalizeShape),
|
|
168
|
+
oracleShape: rawOracle.map(normalizeShape),
|
|
141
169
|
preconditionProfile,
|
|
142
170
|
precondition,
|
|
143
|
-
procedureProfile
|
|
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:
|
|
161
|
-
oracle:
|
|
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:
|
|
174
|
-
oracle:
|
|
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))
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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[
|
|
205
|
-
const
|
|
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 (
|
|
210
|
-
else if (counts.
|
|
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
|
|
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
|
|
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
|
-
|
|
259
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
}
|