@sun-asterisk/sungen 3.2.15 → 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.
- package/dist/cli/commands/delivery.d.ts.map +1 -1
- package/dist/cli/commands/delivery.js +139 -1
- package/dist/cli/commands/delivery.js.map +1 -1
- package/dist/dashboard/templates/index.html +1 -1
- package/dist/exporters/feature-parser.js +1 -1
- package/dist/exporters/feature-parser.js.map +1 -1
- package/dist/exporters/matrix/build.d.ts +43 -0
- package/dist/exporters/matrix/build.d.ts.map +1 -0
- package/dist/exporters/matrix/build.js +403 -0
- package/dist/exporters/matrix/build.js.map +1 -0
- package/dist/exporters/matrix/export.d.ts +49 -0
- package/dist/exporters/matrix/export.d.ts.map +1 -0
- package/dist/exporters/matrix/export.js +143 -0
- package/dist/exporters/matrix/export.js.map +1 -0
- package/dist/exporters/matrix/fingerprint.d.ts +20 -0
- package/dist/exporters/matrix/fingerprint.d.ts.map +1 -0
- package/dist/exporters/matrix/fingerprint.js +83 -0
- package/dist/exporters/matrix/fingerprint.js.map +1 -0
- package/dist/exporters/matrix/gates.d.ts +21 -0
- package/dist/exporters/matrix/gates.d.ts.map +1 -0
- package/dist/exporters/matrix/gates.js +295 -0
- package/dist/exporters/matrix/gates.js.map +1 -0
- package/dist/exporters/matrix/map-loader.d.ts +22 -0
- package/dist/exporters/matrix/map-loader.d.ts.map +1 -0
- package/dist/exporters/matrix/map-loader.js +191 -0
- package/dist/exporters/matrix/map-loader.js.map +1 -0
- package/dist/exporters/matrix/render-csv.d.ts +9 -0
- package/dist/exporters/matrix/render-csv.d.ts.map +1 -0
- package/dist/exporters/matrix/render-csv.js +89 -0
- package/dist/exporters/matrix/render-csv.js.map +1 -0
- package/dist/exporters/matrix/render-xlsx.d.ts +25 -0
- package/dist/exporters/matrix/render-xlsx.d.ts.map +1 -0
- package/dist/exporters/matrix/render-xlsx.js +362 -0
- package/dist/exporters/matrix/render-xlsx.js.map +1 -0
- package/dist/exporters/matrix/types.d.ts +175 -0
- package/dist/exporters/matrix/types.d.ts.map +1 -0
- package/dist/exporters/matrix/types.js +15 -0
- package/dist/exporters/matrix/types.js.map +1 -0
- package/dist/exporters/matrix/wording.d.ts +45 -0
- package/dist/exporters/matrix/wording.d.ts.map +1 -0
- package/dist/exporters/matrix/wording.js +150 -0
- package/dist/exporters/matrix/wording.js.map +1 -0
- package/dist/exporters/scenario-merger.js +1 -1
- package/dist/exporters/scenario-merger.js.map +1 -1
- package/dist/exporters/spec-parser.js +1 -1
- package/dist/exporters/spec-parser.js.map +1 -1
- package/dist/exporters/xlsx-report-builder.d.ts +1 -1
- package/dist/exporters/xlsx-report-builder.d.ts.map +1 -1
- package/dist/exporters/xlsx-report-builder.js +3 -3
- package/dist/exporters/xlsx-report-builder.js.map +1 -1
- package/dist/harness/audit.d.ts.map +1 -1
- package/dist/harness/audit.js +6 -1
- package/dist/harness/audit.js.map +1 -1
- package/dist/harness/capability.d.ts +25 -0
- package/dist/harness/capability.d.ts.map +1 -1
- package/dist/harness/capability.js +44 -2
- package/dist/harness/capability.js.map +1 -1
- package/dist/orchestrator/templates/ai-src/commands/create-test.md +1 -1
- package/dist/orchestrator/templates/ai-src/commands/delivery.md +144 -120
- package/dist/orchestrator/templates/ai-src/config/claude.md +1 -1
- package/dist/orchestrator/templates/ai-src/config/copilot.md +1 -1
- package/dist/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +59 -6
- package/dist/orchestrator/templates/ai-src/skills/sungen-harness-audit/SKILL.md +1 -1
- package/dist/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +1 -0
- package/package.json +4 -4
- package/src/cli/commands/delivery.ts +169 -2
- package/src/dashboard/templates/index.html +1 -1
- package/src/exporters/feature-parser.ts +1 -1
- package/src/exporters/matrix/build.ts +432 -0
- package/src/exporters/matrix/export.ts +134 -0
- package/src/exporters/matrix/fingerprint.ts +49 -0
- package/src/exporters/matrix/gates.ts +325 -0
- package/src/exporters/matrix/map-loader.ts +161 -0
- package/src/exporters/matrix/render-csv.ts +91 -0
- package/src/exporters/matrix/render-xlsx.ts +356 -0
- package/src/exporters/matrix/types.ts +202 -0
- package/src/exporters/matrix/wording.ts +157 -0
- package/src/exporters/scenario-merger.ts +1 -1
- package/src/exporters/spec-parser.ts +1 -1
- package/src/exporters/xlsx-report-builder.ts +3 -2
- package/src/harness/audit.ts +7 -2
- package/src/harness/capability.ts +53 -3
- package/src/orchestrator/templates/ai-src/commands/create-test.md +1 -1
- package/src/orchestrator/templates/ai-src/commands/delivery.md +144 -120
- package/src/orchestrator/templates/ai-src/config/claude.md +1 -1
- package/src/orchestrator/templates/ai-src/config/copilot.md +1 -1
- package/src/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +59 -6
- package/src/orchestrator/templates/ai-src/skills/sungen-harness-audit/SKILL.md +1 -1
- package/src/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +1 -0
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the matrix model: derive atomic coverage variants from the merged
|
|
3
|
+
* feature/spec view, assemble delivery items per the Delivery Map, attach
|
|
4
|
+
* per-variant Playwright results, and roll parents up (derived only — Gate F
|
|
5
|
+
* is structural: there is no independent parent result input).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { FeatureMetadata, PlaywrightResult } from '../types';
|
|
9
|
+
import { MergedScenario } from '../scenario-merger';
|
|
10
|
+
import {
|
|
11
|
+
extractAuthRole,
|
|
12
|
+
extractPriority,
|
|
13
|
+
extractTestcaseType,
|
|
14
|
+
splitVpAndName,
|
|
15
|
+
} from '../feature-parser';
|
|
16
|
+
import { getCasesDatasetRows, resolveResultVariants } from '../result-variants';
|
|
17
|
+
import { classifyManualComments, renderAction, renderExpected, renderPrecondition } from './wording';
|
|
18
|
+
import { scenarioFingerprint, combinedFingerprint, mapContentFingerprint } from './fingerprint';
|
|
19
|
+
import {
|
|
20
|
+
CoverageVariant,
|
|
21
|
+
DeliveryItem,
|
|
22
|
+
DeliveryMap,
|
|
23
|
+
ItemResult,
|
|
24
|
+
MatrixDisposition,
|
|
25
|
+
MatrixLayer,
|
|
26
|
+
MatrixModel,
|
|
27
|
+
RequirementCoverage,
|
|
28
|
+
MAX_VARIANTS_PER_ITEM,
|
|
29
|
+
} from './types';
|
|
30
|
+
import { runGates } from './gates';
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Variant derivation
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
/** Collapse `{{var}}` placeholders so data values become parameters, not shape. */
|
|
37
|
+
function normalizeShape(text: string): string {
|
|
38
|
+
return text.replace(/\{\{[^}]*\}\}/g, '{{*}}').replace(/\s+/g, ' ').trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function deriveLayers(tags: string[]): MatrixLayer[] {
|
|
42
|
+
const layers = new Set<MatrixLayer>(['ui']);
|
|
43
|
+
if (tags.some((t) => t === '@api' || t.startsWith('@api:') || t === '@requires:api')) layers.add('api');
|
|
44
|
+
if (tags.some((t) => t.startsWith('@query') || t === '@requires:db')) layers.add('db');
|
|
45
|
+
return Array.from(layers);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 'simple' single G*→W*→T* pass; 'sequence' when the flow re-enters Given/When after a Then. */
|
|
49
|
+
function deriveProcedureProfile(m: MergedScenario): 'simple' | 'sequence' | 'manual' {
|
|
50
|
+
if (extractTestcaseType(m.feature.tags) === 'Manual') return 'manual';
|
|
51
|
+
const order = m.feature.orderedSteps.map((s) => s.bucket);
|
|
52
|
+
let seenThen = false;
|
|
53
|
+
for (const bucket of order) {
|
|
54
|
+
if (bucket === 'then') seenThen = true;
|
|
55
|
+
else if (seenThen) return 'sequence'; // action/setup after an assertion → ordered flow
|
|
56
|
+
}
|
|
57
|
+
return 'simple';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function manualReason(tags: string[]): string | undefined {
|
|
61
|
+
const t = tags.find((x) => x.startsWith('@manual:'));
|
|
62
|
+
return t ? t.slice('@manual:'.length) : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Resolve `{{var}}` refs of a scenario into display `key: value` pairs. */
|
|
66
|
+
function resolveDataPairs(
|
|
67
|
+
vars: string[],
|
|
68
|
+
testData: Record<string, string> | null,
|
|
69
|
+
row?: Record<string, unknown>,
|
|
70
|
+
): string[] {
|
|
71
|
+
const pairs: string[] = [];
|
|
72
|
+
if (row) {
|
|
73
|
+
for (const [k, v] of Object.entries(row)) {
|
|
74
|
+
if (k.startsWith('__') || k === 'case' || k === 'name' || k === 'label') continue;
|
|
75
|
+
pairs.push(`${k}: ${String(v)}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
for (const v of vars) {
|
|
79
|
+
const val = row && v in row ? undefined : testData?.[v]; // row columns already listed
|
|
80
|
+
if (val !== undefined) pairs.push(`${v}: ${val}`);
|
|
81
|
+
}
|
|
82
|
+
return pairs;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface BuildInputs {
|
|
86
|
+
unit: string;
|
|
87
|
+
feature: FeatureMetadata;
|
|
88
|
+
merged: MergedScenario[];
|
|
89
|
+
testData: Record<string, string> | null;
|
|
90
|
+
results: Map<string, PlaywrightResult> | null;
|
|
91
|
+
map: DeliveryMap;
|
|
92
|
+
transformerVersion: string;
|
|
93
|
+
/** requirements/spec.md content — source of the requirement-id inventory (FR/TR/NFR). */
|
|
94
|
+
specText?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Derive every atomic coverage variant of the unit (one per scenario, or one per
|
|
99
|
+
* `@cases` dataset row). Exported separately so the gates + the map-proposal
|
|
100
|
+
* support tooling see the same universe the builder does.
|
|
101
|
+
*/
|
|
102
|
+
export function deriveVariants(inputs: Pick<BuildInputs, 'feature' | 'merged' | 'testData' | 'results'>): CoverageVariant[] {
|
|
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);
|
|
107
|
+
const variants: CoverageVariant[] = [];
|
|
108
|
+
|
|
109
|
+
for (const m of merged) {
|
|
110
|
+
const { vpId, category1 } = splitVpAndName(m.feature.name);
|
|
111
|
+
if (!vpId) continue; // Gate A reports it — no variant can be addressed without a VP-id
|
|
112
|
+
const tags = m.feature.tags;
|
|
113
|
+
const mode = extractTestcaseType(tags) === 'Manual' ? 'manual' : 'auto';
|
|
114
|
+
const authRole = extractAuthRole(tags);
|
|
115
|
+
const vpCategory = vpId.replace(/^VP-/, '').replace(/-\d+[a-zA-Z]?$/, '');
|
|
116
|
+
const procedureProfile = deriveProcedureProfile(m);
|
|
117
|
+
|
|
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.
|
|
121
|
+
const manual = mode === 'manual' && m.feature.comments?.length
|
|
122
|
+
? classifyManualComments(m.feature.comments)
|
|
123
|
+
: null;
|
|
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);
|
|
130
|
+
|
|
131
|
+
const preconditionProfile = [
|
|
132
|
+
authRole ?? '-',
|
|
133
|
+
m.feature.extendsName ?? '-',
|
|
134
|
+
...m.feature.rawGivenSteps.map(normalizeShape),
|
|
135
|
+
...(manual?.preconditions ?? []).map(normalizeShape),
|
|
136
|
+
].join(' | ');
|
|
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
|
+
]));
|
|
146
|
+
|
|
147
|
+
const base = {
|
|
148
|
+
vpId,
|
|
149
|
+
vpCategory,
|
|
150
|
+
priority: extractPriority(tags),
|
|
151
|
+
mode: mode as 'auto' | 'manual',
|
|
152
|
+
manualReason: manualReason(tags),
|
|
153
|
+
layers: deriveLayers(tags),
|
|
154
|
+
traces: tags.filter((t) => t.startsWith('@spec:')).map((t) => t.slice('@spec:'.length)),
|
|
155
|
+
triggerShape: rawTrigger.map(normalizeShape),
|
|
156
|
+
oracleShape: rawOracle.map(normalizeShape),
|
|
157
|
+
preconditionProfile,
|
|
158
|
+
precondition,
|
|
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
|
+
};
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const rows = getCasesDatasetRows(m, testData ?? undefined);
|
|
198
|
+
if (rows) {
|
|
199
|
+
// One variant per dataset row; results matched per row label by the shared resolver.
|
|
200
|
+
const resultVariants = resolveResultVariants(m, results ?? null, testData ?? undefined);
|
|
201
|
+
const resultByLabel = new Map(resultVariants.map((rv) => [rv.nameSuffix.replace(/^ — /, ''), rv.result]));
|
|
202
|
+
rows.forEach((row, i) => {
|
|
203
|
+
const label = String(row.case ?? row.name ?? row.label ?? `row ${i + 1}`);
|
|
204
|
+
const cells = renderCells({ ...(testData ?? {}), ...rowAsStrings(row) });
|
|
205
|
+
variants.push({
|
|
206
|
+
...base,
|
|
207
|
+
ref: `${vpId}#${label}`,
|
|
208
|
+
caseLabel: label,
|
|
209
|
+
title: `${category1} — ${label}`,
|
|
210
|
+
condition: label,
|
|
211
|
+
data: resolveDataPairs(m.feature.referencedVars, testData, row),
|
|
212
|
+
trigger: cells.trigger,
|
|
213
|
+
oracle: cells.oracle,
|
|
214
|
+
precondition: cells.precondition,
|
|
215
|
+
verification: cells.verification,
|
|
216
|
+
fingerprint: scenarioFingerprint(m.feature, row),
|
|
217
|
+
result: resultByLabel.get(label),
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
} else {
|
|
221
|
+
const cells = renderCells(testData ?? {});
|
|
222
|
+
variants.push({
|
|
223
|
+
...base,
|
|
224
|
+
ref: vpId,
|
|
225
|
+
title: category1,
|
|
226
|
+
condition: category1,
|
|
227
|
+
data: resolveDataPairs(m.feature.referencedVars, testData),
|
|
228
|
+
trigger: cells.trigger,
|
|
229
|
+
oracle: cells.oracle,
|
|
230
|
+
precondition: cells.precondition,
|
|
231
|
+
verification: cells.verification,
|
|
232
|
+
fingerprint: scenarioFingerprint(m.feature),
|
|
233
|
+
result: resolveResultVariants(m, results ?? null, testData ?? undefined)[0]?.result,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return variants;
|
|
238
|
+
}
|
|
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
|
+
|
|
305
|
+
function rowAsStrings(row: Record<string, unknown>): Record<string, string> {
|
|
306
|
+
const out: Record<string, string> = {};
|
|
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
|
+
}
|
|
311
|
+
return out;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
// Item assembly + roll-up
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
function classifyResult(r: PlaywrightResult | undefined): 'passed' | 'failed' | 'blocked' | 'notRun' {
|
|
319
|
+
if (!r) return 'notRun';
|
|
320
|
+
if (r.status === 'passed') return 'passed';
|
|
321
|
+
if (r.status === 'failed' || r.status === 'timedOut') return 'failed';
|
|
322
|
+
if (r.status === 'interrupted') return 'blocked';
|
|
323
|
+
return 'notRun'; // skipped / unknown
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Derived parent result — precedence: failed → blocked → not_run → partial → passed. */
|
|
327
|
+
export function rollUp(variants: CoverageVariant[]): { result: ItemResult; counts: DeliveryItem['resultCounts'] } {
|
|
328
|
+
const counts = { passed: 0, failed: 0, blocked: 0, notRun: 0 };
|
|
329
|
+
for (const v of variants) counts[classifyResult(v.result)]++;
|
|
330
|
+
const run = counts.passed + counts.failed + counts.blocked;
|
|
331
|
+
let result: ItemResult;
|
|
332
|
+
if (counts.failed > 0) result = 'failed';
|
|
333
|
+
else if (counts.blocked > 0) result = 'blocked';
|
|
334
|
+
else if (run === 0) result = 'not_run';
|
|
335
|
+
else if (counts.notRun > 0) result = 'partial';
|
|
336
|
+
else result = 'passed';
|
|
337
|
+
return { result, counts };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Expand one map variant ref to concrete variant refs:
|
|
342
|
+
* a bare VP-id whose scenario has a dataset means ALL its rows.
|
|
343
|
+
*/
|
|
344
|
+
export function expandMapRef(ref: string, variantsByVp: Map<string, CoverageVariant[]>): CoverageVariant[] {
|
|
345
|
+
if (ref.includes('#')) {
|
|
346
|
+
const [vp] = ref.split('#');
|
|
347
|
+
return (variantsByVp.get(vp) ?? []).filter((v) => v.ref === ref);
|
|
348
|
+
}
|
|
349
|
+
return variantsByVp.get(ref) ?? [];
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export function buildMatrix(inputs: BuildInputs): MatrixModel {
|
|
353
|
+
const { unit, map, transformerVersion } = inputs;
|
|
354
|
+
const variants = deriveVariants(inputs);
|
|
355
|
+
|
|
356
|
+
const variantsByVp = new Map<string, CoverageVariant[]>();
|
|
357
|
+
for (const v of variants) {
|
|
358
|
+
const list = variantsByVp.get(v.vpId) ?? [];
|
|
359
|
+
list.push(v);
|
|
360
|
+
variantsByVp.set(v.vpId, list);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const findings = runGates({ inputs, variants, variantsByVp });
|
|
364
|
+
const stale = new Set(findings.filter((f) => f.gate === 'E' && f.ref).map((f) => f.ref as string));
|
|
365
|
+
|
|
366
|
+
const items: DeliveryItem[] = map.groups.map((g) => {
|
|
367
|
+
const groupVariants = g.variants.flatMap((ref) => expandMapRef(ref, variantsByVp));
|
|
368
|
+
const { result, counts } = rollUp(groupVariants);
|
|
369
|
+
const first = groupVariants[0];
|
|
370
|
+
const triggerShapes = new Set(groupVariants.map((v) => v.triggerShape.join(' ; ')));
|
|
371
|
+
return {
|
|
372
|
+
id: g.id,
|
|
373
|
+
target: g.target,
|
|
374
|
+
intent: g.intent,
|
|
375
|
+
oracle: g.oracle,
|
|
376
|
+
category: g.category,
|
|
377
|
+
// A stale (drifted) group presents as proposed regardless of its stored state.
|
|
378
|
+
review: stale.has(g.id) ? 'proposed' : g.review,
|
|
379
|
+
priority: first?.priority ?? 'Normal',
|
|
380
|
+
mode: first?.mode ?? 'auto',
|
|
381
|
+
layers: Array.from(new Set(groupVariants.flatMap((v) => v.layers))),
|
|
382
|
+
traces: Array.from(new Set(groupVariants.flatMap((v) => v.traces))),
|
|
383
|
+
precondition: first?.precondition ?? [],
|
|
384
|
+
trigger: triggerShapes.size === 1 ? (first?.trigger ?? []) : ['(per variant)'],
|
|
385
|
+
variants: groupVariants,
|
|
386
|
+
result,
|
|
387
|
+
resultCounts: counts,
|
|
388
|
+
};
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
const dispositions: MatrixDisposition[] = Object.entries(map.dispositions).map(([vpId, d]) => ({
|
|
392
|
+
vpId,
|
|
393
|
+
title: variantsByVp.get(vpId)?.[0]?.title ?? '',
|
|
394
|
+
as: d.as,
|
|
395
|
+
reason: d.reason ?? '',
|
|
396
|
+
}));
|
|
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
|
+
|
|
410
|
+
const variantCount = items.reduce((a, i) => a + i.variants.length, 0);
|
|
411
|
+
const approved = items.every((i) => i.review === 'approved')
|
|
412
|
+
&& !findings.some((f) => f.severity === 'error' || f.severity === 'review');
|
|
413
|
+
|
|
414
|
+
return {
|
|
415
|
+
unit,
|
|
416
|
+
formNo: map.formNo ?? 'BM-2-901-13',
|
|
417
|
+
items,
|
|
418
|
+
dispositions,
|
|
419
|
+
requirements,
|
|
420
|
+
findings,
|
|
421
|
+
manifest: {
|
|
422
|
+
unit,
|
|
423
|
+
transformerVersion,
|
|
424
|
+
sourceFingerprint: combinedFingerprint(variants.map((v) => v.fingerprint)),
|
|
425
|
+
mapFingerprint: mapContentFingerprint(map.groups, map.dispositions),
|
|
426
|
+
approvalState: approved ? 'approved' : 'draft',
|
|
427
|
+
itemCount: items.length,
|
|
428
|
+
variantCount,
|
|
429
|
+
thresholds: { maxVariantsPerItem: MAX_VARIANTS_PER_ITEM },
|
|
430
|
+
},
|
|
431
|
+
};
|
|
432
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Matrix export orchestration — the piece `sungen delivery` calls.
|
|
3
|
+
*
|
|
4
|
+
* Composes the existing parsing seams (feature-parser, spec-parser, test-data,
|
|
5
|
+
* playwright report) with the map loader, builder, gates, and renderers. All
|
|
6
|
+
* inputs arrive as resolved paths so the CLI's discovery logic stays the single
|
|
7
|
+
* owner of unit-kind layout rules.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as fs from 'fs';
|
|
11
|
+
import { parseFeatureMetadata } from '../feature-parser';
|
|
12
|
+
import { parseSpecFile } from '../spec-parser';
|
|
13
|
+
import { loadTestData } from '../test-data-resolver';
|
|
14
|
+
import { loadPlaywrightReport } from '../playwright-report-parser';
|
|
15
|
+
import { mergeFeatureAndSpec } from '../scenario-merger';
|
|
16
|
+
import { getPackageVersion } from '../package-info';
|
|
17
|
+
import { writeCsv } from '../csv-exporter';
|
|
18
|
+
import { writeXlsx } from '../xlsx-exporter';
|
|
19
|
+
import { loadDeliveryMap, writeDeliveryMap } from './map-loader';
|
|
20
|
+
import { buildMatrix, deriveVariants } from './build';
|
|
21
|
+
import { renderMatrixXlsx } from './render-xlsx';
|
|
22
|
+
import { renderMatrixCsv } from './render-csv';
|
|
23
|
+
import { DeliveryMap, MatrixFinding, MatrixModel } from './types';
|
|
24
|
+
|
|
25
|
+
export interface MatrixTargetPaths {
|
|
26
|
+
cwd: string;
|
|
27
|
+
/** Feature basename — names the deliverable files. */
|
|
28
|
+
unit: string;
|
|
29
|
+
/** Display label (flow/… prefix for flows). */
|
|
30
|
+
label: string;
|
|
31
|
+
featureFile: string;
|
|
32
|
+
testDataFile: string;
|
|
33
|
+
specFile: string;
|
|
34
|
+
/** requirements/spec.md — the requirement-id inventory for coverage (optional). */
|
|
35
|
+
specMdFile?: string;
|
|
36
|
+
resultsPath: string | null;
|
|
37
|
+
mapFile: string;
|
|
38
|
+
}
|
|
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
|
+
|
|
46
|
+
export interface MatrixLoadResult {
|
|
47
|
+
model?: MatrixModel;
|
|
48
|
+
map?: DeliveryMap;
|
|
49
|
+
/** No map file → the caller prints the guidance path (AI proposal or --legacy). */
|
|
50
|
+
mapMissing?: boolean;
|
|
51
|
+
/** Structural map problems (bad YAML/shape) — fix the file before anything else. */
|
|
52
|
+
mapErrors: string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function loadMatrixModel(paths: MatrixTargetPaths): MatrixLoadResult {
|
|
56
|
+
const { map, errors } = loadDeliveryMap(paths.mapFile);
|
|
57
|
+
if (!map) {
|
|
58
|
+
return errors.length === 0 ? { mapMissing: true, mapErrors: [] } : { mapErrors: errors };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const feature = parseFeatureMetadata(paths.featureFile);
|
|
62
|
+
const spec = parseSpecFile(paths.specFile);
|
|
63
|
+
const merged = mergeFeatureAndSpec(feature, spec);
|
|
64
|
+
const testData = fs.existsSync(paths.testDataFile) ? loadTestData(paths.testDataFile, process.env.SUNGEN_ENV ?? null) : null;
|
|
65
|
+
const results = paths.resultsPath ? loadPlaywrightReport(paths.resultsPath) : null;
|
|
66
|
+
|
|
67
|
+
const model = buildMatrix({
|
|
68
|
+
unit: paths.unit,
|
|
69
|
+
feature,
|
|
70
|
+
merged,
|
|
71
|
+
testData,
|
|
72
|
+
results,
|
|
73
|
+
map,
|
|
74
|
+
transformerVersion: getPackageVersion(),
|
|
75
|
+
specText: readSpecText(paths),
|
|
76
|
+
});
|
|
77
|
+
return { model, map, mapErrors: [] };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const hasErrors = (f: MatrixFinding[]): boolean => f.some((x) => x.severity === 'error');
|
|
81
|
+
export const hasReview = (f: MatrixFinding[]): boolean => f.some((x) => x.severity === 'review');
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Stamp fingerprints + flip groups to `approved` (all groups, or just `groupIds`).
|
|
85
|
+
* Refuses when gate ERRORs remain — an invalid map must not be frozen as approved.
|
|
86
|
+
* Returns the findings so the caller reports what blocked or what was approved.
|
|
87
|
+
*/
|
|
88
|
+
export function approveMatrix(paths: MatrixTargetPaths, groupIds?: string[]): { findings: MatrixFinding[]; approved: string[] } {
|
|
89
|
+
const { map, errors } = loadDeliveryMap(paths.mapFile);
|
|
90
|
+
if (!map) throw new Error(errors.length > 0 ? `map is invalid:\n - ${errors.join('\n - ')}` : `no delivery map at ${paths.mapFile}`);
|
|
91
|
+
|
|
92
|
+
const feature = parseFeatureMetadata(paths.featureFile);
|
|
93
|
+
const spec = parseSpecFile(paths.specFile);
|
|
94
|
+
const merged = mergeFeatureAndSpec(feature, spec);
|
|
95
|
+
const testData = fs.existsSync(paths.testDataFile) ? loadTestData(paths.testDataFile, process.env.SUNGEN_ENV ?? null) : null;
|
|
96
|
+
|
|
97
|
+
const model = buildMatrix({
|
|
98
|
+
unit: paths.unit, feature, merged, testData, results: null, map,
|
|
99
|
+
transformerVersion: getPackageVersion(),
|
|
100
|
+
specText: readSpecText(paths),
|
|
101
|
+
});
|
|
102
|
+
const blocking = model.findings.filter((f) => f.severity === 'error');
|
|
103
|
+
if (blocking.length > 0) return { findings: blocking, approved: [] };
|
|
104
|
+
|
|
105
|
+
const variants = deriveVariants({ feature, merged, testData, results: null });
|
|
106
|
+
const byRef = new Map(variants.map((v) => [v.ref, v] as const));
|
|
107
|
+
const byVp = new Map<string, typeof variants>();
|
|
108
|
+
for (const v of variants) {
|
|
109
|
+
const list = byVp.get(v.vpId) ?? [];
|
|
110
|
+
list.push(v);
|
|
111
|
+
byVp.set(v.vpId, list);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const approved: string[] = [];
|
|
115
|
+
for (const g of map.groups) {
|
|
116
|
+
if (groupIds && groupIds.length > 0 && !groupIds.includes(g.id)) continue;
|
|
117
|
+
for (const ref of g.variants) {
|
|
118
|
+
const expanded = ref.includes('#') ? [byRef.get(ref)].filter(Boolean) : (byVp.get(ref) ?? []);
|
|
119
|
+
for (const v of expanded) map.fingerprints[(v as NonNullable<typeof expanded[number]>).ref] = (v as NonNullable<typeof expanded[number]>).fingerprint;
|
|
120
|
+
}
|
|
121
|
+
g.review = 'approved';
|
|
122
|
+
approved.push(g.id);
|
|
123
|
+
}
|
|
124
|
+
writeDeliveryMap(paths.mapFile, map);
|
|
125
|
+
return { findings: model.findings.filter((f) => f.severity !== 'error'), approved };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Render + write both artifacts. Caller has already enforced the gate policy. */
|
|
129
|
+
export async function writeMatrixDeliverables(paths: MatrixTargetPaths, model: MatrixModel): Promise<{ csvPath: string; xlsxPath: string }> {
|
|
130
|
+
const csvPath = writeCsv(paths.cwd, paths.unit, renderMatrixCsv(model));
|
|
131
|
+
const wb = renderMatrixXlsx(model, getPackageVersion());
|
|
132
|
+
const xlsxPath = await writeXlsx(paths.cwd, paths.unit, wb);
|
|
133
|
+
return { csvPath, xlsxPath };
|
|
134
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical scenario fingerprints for the Delivery Map drift detector (Gate E).
|
|
3
|
+
*
|
|
4
|
+
* A fingerprint hashes what changes the MEANING of a variant: sorted tags + ordered
|
|
5
|
+
* step text (+ the dataset row for a @cases variant). Formatting, comments, and
|
|
6
|
+
* scenario order do NOT change it. `--approve` stamps these into the map; a later
|
|
7
|
+
* mismatch drops the group back to review (stale grouping).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as crypto from 'crypto';
|
|
11
|
+
import { ScenarioMetadata } from '../types';
|
|
12
|
+
|
|
13
|
+
/** Stable stringify: objects get sorted keys so row column order can't shift the hash. */
|
|
14
|
+
function canonical(value: unknown): string {
|
|
15
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
16
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
|
17
|
+
const obj = value as Record<string, unknown>;
|
|
18
|
+
const keys = Object.keys(obj).sort();
|
|
19
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonical(obj[k])}`).join(',')}}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function sha1(text: string): string {
|
|
23
|
+
return 'sha1:' + crypto.createHash('sha1').update(text, 'utf-8').digest('hex');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Fingerprint one scenario. Tags are sorted (order is presentation); steps keep
|
|
28
|
+
* their order (order is meaning). Manual scenarios include their procedure
|
|
29
|
+
* comments — for them the comments ARE the test.
|
|
30
|
+
*/
|
|
31
|
+
export function scenarioFingerprint(sc: ScenarioMetadata, datasetRow?: Record<string, unknown>): string {
|
|
32
|
+
const body = canonical({
|
|
33
|
+
tags: [...sc.tags].sort(),
|
|
34
|
+
steps: sc.orderedSteps.map((s) => `${s.bucket}:${s.text}`),
|
|
35
|
+
comments: sc.tags.some((t) => t === '@manual' || t.startsWith('@manual:')) ? (sc.comments ?? []) : [],
|
|
36
|
+
row: datasetRow ?? null,
|
|
37
|
+
});
|
|
38
|
+
return sha1(body);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Fingerprint of the whole source (all variants) — manifest `sourceFingerprint`. */
|
|
42
|
+
export function combinedFingerprint(parts: string[]): string {
|
|
43
|
+
return sha1(canonical([...parts].sort()));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Fingerprint of the map's semantic content (groups + dispositions, not stamps). */
|
|
47
|
+
export function mapContentFingerprint(groups: unknown, dispositions: unknown): string {
|
|
48
|
+
return sha1(canonical({ groups, dispositions }));
|
|
49
|
+
}
|