@sun-asterisk/sungen 3.2.17 → 3.2.18

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 (187) hide show
  1. package/dist/cli/commands/audit.d.ts.map +1 -1
  2. package/dist/cli/commands/audit.js +23 -0
  3. package/dist/cli/commands/audit.js.map +1 -1
  4. package/dist/cli/commands/delivery.d.ts.map +1 -1
  5. package/dist/cli/commands/delivery.js +209 -16
  6. package/dist/cli/commands/delivery.js.map +1 -1
  7. package/dist/cli/index.js +5 -0
  8. package/dist/cli/index.js.map +1 -1
  9. package/dist/dashboard/snapshot-builder.d.ts.map +1 -1
  10. package/dist/dashboard/snapshot-builder.js +8 -1
  11. package/dist/dashboard/snapshot-builder.js.map +1 -1
  12. package/dist/dashboard/templates/index.html +1 -1
  13. package/dist/exporters/api-environment.d.ts +11 -0
  14. package/dist/exporters/api-environment.d.ts.map +1 -0
  15. package/dist/exporters/api-environment.js +129 -0
  16. package/dist/exporters/api-environment.js.map +1 -0
  17. package/dist/exporters/feature-parser.d.ts +16 -1
  18. package/dist/exporters/feature-parser.d.ts.map +1 -1
  19. package/dist/exporters/feature-parser.js +21 -2
  20. package/dist/exporters/feature-parser.js.map +1 -1
  21. package/dist/exporters/matrix/build.d.ts +70 -0
  22. package/dist/exporters/matrix/build.d.ts.map +1 -0
  23. package/dist/exporters/matrix/build.js +516 -0
  24. package/dist/exporters/matrix/build.js.map +1 -0
  25. package/dist/exporters/matrix/export.d.ts +50 -0
  26. package/dist/exporters/matrix/export.d.ts.map +1 -0
  27. package/dist/exporters/matrix/export.js +152 -0
  28. package/dist/exporters/matrix/export.js.map +1 -0
  29. package/dist/exporters/matrix/fingerprint.d.ts +20 -0
  30. package/dist/exporters/matrix/fingerprint.d.ts.map +1 -0
  31. package/dist/exporters/matrix/fingerprint.js +83 -0
  32. package/dist/exporters/matrix/fingerprint.js.map +1 -0
  33. package/dist/exporters/matrix/gates.d.ts +21 -0
  34. package/dist/exporters/matrix/gates.d.ts.map +1 -0
  35. package/dist/exporters/matrix/gates.js +367 -0
  36. package/dist/exporters/matrix/gates.js.map +1 -0
  37. package/dist/exporters/matrix/map-loader.d.ts +22 -0
  38. package/dist/exporters/matrix/map-loader.d.ts.map +1 -0
  39. package/dist/exporters/matrix/map-loader.js +193 -0
  40. package/dist/exporters/matrix/map-loader.js.map +1 -0
  41. package/dist/exporters/matrix/render-csv.d.ts +9 -0
  42. package/dist/exporters/matrix/render-csv.d.ts.map +1 -0
  43. package/dist/exporters/matrix/render-csv.js +92 -0
  44. package/dist/exporters/matrix/render-csv.js.map +1 -0
  45. package/dist/exporters/matrix/render-xlsx.d.ts +41 -0
  46. package/dist/exporters/matrix/render-xlsx.d.ts.map +1 -0
  47. package/dist/exporters/matrix/render-xlsx.js +430 -0
  48. package/dist/exporters/matrix/render-xlsx.js.map +1 -0
  49. package/dist/exporters/matrix/types.d.ts +199 -0
  50. package/dist/exporters/matrix/types.d.ts.map +1 -0
  51. package/dist/exporters/matrix/types.js +15 -0
  52. package/dist/exporters/matrix/types.js.map +1 -0
  53. package/dist/exporters/matrix/wording.d.ts +61 -0
  54. package/dist/exporters/matrix/wording.d.ts.map +1 -0
  55. package/dist/exporters/matrix/wording.js +221 -0
  56. package/dist/exporters/matrix/wording.js.map +1 -0
  57. package/dist/exporters/scenario-merger.d.ts.map +1 -1
  58. package/dist/exporters/scenario-merger.js +2 -1
  59. package/dist/exporters/scenario-merger.js.map +1 -1
  60. package/dist/exporters/spec-parser.d.ts.map +1 -1
  61. package/dist/exporters/spec-parser.js +2 -1
  62. package/dist/exporters/spec-parser.js.map +1 -1
  63. package/dist/exporters/xlsx-report-builder.d.ts +1 -1
  64. package/dist/exporters/xlsx-report-builder.d.ts.map +1 -1
  65. package/dist/exporters/xlsx-report-builder.js +3 -3
  66. package/dist/exporters/xlsx-report-builder.js.map +1 -1
  67. package/dist/harness/audit.d.ts +10 -0
  68. package/dist/harness/audit.d.ts.map +1 -1
  69. package/dist/harness/audit.js +98 -16
  70. package/dist/harness/audit.js.map +1 -1
  71. package/dist/harness/blindspot.d.ts.map +1 -1
  72. package/dist/harness/blindspot.js +2 -1
  73. package/dist/harness/blindspot.js.map +1 -1
  74. package/dist/harness/capability-plan.d.ts +2 -0
  75. package/dist/harness/capability-plan.d.ts.map +1 -1
  76. package/dist/harness/capability-plan.js +17 -4
  77. package/dist/harness/capability-plan.js.map +1 -1
  78. package/dist/harness/feedback.d.ts.map +1 -1
  79. package/dist/harness/feedback.js +3 -2
  80. package/dist/harness/feedback.js.map +1 -1
  81. package/dist/harness/flow-check.d.ts.map +1 -1
  82. package/dist/harness/flow-check.js +2 -1
  83. package/dist/harness/flow-check.js.map +1 -1
  84. package/dist/harness/flow-plan.d.ts.map +1 -1
  85. package/dist/harness/flow-plan.js +3 -2
  86. package/dist/harness/flow-plan.js.map +1 -1
  87. package/dist/harness/intent.d.ts.map +1 -1
  88. package/dist/harness/intent.js +2 -1
  89. package/dist/harness/intent.js.map +1 -1
  90. package/dist/harness/journey.d.ts.map +1 -1
  91. package/dist/harness/journey.js +3 -2
  92. package/dist/harness/journey.js.map +1 -1
  93. package/dist/harness/ledger.d.ts.map +1 -1
  94. package/dist/harness/ledger.js +3 -2
  95. package/dist/harness/ledger.js.map +1 -1
  96. package/dist/harness/manifest.d.ts.map +1 -1
  97. package/dist/harness/manifest.js +4 -3
  98. package/dist/harness/manifest.js.map +1 -1
  99. package/dist/harness/parse.d.ts.map +1 -1
  100. package/dist/harness/parse.js +16 -3
  101. package/dist/harness/parse.js.map +1 -1
  102. package/dist/harness/quality-gates.d.ts.map +1 -1
  103. package/dist/harness/quality-gates.js +2 -1
  104. package/dist/harness/quality-gates.js.map +1 -1
  105. package/dist/harness/read-text.d.ts +22 -0
  106. package/dist/harness/read-text.d.ts.map +1 -0
  107. package/dist/harness/read-text.js +64 -0
  108. package/dist/harness/read-text.js.map +1 -0
  109. package/dist/harness/script-check.d.ts.map +1 -1
  110. package/dist/harness/script-check.js +3 -2
  111. package/dist/harness/script-check.js.map +1 -1
  112. package/dist/harness/sensors.d.ts +13 -1
  113. package/dist/harness/sensors.d.ts.map +1 -1
  114. package/dist/harness/sensors.js +63 -20
  115. package/dist/harness/sensors.js.map +1 -1
  116. package/dist/harness/spec-coverage.d.ts +5 -0
  117. package/dist/harness/spec-coverage.d.ts.map +1 -1
  118. package/dist/harness/spec-coverage.js +17 -7
  119. package/dist/harness/spec-coverage.js.map +1 -1
  120. package/dist/harness/trace.d.ts.map +1 -1
  121. package/dist/harness/trace.js +4 -3
  122. package/dist/harness/trace.js.map +1 -1
  123. package/dist/harness/viewpoint-baseline.d.ts +49 -0
  124. package/dist/harness/viewpoint-baseline.d.ts.map +1 -0
  125. package/dist/harness/viewpoint-baseline.js +141 -0
  126. package/dist/harness/viewpoint-baseline.js.map +1 -0
  127. package/dist/harness/viewpoint-ledger.d.ts.map +1 -1
  128. package/dist/harness/viewpoint-ledger.js +2 -1
  129. package/dist/harness/viewpoint-ledger.js.map +1 -1
  130. package/dist/orchestrator/assets-drift.d.ts +24 -0
  131. package/dist/orchestrator/assets-drift.d.ts.map +1 -0
  132. package/dist/orchestrator/assets-drift.js +80 -0
  133. package/dist/orchestrator/assets-drift.js.map +1 -0
  134. package/dist/orchestrator/templates/ai-src/commands/create-test.md +24 -0
  135. package/dist/orchestrator/templates/ai-src/commands/delivery.md +203 -120
  136. package/dist/orchestrator/templates/ai-src/config/claude.md +1 -1
  137. package/dist/orchestrator/templates/ai-src/config/copilot.md +1 -1
  138. package/dist/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +102 -6
  139. package/dist/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +1 -0
  140. package/dist/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +23 -0
  141. package/package.json +3 -3
  142. package/src/cli/commands/audit.ts +23 -1
  143. package/src/cli/commands/delivery.ts +240 -18
  144. package/src/cli/index.ts +6 -0
  145. package/src/dashboard/snapshot-builder.ts +8 -1
  146. package/src/dashboard/templates/index.html +1 -1
  147. package/src/exporters/api-environment.ts +97 -0
  148. package/src/exporters/feature-parser.ts +21 -2
  149. package/src/exporters/matrix/build.ts +551 -0
  150. package/src/exporters/matrix/export.ts +149 -0
  151. package/src/exporters/matrix/fingerprint.ts +49 -0
  152. package/src/exporters/matrix/gates.ts +400 -0
  153. package/src/exporters/matrix/map-loader.ts +163 -0
  154. package/src/exporters/matrix/render-csv.ts +94 -0
  155. package/src/exporters/matrix/render-xlsx.ts +420 -0
  156. package/src/exporters/matrix/types.ts +226 -0
  157. package/src/exporters/matrix/wording.ts +221 -0
  158. package/src/exporters/scenario-merger.ts +2 -1
  159. package/src/exporters/spec-parser.ts +2 -1
  160. package/src/exporters/xlsx-report-builder.ts +3 -2
  161. package/src/harness/audit.ts +104 -18
  162. package/src/harness/blindspot.ts +2 -1
  163. package/src/harness/capability-plan.ts +17 -4
  164. package/src/harness/feedback.ts +3 -2
  165. package/src/harness/flow-check.ts +2 -1
  166. package/src/harness/flow-plan.ts +3 -2
  167. package/src/harness/intent.ts +2 -1
  168. package/src/harness/journey.ts +3 -2
  169. package/src/harness/ledger.ts +3 -2
  170. package/src/harness/manifest.ts +4 -3
  171. package/src/harness/parse.ts +17 -3
  172. package/src/harness/quality-gates.ts +2 -1
  173. package/src/harness/read-text.ts +28 -0
  174. package/src/harness/script-check.ts +3 -2
  175. package/src/harness/sensors.ts +55 -8
  176. package/src/harness/spec-coverage.ts +22 -7
  177. package/src/harness/trace.ts +4 -3
  178. package/src/harness/viewpoint-baseline.ts +128 -0
  179. package/src/harness/viewpoint-ledger.ts +2 -1
  180. package/src/orchestrator/assets-drift.ts +64 -0
  181. package/src/orchestrator/templates/ai-src/commands/create-test.md +24 -0
  182. package/src/orchestrator/templates/ai-src/commands/delivery.md +203 -120
  183. package/src/orchestrator/templates/ai-src/config/claude.md +1 -1
  184. package/src/orchestrator/templates/ai-src/config/copilot.md +1 -1
  185. package/src/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +102 -6
  186. package/src/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +1 -0
  187. package/src/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +23 -0
@@ -0,0 +1,149 @@
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 { mapContentFingerprint } from './fingerprint';
21
+ import { buildMatrix, deriveVariants } from './build';
22
+ import { renderMatrixXlsx } from './render-xlsx';
23
+ import { renderMatrixCsv } from './render-csv';
24
+ import { DeliveryMap, MatrixFinding, MatrixModel } from './types';
25
+
26
+ export interface MatrixTargetPaths {
27
+ cwd: string;
28
+ /** Feature basename — names the deliverable files. */
29
+ unit: string;
30
+ /** Display label (flow/… prefix for flows). */
31
+ label: string;
32
+ featureFile: string;
33
+ testDataFile: string;
34
+ specFile: string;
35
+ /** requirements/spec.md — the requirement-id inventory for coverage (optional). */
36
+ specMdFile?: string;
37
+ resultsPath: string | null;
38
+ mapFile: string;
39
+ }
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
+
47
+ export interface MatrixLoadResult {
48
+ model?: MatrixModel;
49
+ map?: DeliveryMap;
50
+ /** No map file → the caller prints the guidance path (AI proposal or --legacy). */
51
+ mapMissing?: boolean;
52
+ /** Structural map problems (bad YAML/shape) — fix the file before anything else. */
53
+ mapErrors: string[];
54
+ }
55
+
56
+ export function loadMatrixModel(paths: MatrixTargetPaths): MatrixLoadResult {
57
+ const { map, errors } = loadDeliveryMap(paths.mapFile);
58
+ if (!map) {
59
+ return errors.length === 0 ? { mapMissing: true, mapErrors: [] } : { mapErrors: errors };
60
+ }
61
+
62
+ const feature = parseFeatureMetadata(paths.featureFile);
63
+ const spec = parseSpecFile(paths.specFile);
64
+ const merged = mergeFeatureAndSpec(feature, spec);
65
+ const testData = fs.existsSync(paths.testDataFile) ? loadTestData(paths.testDataFile, process.env.SUNGEN_ENV ?? null) : null;
66
+ const results = paths.resultsPath ? loadPlaywrightReport(paths.resultsPath) : null;
67
+
68
+ const model = buildMatrix({
69
+ unit: paths.unit,
70
+ feature,
71
+ merged,
72
+ testData,
73
+ results,
74
+ map,
75
+ transformerVersion: getPackageVersion(),
76
+ specText: readSpecText(paths),
77
+ });
78
+ return { model, map, mapErrors: [] };
79
+ }
80
+
81
+ export const hasErrors = (f: MatrixFinding[]): boolean => f.some((x) => x.severity === 'error');
82
+ export const hasReview = (f: MatrixFinding[]): boolean => f.some((x) => x.severity === 'review');
83
+
84
+ /**
85
+ * Stamp fingerprints + flip groups to `approved` (all groups, or just `groupIds`).
86
+ * Refuses when gate ERRORs remain — an invalid map must not be frozen as approved.
87
+ * Returns the findings so the caller reports what blocked or what was approved.
88
+ */
89
+ export function approveMatrix(paths: MatrixTargetPaths, groupIds?: string[]): { findings: MatrixFinding[]; approved: string[] } {
90
+ const { map, errors } = loadDeliveryMap(paths.mapFile);
91
+ if (!map) throw new Error(errors.length > 0 ? `map is invalid:\n - ${errors.join('\n - ')}` : `no delivery map at ${paths.mapFile}`);
92
+
93
+ const feature = parseFeatureMetadata(paths.featureFile);
94
+ const spec = parseSpecFile(paths.specFile);
95
+ const merged = mergeFeatureAndSpec(feature, spec);
96
+ const testData = fs.existsSync(paths.testDataFile) ? loadTestData(paths.testDataFile, process.env.SUNGEN_ENV ?? null) : null;
97
+
98
+ const model = buildMatrix({
99
+ unit: paths.unit, feature, merged, testData, results: null, map,
100
+ transformerVersion: getPackageVersion(),
101
+ specText: readSpecText(paths),
102
+ });
103
+ const blocking = model.findings.filter((f) => f.severity === 'error');
104
+ if (blocking.length > 0) return { findings: blocking, approved: [] };
105
+
106
+ const variants = deriveVariants({ feature, merged, testData, results: null });
107
+ const byRef = new Map(variants.map((v) => [v.ref, v] as const));
108
+ const byVp = new Map<string, typeof variants>();
109
+ for (const v of variants) {
110
+ const list = byVp.get(v.vpId) ?? [];
111
+ list.push(v);
112
+ byVp.set(v.vpId, list);
113
+ }
114
+
115
+ const approved: string[] = [];
116
+ for (const g of map.groups) {
117
+ if (groupIds && groupIds.length > 0 && !groupIds.includes(g.id)) continue;
118
+ for (const ref of g.variants) {
119
+ const expanded = ref.includes('#') ? [byRef.get(ref)].filter(Boolean) : (byVp.get(ref) ?? []);
120
+ for (const v of expanded) map.fingerprints[(v as NonNullable<typeof expanded[number]>).ref] = (v as NonNullable<typeof expanded[number]>).fingerprint;
121
+ }
122
+ g.review = 'approved';
123
+ approved.push(g.id);
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);
128
+ writeDeliveryMap(paths.mapFile, map);
129
+ return { findings: model.findings.filter((f) => f.severity !== 'error'), approved };
130
+ }
131
+
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;
149
+ }
@@ -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
+ }
@@ -0,0 +1,400 @@
1
+ /**
2
+ * Deterministic quality gates for the Test Case & Coverage Matrix
3
+ * (docs/spec/delivery-coverage-matrix-spec.md §6).
4
+ *
5
+ * Severity behavior:
6
+ * error → blocks any render
7
+ * review → blocks the official render; `--preview` renders with a DRAFT watermark
8
+ * warning → prints + renders
9
+ *
10
+ * The gates recompute every signature part from the .feature — the map is never
11
+ * trusted for derivable facts, only for the semantic fields (target/intent/oracle).
12
+ */
13
+
14
+ import { splitVpAndName, extractTestcaseType } from '../feature-parser';
15
+ import { mapContentFingerprint } from './fingerprint';
16
+ import { CoverageVariant, MatrixFinding, MAX_VARIANTS_PER_ITEM } from './types';
17
+ import { expandMapRef, BuildInputs } from './build';
18
+
19
+ export interface GateContext {
20
+ inputs: BuildInputs;
21
+ variants: CoverageVariant[];
22
+ variantsByVp: Map<string, CoverageVariant[]>;
23
+ }
24
+
25
+ export function runGates(ctx: GateContext): MatrixFinding[] {
26
+ const findings: MatrixFinding[] = [];
27
+ gateASource(ctx, findings);
28
+ gateBMapping(ctx, findings);
29
+ gateCAggregation(ctx, findings);
30
+ gateDExecutability(ctx, findings);
31
+ gateEDrift(ctx, findings);
32
+ gateGReviewState(ctx, findings);
33
+ gateWWording(ctx, findings);
34
+ gateKCategory(ctx, findings);
35
+ return findings;
36
+ }
37
+
38
+ // --- Gate K — category vs the source viewpoint class -------------------------
39
+
40
+ /**
41
+ * Viewpoint classes whose delivery category is not a judgement call — the source
42
+ * decides it. Matched on the id's category segment, so a project scheme without
43
+ * the `VP-` prefix (`SEC-123`, `PER-345`) classifies the same as `VP-SEC-001`.
44
+ */
45
+ const VP_CATEGORY_RULE: Array<[RegExp, string, string]> = [
46
+ [/^(SEC|SECURITY)$/i, 'security', 'SEC'],
47
+ [/^(NFR|PERF|PER|PERFORMANCE|A11Y|ACCESSIBILITY)$/i, 'nfr', 'NFR/PERF/A11Y'],
48
+ ];
49
+
50
+ /**
51
+ * A `VP-SEC-*` scenario filed under `normal`/`abnormal` empties the Coverage
52
+ * sheet's `security` column — the grid then reports a gap the unit does not
53
+ * have while hiding the security work it does have. Same for `VP-NFR-*`.
54
+ * (normal vs abnormal stays a judgement call and is never checked.)
55
+ */
56
+ function gateKCategory(ctx: GateContext, findings: MatrixFinding[]): void {
57
+ for (const g of ctx.inputs.map.groups) {
58
+ const vs = g.variants.flatMap((ref) => expandMapRef(ref, ctx.variantsByVp));
59
+ for (const [re, expected, label] of VP_CATEGORY_RULE) {
60
+ const hits = vs.filter((v) => re.test(v.vpCategory));
61
+ if (hits.length > 0 && g.category !== expected) {
62
+ findings.push({
63
+ gate: 'K', severity: 'warning', ref: g.id,
64
+ message: `group ${g.id} is \`category: ${g.category}\` but ${hits.length}/${vs.length} of its variants are ${label} scenarios (${hits.slice(0, 3).map((v) => v.vpId).join(', ')}) — use \`category: ${expected}\` or the Coverage grid's ${expected} column stays empty`,
65
+ });
66
+ }
67
+ }
68
+ }
69
+ }
70
+
71
+ // --- Gate A — source quality ------------------------------------------------
72
+
73
+ function gateASource(ctx: GateContext, findings: MatrixFinding[]): void {
74
+ const { feature, merged } = ctx.inputs;
75
+
76
+ if (feature.backgroundWhenSteps.length > 0 || feature.backgroundThenSteps.length > 0) {
77
+ findings.push({
78
+ gate: 'A', severity: 'error',
79
+ message: 'Background contains When/Then steps — setup only; move the action/oracle into scenarios',
80
+ });
81
+ }
82
+
83
+ const seen = new Map<string, number>();
84
+ for (const m of merged) {
85
+ const { vpId } = splitVpAndName(m.feature.name);
86
+ if (!vpId) {
87
+ findings.push({
88
+ gate: 'A', severity: 'error', ref: m.feature.name,
89
+ message: `scenario "${m.feature.name}" has no VP-id prefix — matrix variants must be addressable`,
90
+ });
91
+ continue;
92
+ }
93
+ seen.set(vpId, (seen.get(vpId) ?? 0) + 1);
94
+
95
+ const isManual = extractTestcaseType(m.feature.tags) === 'Manual';
96
+ if (m.resolvedExpected.length === 0) {
97
+ findings.push({
98
+ gate: 'A', severity: 'error', ref: vpId,
99
+ message: isManual
100
+ ? `manual scenario ${vpId} has no observable oracle (no Then steps and no Observable/Oracle comment lines)`
101
+ : `scenario ${vpId} has no Then step — a variant without an observable oracle cannot be delivered`,
102
+ });
103
+ }
104
+ }
105
+ for (const [vpId, n] of seen) {
106
+ if (n > 1) findings.push({ gate: 'A', severity: 'error', ref: vpId, message: `duplicate VP-id ${vpId} (${n} scenarios)` });
107
+ }
108
+ }
109
+
110
+ // --- Gate B — mapping completeness -------------------------------------------
111
+
112
+ function gateBMapping(ctx: GateContext, findings: MatrixFinding[]): void {
113
+ const { map } = ctx.inputs;
114
+ const { variants, variantsByVp } = ctx;
115
+
116
+ // Where each derived variant landed: 0 → unmapped, >1 → double-mapped.
117
+ const owners = new Map<string, string[]>();
118
+ for (const g of map.groups) {
119
+ for (const ref of g.variants) {
120
+ const expanded = expandMapRef(ref, variantsByVp);
121
+ if (expanded.length === 0) {
122
+ findings.push({
123
+ gate: 'B', severity: 'error', ref: g.id,
124
+ message: `group ${g.id} references ${ref} which matches no scenario/@cases row in the .feature`,
125
+ });
126
+ continue;
127
+ }
128
+ for (const v of expanded) {
129
+ const list = owners.get(v.ref) ?? [];
130
+ list.push(g.id);
131
+ owners.set(v.ref, list);
132
+ }
133
+ }
134
+ }
135
+
136
+ for (const [ref, groups] of owners) {
137
+ if (groups.length > 1) {
138
+ findings.push({
139
+ gate: 'B', severity: 'error', ref,
140
+ message: `variant ${ref} belongs to ${groups.length} groups (${groups.join(', ')}) — exactly one required`,
141
+ });
142
+ }
143
+ }
144
+
145
+ for (const v of variants) {
146
+ const grouped = owners.has(v.ref);
147
+ const disposed = v.vpId in map.dispositions;
148
+ if (!grouped && !disposed) {
149
+ findings.push({
150
+ gate: 'B', severity: 'error', ref: v.ref,
151
+ message: `variant ${v.ref} is in no group and has no disposition — 100% source disposition is required`,
152
+ });
153
+ }
154
+ if (grouped && disposed) {
155
+ findings.push({
156
+ gate: 'B', severity: 'error', ref: v.ref,
157
+ message: `variant ${v.ref} is grouped AND disposed (${map.dispositions[v.vpId].as}) — pick one`,
158
+ });
159
+ }
160
+ }
161
+
162
+ for (const vpId of Object.keys(map.dispositions)) {
163
+ if (!variantsByVp.has(vpId)) {
164
+ findings.push({
165
+ gate: 'B', severity: 'warning', ref: vpId,
166
+ message: `disposition for ${vpId} matches no scenario (stale entry?)`,
167
+ });
168
+ }
169
+ }
170
+ }
171
+
172
+ // --- Gate C — aggregation safety ---------------------------------------------
173
+
174
+ function gateCAggregation(ctx: GateContext, findings: MatrixFinding[]): void {
175
+ const { map } = ctx.inputs;
176
+ const { variantsByVp } = ctx;
177
+
178
+ for (const g of map.groups) {
179
+ const vs = g.variants.flatMap((ref) => expandMapRef(ref, variantsByVp));
180
+ if (vs.length <= 1) continue;
181
+
182
+ // Approval IS the QA confirmation of the heuristic parts (oracle family,
183
+ // precondition equivalence, sequence safety) — once approved AND unchanged
184
+ // (fingerprints intact), only the hard signature checks keep firing. A
185
+ // drifted variant re-opens the heuristics (Gate E flips the item to
186
+ // proposed at the same time).
187
+ const stale = vs.some((v) => {
188
+ const stamped = map.fingerprints[v.ref] ?? map.fingerprints[v.vpId];
189
+ return !stamped || stamped !== v.fingerprint;
190
+ });
191
+ const heuristicsConfirmed = g.review === 'approved' && !stale;
192
+
193
+ // Hard signature parts — recomputed, never trusted from the map. ERROR on mismatch.
194
+ // Execution mode and priority are deliberately NOT here: they are coverage
195
+ // dimensions (account states may need seeded manual variants next to auto ones;
196
+ // an item takes the highest variant priority) — shown per variant, never split on.
197
+ const hard: Array<[string, (v: CoverageVariant) => string]> = [
198
+ ['test layer', (v) => [...v.layers].sort().join('+')],
199
+ ];
200
+ for (const [label, keyOf] of hard) {
201
+ const distinct = new Set(vs.map(keyOf));
202
+ if (distinct.size > 1) {
203
+ findings.push({
204
+ gate: 'C', severity: 'error', ref: g.id,
205
+ message: `group ${g.id} mixes ${label}s (${[...distinct].join(' vs ')}) — mandatory split`,
206
+ });
207
+ }
208
+ }
209
+
210
+ // Heuristic signature parts — REVIEW_REQUIRED (equivalence was inferred, not declared).
211
+ const preProfiles = new Set(vs.map((v) => v.preconditionProfile));
212
+ if (!heuristicsConfirmed && preProfiles.size > 1) {
213
+ findings.push({
214
+ gate: 'C', severity: 'review', ref: g.id,
215
+ message: `group ${g.id} variants have different precondition profiles — confirm the grouping is safe or split`,
216
+ });
217
+ }
218
+ const oracleShapes = new Set(vs.map((v) => v.oracleShape.join(' ; ')));
219
+ if (!heuristicsConfirmed && oracleShapes.size > 1) {
220
+ findings.push({
221
+ gate: 'C', severity: 'review', ref: g.id,
222
+ message: `group ${g.id} variants assert different oracle shapes (${oracleShapes.size} shapes) — confirm they are one oracle family`,
223
+ });
224
+ }
225
+ if (!heuristicsConfirmed && vs.some((v) => v.procedureProfile === 'sequence')) {
226
+ findings.push({
227
+ gate: 'C', severity: 'review', ref: g.id,
228
+ message: `group ${g.id} contains a sequence-sensitive scenario — ordered flows stay solo items unless QA confirms`,
229
+ });
230
+ }
231
+
232
+ if (vs.length > MAX_VARIANTS_PER_ITEM) {
233
+ findings.push({
234
+ gate: 'C', severity: 'warning', ref: g.id,
235
+ message: `group ${g.id} has ${vs.length} variants (> ${MAX_VARIANTS_PER_ITEM}) — consider splitting for reviewability`,
236
+ });
237
+ }
238
+ }
239
+ }
240
+
241
+ // --- Gate D — manual executability -------------------------------------------
242
+
243
+ function gateDExecutability(ctx: GateContext, findings: MatrixFinding[]): void {
244
+ const { map, testData, feature } = ctx.inputs;
245
+ const { variantsByVp } = ctx;
246
+
247
+ for (const g of map.groups) {
248
+ const vs = g.variants.flatMap((ref) => expandMapRef(ref, variantsByVp));
249
+ for (const v of vs) {
250
+ const hasPrecondition = v.precondition.length > 0 || feature.backgroundGivenSteps.length > 0;
251
+ if (!hasPrecondition) {
252
+ findings.push({
253
+ gate: 'D', severity: 'error', ref: v.ref,
254
+ message: `${v.ref}: no precondition (no Background, auth tag, or Given) — a tester cannot establish the start state`,
255
+ });
256
+ }
257
+ if (v.trigger.length === 0) {
258
+ findings.push({
259
+ gate: 'D', severity: 'error', ref: v.ref,
260
+ message: `${v.ref}: no action/trigger (no When steps${v.mode === 'manual' ? ' and no Setup/Action comment lines' : ''})`,
261
+ });
262
+ }
263
+ // Oracle emptiness is Gate A (source) — not repeated here.
264
+ }
265
+ }
266
+
267
+ // Every {{var}} must resolve — data the tester cannot obtain is not executable.
268
+ for (const [vpId, vs] of variantsByVp) {
269
+ const scenario = ctx.inputs.merged.find((m) => splitVpAndName(m.feature.name).vpId === vpId);
270
+ if (!scenario) continue;
271
+ // Values produced during the run (`remember … as {{v}}`, capability bindings)
272
+ // are provided by the test itself — demanding a test-data entry for them
273
+ // blocked 14 scenarios of a real suite that compiles and runs fine.
274
+ const runtime = new Set(vs.flatMap((v) => v.runtimeVars));
275
+ for (const varName of scenario.feature.referencedVars) {
276
+ const inRows = vs.some((v) => v.caseLabel !== undefined); // dataset rows carry their own columns
277
+ const atRuntime = runtime.has(varName) || runtime.has(varName.split(/[.[]/)[0]);
278
+ if (!inRows && !atRuntime && (!testData || !(varName in testData))) {
279
+ findings.push({
280
+ gate: 'D', severity: 'error', ref: vpId,
281
+ message: `${vpId}: {{${varName}}} does not resolve in test-data — the variant is not reproducible`,
282
+ });
283
+ }
284
+ }
285
+ }
286
+
287
+ // No template token may survive into a RENDERED cell (review B-04) — this also
288
+ // catches cross-referencing test-data values the one-level resolver couldn't close.
289
+ for (const [, vs] of variantsByVp) {
290
+ for (const v of vs) {
291
+ const rendered = [...v.precondition, ...v.trigger, ...v.oracle, ...v.verification, ...v.data];
292
+ const token = rendered.map((t) => t.match(/\{\{[^}]*\}\}/)).find(Boolean);
293
+ if (token) {
294
+ findings.push({
295
+ gate: 'D', severity: 'error', ref: v.ref,
296
+ message: `${v.ref}: unresolved template token ${token[0]} remains in the rendered output — the export is not deterministic for a tester`,
297
+ });
298
+ }
299
+ }
300
+ }
301
+ }
302
+
303
+ // --- Gate E — drift (stale approval) ------------------------------------------
304
+
305
+ function gateEDrift(ctx: GateContext, findings: MatrixFinding[]): void {
306
+ const { map } = ctx.inputs;
307
+ const { variantsByVp } = ctx;
308
+
309
+ // The map's own semantics (targets/intents/oracles/grouping) are part of what
310
+ // was approved — an edit after approval must be re-reviewed, not silently
311
+ // published (review GAP-09).
312
+ const stampedMap = map.fingerprints.__map__;
313
+ const liveMap = mapContentFingerprint(map.groups, map.dispositions);
314
+ if (map.groups.some((g) => g.review === 'approved')) {
315
+ if (!stampedMap) {
316
+ findings.push({
317
+ gate: 'E', severity: 'review',
318
+ message: 'the delivery map has no stamped content fingerprint — run `sungen delivery --approve` to record the reviewed wording',
319
+ });
320
+ } else if (stampedMap !== liveMap) {
321
+ findings.push({
322
+ gate: 'E', severity: 'review',
323
+ message: 'the delivery map wording/grouping changed since approval — re-review, then `sungen delivery --approve`',
324
+ });
325
+ }
326
+ }
327
+
328
+ for (const g of map.groups) {
329
+ if (g.review !== 'approved') continue; // proposed groups are already under review
330
+ for (const ref of g.variants) {
331
+ const vs = expandMapRef(ref, variantsByVp);
332
+ for (const v of vs) {
333
+ const stamped = map.fingerprints[v.ref] ?? map.fingerprints[v.vpId];
334
+ if (!stamped) {
335
+ findings.push({
336
+ gate: 'E', severity: 'review', ref: g.id,
337
+ message: `group ${g.id}: ${v.ref} has no stamped fingerprint — run \`sungen delivery --approve\` after review`,
338
+ });
339
+ } else if (stamped !== v.fingerprint) {
340
+ findings.push({
341
+ gate: 'E', severity: 'review', ref: g.id,
342
+ message: `group ${g.id}: ${v.ref} changed since approval (fingerprint drift) — re-review the grouping`,
343
+ });
344
+ }
345
+ }
346
+ }
347
+ }
348
+ }
349
+
350
+ // --- Gate W — controlled-language lint on the map's semantic fields --------------
351
+
352
+ /** Patterns that make customer-facing wording read machine-generated (review §10). */
353
+ const WORDING_SMELLS: Array<[RegExp, string]> = [
354
+ [/\{\{[^}]*\}\}/, 'an unresolved {{token}}'],
355
+ [/\b(?:Setup|Observable|Oracle):/, 'a generator label (Setup:/Observable:/Oracle:)'],
356
+ [/\bUser (?:fill|click|press|see|wait)\b/i, 'DSL phrasing ("User fill/click/see…") — write plain product language'],
357
+ [/\[[^\]]+\]/, 'a [selector-style] reference — use the visible UI label'],
358
+ ];
359
+
360
+ function gateWWording(ctx: GateContext, findings: MatrixFinding[]): void {
361
+ for (const g of ctx.inputs.map.groups) {
362
+ const variantCount = g.variants.flatMap((ref) => expandMapRef(ref, ctx.variantsByVp)).length;
363
+ // Without a digest the parent row can only list variant refs — a reviewer
364
+ // then has to expand the item to learn which dimensions it covers.
365
+ if (variantCount > 3 && !g.dimensions) {
366
+ findings.push({
367
+ gate: 'W', severity: 'warning', ref: g.id,
368
+ message: `group ${g.id} has ${variantCount} variants and no \`dimensions:\` digest — add a short one (e.g. "required ×3 · format ×9") so the collapsed view stays informative`,
369
+ });
370
+ }
371
+ if (g.dimensions && g.dimensions.length > 120) {
372
+ findings.push({
373
+ gate: 'W', severity: 'warning', ref: g.id,
374
+ message: `group ${g.id} \`dimensions:\` is ${g.dimensions.length} chars — keep the digest short (≤120)`,
375
+ });
376
+ }
377
+ for (const [field, text] of ([['intent', g.intent], ['oracle', g.oracle], ...(g.dimensions ? [['dimensions', g.dimensions] as const] : [])] as const)) {
378
+ for (const [re, what] of WORDING_SMELLS) {
379
+ if (re.test(text)) {
380
+ findings.push({
381
+ gate: 'W', severity: 'warning', ref: g.id,
382
+ message: `group ${g.id} ${field} contains ${what}`,
383
+ });
384
+ }
385
+ }
386
+ }
387
+ }
388
+ }
389
+
390
+ // --- Gate G — review state -----------------------------------------------------
391
+
392
+ function gateGReviewState(ctx: GateContext, findings: MatrixFinding[]): void {
393
+ const proposed = ctx.inputs.map.groups.filter((g) => g.review !== 'approved');
394
+ if (proposed.length > 0) {
395
+ findings.push({
396
+ gate: 'G', severity: 'review',
397
+ message: `${proposed.length} group(s) still \`proposed\` (${proposed.slice(0, 5).map((g) => g.id).join(', ')}${proposed.length > 5 ? ', …' : ''}) — approve them or render with --preview`,
398
+ });
399
+ }
400
+ }