@hegemonart/get-design-done 1.52.0 → 1.53.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +49 -0
- package/README.md +2 -0
- package/agents/design-context-reviewer-gate.md +102 -0
- package/agents/design-context-reviewer.md +186 -0
- package/dist/claude-code/.claude/skills/discover/SKILL.md +7 -1
- package/dist/claude-code/.claude/skills/explore/SKILL.md +3 -1
- package/package.json +1 -1
- package/scripts/lib/explore-parallel-runner/index.ts +58 -0
- package/scripts/lib/explore-parallel-runner/types.ts +58 -0
- package/scripts/lib/manifest/skills.json +2 -2
- package/scripts/lib/mappers/compute-batches.mjs +625 -0
- package/scripts/lib/mappers/graph-adjacency.mjs +129 -0
- package/scripts/lib/mappers/incremental-discover.cjs +617 -0
- package/scripts/lib/mappers/incremental-discover.d.cts +133 -0
- package/scripts/lib/mappers/neighbor-map.mjs +0 -0
- package/sdk/cli/index.js +369 -2
- package/sdk/fingerprint/classify.cjs +406 -0
- package/sdk/fingerprint/index.ts +405 -0
- package/sdk/fingerprint/store.cjs +523 -0
- package/sdk/index.ts +1 -0
- package/skills/discover/SKILL.md +7 -1
- package/skills/explore/SKILL.md +3 -1
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
// sdk/fingerprint/index.ts — Phase 53 (Semantic Mapper Engine), FP-01.
|
|
2
|
+
//
|
|
3
|
+
// Per-type structural fingerprints over DesignContext entities, used by the
|
|
4
|
+
// incremental discover/explore engine (Phase 53) to decide — cosmetic vs
|
|
5
|
+
// structural vs add/remove — which mapper batches must be re-mapped on a
|
|
6
|
+
// cycle. Zero new dependency: hashing is `node:crypto` sha256 only.
|
|
7
|
+
//
|
|
8
|
+
// Two hashes per entity:
|
|
9
|
+
// * `full` — over ALL fingerprint-relevant fields (cosmetic + structural).
|
|
10
|
+
// * `structural` — over the STRUCTURE-ONLY projection (cosmetic fields omitted).
|
|
11
|
+
//
|
|
12
|
+
// compareFingerprints(a, b) collapses the two-hash pair into a ChangeType:
|
|
13
|
+
// * NONE full hashes equal (nothing changed)
|
|
14
|
+
// * COSMETIC structural equal, full differs (e.g. token VALUE edit)
|
|
15
|
+
// * STRUCTURAL structural differs, OR add/remove (null) (shape changed)
|
|
16
|
+
//
|
|
17
|
+
// Determinism is a HARD contract (CONTEXT D6): the canonicalizer sorts object
|
|
18
|
+
// keys lexicographically, sorts+dedupes set-arrays, collapses whitespace, and
|
|
19
|
+
// formats scalars stably, then serializes as a `type:`-prefixed `key=value`
|
|
20
|
+
// line list (NOT JSON.stringify of the raw input). The type prefix prevents a
|
|
21
|
+
// token and a component (or motion) with coincidentally-identical field values
|
|
22
|
+
// from colliding to the same hash. Summaries are NEVER part of either hash —
|
|
23
|
+
// an LLM re-paraphrasing a summary must not invalidate a fingerprint.
|
|
24
|
+
//
|
|
25
|
+
// Cross-OS reproducibility: no Math.random, no Date.now, no locale-dependent
|
|
26
|
+
// sort (lexicographic on UTF-16 code units via String#localeCompare-free
|
|
27
|
+
// Array#sort default ordering). Identical inputs hash identically on win32,
|
|
28
|
+
// Linux, and macOS.
|
|
29
|
+
|
|
30
|
+
import { createHash } from 'node:crypto';
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Public types — entity inputs + ChangeType.
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
/** The three fingerprintable entity kinds. */
|
|
37
|
+
export type FingerprintType = 'component' | 'token' | 'motion';
|
|
38
|
+
|
|
39
|
+
/** The classification a single before/after fingerprint pair collapses to. */
|
|
40
|
+
export type ChangeType = 'NONE' | 'COSMETIC' | 'STRUCTURAL';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A component entity's fingerprint-relevant projection.
|
|
44
|
+
*
|
|
45
|
+
* `component_signature` carries the entity name plus its member/method names
|
|
46
|
+
* (the structural identity of the component). `props_shape` is the public prop
|
|
47
|
+
* contract. `used_tokens` is the set of token ids the component consumes
|
|
48
|
+
* (COSMETIC: a token-value change does not alter component structure, but
|
|
49
|
+
* gaining/losing a token reference does — `used_tokens` is therefore part of
|
|
50
|
+
* `full` but omitted from `structural`). `exported_variants` is the set of
|
|
51
|
+
* variant names the component exports (STRUCTURAL).
|
|
52
|
+
*/
|
|
53
|
+
export interface ComponentFingerprintInput {
|
|
54
|
+
readonly component_signature: ComponentSignature;
|
|
55
|
+
readonly props_shape: readonly PropShapeEntry[];
|
|
56
|
+
readonly used_tokens?: readonly string[];
|
|
57
|
+
readonly exported_variants?: readonly string[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Structural identity of a component: its name + member/method names. */
|
|
61
|
+
export interface ComponentSignature {
|
|
62
|
+
readonly name: string;
|
|
63
|
+
/** Member/method names (fields, methods, hooks…). Order-insensitive set. */
|
|
64
|
+
readonly members?: readonly string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A single entry in a component's public prop contract. */
|
|
68
|
+
export interface PropShapeEntry {
|
|
69
|
+
readonly name: string;
|
|
70
|
+
/**
|
|
71
|
+
* The prop's type. Normalized verbatim by the caller (e.g. "string",
|
|
72
|
+
* "() => void"); the fingerprinter does not re-parse it, only whitespace-
|
|
73
|
+
* normalizes it.
|
|
74
|
+
*/
|
|
75
|
+
readonly type: string;
|
|
76
|
+
/** Whether the prop is optional (rendered as a trailing `?` in the sig). */
|
|
77
|
+
readonly optional?: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A token entity's fingerprint-relevant projection. */
|
|
81
|
+
export interface TokenFingerprintInput {
|
|
82
|
+
readonly token_name: string;
|
|
83
|
+
/** The resolved token value (COSMETIC — omitted from `structural`). */
|
|
84
|
+
readonly token_value: string | number | boolean | null;
|
|
85
|
+
/** The token type (e.g. "color") and optional finer subtype. */
|
|
86
|
+
readonly token_type: string;
|
|
87
|
+
readonly subtype?: string;
|
|
88
|
+
/** Theme scope the token belongs to (e.g. "light"/"dark"/"global"). */
|
|
89
|
+
readonly theme_scope?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A motion-fragment entity's fingerprint-relevant projection. */
|
|
93
|
+
export interface MotionFingerprintInput {
|
|
94
|
+
readonly animation_target: string;
|
|
95
|
+
/**
|
|
96
|
+
* Duration in milliseconds. Bucketed (fast/base/slow/xslow) so a 198ms vs
|
|
97
|
+
* 200ms tweak stays in-bucket and reads COSMETIC, not STRUCTURAL. COSMETIC —
|
|
98
|
+
* the bucket is omitted from `structural`.
|
|
99
|
+
*/
|
|
100
|
+
readonly duration_ms?: number | null;
|
|
101
|
+
/** Easing function descriptor, classified into a coarse easing_class. */
|
|
102
|
+
readonly easing?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Discriminated union of all fingerprint inputs (by `type` argument). */
|
|
106
|
+
export type FingerprintInput =
|
|
107
|
+
| ComponentFingerprintInput
|
|
108
|
+
| TokenFingerprintInput
|
|
109
|
+
| MotionFingerprintInput;
|
|
110
|
+
|
|
111
|
+
/** The output of `fingerprint()`: a full + structural sha256 hex pair. */
|
|
112
|
+
export interface Fingerprint {
|
|
113
|
+
readonly full: string;
|
|
114
|
+
readonly structural: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Canonicalization.
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Stable scalar formatting. Numbers via `String(Number(x))` (canonical numeric
|
|
123
|
+
* form — `1.0` → "1", `0.50` → "0.5"); booleans as "true"/"false"; null and
|
|
124
|
+
* undefined collapse to the empty string. Strings have whitespace runs
|
|
125
|
+
* collapsed to a single space and are trimmed.
|
|
126
|
+
*/
|
|
127
|
+
function formatScalar(value: unknown): string {
|
|
128
|
+
if (value === null || value === undefined) return '';
|
|
129
|
+
if (typeof value === 'number') {
|
|
130
|
+
// NaN/Infinity are not legal token values; normalize defensively to ''.
|
|
131
|
+
return Number.isFinite(value) ? String(Number(value)) : '';
|
|
132
|
+
}
|
|
133
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
134
|
+
if (typeof value === 'string') return collapseWhitespace(value);
|
|
135
|
+
// Fallback for any other primitive (bigint, symbol → string form).
|
|
136
|
+
return collapseWhitespace(String(value));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Collapse internal whitespace runs to a single space and trim. */
|
|
140
|
+
function collapseWhitespace(s: string): string {
|
|
141
|
+
return s.replace(/\s+/g, ' ').trim();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Recursively canonicalize an arbitrary value into a deterministic, hashable
|
|
146
|
+
* form:
|
|
147
|
+
* * objects → key-sorted (lexicographic) plain object, values recursed
|
|
148
|
+
* * arrays → element-wise canonicalized, order PRESERVED by default
|
|
149
|
+
* * set-arrays → sorted + deduped when `setKey` marks the field a set
|
|
150
|
+
* * scalars → stable scalar string form
|
|
151
|
+
*
|
|
152
|
+
* Set semantics apply to the fields named in `SET_FIELDS` (used_tokens,
|
|
153
|
+
* exported_variants, members) wherever they appear — those are order- and
|
|
154
|
+
* duplicate-insensitive collections.
|
|
155
|
+
*/
|
|
156
|
+
const SET_FIELDS: ReadonlySet<string> = new Set([
|
|
157
|
+
'used_tokens',
|
|
158
|
+
'exported_variants',
|
|
159
|
+
'members',
|
|
160
|
+
]);
|
|
161
|
+
|
|
162
|
+
function canonicalize(value: unknown, fieldName?: string): unknown {
|
|
163
|
+
if (Array.isArray(value)) {
|
|
164
|
+
const mapped = value.map((el) => canonicalize(el));
|
|
165
|
+
if (fieldName !== undefined && SET_FIELDS.has(fieldName)) {
|
|
166
|
+
// Set-array: stringify each element, sort + dedupe lexicographically.
|
|
167
|
+
const asStrings = mapped.map((el) =>
|
|
168
|
+
typeof el === 'string' ? el : JSON.stringify(el),
|
|
169
|
+
);
|
|
170
|
+
const deduped = Array.from(new Set(asStrings));
|
|
171
|
+
deduped.sort();
|
|
172
|
+
return deduped;
|
|
173
|
+
}
|
|
174
|
+
return mapped;
|
|
175
|
+
}
|
|
176
|
+
if (value !== null && typeof value === 'object') {
|
|
177
|
+
const src = value as Record<string, unknown>;
|
|
178
|
+
const out: Record<string, unknown> = {};
|
|
179
|
+
for (const key of Object.keys(src).sort()) {
|
|
180
|
+
out[key] = canonicalize(src[key], key);
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
return formatScalar(value);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Serialize a canonicalized object into a flat `key=value` line list, joined by
|
|
189
|
+
* '\n', with a leading `type:`-prefix line. Nested objects/arrays are rendered
|
|
190
|
+
* via a stable dotted-path flattening so the result is a single deterministic
|
|
191
|
+
* string. This is intentionally NOT `JSON.stringify` — the explicit `type:`
|
|
192
|
+
* prefix is what guarantees a token and a component with identical field values
|
|
193
|
+
* hash to DIFFERENT digests.
|
|
194
|
+
*/
|
|
195
|
+
function serializeCanonical(type: string, canonical: unknown): string {
|
|
196
|
+
const lines: string[] = [`type:${type}`];
|
|
197
|
+
flatten('', canonical, lines);
|
|
198
|
+
return lines.join('\n');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function flatten(prefix: string, value: unknown, lines: string[]): void {
|
|
202
|
+
if (Array.isArray(value)) {
|
|
203
|
+
// Index-keyed so [a,b] and [b,a] differ where order is significant, and
|
|
204
|
+
// set-arrays (already sorted+deduped upstream) are stable.
|
|
205
|
+
value.forEach((el, i) => flatten(`${prefix}[${i}]`, el, lines));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (value !== null && typeof value === 'object') {
|
|
209
|
+
const obj = value as Record<string, unknown>;
|
|
210
|
+
const keys = Object.keys(obj); // already key-sorted by canonicalize()
|
|
211
|
+
for (const key of keys) {
|
|
212
|
+
const next = prefix === '' ? key : `${prefix}.${key}`;
|
|
213
|
+
flatten(next, obj[key], lines);
|
|
214
|
+
}
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
// Scalar (already a normalized string from canonicalize()).
|
|
218
|
+
lines.push(`${prefix}=${String(value)}`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** sha256 hex of a UTF-8 string. */
|
|
222
|
+
function sha256Hex(s: string): string {
|
|
223
|
+
return createHash('sha256').update(s, 'utf8').digest('hex');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
// Per-type projections (full + structural).
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
/** ≤100→fast, ≤300→base, ≤600→slow, >600→xslow; absent → "none". */
|
|
231
|
+
function durationBucket(ms: number | null | undefined): string {
|
|
232
|
+
if (ms === null || ms === undefined || !Number.isFinite(ms)) return 'none';
|
|
233
|
+
if (ms <= 100) return 'fast';
|
|
234
|
+
if (ms <= 300) return 'base';
|
|
235
|
+
if (ms <= 600) return 'slow';
|
|
236
|
+
return 'xslow';
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Classify an easing descriptor into a coarse, stable class. */
|
|
240
|
+
const KNOWN_EASINGS: ReadonlySet<string> = new Set([
|
|
241
|
+
'linear',
|
|
242
|
+
'ease',
|
|
243
|
+
'ease-in',
|
|
244
|
+
'ease-out',
|
|
245
|
+
'ease-in-out',
|
|
246
|
+
'spring',
|
|
247
|
+
]);
|
|
248
|
+
|
|
249
|
+
function easingClass(easing: string | undefined): string {
|
|
250
|
+
if (easing === undefined) return 'custom';
|
|
251
|
+
const norm = collapseWhitespace(easing).toLowerCase();
|
|
252
|
+
if (norm === '') return 'custom';
|
|
253
|
+
return KNOWN_EASINGS.has(norm) ? norm : 'custom';
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Render the prop contract as sorted `name:type` (optional → `name?:type`). */
|
|
257
|
+
function propsShapeKeyed(
|
|
258
|
+
props: readonly PropShapeEntry[],
|
|
259
|
+
): { full: string[]; keys: string[] } {
|
|
260
|
+
const full = props
|
|
261
|
+
.map((p) => {
|
|
262
|
+
const opt = p.optional ? '?' : '';
|
|
263
|
+
return `${collapseWhitespace(p.name)}${opt}:${collapseWhitespace(p.type)}`;
|
|
264
|
+
})
|
|
265
|
+
.slice()
|
|
266
|
+
.sort();
|
|
267
|
+
const keys = props
|
|
268
|
+
.map((p) => `${collapseWhitespace(p.name)}${p.optional ? '?' : ''}`)
|
|
269
|
+
.slice()
|
|
270
|
+
.sort();
|
|
271
|
+
return { full, keys };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Build the FULL projection object for a given type. */
|
|
275
|
+
function fullProjection(input: FingerprintInput, type: FingerprintType): unknown {
|
|
276
|
+
switch (type) {
|
|
277
|
+
case 'component': {
|
|
278
|
+
const c = input as ComponentFingerprintInput;
|
|
279
|
+
const { full: propsFull } = propsShapeKeyed(c.props_shape ?? []);
|
|
280
|
+
return {
|
|
281
|
+
component_signature: {
|
|
282
|
+
name: c.component_signature?.name ?? '',
|
|
283
|
+
members: (c.component_signature?.members ?? []) as readonly string[],
|
|
284
|
+
},
|
|
285
|
+
props_shape: propsFull,
|
|
286
|
+
used_tokens: (c.used_tokens ?? []) as readonly string[],
|
|
287
|
+
exported_variants: (c.exported_variants ?? []) as readonly string[],
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
case 'token': {
|
|
291
|
+
const t = input as TokenFingerprintInput;
|
|
292
|
+
return {
|
|
293
|
+
token_name: t.token_name,
|
|
294
|
+
token_value: t.token_value,
|
|
295
|
+
token_type: t.token_type,
|
|
296
|
+
subtype: t.subtype ?? '',
|
|
297
|
+
theme_scope: t.theme_scope ?? '',
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
case 'motion': {
|
|
301
|
+
const m = input as MotionFingerprintInput;
|
|
302
|
+
return {
|
|
303
|
+
animation_target: m.animation_target,
|
|
304
|
+
duration_bucket: durationBucket(m.duration_ms),
|
|
305
|
+
easing_class: easingClass(m.easing),
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
default: {
|
|
309
|
+
// Exhaustiveness guard.
|
|
310
|
+
const never: never = type;
|
|
311
|
+
throw new TypeError(`fingerprint: unknown type "${String(never)}"`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Build the STRUCTURE-ONLY projection object (cosmetic fields omitted):
|
|
318
|
+
* * component → (component_signature, props_shape KEYS, exported_variants) — omits used_tokens
|
|
319
|
+
* * token → (token_name, token_type, theme_scope) — omits token_value
|
|
320
|
+
* * motion → (animation_target, easing_class) — omits duration_bucket
|
|
321
|
+
*/
|
|
322
|
+
function structuralProjection(
|
|
323
|
+
input: FingerprintInput,
|
|
324
|
+
type: FingerprintType,
|
|
325
|
+
): unknown {
|
|
326
|
+
switch (type) {
|
|
327
|
+
case 'component': {
|
|
328
|
+
const c = input as ComponentFingerprintInput;
|
|
329
|
+
const { keys: propsKeys } = propsShapeKeyed(c.props_shape ?? []);
|
|
330
|
+
return {
|
|
331
|
+
component_signature: {
|
|
332
|
+
name: c.component_signature?.name ?? '',
|
|
333
|
+
members: (c.component_signature?.members ?? []) as readonly string[],
|
|
334
|
+
},
|
|
335
|
+
props_shape: propsKeys,
|
|
336
|
+
exported_variants: (c.exported_variants ?? []) as readonly string[],
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
case 'token': {
|
|
340
|
+
const t = input as TokenFingerprintInput;
|
|
341
|
+
return {
|
|
342
|
+
token_name: t.token_name,
|
|
343
|
+
token_type: t.token_type,
|
|
344
|
+
theme_scope: t.theme_scope ?? '',
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
case 'motion': {
|
|
348
|
+
const m = input as MotionFingerprintInput;
|
|
349
|
+
return {
|
|
350
|
+
animation_target: m.animation_target,
|
|
351
|
+
easing_class: easingClass(m.easing),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
default: {
|
|
355
|
+
const never: never = type;
|
|
356
|
+
throw new TypeError(`fingerprint: unknown type "${String(never)}"`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
// Public API.
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Compute the `{ full, structural }` sha256 hex fingerprint pair for one
|
|
367
|
+
* entity. Both hashes are over canonicalized, `type:`-prefixed line-joined
|
|
368
|
+
* projections (see module header). Summaries are never hashed.
|
|
369
|
+
*
|
|
370
|
+
* @param input Entity projection matching `type`.
|
|
371
|
+
* @param type One of 'component' | 'token' | 'motion'.
|
|
372
|
+
*/
|
|
373
|
+
export function fingerprint(
|
|
374
|
+
input: FingerprintInput,
|
|
375
|
+
type: FingerprintType,
|
|
376
|
+
): Fingerprint {
|
|
377
|
+
if (input === null || typeof input !== 'object') {
|
|
378
|
+
throw new TypeError('fingerprint: input must be an object');
|
|
379
|
+
}
|
|
380
|
+
const fullCanonical = canonicalize(fullProjection(input, type));
|
|
381
|
+
const structuralCanonical = canonicalize(structuralProjection(input, type));
|
|
382
|
+
return {
|
|
383
|
+
full: sha256Hex(serializeCanonical(type, fullCanonical)),
|
|
384
|
+
structural: sha256Hex(serializeCanonical(type, structuralCanonical)),
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Classify the change between two fingerprints (before `a`, after `b`):
|
|
390
|
+
* * a && b && a.full === b.full → 'NONE' (identical)
|
|
391
|
+
* * !a || !b → 'STRUCTURAL' (add when !a, remove when !b)
|
|
392
|
+
* * a.structural === b.structural → 'COSMETIC' (cosmetic-only delta)
|
|
393
|
+
* * else → 'STRUCTURAL' (shape changed)
|
|
394
|
+
*
|
|
395
|
+
* `null` on either side models add (prior absent) or remove (current absent);
|
|
396
|
+
* both are STRUCTURAL — the batch they belong to must be (re-)mapped.
|
|
397
|
+
*/
|
|
398
|
+
export function compareFingerprints(
|
|
399
|
+
a: Fingerprint | null,
|
|
400
|
+
b: Fingerprint | null,
|
|
401
|
+
): ChangeType {
|
|
402
|
+
if (a && b && a.full === b.full) return 'NONE';
|
|
403
|
+
if (!a || !b) return 'STRUCTURAL';
|
|
404
|
+
return a.structural === b.structural ? 'COSMETIC' : 'STRUCTURAL';
|
|
405
|
+
}
|