@fragments-sdk/classifier 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.
Files changed (51) hide show
  1. package/LICENSE +84 -0
  2. package/dist/index.d.ts +184 -0
  3. package/dist/index.js +1856 -0
  4. package/dist/index.js.map +1 -0
  5. package/package.json +45 -0
  6. package/src/__tests__/combiner.test.ts +222 -0
  7. package/src/__tests__/fixtures.ts +96 -0
  8. package/src/ai/__tests__/cache-key.test.ts +50 -0
  9. package/src/ai/__tests__/prompt.test.ts +95 -0
  10. package/src/ai/__tests__/schema.test.ts +145 -0
  11. package/src/ai/__tests__/secret-scrub.test.ts +70 -0
  12. package/src/ai/__tests__/signal.test.ts +94 -0
  13. package/src/ai/cache-key.ts +46 -0
  14. package/src/ai/index.ts +42 -0
  15. package/src/ai/prompt.ts +154 -0
  16. package/src/ai/schema.ts +148 -0
  17. package/src/ai/secret-scrub.ts +116 -0
  18. package/src/ai/signal.ts +81 -0
  19. package/src/ai/version.ts +15 -0
  20. package/src/canonical-vocab/resolve-by-html-element.ts +72 -0
  21. package/src/combiner/__tests__/band.test.ts +155 -0
  22. package/src/combiner/__tests__/group.test.ts +85 -0
  23. package/src/combiner/__tests__/rank.test.ts +54 -0
  24. package/src/combiner/band.ts +85 -0
  25. package/src/combiner/group.ts +62 -0
  26. package/src/combiner/rank.ts +57 -0
  27. package/src/combiner.ts +124 -0
  28. package/src/index.ts +76 -0
  29. package/src/signals/__tests__/aria-role.test.ts +53 -0
  30. package/src/signals/__tests__/barrel-export.test.ts +29 -0
  31. package/src/signals/__tests__/html-root.test.ts +55 -0
  32. package/src/signals/__tests__/input-type.test.ts +58 -0
  33. package/src/signals/__tests__/library-reexport.test.ts +68 -0
  34. package/src/signals/__tests__/name-match.test.ts +43 -0
  35. package/src/signals/__tests__/path-hint.test.ts +55 -0
  36. package/src/signals/__tests__/prop-fingerprint.test.ts +105 -0
  37. package/src/signals/__tests__/registry.test.ts +27 -0
  38. package/src/signals/aria-role.ts +94 -0
  39. package/src/signals/barrel-export.ts +28 -0
  40. package/src/signals/html-root.ts +85 -0
  41. package/src/signals/index.ts +39 -0
  42. package/src/signals/input-type.ts +63 -0
  43. package/src/signals/library-reexport.ts +70 -0
  44. package/src/signals/name-match.ts +92 -0
  45. package/src/signals/path-hint.ts +94 -0
  46. package/src/signals/prop-fingerprint.ts +121 -0
  47. package/src/types.ts +58 -0
  48. package/src/vocabulary/canonicals.ts +106 -0
  49. package/src/vocabulary/library-map.ts +301 -0
  50. package/src/vocabulary/prop-fingerprints.ts +433 -0
  51. package/src/vocabulary/synonyms.ts +130 -0
@@ -0,0 +1,124 @@
1
+ // Combiner entry point — `01-architecture.md` §10 + §10.5.
2
+ //
3
+ // Takes signal records (from the heuristic extractors and, in Phase 2,
4
+ // the AI tier), groups them by canonical, applies independent-event
5
+ // composition (§10.1), the disagreement penalty (§10.2), and the band
6
+ // table (§10.3) including the tied/ambiguous demotion (§10.4).
7
+ // Returns the verbatim §10.5 "show your work" payload.
8
+ //
9
+ // Pure: no I/O, no Convex, no `Date.now()`. Throws only on a malformed
10
+ // input (signal weight outside `[0, 1]`).
11
+
12
+ import type {
13
+ Classification,
14
+ ClassificationAlternate,
15
+ SignalRecord,
16
+ } from './types.js';
17
+ import { groupAndCompose, type GroupedHypothesis } from './combiner/group.js';
18
+ import { rank } from './combiner/rank.js';
19
+ import {
20
+ deriveBand,
21
+ HIGH_DISAGREEMENT_FLOOR,
22
+ STRONG_WEIGHT,
23
+ TIE_FIRES_FROM,
24
+ TIE_WINDOW,
25
+ } from './combiner/band.js';
26
+
27
+ export interface CombineMeta {
28
+ classifierVersion: string;
29
+ vocabVersion: string;
30
+ }
31
+
32
+ export function combine(
33
+ signals: SignalRecord[],
34
+ meta: CombineMeta,
35
+ ): Classification {
36
+ validate(signals);
37
+
38
+ if (signals.length === 0) {
39
+ return emptyClassification(meta);
40
+ }
41
+
42
+ const groups = groupAndCompose(signals);
43
+ const ranked = rank(groups);
44
+
45
+ if (!ranked.leading) {
46
+ return emptyClassification(meta);
47
+ }
48
+
49
+ const hasStrongSignal = ranked.leading.signals.some(
50
+ (s) => s.weight >= STRONG_WEIGHT,
51
+ );
52
+ const hasHighDisagreement = ranked.alternates.some(
53
+ (a) => a.confidence >= HIGH_DISAGREEMENT_FLOOR,
54
+ );
55
+ const isTied = detectTie(ranked.leading, ranked.alternates);
56
+
57
+ const band = deriveBand({
58
+ adjustedConfidence: ranked.adjustedLeadingConfidence,
59
+ distinctStrongTypes: ranked.leading.distinctStrongTypes.size,
60
+ totalLeadingSignalCount: ranked.leading.signals.length,
61
+ hasStrongSignal,
62
+ hasHighDisagreement,
63
+ isTied,
64
+ });
65
+
66
+ const alternates: ClassificationAlternate[] = ranked.alternates.map((a) => ({
67
+ canonical: a.canonical,
68
+ confidence: a.confidence,
69
+ signals: a.signals,
70
+ }));
71
+
72
+ return {
73
+ canonical: ranked.leading.canonical,
74
+ confidence: ranked.adjustedLeadingConfidence,
75
+ rawConfidence: ranked.rawLeadingConfidence,
76
+ band,
77
+ signals: ranked.leading.signals,
78
+ alternates,
79
+ classifierVersion: meta.classifierVersion,
80
+ vocabVersion: meta.vocabVersion,
81
+ };
82
+ }
83
+
84
+ function detectTie(
85
+ leading: GroupedHypothesis,
86
+ alternates: GroupedHypothesis[],
87
+ ): boolean {
88
+ if (alternates.length === 0) return false;
89
+ const top = alternates[0];
90
+ if (leading.confidence < TIE_FIRES_FROM) return false;
91
+ return Math.abs(leading.confidence - top.confidence) < TIE_WINDOW;
92
+ }
93
+
94
+ function validate(signals: SignalRecord[]): void {
95
+ for (const sig of signals) {
96
+ if (
97
+ typeof sig.weight !== 'number' ||
98
+ Number.isNaN(sig.weight) ||
99
+ sig.weight < 0 ||
100
+ sig.weight > 1
101
+ ) {
102
+ throw new Error(
103
+ `combine: signal weight must be a number in [0, 1], got ${sig.weight} for ${String(sig.canonical)} (${sig.type})`,
104
+ );
105
+ }
106
+ }
107
+ }
108
+
109
+ function emptyClassification(meta: CombineMeta): Classification {
110
+ return {
111
+ canonical: 'unknown',
112
+ confidence: 0,
113
+ rawConfidence: 0,
114
+ band: 'unknown',
115
+ signals: [],
116
+ alternates: [],
117
+ classifierVersion: meta.classifierVersion,
118
+ vocabVersion: meta.vocabVersion,
119
+ };
120
+ }
121
+
122
+ export type { GroupedHypothesis } from './combiner/group.js';
123
+ export type { RankResult } from './combiner/rank.js';
124
+ export type { BandInput } from './combiner/band.js';
package/src/index.ts ADDED
@@ -0,0 +1,76 @@
1
+ // @fragments-sdk/classifier — heuristic signal extractors for the
2
+ // canonical-primitive map. The combiner (brief 04) consumes this barrel.
3
+
4
+ export {
5
+ canonicalId,
6
+ type Band,
7
+ type CanonicalId,
8
+ type Classification,
9
+ type ClassificationAlternate,
10
+ type HeuristicSignalType,
11
+ type SignalExtractor,
12
+ type SignalRecord,
13
+ type SignalRegistry,
14
+ type SignalType,
15
+ } from './types.js';
16
+
17
+ export { combine, type CombineMeta } from './combiner.js';
18
+
19
+ export { HEURISTIC_SIGNALS } from './signals/index.js';
20
+
21
+ export {
22
+ VOCAB_V0,
23
+ VOCAB_V0_INDEX,
24
+ VOCAB_V0_VERSION,
25
+ isCanonicalId,
26
+ type CanonicalCategory,
27
+ type CanonicalEntry,
28
+ } from './vocabulary/canonicals.js';
29
+
30
+ export { LIBRARY_MAP, lookupLibraryImport } from './vocabulary/library-map.js';
31
+
32
+ export {
33
+ NAME_STRIP_PREFIXES,
34
+ NAME_STRIP_SUFFIXES,
35
+ NAME_VARIANT_SUFFIXES,
36
+ SYNONYMS,
37
+ lookupSynonym,
38
+ } from './vocabulary/synonyms.js';
39
+
40
+ export {
41
+ POLYMORPHIC_PROP_NAMES,
42
+ PROP_FINGERPRINTS,
43
+ type PropFingerprint,
44
+ } from './vocabulary/prop-fingerprints.js';
45
+
46
+ export {
47
+ resolveCanonicalForHtmlElement,
48
+ formatRawHtmlElement,
49
+ type RawAttrLike,
50
+ type RawAttrMap,
51
+ } from './canonical-vocab/resolve-by-html-element.js';
52
+
53
+ export {
54
+ AI_PROMPT_VERSION,
55
+ AI_REASONING_CHAR_CAP,
56
+ AI_SIGNAL_WEIGHTS,
57
+ aiResponseToSignals,
58
+ applyVocabWhitelist,
59
+ buildAiPrompt,
60
+ deriveAiCacheKey,
61
+ hashSourceText,
62
+ parseAiResponse,
63
+ sanitizeForInjection,
64
+ scrubSecrets,
65
+ truncateSource,
66
+ type AiConfidence,
67
+ type AiParseResult,
68
+ type AiResponse,
69
+ type AiResponseAlternate,
70
+ type AiSignalInputs,
71
+ type BuiltPrompt,
72
+ type CacheKeyInputs,
73
+ type PromptInputs,
74
+ type SecretScrubResult,
75
+ type VocabularyPromptEntry,
76
+ } from './ai/index.js';
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import ariaRole from '../aria-role.js';
4
+ import { makeUcf, root } from '../../__tests__/fixtures.js';
5
+
6
+ describe('ARIA_ROLE', () => {
7
+ it('fires Dialog on role="dialog"', () => {
8
+ const records = ariaRole(makeUcf({ ariaRoles: ['dialog'] }));
9
+ expect(records).toHaveLength(1);
10
+ expect(records[0].canonical).toBe('Dialog');
11
+ expect(records[0].weight).toBe(0.45);
12
+ });
13
+
14
+ it('does not fire on empty ariaRoles', () => {
15
+ expect(ariaRole(makeUcf({ ariaRoles: [] }))).toEqual([]);
16
+ });
17
+
18
+ it('suppresses Button when root tag is already <button> (no double-counting)', () => {
19
+ const records = ariaRole(
20
+ makeUcf({ ariaRoles: ['button'], rootElements: [root('button')] }),
21
+ );
22
+ expect(records).toEqual([]);
23
+ });
24
+
25
+ it('emits Button when role=button on a non-button element (e.g. <div role="button">)', () => {
26
+ const records = ariaRole(
27
+ makeUcf({ ariaRoles: ['button'], rootElements: [root('div')] }),
28
+ );
29
+ expect(records).toHaveLength(1);
30
+ expect(records[0].canonical).toBe('Button');
31
+ });
32
+
33
+ it('disambiguates listbox via aria-multiselectable=true → MultiSelect', () => {
34
+ const records = ariaRole(
35
+ makeUcf({ ariaRoles: ['listbox'], ariaAttributes: { 'aria-multiselectable': 'true' } }),
36
+ );
37
+ expect(records.map((r) => r.canonical)).toEqual(['MultiSelect']);
38
+ });
39
+
40
+ it('disambiguates listbox without aria-multiselectable → Select', () => {
41
+ const records = ariaRole(makeUcf({ ariaRoles: ['listbox'] }));
42
+ expect(records.map((r) => r.canonical)).toEqual(['Select']);
43
+ });
44
+
45
+ it('emits Toast + Alert for role="status" with reduced weight', () => {
46
+ const records = ariaRole(makeUcf({ ariaRoles: ['status'] }));
47
+ const canonicals = records.map((r) => r.canonical).sort();
48
+ expect(canonicals).toEqual(['Alert', 'Toast']);
49
+ for (const record of records) {
50
+ expect(record.weight).toBe(0.3);
51
+ }
52
+ });
53
+ });
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import barrelExport from '../barrel-export.js';
4
+ import { makeUcf } from '../../__tests__/fixtures.js';
5
+
6
+ describe('BARREL_EXPORT', () => {
7
+ it('fires when exportedFromBarrel + name matches a synonym', () => {
8
+ const records = barrelExport(
9
+ makeUcf({ componentName: 'Button', exportedFromBarrel: true }),
10
+ );
11
+ const button = records.find((r) => r.canonical === 'Button');
12
+ expect(button).toBeDefined();
13
+ expect(button!.weight).toBe(0.08);
14
+ });
15
+
16
+ it('does not fire when exportedFromBarrel is false', () => {
17
+ const records = barrelExport(
18
+ makeUcf({ componentName: 'Button', exportedFromBarrel: false }),
19
+ );
20
+ expect(records).toEqual([]);
21
+ });
22
+
23
+ it('does not fire when name has no canonical', () => {
24
+ const records = barrelExport(
25
+ makeUcf({ componentName: 'WeirdHelper', exportedFromBarrel: true }),
26
+ );
27
+ expect(records).toEqual([]);
28
+ });
29
+ });
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import htmlRoot from '../html-root.js';
4
+ import { makeUcf, root } from '../../__tests__/fixtures.js';
5
+
6
+ describe('HTML_ROOT', () => {
7
+ it('fires Button on a single <button> root', () => {
8
+ const records = htmlRoot(makeUcf({ rootElements: [root('button')] }));
9
+ expect(records).toHaveLength(1);
10
+ expect(records[0].canonical).toBe('Button');
11
+ expect(records[0].weight).toBe(0.45);
12
+ });
13
+
14
+ it('does not fire on a plain <div>', () => {
15
+ const records = htmlRoot(makeUcf({ rootElements: [root('div')] }));
16
+ expect(records).toEqual([]);
17
+ });
18
+
19
+ it('does not fire on Custom: roots (component-typed)', () => {
20
+ const records = htmlRoot(makeUcf({ rootElements: [root('Custom:Foo')] }));
21
+ expect(records).toEqual([]);
22
+ });
23
+
24
+ it('skips <input> entirely (INPUT_TYPE owns it)', () => {
25
+ const records = htmlRoot(
26
+ makeUcf({ rootElements: [root('input', { inputType: 'text' })] }),
27
+ );
28
+ expect(records).toEqual([]);
29
+ });
30
+
31
+ it('emits multiple hypotheses for ambiguous tags (<nav>)', () => {
32
+ const records = htmlRoot(makeUcf({ rootElements: [root('nav')] }));
33
+ const canonicals = records.map((r) => r.canonical).sort();
34
+ expect(canonicals).toEqual(['Breadcrumb', 'NavigationMenu', 'Pagination']);
35
+ });
36
+
37
+ it('reduces weight when conditional roots disagree', () => {
38
+ const records = htmlRoot(
39
+ makeUcf({ rootElements: [root('button'), root('div')] }),
40
+ );
41
+ expect(records).toHaveLength(1);
42
+ expect(records[0].canonical).toBe('Button');
43
+ // Button only fires on 1 of 2 branches → weight halved.
44
+ expect(records[0].weight).toBeCloseTo(0.225, 3);
45
+ });
46
+
47
+ it('keeps full weight when both branches agree', () => {
48
+ const records = htmlRoot(
49
+ makeUcf({ rootElements: [root('button'), root('button')] }),
50
+ );
51
+ expect(records).toHaveLength(1);
52
+ expect(records[0].canonical).toBe('Button');
53
+ expect(records[0].weight).toBe(0.45);
54
+ });
55
+ });
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import inputType from '../input-type.js';
4
+ import { makeUcf, root } from '../../__tests__/fixtures.js';
5
+
6
+ describe('INPUT_TYPE', () => {
7
+ it('fires Checkbox on <input type="checkbox">', () => {
8
+ const records = inputType(
9
+ makeUcf({ rootElements: [root('input', { inputType: 'checkbox' })] }),
10
+ );
11
+ expect(records).toHaveLength(1);
12
+ expect(records[0].canonical).toBe('Checkbox');
13
+ expect(records[0].weight).toBe(0.6);
14
+ });
15
+
16
+ it('upgrades Checkbox → Switch when role="switch" is present', () => {
17
+ const records = inputType(
18
+ makeUcf({
19
+ rootElements: [root('input', { inputType: 'checkbox' })],
20
+ ariaRoles: ['switch'],
21
+ }),
22
+ );
23
+ expect(records).toHaveLength(1);
24
+ expect(records[0].canonical).toBe('Switch');
25
+ expect(records[0].weight).toBe(0.6);
26
+ });
27
+
28
+ it('fires PasswordInput on <input type="password">', () => {
29
+ const records = inputType(
30
+ makeUcf({ rootElements: [root('input', { inputType: 'password' })] }),
31
+ );
32
+ expect(records.map((r) => r.canonical)).toEqual(['PasswordInput']);
33
+ });
34
+
35
+ it('does not fire on a non-input root', () => {
36
+ expect(inputType(makeUcf({ rootElements: [root('button')] }))).toEqual([]);
37
+ });
38
+
39
+ it('does not fire on <input> with no inputType field', () => {
40
+ expect(
41
+ inputType(makeUcf({ rootElements: [root('input')] })),
42
+ ).toEqual([]);
43
+ });
44
+
45
+ it('maps date inputs to DatePicker', () => {
46
+ const records = inputType(
47
+ makeUcf({ rootElements: [root('input', { inputType: 'date' })] }),
48
+ );
49
+ expect(records[0].canonical).toBe('DatePicker');
50
+ });
51
+
52
+ it('maps range inputs to Slider', () => {
53
+ const records = inputType(
54
+ makeUcf({ rootElements: [root('input', { inputType: 'range' })] }),
55
+ );
56
+ expect(records[0].canonical).toBe('Slider');
57
+ });
58
+ });
@@ -0,0 +1,68 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import libraryReexport from '../library-reexport.js';
4
+ import { importRecord, makeUcf, root } from '../../__tests__/fixtures.js';
5
+
6
+ describe('LIBRARY_REEXPORT', () => {
7
+ it('fires on Radix Dialog Root used as a JSX root', () => {
8
+ const ucf = makeUcf({
9
+ imports: [
10
+ importRecord('@radix-ui/react-dialog', { imported: 'Root', local: 'DialogPrimitive' }),
11
+ ],
12
+ rootElements: [root('Custom:DialogPrimitive')],
13
+ });
14
+ const records = libraryReexport(ucf);
15
+ expect(records).toHaveLength(1);
16
+ expect(records[0].canonical).toBe('Dialog');
17
+ expect(records[0].weight).toBe(0.55);
18
+ expect(records[0].evidence).toMatchObject({
19
+ package: '@radix-ui/react-dialog',
20
+ importedName: 'Root',
21
+ localBinding: 'DialogPrimitive',
22
+ });
23
+ });
24
+
25
+ it('does not fire when the imported binding is not used as a root', () => {
26
+ const ucf = makeUcf({
27
+ imports: [
28
+ importRecord('@radix-ui/react-dialog', { imported: 'Root', local: 'DialogPrimitive' }),
29
+ ],
30
+ // The component renders a `div`, not the imported primitive.
31
+ rootElements: [root('div')],
32
+ });
33
+ expect(libraryReexport(ucf)).toEqual([]);
34
+ });
35
+
36
+ it('does not fire on an unknown package', () => {
37
+ const ucf = makeUcf({
38
+ imports: [importRecord('some-unknown-pkg', { imported: 'Root', local: 'Root' })],
39
+ rootElements: [root('Custom:Root')],
40
+ });
41
+ expect(libraryReexport(ucf)).toEqual([]);
42
+ });
43
+
44
+ it('de-duplicates when two imports point to the same canonical', () => {
45
+ const ucf = makeUcf({
46
+ imports: [
47
+ importRecord('@mui/material/Button', { imported: 'default', local: 'MuiButton' }),
48
+ importRecord('@chakra-ui/react', { imported: 'Button', local: 'ChakraBtn' }),
49
+ ],
50
+ rootElements: [root('Custom:MuiButton'), root('Custom:ChakraBtn')],
51
+ });
52
+ const records = libraryReexport(ucf);
53
+ expect(records).toHaveLength(1);
54
+ expect(records[0].canonical).toBe('Button');
55
+ });
56
+
57
+ it('handles default-export packages (MUI per-module)', () => {
58
+ const ucf = makeUcf({
59
+ imports: [
60
+ importRecord('@mui/material/Dialog', { imported: 'default', local: 'Dialog' }),
61
+ ],
62
+ rootElements: [root('Custom:Dialog')],
63
+ });
64
+ const records = libraryReexport(ucf);
65
+ expect(records).toHaveLength(1);
66
+ expect(records[0].canonical).toBe('Dialog');
67
+ });
68
+ });
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import nameMatch from '../name-match.js';
4
+ import { makeUcf } from '../../__tests__/fixtures.js';
5
+
6
+ describe('NAME_MATCH', () => {
7
+ it('fires Button on direct canonical name', () => {
8
+ const records = nameMatch(makeUcf({ componentName: 'Button' }));
9
+ const button = records.find((r) => r.canonical === 'Button');
10
+ expect(button).toBeDefined();
11
+ expect(button!.weight).toBe(0.15);
12
+ });
13
+
14
+ it('fires Button via synonym (PrimaryButton)', () => {
15
+ const records = nameMatch(makeUcf({ componentName: 'PrimaryButton' }));
16
+ expect(records.some((r) => r.canonical === 'Button')).toBe(true);
17
+ });
18
+
19
+ it('fires Button after stripping library prefix (MuiButton)', () => {
20
+ const records = nameMatch(makeUcf({ componentName: 'MuiButton' }));
21
+ expect(records.some((r) => r.canonical === 'Button')).toBe(true);
22
+ });
23
+
24
+ it('fires Button after stripping suffix (ButtonRoot)', () => {
25
+ const records = nameMatch(makeUcf({ componentName: 'ButtonRoot' }));
26
+ expect(records.some((r) => r.canonical === 'Button')).toBe(true);
27
+ });
28
+
29
+ it('does not fire on an unrelated name', () => {
30
+ const records = nameMatch(makeUcf({ componentName: 'Foo' }));
31
+ expect(records).toEqual([]);
32
+ });
33
+
34
+ it('weight is always 0.15 alone (never load-bearing)', () => {
35
+ const records = nameMatch(makeUcf({ componentName: 'Dialog' }));
36
+ for (const record of records) expect(record.weight).toBe(0.15);
37
+ });
38
+
39
+ it('Modal synonym maps to Dialog', () => {
40
+ const records = nameMatch(makeUcf({ componentName: 'Modal' }));
41
+ expect(records.some((r) => r.canonical === 'Dialog')).toBe(true);
42
+ });
43
+ });
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import pathHint from '../path-hint.js';
4
+ import { makeUcf } from '../../__tests__/fixtures.js';
5
+
6
+ describe('PATH_HINT', () => {
7
+ it('fires on src/components/ui/button.tsx → Button', () => {
8
+ const records = pathHint(
9
+ makeUcf({
10
+ filePath: 'src/components/ui/button.tsx',
11
+ componentName: 'Button',
12
+ }),
13
+ );
14
+ const button = records.find((r) => r.canonical === 'Button');
15
+ expect(button).toBeDefined();
16
+ expect(button!.weight).toBe(0.1);
17
+ });
18
+
19
+ it('does not fire on src/pages/login.tsx', () => {
20
+ const records = pathHint(
21
+ makeUcf({ filePath: 'src/pages/login.tsx', componentName: 'Button' }),
22
+ );
23
+ expect(records).toEqual([]);
24
+ });
25
+
26
+ it('fires on primitives/ directory hint', () => {
27
+ const records = pathHint(
28
+ makeUcf({
29
+ filePath: 'src/primitives/dialog.tsx',
30
+ componentName: 'Modal',
31
+ }),
32
+ );
33
+ expect(records.some((r) => r.canonical === 'Dialog')).toBe(true);
34
+ });
35
+
36
+ it('falls back to component name when basename has no canonical', () => {
37
+ const records = pathHint(
38
+ makeUcf({
39
+ filePath: 'src/atoms/index.tsx',
40
+ componentName: 'Tooltip',
41
+ }),
42
+ );
43
+ expect(records.some((r) => r.canonical === 'Tooltip')).toBe(true);
44
+ });
45
+
46
+ it('handles dasherized basenames', () => {
47
+ const records = pathHint(
48
+ makeUcf({
49
+ filePath: 'src/ui/icon-button.tsx',
50
+ componentName: 'IconButton',
51
+ }),
52
+ );
53
+ expect(records.some((r) => r.canonical === 'IconButton')).toBe(true);
54
+ });
55
+ });
@@ -0,0 +1,105 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import propFingerprint from '../prop-fingerprint.js';
4
+ import { makeUcf, prop } from '../../__tests__/fixtures.js';
5
+
6
+ describe('PROP_FINGERPRINT', () => {
7
+ it('fires Button on { onClick, children } (§9.5 reference)', () => {
8
+ const records = propFingerprint(
9
+ makeUcf({
10
+ props: [
11
+ prop('onClick', '(event: MouseEvent) => void'),
12
+ prop('children', 'ReactNode'),
13
+ ],
14
+ }),
15
+ );
16
+ const button = records.find((r) => r.canonical === 'Button');
17
+ expect(button).toBeDefined();
18
+ expect(button!.weight).toBe(0.3);
19
+ });
20
+
21
+ it('boosts Button weight with optional matches (variant, size, disabled)', () => {
22
+ const records = propFingerprint(
23
+ makeUcf({
24
+ props: [
25
+ prop('onClick', '() => void'),
26
+ prop('children', 'ReactNode'),
27
+ prop('variant', 'string'),
28
+ prop('size', 'string'),
29
+ prop('disabled', 'boolean'),
30
+ ],
31
+ }),
32
+ );
33
+ const button = records.find((r) => r.canonical === 'Button');
34
+ expect(button).toBeDefined();
35
+ // Base 0.3 + 3 optionals × 0.05 = 0.45
36
+ expect(button!.weight).toBeCloseTo(0.45, 3);
37
+ });
38
+
39
+ it('fires Dialog on { open, onOpenChange } (§10.1 example)', () => {
40
+ const records = propFingerprint(
41
+ makeUcf({
42
+ props: [
43
+ prop('open', 'boolean'),
44
+ prop('onOpenChange', '(open: boolean) => void'),
45
+ ],
46
+ }),
47
+ );
48
+ const dialog = records.find((r) => r.canonical === 'Dialog');
49
+ expect(dialog).toBeDefined();
50
+ expect(dialog!.weight).toBe(0.3);
51
+ });
52
+
53
+ it('does not fire Button on input-shaped props (negative case)', () => {
54
+ const records = propFingerprint(
55
+ makeUcf({
56
+ props: [
57
+ prop('value', 'string'),
58
+ prop('onChange', '(value: string) => void'),
59
+ ],
60
+ }),
61
+ );
62
+ const button = records.find((r) => r.canonical === 'Button');
63
+ expect(button).toBeUndefined();
64
+ });
65
+
66
+ it('penalizes hypotheses when polymorphic prop `as` is present', () => {
67
+ const recordsBase = propFingerprint(
68
+ makeUcf({
69
+ props: [prop('onClick', '() => void'), prop('children', 'ReactNode')],
70
+ }),
71
+ );
72
+ const recordsPoly = propFingerprint(
73
+ makeUcf({
74
+ props: [
75
+ prop('onClick', '() => void'),
76
+ prop('children', 'ReactNode'),
77
+ prop('as', 'ElementType'),
78
+ ],
79
+ }),
80
+ );
81
+ const baseWeight = recordsBase.find((r) => r.canonical === 'Button')!.weight;
82
+ const polyWeight = recordsPoly.find((r) => r.canonical === 'Button')!.weight;
83
+ expect(polyWeight).toBeCloseTo(baseWeight - 0.1, 3);
84
+ });
85
+
86
+ it('fires Checkbox on { checked, onCheckedChange }', () => {
87
+ const records = propFingerprint(
88
+ makeUcf({
89
+ props: [
90
+ prop('checked', 'boolean'),
91
+ prop('onCheckedChange', '(checked: boolean) => void'),
92
+ ],
93
+ }),
94
+ );
95
+ const checkbox = records.find((r) => r.canonical === 'Checkbox');
96
+ expect(checkbox).toBeDefined();
97
+ });
98
+
99
+ it('emits no records when no canonical fingerprint is satisfied', () => {
100
+ const records = propFingerprint(
101
+ makeUcf({ props: [prop('foo', 'unknown')] }),
102
+ );
103
+ expect(records).toEqual([]);
104
+ });
105
+ });