@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,326 @@
|
|
|
1
|
+
// Deterministic structural test baseline from a Screen-IR (Epic #750, Issue #754).
|
|
2
|
+
//
|
|
3
|
+
// Pure domain: a Screen-IR (#752) is reduced by structural rules to a reproducible, citation-ready
|
|
4
|
+
// list of test items — every input field → presence + validation tests; every control (button/link)
|
|
5
|
+
// → action + expected-result tests; every screen → a render/navigation test; every state/variant
|
|
6
|
+
// → a state test. No IO, no model, no network: the same IR yields a byte-identical baseline so the
|
|
7
|
+
// QI source ships a usable baseline with NO model available.
|
|
8
|
+
//
|
|
9
|
+
// Generic by construction: the rules read only the IR's structural shape (interactionHint, node
|
|
10
|
+
// type, text, and a generic `state`/`variant` naming convention shared across design tools). No
|
|
11
|
+
// rule, threshold, name, or template is tuned to a specific board.
|
|
12
|
+
//
|
|
13
|
+
// Additive seam: `deriveScreenTestBaseline` accepts optional `extraItems`, so sibling derivations
|
|
14
|
+
// (navigation/flow #811, a11y #812) can contribute extra per-screen test items WITHOUT changing the
|
|
15
|
+
// baseline shape. Vision-derived semantics are layered separately (see visionAugmentation.ts) and
|
|
16
|
+
// never replace these structural items.
|
|
17
|
+
const INTERACTION_HINTS = new Set([
|
|
18
|
+
"button",
|
|
19
|
+
"input",
|
|
20
|
+
"link",
|
|
21
|
+
"text",
|
|
22
|
+
"image",
|
|
23
|
+
"container",
|
|
24
|
+
]);
|
|
25
|
+
function isObject(value) {
|
|
26
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
27
|
+
}
|
|
28
|
+
function isString(value) {
|
|
29
|
+
return typeof value === "string";
|
|
30
|
+
}
|
|
31
|
+
function isFiniteNumber(value) {
|
|
32
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
33
|
+
}
|
|
34
|
+
// Parse the optional bounding box from the serialised `irJson` (used by the a11y focus-order and
|
|
35
|
+
// target-size derivation, #812). A malformed or partial box is dropped rather than crashing.
|
|
36
|
+
function parseBoundingBox(value) {
|
|
37
|
+
if (!isObject(value))
|
|
38
|
+
return undefined;
|
|
39
|
+
const { x, y, width, height } = value;
|
|
40
|
+
if (!isFiniteNumber(x) ||
|
|
41
|
+
!isFiniteNumber(y) ||
|
|
42
|
+
!isFiniteNumber(width) ||
|
|
43
|
+
!isFiniteNumber(height)) {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
return { x, y, width, height };
|
|
47
|
+
}
|
|
48
|
+
function parseImageFills(value) {
|
|
49
|
+
if (!Array.isArray(value))
|
|
50
|
+
return [];
|
|
51
|
+
const refs = [];
|
|
52
|
+
for (const entry of value) {
|
|
53
|
+
if (isObject(entry) && isString(entry.imageRef))
|
|
54
|
+
refs.push({ imageRef: entry.imageRef });
|
|
55
|
+
}
|
|
56
|
+
return refs;
|
|
57
|
+
}
|
|
58
|
+
// Subtrees deeper than this are truncated during parse so a chain-like persisted irJson cannot cause
|
|
59
|
+
// a RangeError. Shared constant — see prune.ts for rationale. Must stay in sync with every walk.
|
|
60
|
+
const MAX_TREE_DEPTH = 512;
|
|
61
|
+
// The total accepted node count for one screen is bounded so a pathologically WIDE persisted irJson
|
|
62
|
+
// (flat siblings, not deep nesting — invisible to MAX_TREE_DEPTH) cannot materialise unbounded
|
|
63
|
+
// memory/time during parse and every derivation that walks the resulting tree. The cap is set well
|
|
64
|
+
// above the per-screen node ceiling the governed Figma pipeline enforces before a snapshot is ever
|
|
65
|
+
// written (the connector rejects oversized scopes/screens upstream), so every real board parses fully
|
|
66
|
+
// and byte-identically; only an out-of-band, oversized irJson (e.g. a direct write into the evidence
|
|
67
|
+
// dir) is truncated. Breadth is bounded the same way depth is — silently, at the read boundary.
|
|
68
|
+
export const MAX_IR_NODES_PER_SCREEN = 20_000;
|
|
69
|
+
// Parse a node's children list, dropping any malformed child rather than failing the whole node.
|
|
70
|
+
// depth bounds recursion at MAX_TREE_DEPTH; budget bounds the total accepted node count — once it is
|
|
71
|
+
// exhausted no further siblings are parsed, so a flat-wide children array degrades to a truncated
|
|
72
|
+
// (still well-formed) subtree rather than an unbounded allocation.
|
|
73
|
+
function parseIrChildren(value, depth, budget) {
|
|
74
|
+
if (depth > MAX_TREE_DEPTH)
|
|
75
|
+
return [];
|
|
76
|
+
if (!Array.isArray(value))
|
|
77
|
+
return [];
|
|
78
|
+
const children = [];
|
|
79
|
+
for (const child of value) {
|
|
80
|
+
if (budget.remaining <= 0)
|
|
81
|
+
break;
|
|
82
|
+
const parsed = parseIrNodeAt(child, depth, budget);
|
|
83
|
+
if (parsed !== undefined)
|
|
84
|
+
children.push(parsed);
|
|
85
|
+
}
|
|
86
|
+
return children;
|
|
87
|
+
}
|
|
88
|
+
// The optional, additive node fields (text, bounding box, a11y colours #812, layout fidelity).
|
|
89
|
+
// Each is present only when well-typed; a malformed value is dropped so a corrupt node degrades
|
|
90
|
+
// rather than crashing.
|
|
91
|
+
function parseOptionalNodeFields(value) {
|
|
92
|
+
const boundingBox = parseBoundingBox(value.boundingBox);
|
|
93
|
+
const layout = parseIrLayout(value.layout);
|
|
94
|
+
const sizing = parseIrSizing(value.sizing);
|
|
95
|
+
const typography = parseIrTypography(value.typography);
|
|
96
|
+
const cornerRadius = isFiniteNumber(value.cornerRadius) ? value.cornerRadius : undefined;
|
|
97
|
+
return {
|
|
98
|
+
...(isString(value.text) ? { text: value.text } : {}),
|
|
99
|
+
...(boundingBox !== undefined ? { boundingBox } : {}),
|
|
100
|
+
...(isString(value.textColor) ? { textColor: value.textColor } : {}),
|
|
101
|
+
...(isString(value.backgroundColor) ? { backgroundColor: value.backgroundColor } : {}),
|
|
102
|
+
...(layout !== undefined ? { layout } : {}),
|
|
103
|
+
...(sizing !== undefined ? { sizing } : {}),
|
|
104
|
+
...(cornerRadius !== undefined ? { cornerRadius } : {}),
|
|
105
|
+
...(typography !== undefined ? { typography } : {}),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
const LAYOUT_MODES = new Set(["row", "column"]);
|
|
109
|
+
const ALIGN_VALUES = new Set(["start", "center", "end", "space-between"]);
|
|
110
|
+
const SIZING_VALUES = new Set(["fixed", "hug", "fill"]);
|
|
111
|
+
const parseAlign = (value) => isString(value) && ALIGN_VALUES.has(value) ? value : undefined;
|
|
112
|
+
// Re-hydrate the layout-fidelity fields persisted in irJson (codegen path). Persisted snapshots
|
|
113
|
+
// from before these fields existed simply omit them — every branch is total and optional.
|
|
114
|
+
function parseIrLayout(value) {
|
|
115
|
+
if (!isObject(value) || !isString(value.mode) || !LAYOUT_MODES.has(value.mode))
|
|
116
|
+
return undefined;
|
|
117
|
+
const padding = parsePadding(value.padding);
|
|
118
|
+
const itemSpacing = isFiniteNumber(value.itemSpacing) ? value.itemSpacing : undefined;
|
|
119
|
+
const primaryAlign = parseAlign(value.primaryAlign);
|
|
120
|
+
const counterAlign = parseAlign(value.counterAlign);
|
|
121
|
+
return {
|
|
122
|
+
mode: value.mode,
|
|
123
|
+
...(itemSpacing !== undefined ? { itemSpacing } : {}),
|
|
124
|
+
...(padding !== undefined ? { padding } : {}),
|
|
125
|
+
...(primaryAlign !== undefined ? { primaryAlign } : {}),
|
|
126
|
+
...(counterAlign !== undefined ? { counterAlign } : {}),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
function parsePadding(value) {
|
|
130
|
+
if (!Array.isArray(value) || value.length !== 4)
|
|
131
|
+
return undefined;
|
|
132
|
+
return value.every(isFiniteNumber)
|
|
133
|
+
? value
|
|
134
|
+
: undefined;
|
|
135
|
+
}
|
|
136
|
+
const parseSizingValue = (value) => isString(value) && SIZING_VALUES.has(value) ? value : undefined;
|
|
137
|
+
function parseIrSizing(value) {
|
|
138
|
+
if (!isObject(value))
|
|
139
|
+
return undefined;
|
|
140
|
+
const horizontal = parseSizingValue(value.horizontal);
|
|
141
|
+
const vertical = parseSizingValue(value.vertical);
|
|
142
|
+
if (horizontal === undefined && vertical === undefined)
|
|
143
|
+
return undefined;
|
|
144
|
+
return {
|
|
145
|
+
...(horizontal !== undefined ? { horizontal } : {}),
|
|
146
|
+
...(vertical !== undefined ? { vertical } : {}),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
function parseIrTypography(value) {
|
|
150
|
+
if (!isObject(value))
|
|
151
|
+
return undefined;
|
|
152
|
+
const { fontFamily, fontSize, fontWeight, lineHeight } = value;
|
|
153
|
+
if (!isString(fontFamily) || !isFiniteNumber(fontSize) || !isFiniteNumber(fontWeight)) {
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
fontFamily,
|
|
158
|
+
fontSize,
|
|
159
|
+
fontWeight,
|
|
160
|
+
...(isFiniteNumber(lineHeight) ? { lineHeight } : {}),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
// Total, defensive IR-node parser: an opaque serialised node (from the snapshot's `irJson`) is
|
|
164
|
+
// accepted only when its required structural fields are present and well-typed; anything malformed
|
|
165
|
+
// yields `undefined` so a corrupt screen degrades to "no items" rather than crashing the run.
|
|
166
|
+
// depth bounds recursion at MAX_TREE_DEPTH; budget bounds the total accepted node count. A node is
|
|
167
|
+
// counted against the budget only once it is accepted, so malformed nodes are free.
|
|
168
|
+
function parseIrNodeAt(value, depth, budget) {
|
|
169
|
+
if (!isObject(value))
|
|
170
|
+
return undefined;
|
|
171
|
+
const { id, name, type, interactionHint } = value;
|
|
172
|
+
if (!isString(id) || !isString(name) || !isString(type))
|
|
173
|
+
return undefined;
|
|
174
|
+
if (!isString(interactionHint) || !INTERACTION_HINTS.has(interactionHint))
|
|
175
|
+
return undefined;
|
|
176
|
+
budget.remaining -= 1;
|
|
177
|
+
return {
|
|
178
|
+
id,
|
|
179
|
+
name,
|
|
180
|
+
type,
|
|
181
|
+
interactionHint: interactionHint,
|
|
182
|
+
...parseOptionalNodeFields(value),
|
|
183
|
+
imageFills: parseImageFills(value.imageFills),
|
|
184
|
+
children: parseIrChildren(value.children, depth + 1, budget),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
function parseIrNode(value) {
|
|
188
|
+
return parseIrNodeAt(value, 0, { remaining: MAX_IR_NODES_PER_SCREEN });
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Total, defensive Screen-IR parser for the snapshot's opaque `irJson`. Returns `undefined` for a
|
|
192
|
+
* missing or malformed value (no `root`, malformed node tree) so the caller can skip an unparseable
|
|
193
|
+
* screen without crashing. A valid IR is returned verbatim through the typed shape.
|
|
194
|
+
*/
|
|
195
|
+
export function parseScreenIr(value) {
|
|
196
|
+
if (!isObject(value) || !isString(value.id) || !isString(value.name))
|
|
197
|
+
return undefined;
|
|
198
|
+
const root = parseIrNode(value.root);
|
|
199
|
+
if (root === undefined)
|
|
200
|
+
return undefined;
|
|
201
|
+
return { id: value.id, name: value.name, root };
|
|
202
|
+
}
|
|
203
|
+
// A generic state/variant naming convention shared across design tools: a node whose name carries an
|
|
204
|
+
// explicit `state=` / `variant=` property segment, or a slash-delimited property. NOT tuned to any
|
|
205
|
+
// board's vocabulary — it matches the structural property syntax, never specific state names.
|
|
206
|
+
const STATE_PROPERTY = /(?:^|[,\s])(?:state|variant)\s*=\s*([^,]+)/iu;
|
|
207
|
+
function stateLabel(name) {
|
|
208
|
+
const match = STATE_PROPERTY.exec(name);
|
|
209
|
+
const captured = match?.[1]?.trim();
|
|
210
|
+
return captured !== undefined && captured.length > 0 ? captured : undefined;
|
|
211
|
+
}
|
|
212
|
+
function shortHash(input) {
|
|
213
|
+
// Deterministic non-cryptographic id suffix (FNV-1a) — stable across runs, no IO, no import.
|
|
214
|
+
let hash = 0x811c9dc5;
|
|
215
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
216
|
+
hash ^= input.charCodeAt(i);
|
|
217
|
+
hash = Math.imul(hash, 0x01000193);
|
|
218
|
+
}
|
|
219
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
220
|
+
}
|
|
221
|
+
function itemId(screenId, category, nodeId, ordinal) {
|
|
222
|
+
return `fst-${shortHash(`${screenId}|${category}|${nodeId}|${String(ordinal)}`)}`;
|
|
223
|
+
}
|
|
224
|
+
function fieldItems(node, ctx) {
|
|
225
|
+
const label = node.text !== undefined && node.text.length > 0 ? node.text : node.name;
|
|
226
|
+
const base = { screenId: ctx.screenId, screenName: ctx.screenName, sourceNodeId: node.id };
|
|
227
|
+
return [
|
|
228
|
+
{
|
|
229
|
+
...base,
|
|
230
|
+
id: itemId(ctx.screenId, "field-presence", node.id, 0),
|
|
231
|
+
category: "field-presence",
|
|
232
|
+
title: `Field "${label}" is present and editable on screen "${ctx.screenName}"`,
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
...base,
|
|
236
|
+
id: itemId(ctx.screenId, "field-validation", node.id, 0),
|
|
237
|
+
category: "field-validation",
|
|
238
|
+
title: `Field "${label}" rejects empty / malformed input on screen "${ctx.screenName}"`,
|
|
239
|
+
},
|
|
240
|
+
];
|
|
241
|
+
}
|
|
242
|
+
function controlItems(node, ctx) {
|
|
243
|
+
const label = node.text !== undefined && node.text.length > 0 ? node.text : node.name;
|
|
244
|
+
const verb = node.interactionHint === "link" ? "Following" : "Activating";
|
|
245
|
+
return [
|
|
246
|
+
{
|
|
247
|
+
screenId: ctx.screenId,
|
|
248
|
+
screenName: ctx.screenName,
|
|
249
|
+
sourceNodeId: node.id,
|
|
250
|
+
id: itemId(ctx.screenId, "control-action", node.id, 0),
|
|
251
|
+
category: "control-action",
|
|
252
|
+
title: `${verb} "${label}" produces its expected result on screen "${ctx.screenName}"`,
|
|
253
|
+
},
|
|
254
|
+
];
|
|
255
|
+
}
|
|
256
|
+
function stateItem(node, ctx, label) {
|
|
257
|
+
return {
|
|
258
|
+
screenId: ctx.screenId,
|
|
259
|
+
screenName: ctx.screenName,
|
|
260
|
+
sourceNodeId: node.id,
|
|
261
|
+
id: itemId(ctx.screenId, "state", node.id, 0),
|
|
262
|
+
category: "state",
|
|
263
|
+
title: `Element "${node.name}" renders correctly in state "${label}" on screen "${ctx.screenName}"`,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
function collectNodeItemsAt(node, ctx, out, depth) {
|
|
267
|
+
if (depth > MAX_TREE_DEPTH)
|
|
268
|
+
return;
|
|
269
|
+
if (node.interactionHint === "input")
|
|
270
|
+
out.push(...fieldItems(node, ctx));
|
|
271
|
+
if (node.interactionHint === "button" || node.interactionHint === "link") {
|
|
272
|
+
out.push(...controlItems(node, ctx));
|
|
273
|
+
}
|
|
274
|
+
const state = stateLabel(node.name);
|
|
275
|
+
if (state !== undefined)
|
|
276
|
+
out.push(stateItem(node, ctx, state));
|
|
277
|
+
for (const child of node.children)
|
|
278
|
+
collectNodeItemsAt(child, ctx, out, depth + 1);
|
|
279
|
+
}
|
|
280
|
+
function collectNodeItems(node, ctx, out) {
|
|
281
|
+
collectNodeItemsAt(node, ctx, out, 0);
|
|
282
|
+
}
|
|
283
|
+
function screenRenderItem(ctx) {
|
|
284
|
+
return {
|
|
285
|
+
screenId: ctx.screenId,
|
|
286
|
+
screenName: ctx.screenName,
|
|
287
|
+
id: itemId(ctx.screenId, "screen-render", ctx.screenId, 0),
|
|
288
|
+
category: "screen-render",
|
|
289
|
+
title: `Screen "${ctx.screenName}" renders correctly`,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Derive the deterministic structural baseline for one screen. The screen-render item comes first,
|
|
294
|
+
* then field / control / state items in stable depth-first node order. `extraItems` is the additive
|
|
295
|
+
* seam (#811 navigation, #812 a11y): a sibling derivation may contribute already-built test items
|
|
296
|
+
* for this screen without changing the baseline shape. The result is reproducible for a given IR.
|
|
297
|
+
*/
|
|
298
|
+
export function deriveScreenTestBaseline(screen, extraItems = []) {
|
|
299
|
+
const ctx = { screenId: screen.id, screenName: screen.name };
|
|
300
|
+
const items = [screenRenderItem(ctx)];
|
|
301
|
+
collectNodeItems(screen.root, ctx, items);
|
|
302
|
+
items.push(...extraItems);
|
|
303
|
+
return { screenId: screen.id, screenName: screen.name, items };
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Render the structural baseline as citation-ready canonical text carrying per-screen provenance.
|
|
307
|
+
* The text is deterministic (no timestamps, stable order) and is what the QI ingestion turns into a
|
|
308
|
+
* content-bearing atom. Vision-derived hints, when present, are appended SEPARATELY by the caller
|
|
309
|
+
* (visionAugmentation.ts) and never replace these lines.
|
|
310
|
+
*/
|
|
311
|
+
/**
|
|
312
|
+
* Stable marker line introducing the structural baseline inside a Figma screen atom's canonical text.
|
|
313
|
+
* Exported so the deterministic test-design model (`testDesignModel.ts`) can recognise a Figma screen
|
|
314
|
+
* atom and emit a clean, screen-scoped structural candidate instead of the generic prose-requirement
|
|
315
|
+
* template (which produces an atom-id/hash-laden stub for a structural baseline). Intentionally NOT
|
|
316
|
+
* re-exported from the figma barrel (`domain/figma/index.ts`), so it stays off the package surface.
|
|
317
|
+
*/
|
|
318
|
+
export const STRUCTURAL_BASELINE_MARKER = "Structural test baseline (deterministic, derived from Screen-IR):";
|
|
319
|
+
export function renderBaselineText(baseline) {
|
|
320
|
+
const header = `Screen: ${baseline.screenName} [${baseline.screenId}]`;
|
|
321
|
+
const lines = baseline.items.map((item) => {
|
|
322
|
+
const nodeRef = item.sourceNodeId !== undefined ? ` [node:${item.sourceNodeId}]` : "";
|
|
323
|
+
return `- (${item.category}) ${item.title}${nodeRef}`;
|
|
324
|
+
});
|
|
325
|
+
return [header, STRUCTURAL_BASELINE_MARKER, ...lines].join("\n");
|
|
326
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { CodeEmissionPlan, EmissionElement } from "./emissionPlan.js";
|
|
2
|
+
/** A request for semantic names: each element's id, role, and current structural default name. */
|
|
3
|
+
export interface SemanticNamingRequest {
|
|
4
|
+
readonly elements: readonly {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
readonly role: EmissionElement["role"];
|
|
7
|
+
readonly structuralName: string;
|
|
8
|
+
}[];
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The naming port: given the structural elements, return a map (or record) from element id to a
|
|
12
|
+
* proposed semantic display name. The server backs this with a capability-routed model; tests back it
|
|
13
|
+
* with a plain function. Returning nothing for an id leaves its structural default untouched.
|
|
14
|
+
*/
|
|
15
|
+
export type SemanticNamingProvider = (request: SemanticNamingRequest) => ReadonlyMap<string, string> | Readonly<Record<string, string>>;
|
|
16
|
+
/**
|
|
17
|
+
* Apply a semantic-naming provider to a plan, overriding ONLY element display names. The element
|
|
18
|
+
* tree, roles, text, tokens, and navigation are preserved byte-for-byte; only `displayName` fields
|
|
19
|
+
* may change, and only for elements the provider names with a non-empty string. With no usable name
|
|
20
|
+
* for an element (or a missing provider entry), the structural default stands. Deterministic and
|
|
21
|
+
* side-effect-free.
|
|
22
|
+
*/
|
|
23
|
+
export declare function applyNaming(plan: CodeEmissionPlan, provider: SemanticNamingProvider): CodeEmissionPlan;
|
|
24
|
+
//# sourceMappingURL=semanticNaming.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"semanticNaming.d.ts","sourceRoot":"","sources":["../../../src/domain/figma/semanticNaming.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAkB,MAAM,mBAAmB,CAAC;AAE3F,kGAAkG;AAClG,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,QAAQ,EAAE,SAAS;QAC1B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;QACpB,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAC,MAAM,CAAC,CAAC;QACvC,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;KACjC,EAAE,CAAC;CACL;AAED;;;;GAIG;AACH,MAAM,MAAM,sBAAsB,GAAG,CACnC,OAAO,EAAE,qBAAqB,KAC3B,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;AAyDpE;;;;;;GAMG;AACH,wBAAgB,WAAW,CACzB,IAAI,EAAE,gBAAgB,EACtB,QAAQ,EAAE,sBAAsB,GAC/B,gBAAgB,CAIlB"}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Model-only-for-naming port for design-to-code emission (Epic #750, Issue #755).
|
|
2
|
+
//
|
|
3
|
+
// The deterministic emitter (emissionPlan.ts) already produces a complete, usable plan with
|
|
4
|
+
// structural default names and NO model. This module is the strictly-additive seam by which a
|
|
5
|
+
// capability-routed model may supply BETTER semantic display names — and nothing else. The override
|
|
6
|
+
// is enforced STRUCTURALLY: `applyNaming` only ever replaces an element's `displayName`; it cannot
|
|
7
|
+
// add, remove, reorder, or re-parent an element, change a role, change text, or change navigation.
|
|
8
|
+
// There is no code path by which a model name reaches structure. An absent provider, an unknown id,
|
|
9
|
+
// or an empty/whitespace name leaves the structural default in place — so naming degrades gracefully
|
|
10
|
+
// to the deterministic baseline. The provider is injected (a port), so no model id is hard-coded
|
|
11
|
+
// here; the server tier resolves the model by capability (#810) and backs this port.
|
|
12
|
+
function collectRequestElements(element, out) {
|
|
13
|
+
out.push({ id: element.id, role: element.role, structuralName: element.displayName });
|
|
14
|
+
for (const child of element.children)
|
|
15
|
+
collectRequestElements(child, out);
|
|
16
|
+
}
|
|
17
|
+
function buildRequest(plan) {
|
|
18
|
+
const elements = [];
|
|
19
|
+
for (const screen of plan.screens)
|
|
20
|
+
collectRequestElements(screen.root, elements);
|
|
21
|
+
return { elements };
|
|
22
|
+
}
|
|
23
|
+
// Project the provider result to id/value pairs, accepting either a Map or a plain record.
|
|
24
|
+
function namingEntries(result) {
|
|
25
|
+
if (result instanceof Map) {
|
|
26
|
+
const map = result;
|
|
27
|
+
return [...map.entries()];
|
|
28
|
+
}
|
|
29
|
+
return Object.entries(result);
|
|
30
|
+
}
|
|
31
|
+
// Normalise the provider result to a lookup. A non-string or empty/whitespace value is dropped so a
|
|
32
|
+
// garbage model output never overrides a structural default — it simply contributes nothing.
|
|
33
|
+
function toNameLookup(result) {
|
|
34
|
+
const lookup = new Map();
|
|
35
|
+
for (const [id, value] of namingEntries(result)) {
|
|
36
|
+
if (typeof value !== "string")
|
|
37
|
+
continue;
|
|
38
|
+
const trimmed = value.trim();
|
|
39
|
+
if (trimmed.length > 0)
|
|
40
|
+
lookup.set(id, trimmed);
|
|
41
|
+
}
|
|
42
|
+
return lookup;
|
|
43
|
+
}
|
|
44
|
+
function renameElement(element, names) {
|
|
45
|
+
const override = names.get(element.id);
|
|
46
|
+
return {
|
|
47
|
+
...element,
|
|
48
|
+
...(override !== undefined ? { displayName: override } : {}),
|
|
49
|
+
children: element.children.map((child) => renameElement(child, names)),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function renameScreen(screen, names) {
|
|
53
|
+
return { ...screen, root: renameElement(screen.root, names) };
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Apply a semantic-naming provider to a plan, overriding ONLY element display names. The element
|
|
57
|
+
* tree, roles, text, tokens, and navigation are preserved byte-for-byte; only `displayName` fields
|
|
58
|
+
* may change, and only for elements the provider names with a non-empty string. With no usable name
|
|
59
|
+
* for an element (or a missing provider entry), the structural default stands. Deterministic and
|
|
60
|
+
* side-effect-free.
|
|
61
|
+
*/
|
|
62
|
+
export function applyNaming(plan, provider) {
|
|
63
|
+
const names = toNameLookup(provider(buildRequest(plan)));
|
|
64
|
+
if (names.size === 0)
|
|
65
|
+
return plan;
|
|
66
|
+
return { ...plan, screens: plan.screens.map((screen) => renameScreen(screen, names)) };
|
|
67
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The subset of a raw Figma node the cleaner reads. Open-ended (`[key: string]: unknown`) because
|
|
3
|
+
* the provider payload carries many fields we ignore; readers narrow before use.
|
|
4
|
+
*/
|
|
5
|
+
export interface FigmaSourceNode {
|
|
6
|
+
readonly id?: unknown;
|
|
7
|
+
readonly name?: unknown;
|
|
8
|
+
readonly type?: unknown;
|
|
9
|
+
readonly visible?: unknown;
|
|
10
|
+
readonly children?: unknown;
|
|
11
|
+
readonly [key: string]: unknown;
|
|
12
|
+
}
|
|
13
|
+
export declare const isRecord: (value: unknown) => value is Record<string, unknown>;
|
|
14
|
+
export declare const asNode: (value: unknown) => FigmaSourceNode | undefined;
|
|
15
|
+
export declare const readString: (value: unknown) => string | undefined;
|
|
16
|
+
export declare const readNumber: (value: unknown) => number | undefined;
|
|
17
|
+
export declare const readArray: (value: unknown) => readonly unknown[];
|
|
18
|
+
/** Figma omits `visible` when a node is shown; only an explicit `false` hides it. */
|
|
19
|
+
export declare const isHidden: (node: FigmaSourceNode) => boolean;
|
|
20
|
+
export declare const nodeId: (node: FigmaSourceNode) => string;
|
|
21
|
+
export declare const nodeName: (node: FigmaSourceNode) => string;
|
|
22
|
+
export declare const nodeType: (node: FigmaSourceNode) => string;
|
|
23
|
+
export declare const childNodes: (node: FigmaSourceNode) => readonly FigmaSourceNode[];
|
|
24
|
+
//# sourceMappingURL=sourceNode.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sourceNode.d.ts","sourceRoot":"","sources":["../../../src/domain/figma/sourceNode.ts"],"names":[],"mappings":"AAQA;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,EAAE,CAAC,EAAE,OAAO,CAAC;IACtB,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IACxB,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IACxB,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;IAC3B,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;IAC5B,QAAQ,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACjC;AAED,eAAO,MAAM,QAAQ,GAAI,OAAO,OAAO,KAAG,KAAK,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CACH,CAAC;AAEvE,eAAO,MAAM,MAAM,GAAI,OAAO,OAAO,KAAG,eAAe,GAAG,SACrB,CAAC;AAEtC,eAAO,MAAM,UAAU,GAAI,OAAO,OAAO,KAAG,MAAM,GAAG,SACN,CAAC;AAEhD,eAAO,MAAM,UAAU,GAAI,OAAO,OAAO,KAAG,MAAM,GAAG,SACoB,CAAC;AAE1E,eAAO,MAAM,SAAS,GAAI,OAAO,OAAO,KAAG,SAAS,OAAO,EACxB,CAAC;AAEpC,qFAAqF;AACrF,eAAO,MAAM,QAAQ,GAAI,MAAM,eAAe,KAAG,OAAiC,CAAC;AAEnF,eAAO,MAAM,MAAM,GAAI,MAAM,eAAe,KAAG,MAAmC,CAAC;AAEnF,eAAO,MAAM,QAAQ,GAAI,MAAM,eAAe,KAAG,MAAqC,CAAC;AAEvF,eAAO,MAAM,QAAQ,GAAI,MAAM,eAAe,KAAG,MAAqC,CAAC;AAEvF,eAAO,MAAM,UAAU,GAAI,MAAM,eAAe,KAAG,SAAS,eAAe,EAO1E,CAAC"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Minimal, untyped-tolerant input model for the raw scoped Figma node subtree (Epic #750, Issue #752).
|
|
2
|
+
//
|
|
3
|
+
// The cleaner consumes the raw Figma `document` node tree returned by the server-side connector
|
|
4
|
+
// (#751). That JSON is provider-shaped and only partially typed, so this module models the few
|
|
5
|
+
// fields the IR needs and exposes narrowing readers over `unknown` — never `any`. Direction is
|
|
6
|
+
// clean: this package depends on nothing from keiko-server; the readers tolerate absent/malformed
|
|
7
|
+
// fields so a single bad node never aborts the transform.
|
|
8
|
+
export const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9
|
+
export const asNode = (value) => isRecord(value) ? value : undefined;
|
|
10
|
+
export const readString = (value) => typeof value === "string" ? value : undefined;
|
|
11
|
+
export const readNumber = (value) => typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
12
|
+
export const readArray = (value) => Array.isArray(value) ? value : [];
|
|
13
|
+
/** Figma omits `visible` when a node is shown; only an explicit `false` hides it. */
|
|
14
|
+
export const isHidden = (node) => node.visible === false;
|
|
15
|
+
export const nodeId = (node) => readString(node.id) ?? "";
|
|
16
|
+
export const nodeName = (node) => readString(node.name) ?? "";
|
|
17
|
+
export const nodeType = (node) => readString(node.type) ?? "";
|
|
18
|
+
export const childNodes = (node) => {
|
|
19
|
+
const out = [];
|
|
20
|
+
for (const child of readArray(node.children)) {
|
|
21
|
+
const record = asNode(child);
|
|
22
|
+
if (record !== undefined)
|
|
23
|
+
out.push(record);
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { DesignTokens } from "./irTypes.js";
|
|
2
|
+
import type { PrunedNode } from "./prune.js";
|
|
3
|
+
/** Extract deduped, stable-ordered design tokens from the pruned screen roots. */
|
|
4
|
+
export declare const extractDesignTokens: (screens: readonly PrunedNode[]) => DesignTokens;
|
|
5
|
+
/**
|
|
6
|
+
* Re-hydrate a {@link DesignTokens} value from the opaque, serialised tokens artifact persisted in a
|
|
7
|
+
* Figma Snapshot (#753). Total + defensive: a non-object or any malformed family degrades to an empty
|
|
8
|
+
* list so design-to-code (#755) never crashes on an old or partial snapshot. Stable shape, no IO.
|
|
9
|
+
*/
|
|
10
|
+
export declare const parseDesignTokens: (value: unknown) => DesignTokens;
|
|
11
|
+
//# sourceMappingURL=tokens.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../../../src/domain/figma/tokens.ts"],"names":[],"mappings":"AAqBA,OAAO,KAAK,EAEV,YAAY,EAIb,MAAM,cAAc,CAAC;AACtB,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAoF7C,kFAAkF;AAClF,eAAO,MAAM,mBAAmB,GAAI,SAAS,SAAS,UAAU,EAAE,KAAG,YAiBpE,CAAC;AA0DF;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,GAAI,OAAO,OAAO,KAAG,YAQlD,CAAC"}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Deterministic design-token extraction (Epic #750, Issue #752).
|
|
2
|
+
//
|
|
3
|
+
// Walks the kept (pruned) node trees and projects four token families out of structural fields:
|
|
4
|
+
// color — solid `fills`/`strokes` → normalized #RRGGBB / #RRGGBBAA (alpha only when < 1).
|
|
5
|
+
// typography — TEXT `style` → family|size|weight|lineHeight.
|
|
6
|
+
// spacing — auto-layout `itemSpacing` + paddingTop/Right/Bottom/Left → distinct numbers.
|
|
7
|
+
// radius — `cornerRadius` → distinct numbers.
|
|
8
|
+
//
|
|
9
|
+
// Token identity is the canonical value (content-free): same value → one token, regardless of how
|
|
10
|
+
// many nodes carry it or in what order. Every family is sorted by its canonical key before emit, so
|
|
11
|
+
// output never depends on traversal or map-insertion order.
|
|
12
|
+
import { asNode, nodeType, readArray, readNumber, readString, } from "./sourceNode.js";
|
|
13
|
+
import { isVisiblePaint, paintColorToHex } from "./color.js";
|
|
14
|
+
const collectPaintColors = (node, key, out) => {
|
|
15
|
+
for (const paint of readArray(node[key])) {
|
|
16
|
+
const record = asNode(paint);
|
|
17
|
+
if (record === undefined || readString(record.type) !== "SOLID")
|
|
18
|
+
continue;
|
|
19
|
+
if (!isVisiblePaint(record))
|
|
20
|
+
continue;
|
|
21
|
+
const hex = paintColorToHex(record);
|
|
22
|
+
if (hex !== undefined)
|
|
23
|
+
out.add(hex);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const collectTypography = (node, out) => {
|
|
27
|
+
if (nodeType(node) !== "TEXT")
|
|
28
|
+
return;
|
|
29
|
+
const style = asNode(node.style);
|
|
30
|
+
if (style === undefined)
|
|
31
|
+
return;
|
|
32
|
+
const fontFamily = readString(style.fontFamily);
|
|
33
|
+
const fontSize = readNumber(style.fontSize);
|
|
34
|
+
const fontWeight = readNumber(style.fontWeight);
|
|
35
|
+
const lineHeight = readNumber(style.lineHeightPx);
|
|
36
|
+
if (fontFamily === undefined ||
|
|
37
|
+
fontSize === undefined ||
|
|
38
|
+
fontWeight === undefined ||
|
|
39
|
+
lineHeight === undefined) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const id = `typography:${fontFamily}|${String(fontSize)}|${String(fontWeight)}|${String(lineHeight)}`;
|
|
43
|
+
out.set(id, { id, kind: "typography", fontFamily, fontSize, fontWeight, lineHeight });
|
|
44
|
+
};
|
|
45
|
+
const SPACING_KEYS = ["itemSpacing", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft"];
|
|
46
|
+
const collectSpacing = (node, out) => {
|
|
47
|
+
for (const key of SPACING_KEYS) {
|
|
48
|
+
const value = readNumber(node[key]);
|
|
49
|
+
if (value !== undefined && value > 0)
|
|
50
|
+
out.add(value);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
const collectRadius = (node, out) => {
|
|
54
|
+
const value = readNumber(node.cornerRadius);
|
|
55
|
+
if (value !== undefined && value > 0)
|
|
56
|
+
out.add(value);
|
|
57
|
+
};
|
|
58
|
+
// Shared constant — see prune.ts for rationale. Must stay in sync with every other recursive walk.
|
|
59
|
+
const MAX_TREE_DEPTH = 512;
|
|
60
|
+
function visitAt(pruned, acc, depth) {
|
|
61
|
+
if (depth > MAX_TREE_DEPTH)
|
|
62
|
+
return;
|
|
63
|
+
const node = pruned.source;
|
|
64
|
+
collectPaintColors(node, "fills", acc.colors);
|
|
65
|
+
collectPaintColors(node, "strokes", acc.colors);
|
|
66
|
+
collectTypography(node, acc.typography);
|
|
67
|
+
collectSpacing(node, acc.spacing);
|
|
68
|
+
collectRadius(node, acc.radius);
|
|
69
|
+
for (const child of pruned.children)
|
|
70
|
+
visitAt(child, acc, depth + 1);
|
|
71
|
+
}
|
|
72
|
+
const visit = (pruned, acc) => {
|
|
73
|
+
visitAt(pruned, acc, 0);
|
|
74
|
+
};
|
|
75
|
+
const toColorTokens = (values) => [...values].sort().map((value) => ({ id: `color:${value}`, kind: "color", value }));
|
|
76
|
+
const toSpacingTokens = (values) => [...values]
|
|
77
|
+
.sort((a, b) => a - b)
|
|
78
|
+
.map((value) => ({ id: `spacing:${String(value)}`, kind: "spacing", value }));
|
|
79
|
+
const toRadiusTokens = (values) => [...values]
|
|
80
|
+
.sort((a, b) => a - b)
|
|
81
|
+
.map((value) => ({ id: `radius:${String(value)}`, kind: "radius", value }));
|
|
82
|
+
/** Extract deduped, stable-ordered design tokens from the pruned screen roots. */
|
|
83
|
+
export const extractDesignTokens = (screens) => {
|
|
84
|
+
const acc = {
|
|
85
|
+
colors: new Set(),
|
|
86
|
+
typography: new Map(),
|
|
87
|
+
spacing: new Set(),
|
|
88
|
+
radius: new Set(),
|
|
89
|
+
};
|
|
90
|
+
for (const screen of screens)
|
|
91
|
+
visit(screen, acc);
|
|
92
|
+
return {
|
|
93
|
+
colors: toColorTokens(acc.colors),
|
|
94
|
+
typography: [...acc.typography.values()].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
|
|
95
|
+
spacing: toSpacingTokens(acc.spacing),
|
|
96
|
+
radius: toRadiusTokens(acc.radius),
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
// ─── Tolerant parse of a persisted/serialised design-tokens artifact (#752 → #755) ──────
|
|
100
|
+
//
|
|
101
|
+
// The snapshot persists the tokens artifact as an opaque JSON value (like the screen IR). Design-to-
|
|
102
|
+
// code (#755) reads the STORED snapshot, so it must re-hydrate the typed shape defensively: a missing
|
|
103
|
+
// or malformed family yields an empty list (codegen emits a smaller token table) rather than crashing.
|
|
104
|
+
const EMPTY_DESIGN_TOKENS = { colors: [], typography: [], spacing: [], radius: [] };
|
|
105
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
106
|
+
const isString = (value) => typeof value === "string";
|
|
107
|
+
const isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);
|
|
108
|
+
const parseColorRows = (value) => (Array.isArray(value) ? value : []).flatMap((entry) => isRecord(entry) && isString(entry.value)
|
|
109
|
+
? [{ id: `color:${entry.value}`, kind: "color", value: entry.value }]
|
|
110
|
+
: []);
|
|
111
|
+
const parseTypographyRows = (value) => (Array.isArray(value) ? value : []).flatMap((entry) => {
|
|
112
|
+
if (!isRecord(entry) ||
|
|
113
|
+
!isString(entry.fontFamily) ||
|
|
114
|
+
!isFiniteNumber(entry.fontSize) ||
|
|
115
|
+
!isFiniteNumber(entry.fontWeight) ||
|
|
116
|
+
!isFiniteNumber(entry.lineHeight)) {
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
const id = `typography:${entry.fontFamily}|${String(entry.fontSize)}|${String(entry.fontWeight)}|${String(entry.lineHeight)}`;
|
|
120
|
+
return [
|
|
121
|
+
{
|
|
122
|
+
id,
|
|
123
|
+
kind: "typography",
|
|
124
|
+
fontFamily: entry.fontFamily,
|
|
125
|
+
fontSize: entry.fontSize,
|
|
126
|
+
fontWeight: entry.fontWeight,
|
|
127
|
+
lineHeight: entry.lineHeight,
|
|
128
|
+
},
|
|
129
|
+
];
|
|
130
|
+
});
|
|
131
|
+
const parseScalarRows = (value, kind) => (Array.isArray(value) ? value : []).flatMap((entry) => isRecord(entry) && isFiniteNumber(entry.value)
|
|
132
|
+
? [{ id: `${kind}:${String(entry.value)}`, kind, value: entry.value }]
|
|
133
|
+
: []);
|
|
134
|
+
/**
|
|
135
|
+
* Re-hydrate a {@link DesignTokens} value from the opaque, serialised tokens artifact persisted in a
|
|
136
|
+
* Figma Snapshot (#753). Total + defensive: a non-object or any malformed family degrades to an empty
|
|
137
|
+
* list so design-to-code (#755) never crashes on an old or partial snapshot. Stable shape, no IO.
|
|
138
|
+
*/
|
|
139
|
+
export const parseDesignTokens = (value) => {
|
|
140
|
+
if (!isRecord(value))
|
|
141
|
+
return EMPTY_DESIGN_TOKENS;
|
|
142
|
+
return {
|
|
143
|
+
colors: parseColorRows(value.colors),
|
|
144
|
+
typography: parseTypographyRows(value.typography),
|
|
145
|
+
spacing: parseScalarRows(value.spacing, "spacing"),
|
|
146
|
+
radius: parseScalarRows(value.radius, "radius"),
|
|
147
|
+
};
|
|
148
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface VisionMergeResult {
|
|
2
|
+
/** Baseline text with the additive vision section appended; equals `baselineText` when no hints. */
|
|
3
|
+
readonly text: string;
|
|
4
|
+
/** How many vision hints survived sanitisation and were appended. */
|
|
5
|
+
readonly augmentedCount: number;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Merge vision hints into the deterministic baseline text WITHOUT overriding it. The baseline text is
|
|
9
|
+
* always the prefix of the result; hints (if any survive sanitisation) follow under a labelled
|
|
10
|
+
* section. When no hint survives, the result text is identical to `baselineText`, so the structural
|
|
11
|
+
* baseline always ships and the vision contribution is provably additive.
|
|
12
|
+
*/
|
|
13
|
+
export declare function mergeVisionHints(baselineText: string, hints: readonly string[]): VisionMergeResult;
|
|
14
|
+
//# sourceMappingURL=visionAugmentation.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"visionAugmentation.d.ts","sourceRoot":"","sources":["../../../src/domain/figma/visionAugmentation.ts"],"names":[],"mappings":"AAmCA,MAAM,WAAW,iBAAiB;IAChC,oGAAoG;IACpG,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,qEAAqE;IACrE,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;CACjC;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAC9B,YAAY,EAAE,MAAM,EACpB,KAAK,EAAE,SAAS,MAAM,EAAE,GACvB,iBAAiB,CAOnB"}
|