@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,155 @@
|
|
|
1
|
+
// Quality Intelligence coverage relevance (Epic #270, Issue #272).
|
|
2
|
+
//
|
|
3
|
+
// Builds a deterministic `QualityIntelligenceCoverageMap` linking each
|
|
4
|
+
// evidence atom to the candidates derived from it, scoring each mapping
|
|
5
|
+
// with a simple structural confidence in [0, 1]. NO model judges — model-
|
|
6
|
+
// based coverage scoring lives in #279.
|
|
7
|
+
//
|
|
8
|
+
// Structurally inspired by
|
|
9
|
+
// Test Intelligence reference (TI) packages/core-engine/src/coverage-relevance.ts
|
|
10
|
+
// (`isCoverageRelevantElementLike`, `normalizeCoverageText`) — but the TI
|
|
11
|
+
// reference scores UI element coverage; our Keiko port scores atom-to-
|
|
12
|
+
// candidate provenance coverage.
|
|
13
|
+
import { QualityIntelligence } from "@oscharko-dev/keiko-contracts";
|
|
14
|
+
import { sha256Hex } from "@oscharko-dev/keiko-security";
|
|
15
|
+
// ─── Coverage classification ────────────────────────────────────────────────────
|
|
16
|
+
/** Thresholds for atom coverage classification. */
|
|
17
|
+
export const COVERAGE_THRESHOLD_COVERED = 0.7;
|
|
18
|
+
export const COVERAGE_THRESHOLD_WEAKLY_COVERED = 0.3;
|
|
19
|
+
/**
|
|
20
|
+
* Classify a single atom given its coverage-map mapping (undefined when the atom has
|
|
21
|
+
* no mapping entry — i.e. no candidate cited it at all).
|
|
22
|
+
*/
|
|
23
|
+
export function classifyAtomCoverage(atom, mapping) {
|
|
24
|
+
if (mapping === undefined || mapping.confidence < COVERAGE_THRESHOLD_WEAKLY_COVERED) {
|
|
25
|
+
return {
|
|
26
|
+
atomId: atom.id,
|
|
27
|
+
status: "uncovered",
|
|
28
|
+
confidence: mapping?.confidence ?? 0,
|
|
29
|
+
coveringCandidateIds: Object.freeze([]),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
const status = mapping.confidence >= COVERAGE_THRESHOLD_COVERED ? "covered" : "weakly-covered";
|
|
33
|
+
return {
|
|
34
|
+
atomId: atom.id,
|
|
35
|
+
status,
|
|
36
|
+
confidence: mapping.confidence,
|
|
37
|
+
coveringCandidateIds: mapping.candidateIds,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Classify every atom in `atoms` against the supplied coverage map. Atoms with no
|
|
42
|
+
* mapping are classified as "uncovered". The result is sorted by atomId ascending.
|
|
43
|
+
*/
|
|
44
|
+
export function buildAtomCoverageStatuses(atoms, coverageMap) {
|
|
45
|
+
const byAtomId = new Map();
|
|
46
|
+
for (const mapping of coverageMap.mappings) {
|
|
47
|
+
byAtomId.set(String(mapping.atomId), mapping);
|
|
48
|
+
}
|
|
49
|
+
const statuses = atoms.map((atom) => classifyAtomCoverage(atom, byAtomId.get(String(atom.id))));
|
|
50
|
+
statuses.sort((a, b) => String(a.atomId) < String(b.atomId) ? -1 : String(a.atomId) > String(b.atomId) ? 1 : 0);
|
|
51
|
+
return Object.freeze(statuses);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Returns the percentage of atoms classified as "covered" out of all atoms. Returns
|
|
55
|
+
* 0 when the array is empty (deterministic, no division by zero).
|
|
56
|
+
*/
|
|
57
|
+
export function runCoveragePercentage(statuses) {
|
|
58
|
+
if (statuses.length === 0)
|
|
59
|
+
return 0;
|
|
60
|
+
const covered = statuses.filter((s) => s.status === "covered").length;
|
|
61
|
+
return (covered / statuses.length) * 100;
|
|
62
|
+
}
|
|
63
|
+
const compareString = (left, right) => left < right ? -1 : left > right ? 1 : 0;
|
|
64
|
+
/**
|
|
65
|
+
* A citing test counts as a *focused* cover of an atom when it derives from at most this many
|
|
66
|
+
* atoms — i.e. the test is reasonably specific to the requirement rather than a sprawling
|
|
67
|
+
* integration test that incidentally touches it. An atom whose only covering tests are all
|
|
68
|
+
* broader than this is classified "weakly-covered" (covered only incidentally).
|
|
69
|
+
*
|
|
70
|
+
* Conservative for regulated use: a dedicated test (focus 1) always yields "covered"; coverage
|
|
71
|
+
* is NEVER diluted by the total run size (see the regression test for the historical bug where
|
|
72
|
+
* `citedCount / candidates.length` reported a perfectly-covered run as 0%).
|
|
73
|
+
*/
|
|
74
|
+
export const FOCUS_COVERED_MAX = 3;
|
|
75
|
+
const clamp01 = (value, max) => Math.max(0, Math.min(max, value));
|
|
76
|
+
/**
|
|
77
|
+
* Deterministic, run-size-INDEPENDENT structural confidence in [0, 1] that an atom is covered.
|
|
78
|
+
*
|
|
79
|
+
* Inputs are atom-local: `citerCount` is the number of candidates that DIRECTLY derive from the
|
|
80
|
+
* atom, and `bestFocus` is the smallest `derivedFromAtomIds` length among those citers (1 = a test
|
|
81
|
+
* dedicated to this atom alone). Confidence is monotonic non-decreasing in `citerCount` so more
|
|
82
|
+
* tracing tests never lowers confidence.
|
|
83
|
+
*
|
|
84
|
+
* - no citers -> 0 (uncovered)
|
|
85
|
+
* - >=1 focused citer -> [0.7, 1.0] (covered; threshold COVERAGE_THRESHOLD_COVERED)
|
|
86
|
+
* - only broad citers -> [0.3, 0.7) (weakly-covered; incidental coverage only)
|
|
87
|
+
*/
|
|
88
|
+
export const coverageConfidence = (citerCount, bestFocus) => {
|
|
89
|
+
if (citerCount <= 0)
|
|
90
|
+
return 0;
|
|
91
|
+
const focused = Math.max(1, bestFocus) <= FOCUS_COVERED_MAX;
|
|
92
|
+
const saturation = 1 - 1 / (1 + citerCount); // 0.5, 0.667, 0.75, ... (in [0.5, 1))
|
|
93
|
+
return focused
|
|
94
|
+
? clamp01(COVERAGE_THRESHOLD_COVERED + 0.3 * saturation, 1) // 0.85, 0.90, 0.925, ...
|
|
95
|
+
: clamp01(COVERAGE_THRESHOLD_WEAKLY_COVERED + 0.4 * saturation, 0.699); // 0.5, 0.567, ...
|
|
96
|
+
};
|
|
97
|
+
const deriveCoverageMapIdString = (runId, atomHashes, candidateIds) => {
|
|
98
|
+
const payload = [
|
|
99
|
+
"v1",
|
|
100
|
+
String(runId),
|
|
101
|
+
[...atomHashes].sort().join(""),
|
|
102
|
+
[...candidateIds].sort().join(""),
|
|
103
|
+
].join("");
|
|
104
|
+
return `qi-coverage-${sha256Hex(payload).slice(0, 32)}`;
|
|
105
|
+
};
|
|
106
|
+
/**
|
|
107
|
+
* Build a coverage map for the supplied run. The returned map is validated
|
|
108
|
+
* against `assertCoverageMapInvariant` before being returned — callers can
|
|
109
|
+
* trust every confidence value lies in [0, 1] and every mapping cites at
|
|
110
|
+
* least one candidate.
|
|
111
|
+
*
|
|
112
|
+
* Atoms whose structural score is 0 (no candidate cites them and no step
|
|
113
|
+
* mentions them) are omitted from the returned mappings — including a zero-
|
|
114
|
+
* confidence mapping would violate the contract invariant (every mapping
|
|
115
|
+
* must cite at least one candidate).
|
|
116
|
+
*/
|
|
117
|
+
export const buildCoverageMap = (input) => {
|
|
118
|
+
const { runId, atoms, candidates } = input;
|
|
119
|
+
const sortedAtoms = [...atoms].sort((left, right) => compareString(left.canonicalHashSha256Hex, right.canonicalHashSha256Hex));
|
|
120
|
+
const mappings = [];
|
|
121
|
+
for (const atom of sortedAtoms) {
|
|
122
|
+
const candidateIds = [];
|
|
123
|
+
// Track the most-focused citing test (smallest derivedFromAtomIds) so an atom covered only by
|
|
124
|
+
// sprawling tests is classified weakly-covered, while a dedicated test yields "covered". The
|
|
125
|
+
// confidence is atom-local — it does NOT depend on how many candidates the run produced.
|
|
126
|
+
let bestFocus = Number.POSITIVE_INFINITY;
|
|
127
|
+
for (const candidate of candidates) {
|
|
128
|
+
if (candidate.derivedFromAtomIds.includes(atom.id)) {
|
|
129
|
+
candidateIds.push(candidate.id);
|
|
130
|
+
bestFocus = Math.min(bestFocus, candidate.derivedFromAtomIds.length);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (candidateIds.length === 0) {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const confidence = coverageConfidence(candidateIds.length, bestFocus);
|
|
137
|
+
if (confidence <= 0) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
mappings.push(Object.freeze({
|
|
141
|
+
atomId: atom.id,
|
|
142
|
+
candidateIds: Object.freeze([...candidateIds].sort(compareString)),
|
|
143
|
+
coverageKind: "derived",
|
|
144
|
+
confidence,
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
const idString = deriveCoverageMapIdString(runId, sortedAtoms.map((atom) => atom.canonicalHashSha256Hex), candidates.map((candidate) => candidate.id));
|
|
148
|
+
const map = Object.freeze({
|
|
149
|
+
id: QualityIntelligence.asQualityIntelligenceCoverageMapId(idString),
|
|
150
|
+
runId,
|
|
151
|
+
mappings: Object.freeze(mappings),
|
|
152
|
+
});
|
|
153
|
+
QualityIntelligence.assertCoverageMapInvariant(map);
|
|
154
|
+
return map;
|
|
155
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { QualityIntelligence } from "@oscharko-dev/keiko-contracts";
|
|
2
|
+
/**
|
|
3
|
+
* Compute the canonical equivalence signature for a candidate. Exported so
|
|
4
|
+
* callers (validators, audit-summary builders) can inspect what makes two
|
|
5
|
+
* candidates collide without re-implementing the rule.
|
|
6
|
+
*
|
|
7
|
+
* @returns Lower-case hex sha256 of the canonical projection.
|
|
8
|
+
*/
|
|
9
|
+
export declare const computeCandidateEquivalenceSignature: (candidate: QualityIntelligence.QualityIntelligenceTestCaseCandidate) => string;
|
|
10
|
+
/**
|
|
11
|
+
* Returns the deduplicated subset of `candidates`. Order is the original
|
|
12
|
+
* input order with duplicates removed (the lexicographically-smallest `id`
|
|
13
|
+
* within each equivalence class is the survivor). Empty input returns the
|
|
14
|
+
* empty array.
|
|
15
|
+
*/
|
|
16
|
+
export declare const deduplicateCandidates: (candidates: readonly QualityIntelligence.QualityIntelligenceTestCaseCandidate[]) => readonly QualityIntelligence.QualityIntelligenceTestCaseCandidate[];
|
|
17
|
+
//# sourceMappingURL=deduplication.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deduplication.d.ts","sourceRoot":"","sources":["../../src/domain/deduplication.ts"],"names":[],"mappings":"AAuBA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,+BAA+B,CAAC;AAgCzE;;;;;;GAMG;AACH,eAAO,MAAM,oCAAoC,GAC/C,WAAW,mBAAmB,CAAC,oCAAoC,KAClE,MAWF,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,GAChC,YAAY,SAAS,mBAAmB,CAAC,oCAAoC,EAAE,KAC9E,SAAS,mBAAmB,CAAC,oCAAoC,EAkCnE,CAAC"}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Quality Intelligence deduplication (Epic #270, Issue #272).
|
|
2
|
+
//
|
|
3
|
+
// Given a list of `QualityIntelligenceTestCaseCandidate` records, returns the
|
|
4
|
+
// deduplicated subset using deterministic equivalence: two candidates are
|
|
5
|
+
// considered equivalent iff their canonical equivalence signature collides.
|
|
6
|
+
// The signature is computed from
|
|
7
|
+
// * the NFKC-normalised + lower-cased + whitespace-collapsed title
|
|
8
|
+
// * the canonicalised precondition sequence (each entry normalised the same way)
|
|
9
|
+
// * the canonicalised step sequence (each step normalised the same way)
|
|
10
|
+
// * the canonicalised expected-result sequence
|
|
11
|
+
// * the priority and risk-class fields
|
|
12
|
+
//
|
|
13
|
+
// NO embeddings, NO model judges — model-assisted dedup lives in #279.
|
|
14
|
+
//
|
|
15
|
+
// When two candidates are equivalent we keep the one with the lexicographic-
|
|
16
|
+
// ally smallest `id` so the function is order-independent.
|
|
17
|
+
//
|
|
18
|
+
// Structurally inspired by
|
|
19
|
+
// Test Intelligence reference (TI) packages/core-engine/src/test-case-dedupe.ts
|
|
20
|
+
// (`detectTestCaseDuplicatesExtended`), but stripped of the embedding cosine
|
|
21
|
+
// path that issue #279 owns; the deterministic canonical-signature path is
|
|
22
|
+
// what we port here.
|
|
23
|
+
import { sha256Hex } from "@oscharko-dev/keiko-security";
|
|
24
|
+
import { normaliseCandidateText } from "./assertions.js";
|
|
25
|
+
const collapseWhitespace = (value) => value.replace(/\s+/gu, " ").trim();
|
|
26
|
+
// Use normaliseCandidateText (NFKC + bidi/zero-width strip + trim) so that two candidates
|
|
27
|
+
// differing only by injected bidi or zero-width spoofing chars produce the same signature.
|
|
28
|
+
const canonicaliseLine = (value) => collapseWhitespace(normaliseCandidateText(value).toLowerCase());
|
|
29
|
+
const canonicaliseSequence = (values) => {
|
|
30
|
+
const out = [];
|
|
31
|
+
for (const value of values) {
|
|
32
|
+
const canonical = canonicaliseLine(value);
|
|
33
|
+
if (canonical.length === 0) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
out.push(canonical);
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
};
|
|
40
|
+
const compareString = (left, right) => left < right ? -1 : left > right ? 1 : 0;
|
|
41
|
+
const compareCandidateById = (left, right) => compareString(String(left.id), String(right.id));
|
|
42
|
+
/**
|
|
43
|
+
* Compute the canonical equivalence signature for a candidate. Exported so
|
|
44
|
+
* callers (validators, audit-summary builders) can inspect what makes two
|
|
45
|
+
* candidates collide without re-implementing the rule.
|
|
46
|
+
*
|
|
47
|
+
* @returns Lower-case hex sha256 of the canonical projection.
|
|
48
|
+
*/
|
|
49
|
+
export const computeCandidateEquivalenceSignature = (candidate) => {
|
|
50
|
+
const projection = JSON.stringify({
|
|
51
|
+
v: "1",
|
|
52
|
+
title: canonicaliseLine(candidate.title),
|
|
53
|
+
preconditions: canonicaliseSequence(candidate.preconditions),
|
|
54
|
+
steps: canonicaliseSequence(candidate.steps),
|
|
55
|
+
expectedResults: canonicaliseSequence(candidate.expectedResults),
|
|
56
|
+
priority: candidate.priority,
|
|
57
|
+
riskClass: candidate.riskClass,
|
|
58
|
+
});
|
|
59
|
+
return sha256Hex(projection);
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Returns the deduplicated subset of `candidates`. Order is the original
|
|
63
|
+
* input order with duplicates removed (the lexicographically-smallest `id`
|
|
64
|
+
* within each equivalence class is the survivor). Empty input returns the
|
|
65
|
+
* empty array.
|
|
66
|
+
*/
|
|
67
|
+
export const deduplicateCandidates = (candidates) => {
|
|
68
|
+
if (candidates.length === 0) {
|
|
69
|
+
return Object.freeze([]);
|
|
70
|
+
}
|
|
71
|
+
const survivorBySignature = new Map();
|
|
72
|
+
for (const candidate of candidates) {
|
|
73
|
+
const signature = computeCandidateEquivalenceSignature(candidate);
|
|
74
|
+
const incumbent = survivorBySignature.get(signature);
|
|
75
|
+
if (incumbent === undefined) {
|
|
76
|
+
survivorBySignature.set(signature, candidate);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (compareCandidateById(candidate, incumbent) < 0) {
|
|
80
|
+
survivorBySignature.set(signature, candidate);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const survivorIds = new Set();
|
|
84
|
+
for (const survivor of survivorBySignature.values()) {
|
|
85
|
+
survivorIds.add(String(survivor.id));
|
|
86
|
+
}
|
|
87
|
+
const out = [];
|
|
88
|
+
for (const candidate of candidates) {
|
|
89
|
+
if (survivorIds.has(String(candidate.id))) {
|
|
90
|
+
out.push(candidate);
|
|
91
|
+
survivorIds.delete(String(candidate.id));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return Object.freeze(out);
|
|
95
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type Rgb } from "./color.js";
|
|
2
|
+
import type { ScreenIr } from "./irTypes.js";
|
|
3
|
+
import type { StructuralTestItem } from "./screenIrTestBaseline.js";
|
|
4
|
+
/** WCAG relative luminance of an sRGB colour: 0 for black, 1 for white. */
|
|
5
|
+
export declare const relativeLuminance: (rgb: Rgb) => number;
|
|
6
|
+
/** WCAG contrast ratio (L_lighter + 0.05) / (L_darker + 0.05); symmetric, 1..21. */
|
|
7
|
+
export declare const contrastRatio: (a: Rgb, b: Rgb) => number;
|
|
8
|
+
/** Whether a ratio meets the WCAG AA threshold (3.0 for large text, else the stricter 4.5). */
|
|
9
|
+
export declare const meetsContrastAa: (ratio: number, isLargeText: boolean) => boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Derive the deterministic a11y test items per screen, keyed by the screen they are attributed to.
|
|
12
|
+
* Reuses #754's StructuralTestItem shape so the items compose additively through
|
|
13
|
+
* `deriveScreenTestBaseline(screen, extraItems)`, ALONGSIDE #811's navigation items. Model-free and
|
|
14
|
+
* reproducible: the same IR yields a byte-identical map.
|
|
15
|
+
*/
|
|
16
|
+
export declare function deriveA11yTestItemsByScreen(screens: readonly ScreenIr[]): ReadonlyMap<string, readonly StructuralTestItem[]>;
|
|
17
|
+
//# sourceMappingURL=a11yBaseline.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"a11yBaseline.d.ts","sourceRoot":"","sources":["../../../src/domain/figma/a11yBaseline.ts"],"names":[],"mappings":"AAwBA,OAAO,EAAgB,KAAK,GAAG,EAAa,MAAM,YAAY,CAAC;AAC/D,OAAO,KAAK,EAAuB,QAAQ,EAAE,MAAM,cAAc,CAAC;AAClE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AA0BpE,2EAA2E;AAC3E,eAAO,MAAM,iBAAiB,GAAI,KAAK,GAAG,KAAG,MAGX,CAAC;AAEnC,oFAAoF;AACpF,eAAO,MAAM,aAAa,GAAI,GAAG,GAAG,EAAE,GAAG,GAAG,KAAG,MAM9C,CAAC;AAEF,+FAA+F;AAC/F,eAAO,MAAM,eAAe,GAAI,OAAO,MAAM,EAAE,aAAa,OAAO,KAAG,OACvB,CAAC;AA+PhD;;;;;GAKG;AACH,wBAAgB,2BAA2B,CACzC,OAAO,EAAE,SAAS,QAAQ,EAAE,GAC3B,WAAW,CAAC,MAAM,EAAE,SAAS,kBAAkB,EAAE,CAAC,CAUpD"}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// Deterministic accessibility (a11y) baseline from a Screen-IR (Epic #750, Issue #812).
|
|
2
|
+
//
|
|
3
|
+
// Pure domain: a Screen-IR (#752) — per-screen kept-node trees plus the additive `textColor` /
|
|
4
|
+
// `backgroundColor` / `boundingBox` fields — is reduced, with NO model and NO IO, to per-screen a11y
|
|
5
|
+
// test items reusing #754's `StructuralTestItem` shape so they compose into the figma-snapshot QI run
|
|
6
|
+
// additively through `deriveScreenTestBaseline`'s `extraItems` seam (ALONGSIDE #811's nav items):
|
|
7
|
+
// • accessible-name presence — an interactive node (button/input/link) with neither visible text
|
|
8
|
+
// nor a descriptive name → a "needs an accessible name" item;
|
|
9
|
+
// • colour-contrast — every TEXT node with a resolvable text colour and a nearest-ancestor
|
|
10
|
+
// background colour → an exact WCAG relative-luminance contrast item (meets / below AA), with
|
|
11
|
+
// the stricter AA-normal 4.5 threshold used when "large text" cannot be determined from the IR;
|
|
12
|
+
// an uncomputable pairing (missing/malformed colour) → a coverage-notice item, never a crash;
|
|
13
|
+
// • focus / reading order — ≥ 2 interactive nodes with bounding boxes → one reading-order item
|
|
14
|
+
// (top-to-bottom, then left-to-right);
|
|
15
|
+
// • minimum target size — an interactive node whose bounding box is smaller than 24×24 (WCAG 2.2
|
|
16
|
+
// AA 2.5.8) → a target-size item;
|
|
17
|
+
// • image-fill alt-text — a node carrying image fills → an alt-text expectation item.
|
|
18
|
+
//
|
|
19
|
+
// Generic by construction: every rule reads only structural shape (interactionHint, text, colour,
|
|
20
|
+
// box) — no screen name, layout, mask style, or copy is special-cased. Deterministic: items are
|
|
21
|
+
// emitted in a stable depth-first / stable-sorted order and carry no timestamp, so the same IR yields
|
|
22
|
+
// a byte-identical result. The baseline is model-free and stands alone; vision augmentation (#810) is
|
|
23
|
+
// layered separately and never replaces these items.
|
|
24
|
+
import { parseHexRgba } from "./color.js";
|
|
25
|
+
const MIN_TARGET_SIZE = 24;
|
|
26
|
+
const AA_NORMAL = 4.5;
|
|
27
|
+
const AA_LARGE = 3.0;
|
|
28
|
+
const LARGE_TEXT_MIN_PX = 24;
|
|
29
|
+
const LARGE_BOLD_TEXT_MIN_PX = 18.6667;
|
|
30
|
+
const LARGE_TEXT_MIN_WEIGHT = 700;
|
|
31
|
+
// Generous per-screen item cap so a pathologically dense real screen (thousands of TEXT nodes →
|
|
32
|
+
// thousands of contrast checks) cannot make the a11y baseline unbounded in memory/output. Small
|
|
33
|
+
// fixtures stay far below it, so it is invisible except on huge boards. Deterministic: the first
|
|
34
|
+
// `MAX_A11Y_ITEMS_PER_SCREEN` items (stable order) are kept and a single coverage notice records the
|
|
35
|
+
// remainder, so the same IR yields the same truncated set every run.
|
|
36
|
+
const MAX_A11Y_ITEMS_PER_SCREEN = 800;
|
|
37
|
+
const INTERACTIVE = new Set(["button", "input", "link"]);
|
|
38
|
+
// ─── WCAG relative luminance + contrast ratio (exact, model-free) ─────────────────
|
|
39
|
+
// sRGB → linear-light channel (WCAG 2.x): c/12.92 below the knee, else ((c+0.055)/1.055)^2.4.
|
|
40
|
+
const linearizeChannel = (channel255) => {
|
|
41
|
+
const c = channel255 / 255;
|
|
42
|
+
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
43
|
+
};
|
|
44
|
+
/** WCAG relative luminance of an sRGB colour: 0 for black, 1 for white. */
|
|
45
|
+
export const relativeLuminance = (rgb) => 0.2126 * linearizeChannel(rgb.r) +
|
|
46
|
+
0.7152 * linearizeChannel(rgb.g) +
|
|
47
|
+
0.0722 * linearizeChannel(rgb.b);
|
|
48
|
+
/** WCAG contrast ratio (L_lighter + 0.05) / (L_darker + 0.05); symmetric, 1..21. */
|
|
49
|
+
export const contrastRatio = (a, b) => {
|
|
50
|
+
const la = relativeLuminance(a);
|
|
51
|
+
const lb = relativeLuminance(b);
|
|
52
|
+
const lighter = Math.max(la, lb);
|
|
53
|
+
const darker = Math.min(la, lb);
|
|
54
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
55
|
+
};
|
|
56
|
+
/** Whether a ratio meets the WCAG AA threshold (3.0 for large text, else the stricter 4.5). */
|
|
57
|
+
export const meetsContrastAa = (ratio, isLargeText) => ratio >= (isLargeText ? AA_LARGE : AA_NORMAL);
|
|
58
|
+
function a11yItemId(prefix, key) {
|
|
59
|
+
// Deterministic non-cryptographic id (FNV-1a) — stable across runs, no IO. Mirrors #754/#811.
|
|
60
|
+
let hash = 0x811c9dc5;
|
|
61
|
+
for (let i = 0; i < key.length; i += 1) {
|
|
62
|
+
hash ^= key.charCodeAt(i);
|
|
63
|
+
hash = Math.imul(hash, 0x01000193);
|
|
64
|
+
}
|
|
65
|
+
return `${prefix}-${(hash >>> 0).toString(16).padStart(8, "0")}`;
|
|
66
|
+
}
|
|
67
|
+
const a11yItem = (ctx, nodeId, kind, title, outcome) => ({
|
|
68
|
+
id: a11yItemId("fa11y", `${ctx.screenId}|${kind}|${nodeId}`),
|
|
69
|
+
category: "a11y",
|
|
70
|
+
...(outcome !== undefined ? { outcome } : {}),
|
|
71
|
+
screenId: ctx.screenId,
|
|
72
|
+
screenName: ctx.screenName,
|
|
73
|
+
sourceNodeId: nodeId,
|
|
74
|
+
title,
|
|
75
|
+
});
|
|
76
|
+
const noticeItem = (ctx, nodeId, kind, title) => ({
|
|
77
|
+
id: a11yItemId("fa11ycov", `${ctx.screenId}|${kind}|${nodeId}`),
|
|
78
|
+
category: "coverage-notice",
|
|
79
|
+
screenId: ctx.screenId,
|
|
80
|
+
screenName: ctx.screenName,
|
|
81
|
+
sourceNodeId: nodeId,
|
|
82
|
+
title,
|
|
83
|
+
});
|
|
84
|
+
// ─── Per-rule derivation ───────────────────────────────────────────────────────────
|
|
85
|
+
const nodeLabel = (node) => node.text !== undefined && node.text.length > 0 ? node.text : node.name;
|
|
86
|
+
const GENERIC_LAYER_NAME = /^(?:frame|rectangle|group|button|instance|component|text|vector|ellipse|line|image|icon)(?:\s+\d+)?$/iu;
|
|
87
|
+
// A node's name is descriptive only when it is neither an opaque Figma node id nor a generic default
|
|
88
|
+
// layer name. This stays structural: it rejects provider-default vocabulary, not board-specific copy.
|
|
89
|
+
const hasDescriptiveName = (node) => {
|
|
90
|
+
const trimmed = node.name.trim();
|
|
91
|
+
return (trimmed.length > 0 && !/^[0-9]+:[0-9]+$/u.test(trimmed) && !GENERIC_LAYER_NAME.test(trimmed));
|
|
92
|
+
};
|
|
93
|
+
const hasAccessibleName = (node) => (node.text !== undefined && node.text.trim().length > 0) || hasDescriptiveName(node);
|
|
94
|
+
const isLargeText = (node) => {
|
|
95
|
+
const typography = node.typography;
|
|
96
|
+
if (typography === undefined)
|
|
97
|
+
return false;
|
|
98
|
+
if (typography.fontSize >= LARGE_TEXT_MIN_PX)
|
|
99
|
+
return true;
|
|
100
|
+
return (typography.fontSize >= LARGE_BOLD_TEXT_MIN_PX && typography.fontWeight >= LARGE_TEXT_MIN_WEIGHT);
|
|
101
|
+
};
|
|
102
|
+
const compositeChannel = (foreground, background, alpha) => Math.round(foreground * alpha + background * (1 - alpha));
|
|
103
|
+
const compositeOver = (foreground, background) => {
|
|
104
|
+
if (foreground.a >= 1)
|
|
105
|
+
return { r: foreground.r, g: foreground.g, b: foreground.b };
|
|
106
|
+
return {
|
|
107
|
+
r: compositeChannel(foreground.r, background.r, foreground.a),
|
|
108
|
+
g: compositeChannel(foreground.g, background.g, foreground.a),
|
|
109
|
+
b: compositeChannel(foreground.b, background.b, foreground.a),
|
|
110
|
+
};
|
|
111
|
+
};
|
|
112
|
+
const resolveBackground = (backgroundColor, inheritedBackground) => {
|
|
113
|
+
if (backgroundColor === undefined)
|
|
114
|
+
return inheritedBackground;
|
|
115
|
+
const ownBackground = parseHexRgba(backgroundColor);
|
|
116
|
+
if (ownBackground === undefined)
|
|
117
|
+
return undefined;
|
|
118
|
+
if (inheritedBackground === undefined && ownBackground.a < 1)
|
|
119
|
+
return undefined;
|
|
120
|
+
return compositeOver(ownBackground, inheritedBackground ?? { r: 255, g: 255, b: 255 });
|
|
121
|
+
};
|
|
122
|
+
const contrastItem = (ctx, node, background) => {
|
|
123
|
+
const fg = node.textColor === undefined ? undefined : parseHexRgba(node.textColor);
|
|
124
|
+
const bg = background;
|
|
125
|
+
if (fg === undefined || bg === undefined) {
|
|
126
|
+
return noticeItem(ctx, node.id, "contrast", `Text "${nodeLabel(node)}" needs a verifiable colour contrast: text/background colour could not be resolved from the design tokens`);
|
|
127
|
+
}
|
|
128
|
+
const renderedFg = compositeOver(fg, bg);
|
|
129
|
+
const ratio = contrastRatio(renderedFg, bg);
|
|
130
|
+
const rounded = Math.round(ratio * 100) / 100;
|
|
131
|
+
const largeText = isLargeText(node);
|
|
132
|
+
const threshold = largeText ? AA_LARGE : AA_NORMAL;
|
|
133
|
+
const label = largeText ? "WCAG AA large text" : "WCAG AA";
|
|
134
|
+
const passes = meetsContrastAa(ratio, largeText);
|
|
135
|
+
const verdict = passes
|
|
136
|
+
? `meets ${label} (${String(rounded)}:1 ≥ ${String(threshold)}:1)`
|
|
137
|
+
: `is below ${label} (${String(rounded)}:1 < ${String(threshold)}:1)`;
|
|
138
|
+
return a11yItem(ctx, node.id, "contrast", `Text "${nodeLabel(node)}" colour contrast ${verdict}`, passes ? "pass" : "fail");
|
|
139
|
+
};
|
|
140
|
+
const isTooSmall = (box) => box.width < MIN_TARGET_SIZE || box.height < MIN_TARGET_SIZE;
|
|
141
|
+
const byReadingOrder = (a, b) => a.box.y !== b.box.y ? a.box.y - b.box.y : a.box.x - b.box.x;
|
|
142
|
+
// Each rule returns its item (or undefined when the node is out of scope), keeping `visit` flat so
|
|
143
|
+
// its cyclomatic complexity stays within budget. Rules read only structural shape — never copy.
|
|
144
|
+
const nameRule = (ctx, node) => INTERACTIVE.has(node.interactionHint) && !hasAccessibleName(node)
|
|
145
|
+
? a11yItem(ctx, node.id, "name", `Interactive "${node.name}" needs an accessible name`, "fail")
|
|
146
|
+
: undefined;
|
|
147
|
+
const contrastRule = (ctx, node, background) => node.interactionHint === "text" && node.text !== undefined && node.text.trim().length > 0
|
|
148
|
+
? contrastItem(ctx, node, background)
|
|
149
|
+
: undefined;
|
|
150
|
+
const layoutCoverageRule = (ctx, node) => INTERACTIVE.has(node.interactionHint) && node.boundingBox === undefined
|
|
151
|
+
? noticeItem(ctx, node.id, "bounds", `Control "${nodeLabel(node)}" needs verifiable layout bounds: target size and focus order could not be resolved from the Screen-IR`)
|
|
152
|
+
: undefined;
|
|
153
|
+
const targetSizeRule = (ctx, node) => INTERACTIVE.has(node.interactionHint) &&
|
|
154
|
+
node.boundingBox !== undefined &&
|
|
155
|
+
isTooSmall(node.boundingBox)
|
|
156
|
+
? a11yItem(ctx, node.id, "target", `Control "${nodeLabel(node)}" does not meet the 24×24 minimum target size`, "fail")
|
|
157
|
+
: undefined;
|
|
158
|
+
const altTextRule = (ctx, node) => node.imageFills.length > 0
|
|
159
|
+
? a11yItem(ctx, node.id, "alt", `Image "${node.name}" needs descriptive alt text verification`, "expectation")
|
|
160
|
+
: undefined;
|
|
161
|
+
// Shared constant — see prune.ts for rationale. Must stay in sync with every other recursive walk.
|
|
162
|
+
const MAX_TREE_DEPTH = 512;
|
|
163
|
+
function visitAt(node, ctx, inheritedBackground, acc, depth) {
|
|
164
|
+
if (depth > MAX_TREE_DEPTH)
|
|
165
|
+
return;
|
|
166
|
+
const background = resolveBackground(node.backgroundColor, inheritedBackground);
|
|
167
|
+
for (const item of [
|
|
168
|
+
nameRule(ctx, node),
|
|
169
|
+
contrastRule(ctx, node, background),
|
|
170
|
+
layoutCoverageRule(ctx, node),
|
|
171
|
+
targetSizeRule(ctx, node),
|
|
172
|
+
altTextRule(ctx, node),
|
|
173
|
+
]) {
|
|
174
|
+
if (item !== undefined)
|
|
175
|
+
acc.items.push(item);
|
|
176
|
+
}
|
|
177
|
+
if (INTERACTIVE.has(node.interactionHint) && node.boundingBox !== undefined) {
|
|
178
|
+
acc.focusables.push({ nodeId: node.id, label: nodeLabel(node), box: node.boundingBox });
|
|
179
|
+
}
|
|
180
|
+
for (const child of node.children)
|
|
181
|
+
visitAt(child, ctx, background, acc, depth + 1);
|
|
182
|
+
}
|
|
183
|
+
function visit(node, ctx, inheritedBackground, acc) {
|
|
184
|
+
visitAt(node, ctx, inheritedBackground, acc, 0);
|
|
185
|
+
}
|
|
186
|
+
const focusOrderItem = (ctx, focusables) => {
|
|
187
|
+
const ordered = [...focusables].sort(byReadingOrder);
|
|
188
|
+
const sequence = ordered.map((f) => `"${f.label}"`).join(" → ");
|
|
189
|
+
return a11yItem(ctx, ordered[0]?.nodeId ?? "", "focus-order", `Focus order follows the visual reading order: ${sequence}`, "expectation");
|
|
190
|
+
};
|
|
191
|
+
/**
|
|
192
|
+
* Derive the deterministic a11y test items per screen, keyed by the screen they are attributed to.
|
|
193
|
+
* Reuses #754's StructuralTestItem shape so the items compose additively through
|
|
194
|
+
* `deriveScreenTestBaseline(screen, extraItems)`, ALONGSIDE #811's navigation items. Model-free and
|
|
195
|
+
* reproducible: the same IR yields a byte-identical map.
|
|
196
|
+
*/
|
|
197
|
+
export function deriveA11yTestItemsByScreen(screens) {
|
|
198
|
+
const byScreen = new Map();
|
|
199
|
+
for (const screen of screens) {
|
|
200
|
+
const ctx = { screenId: screen.id, screenName: screen.name };
|
|
201
|
+
const acc = { items: [], focusables: [] };
|
|
202
|
+
visit(screen.root, ctx, undefined, acc);
|
|
203
|
+
if (acc.focusables.length >= 2)
|
|
204
|
+
acc.items.push(focusOrderItem(ctx, acc.focusables));
|
|
205
|
+
byScreen.set(screen.id, capItems(ctx, acc.items));
|
|
206
|
+
}
|
|
207
|
+
return byScreen;
|
|
208
|
+
}
|
|
209
|
+
// Deterministically bound a screen's a11y items: keep the first MAX (stable order), and when more
|
|
210
|
+
// were derived, append ONE coverage notice recording how many were omitted (never a silent cut).
|
|
211
|
+
function capItems(ctx, items) {
|
|
212
|
+
if (items.length <= MAX_A11Y_ITEMS_PER_SCREEN)
|
|
213
|
+
return items;
|
|
214
|
+
const omitted = items.length - MAX_A11Y_ITEMS_PER_SCREEN;
|
|
215
|
+
const kept = items.slice(0, MAX_A11Y_ITEMS_PER_SCREEN);
|
|
216
|
+
kept.push(noticeItem(ctx, ctx.screenId, "a11y-cap", `Screen "${ctx.screenName}" has more accessibility checks than the per-screen baseline shows: ${String(omitted)} additional checks were omitted to keep the baseline bounded`));
|
|
217
|
+
return kept;
|
|
218
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cleanToScreenIr.d.ts","sourceRoot":"","sources":["../../../src/domain/figma/cleanToScreenIr.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAA6B,cAAc,EAAE,MAAM,cAAc,CAAC;AA6B9E,eAAO,MAAM,0BAA0B,GAAI,SAAS,OAAO,KAAG,cA4B7D,CAAC"}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Figma node tree → lean per-screen IR orchestrator (Epic #750, Issue #752).
|
|
2
|
+
//
|
|
3
|
+
// Pure, deterministic, no IO/network/model. Takes the raw scoped Figma `document` node tree
|
|
4
|
+
// (the connector output of #751) and produces the Screen-IR result: per-screen kept-node trees,
|
|
5
|
+
// deduped design tokens, raw inter-screen links, and a reduction report. Every emitted collection
|
|
6
|
+
// is sorted by a stable structural key, and the result carries no timestamp, so the same input
|
|
7
|
+
// yields a byte-identical IR. A malformed input (non-object) degrades to an empty result.
|
|
8
|
+
import { asNode } from "./sourceNode.js";
|
|
9
|
+
import { countSourceNodes, pruneNode } from "./prune.js";
|
|
10
|
+
import { detectScreens } from "./screenDetect.js";
|
|
11
|
+
import { countIrNodes, normalizeScreenRoot } from "./normalize.js";
|
|
12
|
+
import { extractDesignTokens } from "./tokens.js";
|
|
13
|
+
import { extractInterScreenLinks } from "./links.js";
|
|
14
|
+
const RATIO_PRECISION = 6;
|
|
15
|
+
const EMPTY_TOKENS = { colors: [], typography: [], spacing: [], radius: [] };
|
|
16
|
+
const roundRatio = (removed, input) => {
|
|
17
|
+
if (input <= 0)
|
|
18
|
+
return 0;
|
|
19
|
+
const factor = 10 ** RATIO_PRECISION;
|
|
20
|
+
return Math.round((removed / input) * factor) / factor;
|
|
21
|
+
};
|
|
22
|
+
const buildReduction = (inputNodeCount, keptNodeCount) => {
|
|
23
|
+
const removedNodeCount = Math.max(0, inputNodeCount - keptNodeCount);
|
|
24
|
+
return {
|
|
25
|
+
inputNodeCount,
|
|
26
|
+
keptNodeCount,
|
|
27
|
+
removedNodeCount,
|
|
28
|
+
removedRatio: roundRatio(removedNodeCount, inputNodeCount),
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
const emptyResult = (inputNodeCount) => ({
|
|
32
|
+
screens: [],
|
|
33
|
+
tokens: EMPTY_TOKENS,
|
|
34
|
+
links: [],
|
|
35
|
+
reduction: buildReduction(inputNodeCount, 0),
|
|
36
|
+
});
|
|
37
|
+
export const cleanScopedNodesToScreenIr = (rawRoot) => {
|
|
38
|
+
const root = asNode(rawRoot);
|
|
39
|
+
if (root === undefined)
|
|
40
|
+
return emptyResult(0);
|
|
41
|
+
const inputNodeCount = countSourceNodes(root);
|
|
42
|
+
const pruned = pruneNode(root);
|
|
43
|
+
if (pruned === undefined)
|
|
44
|
+
return emptyResult(inputNodeCount);
|
|
45
|
+
const screenRoots = detectScreens(pruned);
|
|
46
|
+
const screens = screenRoots
|
|
47
|
+
.map((screenRoot) => {
|
|
48
|
+
const ir = normalizeScreenRoot(screenRoot);
|
|
49
|
+
return { id: ir.id, name: ir.name, root: ir };
|
|
50
|
+
})
|
|
51
|
+
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
52
|
+
// keptNodeCount counts only IR nodes under detected screens, so surviving scope containers
|
|
53
|
+
// (CANVAS/SECTION) that are not themselves screens count toward removal. removedRatio is therefore
|
|
54
|
+
// the fraction of raw input nodes not retained in any screen IR, not the fraction the pruner dropped.
|
|
55
|
+
const keptNodeCount = screens.reduce((sum, screen) => sum + countIrNodes(screen.root), 0);
|
|
56
|
+
return {
|
|
57
|
+
screens,
|
|
58
|
+
tokens: extractDesignTokens(screenRoots),
|
|
59
|
+
links: extractInterScreenLinks(root, screenRoots),
|
|
60
|
+
reduction: buildReduction(inputNodeCount, keptNodeCount),
|
|
61
|
+
};
|
|
62
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type SemanticNamingProvider } from "./semanticNaming.js";
|
|
2
|
+
import { type CodeEmissionPlan, type EmissionInput } from "./emissionPlan.js";
|
|
3
|
+
/** A single proposed file in the reviewable code artifact. Path is artifact-relative, POSIX-style. */
|
|
4
|
+
export interface CodeFile {
|
|
5
|
+
readonly path: string;
|
|
6
|
+
readonly contents: string;
|
|
7
|
+
}
|
|
8
|
+
/** The reviewable output of a code-emission pass: the producing adapter plus its ordered files. */
|
|
9
|
+
export interface CodeArtifact {
|
|
10
|
+
readonly adapterName: string;
|
|
11
|
+
readonly files: readonly CodeFile[];
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The pluggable code-target seam. An adapter is a pure function from the target-neutral plan to a
|
|
15
|
+
* concrete code artifact. `name` records which target produced an artifact. Additive: future targets
|
|
16
|
+
* implement this interface without changing the emitter.
|
|
17
|
+
*/
|
|
18
|
+
export interface CodeTargetAdapter {
|
|
19
|
+
readonly name: string;
|
|
20
|
+
readonly emit: (plan: CodeEmissionPlan) => CodeArtifact;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Emit reviewable code for a set of screens through the selected adapter. Builds the deterministic,
|
|
24
|
+
* target-neutral plan, applies the optional semantic-naming port (which may rename elements but never
|
|
25
|
+
* change structure), and renders through the adapter. With no naming provider the structural default
|
|
26
|
+
* names are used, so emission is fully deterministic and model-independent. The result is a proposal,
|
|
27
|
+
* not an applied change.
|
|
28
|
+
*/
|
|
29
|
+
export declare function emitCode(input: EmissionInput, adapter: CodeTargetAdapter, naming?: SemanticNamingProvider): CodeArtifact;
|
|
30
|
+
//# sourceMappingURL=codeTargetAdapter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"codeTargetAdapter.d.ts","sourceRoot":"","sources":["../../../src/domain/figma/codeTargetAdapter.ts"],"names":[],"mappings":"AAcA,OAAO,EAAe,KAAK,sBAAsB,EAAE,MAAM,qBAAqB,CAAC;AAC/E,OAAO,EAAqB,KAAK,gBAAgB,EAAE,KAAK,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAEjG,sGAAsG;AACtG,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAED,mGAAmG;AACnG,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,KAAK,EAAE,SAAS,QAAQ,EAAE,CAAC;CACrC;AAED;;;;GAIG;AACH,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,CAAC,IAAI,EAAE,gBAAgB,KAAK,YAAY,CAAC;CACzD;AAED;;;;;;GAMG;AACH,wBAAgB,QAAQ,CACtB,KAAK,EAAE,aAAa,EACpB,OAAO,EAAE,iBAAiB,EAC1B,MAAM,CAAC,EAAE,sBAAsB,GAC9B,YAAY,CAId"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// The pluggable CodeTargetAdapter seam for design-to-code emission (Epic #750, Issue #755).
|
|
2
|
+
//
|
|
3
|
+
// All code emission goes through a single seam: the workflow builds a target-NEUTRAL emission plan
|
|
4
|
+
// (emissionPlan.ts) from the Screen-IR + tokens + routing hints, optionally enriches element names
|
|
5
|
+
// via the injected naming port (semanticNaming.ts), and hands the plan to a `CodeTargetAdapter` which
|
|
6
|
+
// renders it to a concrete `CodeArtifact`. The first slice ships exactly one adapter (the
|
|
7
|
+
// framework-agnostic HTML/CSS adapter). A future MUI / component-library adapter is purely additive:
|
|
8
|
+
// it implements this same interface and is selected at the call site — the emitter, plan, and naming
|
|
9
|
+
// port do not change. No framework is hard-coded into the emitter.
|
|
10
|
+
//
|
|
11
|
+
// The artifact is a REVIEWABLE proposal (an ordered list of files), never auto-applied: this module
|
|
12
|
+
// has no filesystem, network, or model access — the model only reaches naming, through the injected
|
|
13
|
+
// provider. Deterministic: a given input + adapter yields a byte-identical artifact.
|
|
14
|
+
import { applyNaming } from "./semanticNaming.js";
|
|
15
|
+
import { buildEmissionPlan } from "./emissionPlan.js";
|
|
16
|
+
/**
|
|
17
|
+
* Emit reviewable code for a set of screens through the selected adapter. Builds the deterministic,
|
|
18
|
+
* target-neutral plan, applies the optional semantic-naming port (which may rename elements but never
|
|
19
|
+
* change structure), and renders through the adapter. With no naming provider the structural default
|
|
20
|
+
* names are used, so emission is fully deterministic and model-independent. The result is a proposal,
|
|
21
|
+
* not an applied change.
|
|
22
|
+
*/
|
|
23
|
+
export function emitCode(input, adapter, naming) {
|
|
24
|
+
const base = buildEmissionPlan(input);
|
|
25
|
+
const plan = naming !== undefined ? applyNaming(base, naming) : base;
|
|
26
|
+
return adapter.emit(plan);
|
|
27
|
+
}
|