@oscharko-dev/keiko-quality-intelligence 0.2.0
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/.tsbuildinfo +1 -0
- package/dist/__tests__/_fixtureLoader.d.ts +9 -0
- package/dist/__tests__/_fixtureLoader.d.ts.map +1 -0
- package/dist/__tests__/_fixtureLoader.js +75 -0
- package/dist/domain/assertions.d.ts +61 -0
- package/dist/domain/assertions.d.ts.map +1 -0
- package/dist/domain/assertions.js +134 -0
- package/dist/domain/coverageRelevance.d.ts +73 -0
- package/dist/domain/coverageRelevance.d.ts.map +1 -0
- package/dist/domain/coverageRelevance.js +155 -0
- package/dist/domain/deduplication.d.ts +17 -0
- package/dist/domain/deduplication.d.ts.map +1 -0
- package/dist/domain/deduplication.js +95 -0
- package/dist/domain/figma/a11yBaseline.d.ts +17 -0
- package/dist/domain/figma/a11yBaseline.d.ts.map +1 -0
- package/dist/domain/figma/a11yBaseline.js +218 -0
- package/dist/domain/figma/cleanToScreenIr.d.ts +3 -0
- package/dist/domain/figma/cleanToScreenIr.d.ts.map +1 -0
- package/dist/domain/figma/cleanToScreenIr.js +62 -0
- package/dist/domain/figma/codeTargetAdapter.d.ts +30 -0
- package/dist/domain/figma/codeTargetAdapter.d.ts.map +1 -0
- package/dist/domain/figma/codeTargetAdapter.js +27 -0
- package/dist/domain/figma/color.d.ts +31 -0
- package/dist/domain/figma/color.d.ts.map +1 -0
- package/dist/domain/figma/color.js +99 -0
- package/dist/domain/figma/emissionPlan.d.ts +56 -0
- package/dist/domain/figma/emissionPlan.d.ts.map +1 -0
- package/dist/domain/figma/emissionPlan.js +87 -0
- package/dist/domain/figma/htmlCssAdapter.d.ts +13 -0
- package/dist/domain/figma/htmlCssAdapter.d.ts.map +1 -0
- package/dist/domain/figma/htmlCssAdapter.js +452 -0
- package/dist/domain/figma/index.d.ts +19 -0
- package/dist/domain/figma/index.d.ts.map +1 -0
- package/dist/domain/figma/index.js +31 -0
- package/dist/domain/figma/irTypes.d.ts +156 -0
- package/dist/domain/figma/irTypes.d.ts.map +1 -0
- package/dist/domain/figma/irTypes.js +8 -0
- package/dist/domain/figma/links.d.ts +6 -0
- package/dist/domain/figma/links.d.ts.map +1 -0
- package/dist/domain/figma/links.js +102 -0
- package/dist/domain/figma/navGraph.d.ts +74 -0
- package/dist/domain/figma/navGraph.d.ts.map +1 -0
- package/dist/domain/figma/navGraph.js +315 -0
- package/dist/domain/figma/normalize.d.ts +7 -0
- package/dist/domain/figma/normalize.d.ts.map +1 -0
- package/dist/domain/figma/normalize.js +252 -0
- package/dist/domain/figma/prune.d.ts +15 -0
- package/dist/domain/figma/prune.d.ts.map +1 -0
- package/dist/domain/figma/prune.js +65 -0
- package/dist/domain/figma/screenDetect.d.ts +8 -0
- package/dist/domain/figma/screenDetect.d.ts.map +1 -0
- package/dist/domain/figma/screenDetect.js +35 -0
- package/dist/domain/figma/screenIrTestBaseline.d.ts +52 -0
- package/dist/domain/figma/screenIrTestBaseline.d.ts.map +1 -0
- package/dist/domain/figma/screenIrTestBaseline.js +326 -0
- package/dist/domain/figma/semanticNaming.d.ts +24 -0
- package/dist/domain/figma/semanticNaming.d.ts.map +1 -0
- package/dist/domain/figma/semanticNaming.js +67 -0
- package/dist/domain/figma/sourceNode.d.ts +24 -0
- package/dist/domain/figma/sourceNode.d.ts.map +1 -0
- package/dist/domain/figma/sourceNode.js +26 -0
- package/dist/domain/figma/tokens.d.ts +11 -0
- package/dist/domain/figma/tokens.d.ts.map +1 -0
- package/dist/domain/figma/tokens.js +148 -0
- package/dist/domain/figma/visionAugmentation.d.ts +14 -0
- package/dist/domain/figma/visionAugmentation.d.ts.map +1 -0
- package/dist/domain/figma/visionAugmentation.js +48 -0
- package/dist/domain/intentDerivation.d.ts +21 -0
- package/dist/domain/intentDerivation.d.ts.map +1 -0
- package/dist/domain/intentDerivation.js +126 -0
- package/dist/domain/policyProfile.d.ts +37 -0
- package/dist/domain/policyProfile.d.ts.map +1 -0
- package/dist/domain/policyProfile.js +94 -0
- package/dist/domain/requirementExcerpt.d.ts +9 -0
- package/dist/domain/requirementExcerpt.d.ts.map +1 -0
- package/dist/domain/requirementExcerpt.js +39 -0
- package/dist/domain/staleness.d.ts +56 -0
- package/dist/domain/staleness.d.ts.map +1 -0
- package/dist/domain/staleness.js +313 -0
- package/dist/domain/testDesignModel.d.ts +38 -0
- package/dist/domain/testDesignModel.d.ts.map +1 -0
- package/dist/domain/testDesignModel.js +264 -0
- package/dist/domain/testQualityRubric.d.ts +20 -0
- package/dist/domain/testQualityRubric.d.ts.map +1 -0
- package/dist/domain/testQualityRubric.js +38 -0
- package/dist/domain/validation.d.ts +7 -0
- package/dist/domain/validation.d.ts.map +1 -0
- package/dist/domain/validation.js +145 -0
- package/dist/export/adapters/alm.d.ts +4 -0
- package/dist/export/adapters/alm.d.ts.map +1 -0
- package/dist/export/adapters/alm.js +75 -0
- package/dist/export/adapters/csv.d.ts +5 -0
- package/dist/export/adapters/csv.d.ts.map +1 -0
- package/dist/export/adapters/csv.js +55 -0
- package/dist/export/adapters/index.d.ts +13 -0
- package/dist/export/adapters/index.d.ts.map +1 -0
- package/dist/export/adapters/index.js +15 -0
- package/dist/export/adapters/jira.d.ts +5 -0
- package/dist/export/adapters/jira.d.ts.map +1 -0
- package/dist/export/adapters/jira.js +79 -0
- package/dist/export/adapters/json.d.ts +3 -0
- package/dist/export/adapters/json.d.ts.map +1 -0
- package/dist/export/adapters/json.js +54 -0
- package/dist/export/adapters/markdown.d.ts +3 -0
- package/dist/export/adapters/markdown.d.ts.map +1 -0
- package/dist/export/adapters/markdown.js +88 -0
- package/dist/export/adapters/plaintext.d.ts +3 -0
- package/dist/export/adapters/plaintext.d.ts.map +1 -0
- package/dist/export/adapters/plaintext.js +65 -0
- package/dist/export/adapters/polarion.d.ts +4 -0
- package/dist/export/adapters/polarion.d.ts.map +1 -0
- package/dist/export/adapters/polarion.js +67 -0
- package/dist/export/adapters/qtest.d.ts +4 -0
- package/dist/export/adapters/qtest.d.ts.map +1 -0
- package/dist/export/adapters/qtest.js +78 -0
- package/dist/export/adapters/qualityCenter.d.ts +3 -0
- package/dist/export/adapters/qualityCenter.d.ts.map +1 -0
- package/dist/export/adapters/qualityCenter.js +56 -0
- package/dist/export/adapters/spreadsheetSafeCsv.d.ts +36 -0
- package/dist/export/adapters/spreadsheetSafeCsv.d.ts.map +1 -0
- package/dist/export/adapters/spreadsheetSafeCsv.js +157 -0
- package/dist/export/adapters/traceability.d.ts +34 -0
- package/dist/export/adapters/traceability.d.ts.map +1 -0
- package/dist/export/adapters/traceability.js +142 -0
- package/dist/export/adapters/xray.d.ts +4 -0
- package/dist/export/adapters/xray.d.ts.map +1 -0
- package/dist/export/adapters/xray.js +72 -0
- package/dist/export/formats.d.ts +29 -0
- package/dist/export/formats.d.ts.map +1 -0
- package/dist/export/formats.js +34 -0
- package/dist/export/index.d.ts +4 -0
- package/dist/export/index.d.ts.map +1 -0
- package/dist/export/index.js +10 -0
- package/dist/export/serialize.d.ts +17 -0
- package/dist/export/serialize.d.ts.map +1 -0
- package/dist/export/serialize.js +56 -0
- package/dist/export/textSafety.d.ts +15 -0
- package/dist/export/textSafety.d.ts.map +1 -0
- package/dist/export/textSafety.js +30 -0
- package/dist/generation/candidateBounds.d.ts +10 -0
- package/dist/generation/candidateBounds.d.ts.map +1 -0
- package/dist/generation/candidateBounds.js +14 -0
- package/dist/generation/index.d.ts +4 -0
- package/dist/generation/index.d.ts.map +1 -0
- package/dist/generation/index.js +20 -0
- package/dist/generation/parseGeneratedCandidates.d.ts +27 -0
- package/dist/generation/parseGeneratedCandidates.d.ts.map +1 -0
- package/dist/generation/parseGeneratedCandidates.js +253 -0
- package/dist/generation/prompt.d.ts +16 -0
- package/dist/generation/prompt.d.ts.map +1 -0
- package/dist/generation/prompt.js +151 -0
- package/dist/generation/requirementsIngestion.d.ts +21 -0
- package/dist/generation/requirementsIngestion.d.ts.map +1 -0
- package/dist/generation/requirementsIngestion.js +70 -0
- package/dist/hardening/index.d.ts +6 -0
- package/dist/hardening/index.d.ts.map +1 -0
- package/dist/hardening/index.js +8 -0
- package/dist/hardening/oversizeGuards.d.ts +21 -0
- package/dist/hardening/oversizeGuards.d.ts.map +1 -0
- package/dist/hardening/oversizeGuards.js +35 -0
- package/dist/hardening/pathSafety.d.ts +19 -0
- package/dist/hardening/pathSafety.d.ts.map +1 -0
- package/dist/hardening/pathSafety.js +61 -0
- package/dist/hardening/promptInjectionScrub.d.ts +17 -0
- package/dist/hardening/promptInjectionScrub.d.ts.map +1 -0
- package/dist/hardening/promptInjectionScrub.js +72 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +44 -0
- package/dist/ingestion/adfParser.d.ts +61 -0
- package/dist/ingestion/adfParser.d.ts.map +1 -0
- package/dist/ingestion/adfParser.js +262 -0
- package/dist/ingestion/index.d.ts +6 -0
- package/dist/ingestion/index.d.ts.map +1 -0
- package/dist/ingestion/index.js +10 -0
- package/dist/ingestion/sourceMixPlanning.d.ts +36 -0
- package/dist/ingestion/sourceMixPlanning.d.ts.map +1 -0
- package/dist/ingestion/sourceMixPlanning.js +65 -0
- package/dist/ingestion/sourceReconciliation.d.ts +39 -0
- package/dist/ingestion/sourceReconciliation.d.ts.map +1 -0
- package/dist/ingestion/sourceReconciliation.js +74 -0
- package/dist/ingestion/untrustedContentNormalisation.d.ts +23 -0
- package/dist/ingestion/untrustedContentNormalisation.d.ts.map +1 -0
- package/dist/ingestion/untrustedContentNormalisation.js +121 -0
- package/dist/ingestion/workspaceAdapter.d.ts +55 -0
- package/dist/ingestion/workspaceAdapter.d.ts.map +1 -0
- package/dist/ingestion/workspaceAdapter.js +113 -0
- package/dist/review/auditEvents.d.ts +61 -0
- package/dist/review/auditEvents.d.ts.map +1 -0
- package/dist/review/auditEvents.js +50 -0
- package/dist/review/fourEyes.d.ts +24 -0
- package/dist/review/fourEyes.d.ts.map +1 -0
- package/dist/review/fourEyes.js +45 -0
- package/dist/review/index.d.ts +5 -0
- package/dist/review/index.d.ts.map +1 -0
- package/dist/review/index.js +14 -0
- package/dist/review/lifecyclePolicy.d.ts +21 -0
- package/dist/review/lifecyclePolicy.d.ts.map +1 -0
- package/dist/review/lifecyclePolicy.js +38 -0
- package/dist/review/stateMachine.d.ts +28 -0
- package/dist/review/stateMachine.d.ts.map +1 -0
- package/dist/review/stateMachine.js +71 -0
- package/package.json +31 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// Source-fingerprint drift detection and per-test staleness model (Epic #735, Issue #742).
|
|
2
|
+
//
|
|
3
|
+
// Pure function, no IO, deterministic. Compares persisted envelope fingerprints from a previous
|
|
4
|
+
// QI run against current source fingerprints to classify each test-case candidate as fresh or
|
|
5
|
+
// stale. A candidate is stale on ANY of its source envelopes changing (hash differs → "source-changed")
|
|
6
|
+
// or disappearing from the current scan (envelope absent → "source-removed"). The removed case takes
|
|
7
|
+
// precedence over changed. Candidates with no resolvable envelope (unknown atoms) are treated as
|
|
8
|
+
// stale to prevent silently keeping invalid tests.
|
|
9
|
+
/** Build a map from atomId → envelopeId from the manifest evidenceRefs. */
|
|
10
|
+
function buildAtomToEnvelopeMap(evidenceRefs) {
|
|
11
|
+
const map = new Map();
|
|
12
|
+
for (const ref of evidenceRefs) {
|
|
13
|
+
map.set(ref.atomId, ref.envelopeId);
|
|
14
|
+
}
|
|
15
|
+
return map;
|
|
16
|
+
}
|
|
17
|
+
/** Build a map from envelopeId → hash for a fingerprint array. */
|
|
18
|
+
function buildFingerprintMap(fingerprints) {
|
|
19
|
+
const map = new Map();
|
|
20
|
+
for (const fp of fingerprints) {
|
|
21
|
+
map.set(fp.envelopeId, fp.integrityHashSha256Hex);
|
|
22
|
+
}
|
|
23
|
+
return map;
|
|
24
|
+
}
|
|
25
|
+
/** Distinct envelopeIds in first-seen evidenceRefs order (deterministic tie-breaking). */
|
|
26
|
+
function envelopeOrderOf(evidenceRefs) {
|
|
27
|
+
const order = [];
|
|
28
|
+
const seen = new Set();
|
|
29
|
+
for (const ref of evidenceRefs) {
|
|
30
|
+
if (!seen.has(ref.envelopeId)) {
|
|
31
|
+
order.push(ref.envelopeId);
|
|
32
|
+
seen.add(ref.envelopeId);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return order;
|
|
36
|
+
}
|
|
37
|
+
const UNKNOWN_ENVELOPE = "unknown";
|
|
38
|
+
const REQUIREMENTS_ENVELOPE_PREFIX = "qi-src-req-";
|
|
39
|
+
const ALIGN_INSERT_DELETE_COST = 3;
|
|
40
|
+
const ALIGN_SUBSTITUTE_COST = 4;
|
|
41
|
+
const ALIGN_CROSS_OLD_ATOM_COST = 10;
|
|
42
|
+
const staleReason = (candidateId, reason, envelopeId) => ({ candidateId, reason, envelopeId });
|
|
43
|
+
function buildAtomFingerprintMap(fingerprints) {
|
|
44
|
+
const map = new Map();
|
|
45
|
+
for (const fp of fingerprints ?? []) {
|
|
46
|
+
map.set(fp.atomId, {
|
|
47
|
+
envelopeId: fp.envelopeId,
|
|
48
|
+
canonicalHashSha256Hex: fp.canonicalHashSha256Hex,
|
|
49
|
+
...(fp.replacementGroupId !== undefined ? { replacementGroupId: fp.replacementGroupId } : {}),
|
|
50
|
+
...(fp.replacementOrdinal !== undefined ? { replacementOrdinal: fp.replacementOrdinal } : {}),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return map;
|
|
54
|
+
}
|
|
55
|
+
function replacementEntriesByGroup(fingerprints) {
|
|
56
|
+
const groups = new Map();
|
|
57
|
+
for (const fp of fingerprints ?? []) {
|
|
58
|
+
if (fp.replacementGroupId === undefined || fp.replacementOrdinal === undefined)
|
|
59
|
+
continue;
|
|
60
|
+
const entry = {
|
|
61
|
+
atomId: fp.atomId,
|
|
62
|
+
canonicalHashSha256Hex: fp.canonicalHashSha256Hex,
|
|
63
|
+
replacementGroupId: fp.replacementGroupId,
|
|
64
|
+
replacementOrdinal: fp.replacementOrdinal,
|
|
65
|
+
};
|
|
66
|
+
const group = groups.get(fp.replacementGroupId);
|
|
67
|
+
if (group === undefined) {
|
|
68
|
+
groups.set(fp.replacementGroupId, [entry]);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
group.push(entry);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
for (const group of groups.values()) {
|
|
75
|
+
group.sort((a, b) => a.replacementOrdinal - b.replacementOrdinal);
|
|
76
|
+
}
|
|
77
|
+
return groups;
|
|
78
|
+
}
|
|
79
|
+
function alignmentPairCost(oldEntry, currentEntry, oldAtomIds) {
|
|
80
|
+
if (oldEntry.atomId === currentEntry.atomId)
|
|
81
|
+
return 0;
|
|
82
|
+
if (currentEntry.canonicalHashSha256Hex === oldEntry.canonicalHashSha256Hex)
|
|
83
|
+
return 0;
|
|
84
|
+
return oldAtomIds.has(currentEntry.atomId) ? ALIGN_CROSS_OLD_ATOM_COST : ALIGN_SUBSTITUTE_COST;
|
|
85
|
+
}
|
|
86
|
+
function replacementEntryAt(entries, index) {
|
|
87
|
+
const entry = entries[index];
|
|
88
|
+
if (entry === undefined)
|
|
89
|
+
throw new Error("Replacement alignment index out of bounds.");
|
|
90
|
+
return entry;
|
|
91
|
+
}
|
|
92
|
+
function matrixValue(matrix, row, col) {
|
|
93
|
+
const value = matrix[row]?.[col];
|
|
94
|
+
if (value === undefined)
|
|
95
|
+
throw new Error("Replacement alignment matrix index out of bounds.");
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
function setMatrixValue(matrix, row, col, value) {
|
|
99
|
+
const rowValues = matrix[row];
|
|
100
|
+
if (rowValues === undefined) {
|
|
101
|
+
throw new Error("Replacement alignment matrix index out of bounds.");
|
|
102
|
+
}
|
|
103
|
+
rowValues[col] = value;
|
|
104
|
+
}
|
|
105
|
+
function buildAlignmentCostMatrix(oldEntries, currentEntries, oldAtomIds) {
|
|
106
|
+
const matrix = Array.from({ length: oldEntries.length + 1 }, () => Array.from({ length: currentEntries.length + 1 }, () => 0));
|
|
107
|
+
for (let oldIndex = 1; oldIndex <= oldEntries.length; oldIndex += 1) {
|
|
108
|
+
setMatrixValue(matrix, oldIndex, 0, oldIndex * ALIGN_INSERT_DELETE_COST);
|
|
109
|
+
}
|
|
110
|
+
for (let currentIndex = 1; currentIndex <= currentEntries.length; currentIndex += 1) {
|
|
111
|
+
setMatrixValue(matrix, 0, currentIndex, currentIndex * ALIGN_INSERT_DELETE_COST);
|
|
112
|
+
}
|
|
113
|
+
for (let oldIndex = 1; oldIndex <= oldEntries.length; oldIndex += 1) {
|
|
114
|
+
for (let currentIndex = 1; currentIndex <= currentEntries.length; currentIndex += 1) {
|
|
115
|
+
const pairCost = alignmentPairCost(replacementEntryAt(oldEntries, oldIndex - 1), replacementEntryAt(currentEntries, currentIndex - 1), oldAtomIds);
|
|
116
|
+
const pair = matrixValue(matrix, oldIndex - 1, currentIndex - 1) + pairCost;
|
|
117
|
+
const deletion = matrixValue(matrix, oldIndex - 1, currentIndex) + ALIGN_INSERT_DELETE_COST;
|
|
118
|
+
const insertion = matrixValue(matrix, oldIndex, currentIndex - 1) + ALIGN_INSERT_DELETE_COST;
|
|
119
|
+
setMatrixValue(matrix, oldIndex, currentIndex, Math.min(pair, deletion, insertion));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return matrix;
|
|
123
|
+
}
|
|
124
|
+
function alignReplacementEntries(oldEntries, currentEntries) {
|
|
125
|
+
const mapping = new Map();
|
|
126
|
+
const oldAtomIds = new Set(oldEntries.map((entry) => entry.atomId));
|
|
127
|
+
const costs = buildAlignmentCostMatrix(oldEntries, currentEntries, oldAtomIds);
|
|
128
|
+
let oldIndex = oldEntries.length;
|
|
129
|
+
let currentIndex = currentEntries.length;
|
|
130
|
+
while (oldIndex > 0 && currentIndex > 0) {
|
|
131
|
+
const oldEntry = replacementEntryAt(oldEntries, oldIndex - 1);
|
|
132
|
+
const currentEntry = replacementEntryAt(currentEntries, currentIndex - 1);
|
|
133
|
+
const pairCost = alignmentPairCost(oldEntry, currentEntry, oldAtomIds);
|
|
134
|
+
const currentCost = matrixValue(costs, oldIndex, currentIndex);
|
|
135
|
+
const pair = matrixValue(costs, oldIndex - 1, currentIndex - 1) + pairCost;
|
|
136
|
+
const deletion = matrixValue(costs, oldIndex - 1, currentIndex) + ALIGN_INSERT_DELETE_COST;
|
|
137
|
+
const insertion = matrixValue(costs, oldIndex, currentIndex - 1) + ALIGN_INSERT_DELETE_COST;
|
|
138
|
+
if (pairCost === 0 && currentCost === pair) {
|
|
139
|
+
mapping.set(oldEntry.atomId, currentEntry.atomId);
|
|
140
|
+
oldIndex -= 1;
|
|
141
|
+
currentIndex -= 1;
|
|
142
|
+
}
|
|
143
|
+
else if (currentCost === insertion) {
|
|
144
|
+
currentIndex -= 1;
|
|
145
|
+
}
|
|
146
|
+
else if (currentCost === deletion) {
|
|
147
|
+
oldIndex -= 1;
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
mapping.set(oldEntry.atomId, currentEntry.atomId);
|
|
151
|
+
oldIndex -= 1;
|
|
152
|
+
currentIndex -= 1;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return mapping;
|
|
156
|
+
}
|
|
157
|
+
function buildReplacementAtomMap(oldFingerprints, currentFingerprints) {
|
|
158
|
+
const oldGroups = replacementEntriesByGroup(oldFingerprints);
|
|
159
|
+
const currentGroups = replacementEntriesByGroup(currentFingerprints);
|
|
160
|
+
const mapping = new Map();
|
|
161
|
+
for (const [groupId, oldEntries] of oldGroups) {
|
|
162
|
+
const currentEntries = currentGroups.get(groupId);
|
|
163
|
+
if (currentEntries === undefined)
|
|
164
|
+
continue;
|
|
165
|
+
for (const [oldAtomId, currentAtomId] of alignReplacementEntries(oldEntries, currentEntries)) {
|
|
166
|
+
mapping.set(oldAtomId, currentAtomId);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return mapping;
|
|
170
|
+
}
|
|
171
|
+
function buildAtomIdsByEnvelope(fingerprints) {
|
|
172
|
+
const map = new Map();
|
|
173
|
+
for (const fp of fingerprints ?? []) {
|
|
174
|
+
const current = map.get(fp.envelopeId);
|
|
175
|
+
if (current === undefined) {
|
|
176
|
+
map.set(fp.envelopeId, [fp.atomId]);
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
current.push(fp.atomId);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return map;
|
|
183
|
+
}
|
|
184
|
+
function replacementAtomIdForMissingCurrentAtom(atomId, ctx) {
|
|
185
|
+
return ctx.currentReplacementAtomIdsByOldAtomId.get(atomId);
|
|
186
|
+
}
|
|
187
|
+
function positionalRequirementReplacementAtomId(atomId, envelopeId, ctx) {
|
|
188
|
+
const oldAtomIds = ctx.oldAtomIdsByEnvelope.get(envelopeId) ?? [];
|
|
189
|
+
const currentAtomIds = ctx.currentAtomIdsByEnvelope.get(envelopeId) ?? [];
|
|
190
|
+
const oldIndex = oldAtomIds.indexOf(atomId);
|
|
191
|
+
return oldIndex >= 0 ? currentAtomIds[oldIndex] : undefined;
|
|
192
|
+
}
|
|
193
|
+
function classifyMissingCurrentAtom(candidateId, atomId, envelopeId, ctx) {
|
|
194
|
+
if (!ctx.currentMap.has(envelopeId)) {
|
|
195
|
+
return staleReason(candidateId, "source-removed", envelopeId);
|
|
196
|
+
}
|
|
197
|
+
const replacementAtomId = replacementAtomIdForMissingCurrentAtom(atomId, ctx);
|
|
198
|
+
if (replacementAtomId !== undefined && !ctx.oldAtoms.has(replacementAtomId)) {
|
|
199
|
+
return staleReason(candidateId, "source-changed", envelopeId);
|
|
200
|
+
}
|
|
201
|
+
if (!envelopeId.startsWith(REQUIREMENTS_ENVELOPE_PREFIX)) {
|
|
202
|
+
return staleReason(candidateId, "source-removed", envelopeId);
|
|
203
|
+
}
|
|
204
|
+
const currentAtomAtSamePosition = positionalRequirementReplacementAtomId(atomId, envelopeId, ctx);
|
|
205
|
+
if (currentAtomAtSamePosition !== undefined && !ctx.oldAtoms.has(currentAtomAtSamePosition)) {
|
|
206
|
+
return staleReason(candidateId, "source-changed", envelopeId);
|
|
207
|
+
}
|
|
208
|
+
return staleReason(candidateId, "source-removed", envelopeId);
|
|
209
|
+
}
|
|
210
|
+
function classifyCandidateWithAtomFingerprints(candidate, ctx) {
|
|
211
|
+
if (candidate.derivedFromAtomIds.length === 0) {
|
|
212
|
+
return staleReason(candidate.id, "source-removed", UNKNOWN_ENVELOPE);
|
|
213
|
+
}
|
|
214
|
+
let changedEnvelopeId;
|
|
215
|
+
for (const atomId of candidate.derivedFromAtomIds) {
|
|
216
|
+
const oldAtom = ctx.oldAtoms.get(atomId);
|
|
217
|
+
if (oldAtom === undefined) {
|
|
218
|
+
return staleReason(candidate.id, "source-changed", UNKNOWN_ENVELOPE);
|
|
219
|
+
}
|
|
220
|
+
const currentAtom = ctx.currentAtoms.get(atomId);
|
|
221
|
+
if (currentAtom !== undefined) {
|
|
222
|
+
if (currentAtom.canonicalHashSha256Hex !== oldAtom.canonicalHashSha256Hex) {
|
|
223
|
+
changedEnvelopeId ??= oldAtom.envelopeId;
|
|
224
|
+
}
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const missingCurrentAtomReason = classifyMissingCurrentAtom(candidate.id, atomId, oldAtom.envelopeId, ctx);
|
|
228
|
+
if (missingCurrentAtomReason.reason === "source-changed") {
|
|
229
|
+
changedEnvelopeId ??= oldAtom.envelopeId;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
return missingCurrentAtomReason;
|
|
233
|
+
}
|
|
234
|
+
return changedEnvelopeId === undefined
|
|
235
|
+
? null
|
|
236
|
+
: staleReason(candidate.id, "source-changed", changedEnvelopeId);
|
|
237
|
+
}
|
|
238
|
+
function orderedCandidateEnvelopeIds(candidate, ctx) {
|
|
239
|
+
const envelopeIds = new Set();
|
|
240
|
+
for (const atomId of candidate.derivedFromAtomIds) {
|
|
241
|
+
const envelopeId = ctx.atomToEnvelope.get(atomId);
|
|
242
|
+
if (envelopeId !== undefined)
|
|
243
|
+
envelopeIds.add(envelopeId);
|
|
244
|
+
}
|
|
245
|
+
return ctx.envelopeOrder.filter((envelopeId) => envelopeIds.has(envelopeId));
|
|
246
|
+
}
|
|
247
|
+
function classifyEnvelopeLevelCandidate(candidateId, orderedEnvelopeIds, ctx) {
|
|
248
|
+
for (const envelopeId of orderedEnvelopeIds) {
|
|
249
|
+
if (!ctx.currentMap.has(envelopeId)) {
|
|
250
|
+
return staleReason(candidateId, "source-removed", envelopeId);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
for (const envelopeId of orderedEnvelopeIds) {
|
|
254
|
+
const oldHash = ctx.oldMap.get(envelopeId);
|
|
255
|
+
const currentHash = ctx.currentMap.get(envelopeId);
|
|
256
|
+
if (currentHash !== undefined && oldHash !== currentHash) {
|
|
257
|
+
return staleReason(candidateId, "source-changed", envelopeId);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Classify ONE candidate. Returns the dominant staleness reason, or null when fresh.
|
|
264
|
+
* Removed (envelope gone from the current scan) takes precedence over changed (hash differs);
|
|
265
|
+
* an unresolvable atom is treated as changed so an invalid test is never silently kept fresh.
|
|
266
|
+
*/
|
|
267
|
+
function classifyCandidate(candidate, ctx) {
|
|
268
|
+
const hasAtomMetadata = ctx.oldAtoms.size > 0 &&
|
|
269
|
+
candidate.derivedFromAtomIds.every((atomId) => ctx.oldAtoms.has(atomId));
|
|
270
|
+
if (hasAtomMetadata) {
|
|
271
|
+
return classifyCandidateWithAtomFingerprints(candidate, ctx);
|
|
272
|
+
}
|
|
273
|
+
if (candidate.derivedFromAtomIds.length === 0) {
|
|
274
|
+
return staleReason(candidate.id, "source-removed", UNKNOWN_ENVELOPE);
|
|
275
|
+
}
|
|
276
|
+
if (candidate.derivedFromAtomIds.some((atomId) => !ctx.atomToEnvelope.has(atomId))) {
|
|
277
|
+
return staleReason(candidate.id, "source-changed", UNKNOWN_ENVELOPE);
|
|
278
|
+
}
|
|
279
|
+
return classifyEnvelopeLevelCandidate(candidate.id, orderedCandidateEnvelopeIds(candidate, ctx), ctx);
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Classify each candidate as fresh or stale based on whether their source envelopes have changed
|
|
283
|
+
* since the run was persisted. Pure, deterministic; candidate input order is preserved.
|
|
284
|
+
*/
|
|
285
|
+
export function compareStaleness(args) {
|
|
286
|
+
const ctx = {
|
|
287
|
+
atomToEnvelope: buildAtomToEnvelopeMap(args.evidenceRefs),
|
|
288
|
+
oldMap: buildFingerprintMap(args.oldFingerprints),
|
|
289
|
+
currentMap: buildFingerprintMap(args.currentFingerprints),
|
|
290
|
+
envelopeOrder: envelopeOrderOf(args.evidenceRefs),
|
|
291
|
+
oldAtoms: buildAtomFingerprintMap(args.oldAtomFingerprints),
|
|
292
|
+
currentAtoms: buildAtomFingerprintMap(args.currentAtomFingerprints),
|
|
293
|
+
currentReplacementAtomIdsByOldAtomId: buildReplacementAtomMap(args.oldAtomFingerprints, args.currentAtomFingerprints),
|
|
294
|
+
oldAtomIdsByEnvelope: buildAtomIdsByEnvelope(args.oldAtomFingerprints),
|
|
295
|
+
currentAtomIdsByEnvelope: buildAtomIdsByEnvelope(args.currentAtomFingerprints),
|
|
296
|
+
};
|
|
297
|
+
const fresh = [];
|
|
298
|
+
const changedStale = [];
|
|
299
|
+
const orphanedStale = [];
|
|
300
|
+
for (const candidate of args.candidates) {
|
|
301
|
+
const reason = classifyCandidate(candidate, ctx);
|
|
302
|
+
if (reason === null) {
|
|
303
|
+
fresh.push(candidate.id);
|
|
304
|
+
}
|
|
305
|
+
else if (reason.reason === "source-removed") {
|
|
306
|
+
orphanedStale.push(reason);
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
changedStale.push(reason);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return { fresh, changedStale, orphanedStale };
|
|
313
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { QualityIntelligence } from "@oscharko-dev/keiko-contracts";
|
|
2
|
+
import type { IntentSummary } from "./intentDerivation.js";
|
|
3
|
+
import type { PolicyProfile } from "./policyProfile.js";
|
|
4
|
+
export interface DesignTestCaseCandidatesInput {
|
|
5
|
+
readonly runId: QualityIntelligence.QualityIntelligenceRunId;
|
|
6
|
+
readonly intent: IntentSummary;
|
|
7
|
+
readonly atoms: readonly QualityIntelligence.QualityIntelligenceEvidenceAtom[];
|
|
8
|
+
/**
|
|
9
|
+
* Optional canonical source text by atom id. Evidence atoms intentionally carry only hashes in the
|
|
10
|
+
* public contract, but live ingestion has the canonical text available server-side. Supplying it
|
|
11
|
+
* lets the deterministic baseline produce usable, source-specific tests instead of atom-id stubs.
|
|
12
|
+
*/
|
|
13
|
+
readonly atomTextById?: ReadonlyMap<string, string>;
|
|
14
|
+
readonly profile?: PolicyProfile;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Stable provenance discriminator carried by every deterministic-baseline candidate.
|
|
18
|
+
*
|
|
19
|
+
* The model-delta builder (`parseGeneratedCandidates`) never emits this tag, so it is a reliable
|
|
20
|
+
* "this is a generic determinism-floor stub, not a judged model candidate" marker — unlike a null
|
|
21
|
+
* `qualityVerdict`, which is also null whenever the judge stage is skipped entirely. Render-layer
|
|
22
|
+
* consumers sort baseline-tagged candidates to the end so judged candidates lead the deliverable;
|
|
23
|
+
* the persisted manifest order stays `[...baseline, ...delta]` (Issue #763 contract). The tag is
|
|
24
|
+
* additive and feeds no candidate id (`sha256(v2<atomHash><index>)`), dedup signature, or manifest
|
|
25
|
+
* hash, so it is determinism-safe.
|
|
26
|
+
*/
|
|
27
|
+
export declare const DETERMINISTIC_BASELINE_PROVENANCE_TAG = "source:deterministic-baseline";
|
|
28
|
+
/**
|
|
29
|
+
* Produce deterministic draft candidates from the intent summary + atoms.
|
|
30
|
+
* Returns the empty array when the atom list is empty. Atoms are first
|
|
31
|
+
* sorted by canonical hash so input ordering does not affect IDs.
|
|
32
|
+
*
|
|
33
|
+
* Candidate IDs are derived as
|
|
34
|
+
* `qi-candidate-<32-hex-of-sha256(v2<atomHash><index>)>` — collision-resistant,
|
|
35
|
+
* run-independent, and round-trip-stable for the same evidence.
|
|
36
|
+
*/
|
|
37
|
+
export declare const designTestCaseCandidates: (input: DesignTestCaseCandidatesInput) => readonly QualityIntelligence.QualityIntelligenceTestCaseCandidate[];
|
|
38
|
+
//# sourceMappingURL=testDesignModel.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"testDesignModel.d.ts","sourceRoot":"","sources":["../../src/domain/testDesignModel.ts"],"names":[],"mappings":"AAcA,OAAO,EAAE,mBAAmB,EAAE,MAAM,+BAA+B,CAAC;AAKpE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAGxD,MAAM,WAAW,6BAA6B;IAC5C,QAAQ,CAAC,KAAK,EAAE,mBAAmB,CAAC,wBAAwB,CAAC;IAC7D,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;IAC/B,QAAQ,CAAC,KAAK,EAAE,SAAS,mBAAmB,CAAC,+BAA+B,EAAE,CAAC;IAC/E;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpD,QAAQ,CAAC,OAAO,CAAC,EAAE,aAAa,CAAC;CAClC;AAoHD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,qCAAqC,kCAAkC,CAAC;AA2IrF;;;;;;;;GAQG;AACH,eAAO,MAAM,wBAAwB,GACnC,OAAO,6BAA6B,KACnC,SAAS,mBAAmB,CAAC,oCAAoC,EA4CnE,CAAC"}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// Quality Intelligence test-design model (Epic #270, Issue #272).
|
|
2
|
+
//
|
|
3
|
+
// Converts an `IntentSummary` plus the evidence atoms it was derived from
|
|
4
|
+
// into a deterministic list of draft `QualityIntelligenceTestCaseCandidate`
|
|
5
|
+
// records. NO model calls; NO randomness; ID derivation is content-hash +
|
|
6
|
+
// position based, so the same evidence always produces the same candidate IDs
|
|
7
|
+
// regardless of the enclosing run id.
|
|
8
|
+
//
|
|
9
|
+
// Structurally inspired by
|
|
10
|
+
// Test Intelligence reference (TI) packages/core-engine/src/intent-derivation.ts
|
|
11
|
+
// (the IR → candidate translation phase). The TI reference produces richer
|
|
12
|
+
// UI-oriented candidates with screen/route metadata; our Keiko port stays
|
|
13
|
+
// envelope/atom-shaped and policy-driven.
|
|
14
|
+
import { QualityIntelligence } from "@oscharko-dev/keiko-contracts";
|
|
15
|
+
import { sha256Hex } from "@oscharko-dev/keiko-security";
|
|
16
|
+
import { isKnownPriority, normaliseCandidateText } from "./assertions.js";
|
|
17
|
+
import { STRUCTURAL_BASELINE_MARKER } from "./figma/screenIrTestBaseline.js";
|
|
18
|
+
import { regressionDefault } from "./policyProfile.js";
|
|
19
|
+
const compareString = (left, right) => left < right ? -1 : left > right ? 1 : 0;
|
|
20
|
+
const stableSortAtoms = (atoms) => {
|
|
21
|
+
return [...atoms].sort((left, right) => compareString(left.canonicalHashSha256Hex, right.canonicalHashSha256Hex));
|
|
22
|
+
};
|
|
23
|
+
const deriveRiskClass = (atom, profile) => {
|
|
24
|
+
if (atom.kind === "design-fragment") {
|
|
25
|
+
return "visual";
|
|
26
|
+
}
|
|
27
|
+
if (atom.kind === "requirement") {
|
|
28
|
+
return profile.defaultRiskClass === "visual" ? "functional" : profile.defaultRiskClass;
|
|
29
|
+
}
|
|
30
|
+
return profile.defaultRiskClass;
|
|
31
|
+
};
|
|
32
|
+
const derivePriority = (intent, profile) => {
|
|
33
|
+
if (intent.priorityHint !== "unknown" && isKnownPriority(intent.priorityHint)) {
|
|
34
|
+
return intent.priorityHint;
|
|
35
|
+
}
|
|
36
|
+
return profile.defaultPriority;
|
|
37
|
+
};
|
|
38
|
+
const buildTitle = (atom, intent, index, atomText) => {
|
|
39
|
+
const indexLabel = `#${String(index + 1).padStart(3, "0")}`;
|
|
40
|
+
const sourceSubject = atomText === undefined ? undefined : conciseText(atomText, 90);
|
|
41
|
+
if (sourceSubject !== undefined) {
|
|
42
|
+
return `${indexLabel} Prüfe ${sourceSubject}`;
|
|
43
|
+
}
|
|
44
|
+
const themes = intent.themes.slice(0, 2).join(" / ");
|
|
45
|
+
const subject = themes.length > 0 ? themes : atom.kind;
|
|
46
|
+
return `${indexLabel} ${subject} — ${atom.kind}`;
|
|
47
|
+
};
|
|
48
|
+
const buildPreconditions = (intent, atomText) => {
|
|
49
|
+
const preconditions = [];
|
|
50
|
+
if (atomText !== undefined) {
|
|
51
|
+
const sourceRequirement = conciseText(atomText, 220);
|
|
52
|
+
if (sourceRequirement !== undefined) {
|
|
53
|
+
preconditions.push(`Quellanforderung: ${sourceRequirement}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (intent.requirementCandidates.length === 0) {
|
|
57
|
+
return Object.freeze(preconditions);
|
|
58
|
+
}
|
|
59
|
+
preconditions.push(...intent.requirementCandidates.slice(0, atomText === undefined ? 3 : 2));
|
|
60
|
+
return Object.freeze(preconditions);
|
|
61
|
+
};
|
|
62
|
+
const buildSteps = (atom, intent, atomText) => {
|
|
63
|
+
const steps = [];
|
|
64
|
+
const theme = intent.themes[0];
|
|
65
|
+
const requirement = atomText === undefined ? undefined : conciseText(atomText, 180);
|
|
66
|
+
if (theme !== undefined) {
|
|
67
|
+
steps.push(`Öffne den ${theme}-Ablauf oder Servicepfad zu dieser Anforderung.`);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
steps.push("Öffne den Zielablauf oder Servicepfad zu dieser Anforderung.");
|
|
71
|
+
}
|
|
72
|
+
if (requirement !== undefined) {
|
|
73
|
+
steps.push(`Bereite das Szenario vor, das hier beschrieben ist: ${requirement}`);
|
|
74
|
+
}
|
|
75
|
+
steps.push("Führe die fachliche Aktion, Validierungsregel oder Entscheidungsstrecke aus.");
|
|
76
|
+
steps.push(`Dokumentiere Ergebnis, persistierten Zustand, Meldungen und Audit-Evidenz für Atom ${atom.id}.`);
|
|
77
|
+
return Object.freeze(steps);
|
|
78
|
+
};
|
|
79
|
+
const buildExpectedResults = (atom, intent, atomText) => {
|
|
80
|
+
const results = [];
|
|
81
|
+
if (atomText !== undefined) {
|
|
82
|
+
const expectedRequirement = conciseText(atomText, 220);
|
|
83
|
+
if (expectedRequirement !== undefined) {
|
|
84
|
+
results.push(`Das beobachtete Verhalten erfüllt: ${expectedRequirement}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
else if (intent.requirementCandidates.length > 0) {
|
|
88
|
+
results.push(`Der Ablauf erfüllt: ${intent.requirementCandidates[0] ?? ""}`);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
results.push("Der Ablauf wird ohne Fehler abgeschlossen.");
|
|
92
|
+
}
|
|
93
|
+
results.push("Widersprüchliche Ergebnisse, fehlende Validierung und stiller Datenverlust sind nicht akzeptabel.");
|
|
94
|
+
results.push(`Evidenz-Atom ${atom.canonicalHashSha256Hex.slice(0, 12)} bleibt kanonisch.`);
|
|
95
|
+
return Object.freeze(results);
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* Stable provenance discriminator carried by every deterministic-baseline candidate.
|
|
99
|
+
*
|
|
100
|
+
* The model-delta builder (`parseGeneratedCandidates`) never emits this tag, so it is a reliable
|
|
101
|
+
* "this is a generic determinism-floor stub, not a judged model candidate" marker — unlike a null
|
|
102
|
+
* `qualityVerdict`, which is also null whenever the judge stage is skipped entirely. Render-layer
|
|
103
|
+
* consumers sort baseline-tagged candidates to the end so judged candidates lead the deliverable;
|
|
104
|
+
* the persisted manifest order stays `[...baseline, ...delta]` (Issue #763 contract). The tag is
|
|
105
|
+
* additive and feeds no candidate id (`sha256(v2<atomHash><index>)`), dedup signature, or manifest
|
|
106
|
+
* hash, so it is determinism-safe.
|
|
107
|
+
*/
|
|
108
|
+
export const DETERMINISTIC_BASELINE_PROVENANCE_TAG = "source:deterministic-baseline";
|
|
109
|
+
const buildTags = (intent, riskClass) => {
|
|
110
|
+
const tags = new Set();
|
|
111
|
+
for (const theme of intent.themes) {
|
|
112
|
+
tags.add(`theme:${theme}`);
|
|
113
|
+
}
|
|
114
|
+
for (const risk of intent.riskHints) {
|
|
115
|
+
tags.add(`risk-hint:${risk}`);
|
|
116
|
+
}
|
|
117
|
+
tags.add(`risk-class:${riskClass}`);
|
|
118
|
+
tags.add(DETERMINISTIC_BASELINE_PROVENANCE_TAG);
|
|
119
|
+
return Object.freeze(Array.from(tags).sort(compareString));
|
|
120
|
+
};
|
|
121
|
+
const deriveCandidateIdString = (atom, index) => {
|
|
122
|
+
const payload = ["v2", atom.canonicalHashSha256Hex, String(index)].join("");
|
|
123
|
+
const digest = sha256Hex(payload).slice(0, 32);
|
|
124
|
+
return `qi-candidate-${digest}`;
|
|
125
|
+
};
|
|
126
|
+
// Sanitise an ordered fragment list for a persisted candidate field: NFKC-normalise,
|
|
127
|
+
// strip unsafe bidi / zero-width / C0/C1/DEL code points (via normaliseCandidateText),
|
|
128
|
+
// and drop fragments that become empty. Order-preserving, no dedup. The deterministic
|
|
129
|
+
// builder receives untrusted text only through `intent` (derived from source display
|
|
130
|
+
// labels, which sanitiseLabel does NOT strip bidi/zero-width from), so without this the
|
|
131
|
+
// deterministic candidates persist and export those spoofing code points — the
|
|
132
|
+
// deterministic-path twin of the model-path chokepoint in parseGeneratedCandidates
|
|
133
|
+
// (Epic #711 / Issue #724 residual). Clean fragments are byte-identical (strip is a
|
|
134
|
+
// no-op and the fragments are already trimmed), so candidate IDs — derived from
|
|
135
|
+
// atomHash/index, never from text — stay stable.
|
|
136
|
+
const sanitiseFragmentList = (values) => Object.freeze(values.map((value) => normaliseCandidateText(value)).filter((value) => value.length > 0));
|
|
137
|
+
// Sanitise + canonicalise a tag list: strip unsafe code points, drop empties, dedup,
|
|
138
|
+
// and sort — preserving the Set+sort semantics of buildTags on the post-strip values so
|
|
139
|
+
// a zero-width-spoofed theme cannot smuggle a visually-duplicate tag into the export.
|
|
140
|
+
const canonicaliseCandidateTags = (values) => {
|
|
141
|
+
const seen = new Set();
|
|
142
|
+
for (const value of values) {
|
|
143
|
+
const cleaned = normaliseCandidateText(value);
|
|
144
|
+
if (cleaned.length > 0) {
|
|
145
|
+
seen.add(cleaned);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return Object.freeze(Array.from(seen).sort(compareString));
|
|
149
|
+
};
|
|
150
|
+
const COLLAPSED_WHITESPACE = /\s+/gu;
|
|
151
|
+
function conciseText(value, maxLength) {
|
|
152
|
+
const cleaned = normaliseCandidateText(value).replace(COLLAPSED_WHITESPACE, " ").trim();
|
|
153
|
+
if (cleaned.length === 0)
|
|
154
|
+
return undefined;
|
|
155
|
+
if (cleaned.length <= maxLength)
|
|
156
|
+
return cleaned;
|
|
157
|
+
return `${cleaned.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
|
|
158
|
+
}
|
|
159
|
+
function atomTextFor(atom, atomTextById) {
|
|
160
|
+
return atomTextById === undefined
|
|
161
|
+
? undefined
|
|
162
|
+
: conciseText(atomTextById.get(String(atom.id)) ?? "", 320);
|
|
163
|
+
}
|
|
164
|
+
// ─── Figma screen-baseline candidate (clean structural floor) ────────────────────────────────────
|
|
165
|
+
//
|
|
166
|
+
// A Figma screen atom's canonical text is a Screen-IR STRUCTURAL BASELINE (render / field / control /
|
|
167
|
+
// navigation / a11y items), NOT a prose requirement. Run through the generic requirement template
|
|
168
|
+
// above, it degenerates into an atom-id/hash-laden stub ("#001 Prüfe <baseline dump> … Öffne den
|
|
169
|
+
// <name-suffix>-Ablauf … für Atom <id> … Evidenz-Atom <hash> bleibt kanonisch"). Detect it by its
|
|
170
|
+
// stable marker and emit a clean, screen-scoped structural floor test instead. Marker-gated, so every
|
|
171
|
+
// NON-figma source kind's deterministic candidate stays byte-identical (no cross-source regression);
|
|
172
|
+
// the model path (parseGeneratedCandidates) is unaffected and still produces the per-element tests.
|
|
173
|
+
const FIGMA_SCREEN_HEADER = /^Screen:\s+(.+?)\s+\[[^\]]+\]$/u;
|
|
174
|
+
function figmaScreenNameFromAtomText(rawText) {
|
|
175
|
+
if (rawText?.includes(STRUCTURAL_BASELINE_MARKER) !== true)
|
|
176
|
+
return undefined;
|
|
177
|
+
for (const line of rawText.split("\n")) {
|
|
178
|
+
const match = FIGMA_SCREEN_HEADER.exec(line.trim());
|
|
179
|
+
const name = match?.[1]?.trim();
|
|
180
|
+
if (name !== undefined && name.length > 0)
|
|
181
|
+
return name;
|
|
182
|
+
}
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
function figmaBaselineCandidateBody(index, screenName) {
|
|
186
|
+
const indexLabel = `#${String(index + 1).padStart(3, "0")}`;
|
|
187
|
+
const subject = conciseText(screenName, 90) ?? screenName;
|
|
188
|
+
return {
|
|
189
|
+
title: `${indexLabel} Strukturelle Baseline-Prüfung: ${subject}`,
|
|
190
|
+
preconditions: [`Die Anwendung ist gestartet.`, `Der Screen "${subject}" ist geöffnet.`],
|
|
191
|
+
steps: [
|
|
192
|
+
`Prüfe, dass der Screen "${subject}" ohne Darstellungsfehler vollständig rendert.`,
|
|
193
|
+
"Prüfe die im Screen-IR-Baseline erfassten strukturellen Prüfpunkte: vorhandene Felder und " +
|
|
194
|
+
"Eingaben, Bedienelemente und ihre Aktionen, Navigationsziele sowie Accessibility-Kriterien " +
|
|
195
|
+
"(Kontrast, Fokusreihenfolge, Zielgröße).",
|
|
196
|
+
"Halte jede Abweichung je strukturellem Prüfpunkt einzeln fest.",
|
|
197
|
+
],
|
|
198
|
+
expectedResults: [
|
|
199
|
+
`Der Screen "${subject}" rendert korrekt und vollständig.`,
|
|
200
|
+
"Die strukturellen Prüfpunkte (Felder, Bedienelemente, Navigation, Accessibility) des " +
|
|
201
|
+
"Screen-IR-Baselines sind erfüllt.",
|
|
202
|
+
"Es treten keine fehlenden Elemente, Renderfehler oder verletzten Accessibility-Kriterien auf.",
|
|
203
|
+
],
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
function genericCandidateBody(atom, intent, index, atomText) {
|
|
207
|
+
return {
|
|
208
|
+
title: buildTitle(atom, intent, index, atomText),
|
|
209
|
+
preconditions: buildPreconditions(intent, atomText),
|
|
210
|
+
steps: buildSteps(atom, intent, atomText),
|
|
211
|
+
expectedResults: buildExpectedResults(atom, intent, atomText),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Produce deterministic draft candidates from the intent summary + atoms.
|
|
216
|
+
* Returns the empty array when the atom list is empty. Atoms are first
|
|
217
|
+
* sorted by canonical hash so input ordering does not affect IDs.
|
|
218
|
+
*
|
|
219
|
+
* Candidate IDs are derived as
|
|
220
|
+
* `qi-candidate-<32-hex-of-sha256(v2<atomHash><index>)>` — collision-resistant,
|
|
221
|
+
* run-independent, and round-trip-stable for the same evidence.
|
|
222
|
+
*/
|
|
223
|
+
export const designTestCaseCandidates = (input) => {
|
|
224
|
+
const { runId, intent, atoms, atomTextById } = input;
|
|
225
|
+
const profile = input.profile ?? regressionDefault;
|
|
226
|
+
if (atoms.length === 0) {
|
|
227
|
+
return Object.freeze([]);
|
|
228
|
+
}
|
|
229
|
+
const sorted = stableSortAtoms(atoms);
|
|
230
|
+
const priority = derivePriority(intent, profile);
|
|
231
|
+
const candidates = [];
|
|
232
|
+
for (let index = 0; index < sorted.length; index += 1) {
|
|
233
|
+
const atom = sorted[index];
|
|
234
|
+
if (atom === undefined) {
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const idString = deriveCandidateIdString(atom, index);
|
|
238
|
+
const id = QualityIntelligence.asQualityIntelligenceTestCaseId(idString);
|
|
239
|
+
const riskClass = deriveRiskClass(atom, profile);
|
|
240
|
+
const atomText = atomTextFor(atom, atomTextById);
|
|
241
|
+
// A Figma screen atom carries a structural baseline, not a prose requirement — give it a clean
|
|
242
|
+
// screen-scoped structural candidate. Detection reads the FULL canonical text (not the truncated
|
|
243
|
+
// `atomText`) so the marker + screen-name header survive the per-title length cap.
|
|
244
|
+
const figmaScreenName = figmaScreenNameFromAtomText(atomTextById?.get(String(atom.id)));
|
|
245
|
+
const body = figmaScreenName !== undefined
|
|
246
|
+
? figmaBaselineCandidateBody(index, figmaScreenName)
|
|
247
|
+
: genericCandidateBody(atom, intent, index, atomText);
|
|
248
|
+
const candidate = {
|
|
249
|
+
id,
|
|
250
|
+
runId,
|
|
251
|
+
derivedFromAtomIds: Object.freeze([atom.id]),
|
|
252
|
+
title: normaliseCandidateText(body.title),
|
|
253
|
+
preconditions: sanitiseFragmentList(body.preconditions),
|
|
254
|
+
steps: sanitiseFragmentList(body.steps),
|
|
255
|
+
expectedResults: sanitiseFragmentList(body.expectedResults),
|
|
256
|
+
priority,
|
|
257
|
+
riskClass,
|
|
258
|
+
tags: canonicaliseCandidateTags(buildTags(intent, riskClass)),
|
|
259
|
+
status: "proposed",
|
|
260
|
+
};
|
|
261
|
+
candidates.push(Object.freeze(candidate));
|
|
262
|
+
}
|
|
263
|
+
return Object.freeze(candidates);
|
|
264
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { TestQualityRubricDimension } from "@oscharko-dev/keiko-contracts";
|
|
2
|
+
/** Threshold below which an overall score is classified as "weak". */
|
|
3
|
+
export declare const TEST_QUALITY_WEAK_THRESHOLD = 60;
|
|
4
|
+
/**
|
|
5
|
+
* Compute the mean score across all dimensions. Returns 0 when `dimensions` is empty.
|
|
6
|
+
* Deterministic: iteration order of `dimensions` does not affect the result.
|
|
7
|
+
*/
|
|
8
|
+
export declare function scoreFromDimensions(dimensions: readonly TestQualityRubricDimension[]): number;
|
|
9
|
+
/**
|
|
10
|
+
* Classify an overall score as "weak" or "strong".
|
|
11
|
+
* Scores strictly below `TEST_QUALITY_WEAK_THRESHOLD` (60) are "weak"; 60 and above are "strong".
|
|
12
|
+
*/
|
|
13
|
+
export declare function verdictFromScore(score: number): "weak" | "strong";
|
|
14
|
+
/**
|
|
15
|
+
* Classify a full rubric verdict. A candidate is only strong when the aggregate score and every
|
|
16
|
+
* mandatory rubric dimension meet the threshold; one weak dimension is enough to keep the finding
|
|
17
|
+
* visible instead of hiding it behind a passing average.
|
|
18
|
+
*/
|
|
19
|
+
export declare function verdictFromDimensions(dimensions: readonly TestQualityRubricDimension[]): "weak" | "strong";
|
|
20
|
+
//# sourceMappingURL=testQualityRubric.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"testQualityRubric.d.ts","sourceRoot":"","sources":["../../src/domain/testQualityRubric.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,+BAA+B,CAAC;AAEhF,sEAAsE;AACtE,eAAO,MAAM,2BAA2B,KAAK,CAAC;AAE9C;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,UAAU,EAAE,SAAS,0BAA0B,EAAE,GAAG,MAAM,CAO7F;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,QAAQ,CAEjE;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CACnC,UAAU,EAAE,SAAS,0BAA0B,EAAE,GAChD,MAAM,GAAG,QAAQ,CAKnB"}
|