@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.
- package/LICENSE +84 -0
- package/dist/index.d.ts +184 -0
- package/dist/index.js +1856 -0
- package/dist/index.js.map +1 -0
- package/package.json +45 -0
- package/src/__tests__/combiner.test.ts +222 -0
- package/src/__tests__/fixtures.ts +96 -0
- package/src/ai/__tests__/cache-key.test.ts +50 -0
- package/src/ai/__tests__/prompt.test.ts +95 -0
- package/src/ai/__tests__/schema.test.ts +145 -0
- package/src/ai/__tests__/secret-scrub.test.ts +70 -0
- package/src/ai/__tests__/signal.test.ts +94 -0
- package/src/ai/cache-key.ts +46 -0
- package/src/ai/index.ts +42 -0
- package/src/ai/prompt.ts +154 -0
- package/src/ai/schema.ts +148 -0
- package/src/ai/secret-scrub.ts +116 -0
- package/src/ai/signal.ts +81 -0
- package/src/ai/version.ts +15 -0
- package/src/canonical-vocab/resolve-by-html-element.ts +72 -0
- package/src/combiner/__tests__/band.test.ts +155 -0
- package/src/combiner/__tests__/group.test.ts +85 -0
- package/src/combiner/__tests__/rank.test.ts +54 -0
- package/src/combiner/band.ts +85 -0
- package/src/combiner/group.ts +62 -0
- package/src/combiner/rank.ts +57 -0
- package/src/combiner.ts +124 -0
- package/src/index.ts +76 -0
- package/src/signals/__tests__/aria-role.test.ts +53 -0
- package/src/signals/__tests__/barrel-export.test.ts +29 -0
- package/src/signals/__tests__/html-root.test.ts +55 -0
- package/src/signals/__tests__/input-type.test.ts +58 -0
- package/src/signals/__tests__/library-reexport.test.ts +68 -0
- package/src/signals/__tests__/name-match.test.ts +43 -0
- package/src/signals/__tests__/path-hint.test.ts +55 -0
- package/src/signals/__tests__/prop-fingerprint.test.ts +105 -0
- package/src/signals/__tests__/registry.test.ts +27 -0
- package/src/signals/aria-role.ts +94 -0
- package/src/signals/barrel-export.ts +28 -0
- package/src/signals/html-root.ts +85 -0
- package/src/signals/index.ts +39 -0
- package/src/signals/input-type.ts +63 -0
- package/src/signals/library-reexport.ts +70 -0
- package/src/signals/name-match.ts +92 -0
- package/src/signals/path-hint.ts +94 -0
- package/src/signals/prop-fingerprint.ts +121 -0
- package/src/types.ts +58 -0
- package/src/vocabulary/canonicals.ts +106 -0
- package/src/vocabulary/library-map.ts +301 -0
- package/src/vocabulary/prop-fingerprints.ts +433 -0
- package/src/vocabulary/synonyms.ts +130 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { HEURISTIC_SIGNALS } from '../index.js';
|
|
4
|
+
import { makeUcf } from '../../__tests__/fixtures.js';
|
|
5
|
+
|
|
6
|
+
describe('HEURISTIC_SIGNALS registry', () => {
|
|
7
|
+
it('exposes exactly the 8 heuristic signal types', () => {
|
|
8
|
+
expect(Object.keys(HEURISTIC_SIGNALS).sort()).toEqual([
|
|
9
|
+
'ARIA_ROLE',
|
|
10
|
+
'BARREL_EXPORT',
|
|
11
|
+
'HTML_ROOT',
|
|
12
|
+
'INPUT_TYPE',
|
|
13
|
+
'LIBRARY_REEXPORT',
|
|
14
|
+
'NAME_MATCH',
|
|
15
|
+
'PATH_HINT',
|
|
16
|
+
'PROP_FINGERPRINT',
|
|
17
|
+
]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('every extractor returns [] (not null) on an inert UCF', () => {
|
|
21
|
+
const inert = makeUcf({ componentName: '', filePath: '' });
|
|
22
|
+
for (const [type, extractor] of Object.entries(HEURISTIC_SIGNALS)) {
|
|
23
|
+
const result = extractor(inert);
|
|
24
|
+
expect(Array.isArray(result), `${type} returned non-array`).toBe(true);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// ARIA_ROLE — `01-architecture.md` §9.3.
|
|
2
|
+
//
|
|
3
|
+
// High-precision signal (weight 0.45). Native HTML semantics + explicit
|
|
4
|
+
// `role=` attributes both surface here via UCF `ariaRoles`.
|
|
5
|
+
|
|
6
|
+
import type { UniversalComponentFact } from '@fragments-sdk/extract';
|
|
7
|
+
|
|
8
|
+
import { canonicalId, type CanonicalId, type SignalExtractor, type SignalRecord } from '../types.js';
|
|
9
|
+
|
|
10
|
+
const WEIGHT = 0.45;
|
|
11
|
+
const TOAST_ALERT_WEIGHT = 0.3;
|
|
12
|
+
const c = canonicalId;
|
|
13
|
+
|
|
14
|
+
interface AriaMapping {
|
|
15
|
+
canonicals: ReadonlyArray<CanonicalId>;
|
|
16
|
+
// §9.3: `button` only fires if HTML_ROOT was not already `button` —
|
|
17
|
+
// avoid double-counting. We approximate by suppressing when the UCF's
|
|
18
|
+
// root tag is `button`, since HTML_ROOT and ARIA_ROLE see the same UCF.
|
|
19
|
+
suppressIfRootTag?: string;
|
|
20
|
+
// `listbox` → Select OR MultiSelect, disambiguated by aria-multiselectable.
|
|
21
|
+
multiselectableSplit?: boolean;
|
|
22
|
+
// Lower-precision mapping (e.g. `status` → Toast).
|
|
23
|
+
weight?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const ROLE_MAPPING: ReadonlyMap<string, AriaMapping> = new Map([
|
|
27
|
+
['button', { canonicals: [c('Button')], suppressIfRootTag: 'button' }],
|
|
28
|
+
['dialog', { canonicals: [c('Dialog')] }],
|
|
29
|
+
['alertdialog', { canonicals: [c('AlertDialog')] }],
|
|
30
|
+
['tooltip', { canonicals: [c('Tooltip')] }],
|
|
31
|
+
['tab', { canonicals: [c('Tabs')] }],
|
|
32
|
+
['tablist', { canonicals: [c('Tabs')] }],
|
|
33
|
+
['menu', { canonicals: [c('Menu')] }],
|
|
34
|
+
['menuitem', { canonicals: [c('Menu')] }],
|
|
35
|
+
['menubar', { canonicals: [c('MenuBar')] }],
|
|
36
|
+
['combobox', { canonicals: [c('Combobox')] }],
|
|
37
|
+
['listbox', { canonicals: [c('Select'), c('MultiSelect')], multiselectableSplit: true }],
|
|
38
|
+
['slider', { canonicals: [c('Slider')] }],
|
|
39
|
+
['switch', { canonicals: [c('Switch')] }],
|
|
40
|
+
['progressbar', { canonicals: [c('Progress')] }],
|
|
41
|
+
['alert', { canonicals: [c('Alert')] }],
|
|
42
|
+
['status', { canonicals: [c('Toast'), c('Alert')], weight: TOAST_ALERT_WEIGHT }],
|
|
43
|
+
['radio', { canonicals: [c('Radio')] }],
|
|
44
|
+
['radiogroup', { canonicals: [c('RadioGroup')] }],
|
|
45
|
+
['checkbox', { canonicals: [c('Checkbox')] }],
|
|
46
|
+
['textbox', { canonicals: [c('Input')] }],
|
|
47
|
+
['spinbutton', { canonicals: [c('NumberInput')] }],
|
|
48
|
+
['separator', { canonicals: [c('Separator')] }],
|
|
49
|
+
['tree', { canonicals: [c('Tree')] }],
|
|
50
|
+
['table', { canonicals: [c('Table')] }],
|
|
51
|
+
['grid', { canonicals: [c('DataTable')] }],
|
|
52
|
+
['list', { canonicals: [c('List')] }],
|
|
53
|
+
['breadcrumb', { canonicals: [c('Breadcrumb')] }],
|
|
54
|
+
['navigation', { canonicals: [c('NavigationMenu'), c('Breadcrumb'), c('Pagination')] }],
|
|
55
|
+
['form', { canonicals: [c('Form')] }],
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
function rootHasTag(ucf: UniversalComponentFact, tag: string): boolean {
|
|
59
|
+
return ucf.rootElements.some((root) => root.tag.toLowerCase() === tag);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const ariaRole: SignalExtractor = (
|
|
63
|
+
ucf: UniversalComponentFact,
|
|
64
|
+
): SignalRecord[] => {
|
|
65
|
+
if (ucf.ariaRoles.length === 0) return [];
|
|
66
|
+
const out: SignalRecord[] = [];
|
|
67
|
+
const seen = new Set<CanonicalId>();
|
|
68
|
+
const isMultiSelectable = ucf.ariaAttributes['aria-multiselectable'] === 'true' || ucf.ariaAttributes['aria-multiselectable'] === true;
|
|
69
|
+
|
|
70
|
+
for (const role of ucf.ariaRoles) {
|
|
71
|
+
const mapping = ROLE_MAPPING.get(role.toLowerCase());
|
|
72
|
+
if (!mapping) continue;
|
|
73
|
+
if (mapping.suppressIfRootTag && rootHasTag(ucf, mapping.suppressIfRootTag)) continue;
|
|
74
|
+
for (const canonical of mapping.canonicals) {
|
|
75
|
+
// Listbox disambiguation: aria-multiselectable=true → MultiSelect only;
|
|
76
|
+
// false/absent → Select only.
|
|
77
|
+
if (mapping.multiselectableSplit) {
|
|
78
|
+
if (isMultiSelectable && canonical === c('Select')) continue;
|
|
79
|
+
if (!isMultiSelectable && canonical === c('MultiSelect')) continue;
|
|
80
|
+
}
|
|
81
|
+
if (seen.has(canonical)) continue;
|
|
82
|
+
seen.add(canonical);
|
|
83
|
+
out.push({
|
|
84
|
+
type: 'ARIA_ROLE',
|
|
85
|
+
canonical,
|
|
86
|
+
weight: mapping.weight ?? WEIGHT,
|
|
87
|
+
evidence: { role },
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export default ariaRole;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// BARREL_EXPORT — `01-architecture.md` §9.8.
|
|
2
|
+
//
|
|
3
|
+
// Weak signal (weight 0.08). Fires when the UCF reports `exportedFromBarrel:
|
|
4
|
+
// true` AND the component is a primitive name (i.e. a synonym match exists).
|
|
5
|
+
// Like NAME_MATCH and PATH_HINT, never load-bearing alone.
|
|
6
|
+
|
|
7
|
+
import type { UniversalComponentFact } from '@fragments-sdk/extract';
|
|
8
|
+
|
|
9
|
+
import type { SignalExtractor, SignalRecord } from '../types.js';
|
|
10
|
+
import { lookupSynonym } from '../vocabulary/synonyms.js';
|
|
11
|
+
|
|
12
|
+
const WEIGHT = 0.08;
|
|
13
|
+
|
|
14
|
+
const barrelExport: SignalExtractor = (
|
|
15
|
+
ucf: UniversalComponentFact,
|
|
16
|
+
): SignalRecord[] => {
|
|
17
|
+
if (!ucf.exportedFromBarrel) return [];
|
|
18
|
+
const canonicals = lookupSynonym(ucf.componentName);
|
|
19
|
+
if (canonicals.length === 0) return [];
|
|
20
|
+
return canonicals.map((canonical) => ({
|
|
21
|
+
type: 'BARREL_EXPORT',
|
|
22
|
+
canonical,
|
|
23
|
+
weight: WEIGHT,
|
|
24
|
+
evidence: { componentName: ucf.componentName },
|
|
25
|
+
}));
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export default barrelExport;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// HTML_ROOT — `01-architecture.md` §9.2.
|
|
2
|
+
//
|
|
3
|
+
// High-precision signal (weight 0.45). Maps native HTML root tags to their
|
|
4
|
+
// canonical primitive. INPUT_TYPE handles `<input>` specifically; this signal
|
|
5
|
+
// emits HTMLInputElement only when no `inputType` is recorded.
|
|
6
|
+
|
|
7
|
+
import type { RootElementFact, UniversalComponentFact } from '@fragments-sdk/extract';
|
|
8
|
+
|
|
9
|
+
import { canonicalId, type CanonicalId, type SignalExtractor, type SignalRecord } from '../types.js';
|
|
10
|
+
|
|
11
|
+
const WEIGHT = 0.45;
|
|
12
|
+
const c = canonicalId;
|
|
13
|
+
|
|
14
|
+
// Strong → single canonical hypothesis. Ambiguous → multiple hypotheses, each
|
|
15
|
+
// emitted as a separate record (the combiner resolves disagreement per §10.2).
|
|
16
|
+
const TAG_MAPPING: ReadonlyMap<string, ReadonlyArray<CanonicalId>> = new Map([
|
|
17
|
+
['button', [c('Button')]],
|
|
18
|
+
['textarea', [c('Textarea')]],
|
|
19
|
+
['select', [c('Select')]],
|
|
20
|
+
['dialog', [c('Dialog')]],
|
|
21
|
+
['details', [c('Disclosure')]],
|
|
22
|
+
['progress', [c('Progress')]],
|
|
23
|
+
['hr', [c('Separator')]],
|
|
24
|
+
['table', [c('Table'), c('DataTable')]],
|
|
25
|
+
['nav', [c('Breadcrumb'), c('Pagination'), c('NavigationMenu')]],
|
|
26
|
+
// ul / ol are intentionally excluded — too general (§9.2 final note); the
|
|
27
|
+
// NAME_MATCH signal must agree before List can fire.
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
function tagFor(root: RootElementFact): string | undefined {
|
|
31
|
+
if (root.tag.startsWith('Custom:')) return undefined;
|
|
32
|
+
// INPUT_TYPE owns the `input` tag entirely.
|
|
33
|
+
if (root.tag === 'input') return undefined;
|
|
34
|
+
return root.tag.toLowerCase();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const htmlRoot: SignalExtractor = (
|
|
38
|
+
ucf: UniversalComponentFact,
|
|
39
|
+
): SignalRecord[] => {
|
|
40
|
+
if (ucf.rootElements.length === 0) return [];
|
|
41
|
+
|
|
42
|
+
// Per §9.2 conditional-roots: if all branches map to the same canonical
|
|
43
|
+
// → full weight. If branches disagree → emit per-branch with confidence
|
|
44
|
+
// reduced by 1/branches (still per-branch, so one record per branch).
|
|
45
|
+
const branches = ucf.rootElements;
|
|
46
|
+
const tally = new Map<CanonicalId, number>();
|
|
47
|
+
|
|
48
|
+
for (const root of branches) {
|
|
49
|
+
const tag = tagFor(root);
|
|
50
|
+
if (!tag) continue;
|
|
51
|
+
const canonicals = TAG_MAPPING.get(tag);
|
|
52
|
+
if (!canonicals) continue;
|
|
53
|
+
for (const canon of canonicals) {
|
|
54
|
+
tally.set(canon, (tally.get(canon) ?? 0) + 1);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (tally.size === 0) return [];
|
|
59
|
+
|
|
60
|
+
// Divergence factor: when branches disagree, scale the weight per §9.2.
|
|
61
|
+
// We approximate by `presentBranches / totalBranches` for each canonical.
|
|
62
|
+
const totalBranches = branches.length;
|
|
63
|
+
const out: SignalRecord[] = [];
|
|
64
|
+
for (const [canonical, hitBranches] of tally.entries()) {
|
|
65
|
+
const divergenceFactor = totalBranches > 0 ? hitBranches / totalBranches : 1;
|
|
66
|
+
const adjusted = WEIGHT * divergenceFactor;
|
|
67
|
+
out.push({
|
|
68
|
+
type: 'HTML_ROOT',
|
|
69
|
+
canonical,
|
|
70
|
+
weight: adjusted,
|
|
71
|
+
evidence: {
|
|
72
|
+
tag: branches.find((root) => {
|
|
73
|
+
const tag = tagFor(root);
|
|
74
|
+
if (!tag) return false;
|
|
75
|
+
return TAG_MAPPING.get(tag)?.includes(canonical);
|
|
76
|
+
})?.tag,
|
|
77
|
+
hitBranches,
|
|
78
|
+
totalBranches,
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export default htmlRoot;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Heuristic signal registry — `01-architecture.md` §11 Stage 3.
|
|
2
|
+
//
|
|
3
|
+
// The combiner (brief 04) iterates this map in parallel for each UCF. Adding
|
|
4
|
+
// a signal is one entry in this record. The AI_SEMANTIC slot is reserved for
|
|
5
|
+
// brief 07 — it is not part of the heuristic registry and the combiner pulls
|
|
6
|
+
// it from a separate channel.
|
|
7
|
+
|
|
8
|
+
import type { SignalRegistry } from '../types.js';
|
|
9
|
+
|
|
10
|
+
import ariaRole from './aria-role.js';
|
|
11
|
+
import barrelExport from './barrel-export.js';
|
|
12
|
+
import htmlRoot from './html-root.js';
|
|
13
|
+
import inputType from './input-type.js';
|
|
14
|
+
import libraryReexport from './library-reexport.js';
|
|
15
|
+
import nameMatch from './name-match.js';
|
|
16
|
+
import pathHint from './path-hint.js';
|
|
17
|
+
import propFingerprint from './prop-fingerprint.js';
|
|
18
|
+
|
|
19
|
+
export const HEURISTIC_SIGNALS: SignalRegistry = {
|
|
20
|
+
LIBRARY_REEXPORT: libraryReexport,
|
|
21
|
+
HTML_ROOT: htmlRoot,
|
|
22
|
+
ARIA_ROLE: ariaRole,
|
|
23
|
+
INPUT_TYPE: inputType,
|
|
24
|
+
PROP_FINGERPRINT: propFingerprint,
|
|
25
|
+
NAME_MATCH: nameMatch,
|
|
26
|
+
PATH_HINT: pathHint,
|
|
27
|
+
BARREL_EXPORT: barrelExport,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export {
|
|
31
|
+
ariaRole,
|
|
32
|
+
barrelExport,
|
|
33
|
+
htmlRoot,
|
|
34
|
+
inputType,
|
|
35
|
+
libraryReexport,
|
|
36
|
+
nameMatch,
|
|
37
|
+
pathHint,
|
|
38
|
+
propFingerprint,
|
|
39
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// INPUT_TYPE — `01-architecture.md` §9.4.
|
|
2
|
+
//
|
|
3
|
+
// Highest-precision signal (weight 0.6). `<input type="X">` is unambiguous.
|
|
4
|
+
// Suppressed only when `role="switch"` is present (then upgrades to Switch).
|
|
5
|
+
|
|
6
|
+
import type { UniversalComponentFact } from '@fragments-sdk/extract';
|
|
7
|
+
|
|
8
|
+
import { canonicalId, type CanonicalId, type SignalExtractor, type SignalRecord } from '../types.js';
|
|
9
|
+
|
|
10
|
+
const WEIGHT = 0.6;
|
|
11
|
+
const c = canonicalId;
|
|
12
|
+
|
|
13
|
+
const INPUT_TYPE_MAPPING: ReadonlyMap<string, CanonicalId> = new Map([
|
|
14
|
+
['text', c('Input')],
|
|
15
|
+
['email', c('Input')],
|
|
16
|
+
['url', c('Input')],
|
|
17
|
+
['tel', c('Input')],
|
|
18
|
+
['search', c('Input')],
|
|
19
|
+
['password', c('PasswordInput')],
|
|
20
|
+
['number', c('NumberInput')],
|
|
21
|
+
['checkbox', c('Checkbox')],
|
|
22
|
+
['radio', c('Radio')],
|
|
23
|
+
['date', c('DatePicker')],
|
|
24
|
+
['time', c('TimePicker')],
|
|
25
|
+
['datetime-local', c('DatePicker')],
|
|
26
|
+
['range', c('Slider')],
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const inputType: SignalExtractor = (
|
|
30
|
+
ucf: UniversalComponentFact,
|
|
31
|
+
): SignalRecord[] => {
|
|
32
|
+
for (const root of ucf.rootElements) {
|
|
33
|
+
if (root.tag !== 'input') continue;
|
|
34
|
+
const inputTypeValue = root.inputType;
|
|
35
|
+
if (!inputTypeValue) continue;
|
|
36
|
+
const canonical = INPUT_TYPE_MAPPING.get(inputTypeValue.toLowerCase());
|
|
37
|
+
if (!canonical) continue;
|
|
38
|
+
|
|
39
|
+
// §9.4 suppression: `role="switch"` on a checkbox upgrades to Switch.
|
|
40
|
+
if (canonical === c('Checkbox') && ucf.ariaRoles.includes('switch')) {
|
|
41
|
+
return [
|
|
42
|
+
{
|
|
43
|
+
type: 'INPUT_TYPE',
|
|
44
|
+
canonical: c('Switch'),
|
|
45
|
+
weight: WEIGHT,
|
|
46
|
+
evidence: { inputType: inputTypeValue, role: 'switch' },
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return [
|
|
52
|
+
{
|
|
53
|
+
type: 'INPUT_TYPE',
|
|
54
|
+
canonical,
|
|
55
|
+
weight: WEIGHT,
|
|
56
|
+
evidence: { inputType: inputTypeValue },
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
return [];
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export default inputType;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// LIBRARY_REEXPORT — `01-architecture.md` §9.1.
|
|
2
|
+
//
|
|
3
|
+
// High-precision signal (weight 0.55). Fires when a component imports a known
|
|
4
|
+
// primitive from a recognised library AND uses the local binding as a JSX root.
|
|
5
|
+
// False-positive guards:
|
|
6
|
+
// 1. Importing without using the binding as a root → suppress.
|
|
7
|
+
// 2. Sub-primitive policy: only root-aligned wrappers fire (the parent's
|
|
8
|
+
// compound-children classification handles e.g. `Dialog.Header`).
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
ImportRecord,
|
|
12
|
+
RootElementFact,
|
|
13
|
+
UniversalComponentFact,
|
|
14
|
+
} from '@fragments-sdk/extract';
|
|
15
|
+
|
|
16
|
+
import type { CanonicalId, SignalExtractor, SignalRecord } from '../types.js';
|
|
17
|
+
import { lookupLibraryImport } from '../vocabulary/library-map.js';
|
|
18
|
+
|
|
19
|
+
const WEIGHT = 0.55;
|
|
20
|
+
|
|
21
|
+
function rootUsesBinding(
|
|
22
|
+
rootElements: ReadonlyArray<RootElementFact>,
|
|
23
|
+
localBinding: string,
|
|
24
|
+
): boolean {
|
|
25
|
+
// `Custom:Foo` for component-typed roots (per UCF schema §7.1). Bare-tag
|
|
26
|
+
// roots like 'div' never match.
|
|
27
|
+
return rootElements.some((root) => root.tag === `Custom:${localBinding}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function packageMatch(record: ImportRecord): string | undefined {
|
|
31
|
+
// Prefer resolvedPackage (handles monorepo aliases) but fall back to the
|
|
32
|
+
// module specifier — extractors may not always populate the resolved field.
|
|
33
|
+
return record.resolvedPackage ?? record.moduleSpecifier;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const libraryReexport: SignalExtractor = (
|
|
37
|
+
ucf: UniversalComponentFact,
|
|
38
|
+
): SignalRecord[] => {
|
|
39
|
+
const out: SignalRecord[] = [];
|
|
40
|
+
// De-dupe: if two imports point at the same canonical, emit once. The
|
|
41
|
+
// strict-signal-count rule (§10.3) treats them as one signal regardless.
|
|
42
|
+
const seen = new Set<CanonicalId>();
|
|
43
|
+
|
|
44
|
+
for (const record of ucf.imports) {
|
|
45
|
+
const pkg = packageMatch(record);
|
|
46
|
+
if (!pkg) continue;
|
|
47
|
+
for (const { imported, local } of record.importedNames) {
|
|
48
|
+
const canonical = lookupLibraryImport(pkg, imported);
|
|
49
|
+
if (!canonical) continue;
|
|
50
|
+
// False-positive guard: must be used as a JSX root.
|
|
51
|
+
if (!rootUsesBinding(ucf.rootElements, local)) continue;
|
|
52
|
+
if (seen.has(canonical)) continue;
|
|
53
|
+
seen.add(canonical);
|
|
54
|
+
out.push({
|
|
55
|
+
type: 'LIBRARY_REEXPORT',
|
|
56
|
+
canonical,
|
|
57
|
+
weight: WEIGHT,
|
|
58
|
+
evidence: {
|
|
59
|
+
package: pkg,
|
|
60
|
+
importedName: imported,
|
|
61
|
+
localBinding: local,
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return out;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export default libraryReexport;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// NAME_MATCH — `01-architecture.md` §9.6.
|
|
2
|
+
//
|
|
3
|
+
// Weak signal (weight 0.15). NEVER load-bearing alone — must combine with at
|
|
4
|
+
// least one structural signal (HTML_ROOT / ARIA_ROLE / LIBRARY_REEXPORT /
|
|
5
|
+
// PROP_FINGERPRINT) before reaching `auto`. The 0.15 weight already enforces
|
|
6
|
+
// that via §10.3 strict signal-count rule (NAME_MATCH is not "strong").
|
|
7
|
+
|
|
8
|
+
import type { UniversalComponentFact } from '@fragments-sdk/extract';
|
|
9
|
+
|
|
10
|
+
import type { CanonicalId, SignalExtractor, SignalRecord } from '../types.js';
|
|
11
|
+
import {
|
|
12
|
+
NAME_STRIP_PREFIXES,
|
|
13
|
+
NAME_STRIP_SUFFIXES,
|
|
14
|
+
NAME_VARIANT_SUFFIXES,
|
|
15
|
+
lookupSynonym,
|
|
16
|
+
} from '../vocabulary/synonyms.js';
|
|
17
|
+
|
|
18
|
+
const WEIGHT = 0.15;
|
|
19
|
+
|
|
20
|
+
function stripPrefix(name: string): string {
|
|
21
|
+
for (const prefix of NAME_STRIP_PREFIXES) {
|
|
22
|
+
if (name.startsWith(prefix) && name.length > prefix.length) {
|
|
23
|
+
return name.slice(prefix.length);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return name;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function stripSuffix(name: string): string {
|
|
30
|
+
for (const suffix of NAME_STRIP_SUFFIXES) {
|
|
31
|
+
if (name.endsWith(suffix) && name.length > suffix.length) {
|
|
32
|
+
return name.slice(0, -suffix.length);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return name;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function stripVariantIfCanonical(name: string): string {
|
|
39
|
+
for (const variant of NAME_VARIANT_SUFFIXES) {
|
|
40
|
+
if (name.endsWith(variant) && name.length > variant.length) {
|
|
41
|
+
const remainder = name.slice(0, -variant.length);
|
|
42
|
+
if (lookupSynonym(remainder).length > 0) return remainder;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return name;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function stripVariantIfPrefixCanonical(name: string): string {
|
|
49
|
+
for (const variant of NAME_VARIANT_SUFFIXES) {
|
|
50
|
+
if (name.startsWith(variant) && name.length > variant.length) {
|
|
51
|
+
const remainder = name.slice(variant.length);
|
|
52
|
+
if (lookupSynonym(remainder).length > 0) return remainder;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return name;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const nameMatch: SignalExtractor = (
|
|
59
|
+
ucf: UniversalComponentFact,
|
|
60
|
+
): SignalRecord[] => {
|
|
61
|
+
// Try the component name AND the innermost wrapped target — `forwardRef`'d
|
|
62
|
+
// components are often named at the wrapper level too. We attempt both.
|
|
63
|
+
const candidates = new Set<string>();
|
|
64
|
+
candidates.add(ucf.componentName);
|
|
65
|
+
for (const wrapper of ucf.wrappedBy) candidates.add(wrapper);
|
|
66
|
+
|
|
67
|
+
const out: SignalRecord[] = [];
|
|
68
|
+
const seen = new Set<CanonicalId>();
|
|
69
|
+
|
|
70
|
+
for (const raw of candidates) {
|
|
71
|
+
if (!raw) continue;
|
|
72
|
+
let stripped = stripPrefix(raw);
|
|
73
|
+
stripped = stripSuffix(stripped);
|
|
74
|
+
stripped = stripVariantIfCanonical(stripped);
|
|
75
|
+
stripped = stripVariantIfPrefixCanonical(stripped);
|
|
76
|
+
|
|
77
|
+
for (const canonical of lookupSynonym(stripped)) {
|
|
78
|
+
if (seen.has(canonical)) continue;
|
|
79
|
+
seen.add(canonical);
|
|
80
|
+
out.push({
|
|
81
|
+
type: 'NAME_MATCH',
|
|
82
|
+
canonical,
|
|
83
|
+
weight: WEIGHT,
|
|
84
|
+
evidence: { componentName: raw, stripped },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return out;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export default nameMatch;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// PATH_HINT — `01-architecture.md` §9.7.
|
|
2
|
+
//
|
|
3
|
+
// Weak signal (weight 0.10). Fires when the file path has a directory segment
|
|
4
|
+
// suggesting "primitive" intent. Never load-bearing alone (same caveat as
|
|
5
|
+
// NAME_MATCH).
|
|
6
|
+
|
|
7
|
+
import type { UniversalComponentFact } from '@fragments-sdk/extract';
|
|
8
|
+
|
|
9
|
+
import type { SignalExtractor, SignalRecord } from '../types.js';
|
|
10
|
+
import { lookupSynonym } from '../vocabulary/synonyms.js';
|
|
11
|
+
|
|
12
|
+
const WEIGHT = 0.1;
|
|
13
|
+
|
|
14
|
+
const PRIMITIVE_DIR_HINTS: ReadonlyArray<string> = [
|
|
15
|
+
'primitives',
|
|
16
|
+
'atoms',
|
|
17
|
+
'base',
|
|
18
|
+
'core',
|
|
19
|
+
'ui',
|
|
20
|
+
'elements',
|
|
21
|
+
'foundations',
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
// `components/ui` is treated as a single hint (§9.7).
|
|
25
|
+
const COMPOSED_HINTS: ReadonlyArray<RegExp> = [
|
|
26
|
+
/[\\/]components[\\/]ui[\\/]/i,
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
function pathSegments(filePath: string): ReadonlyArray<string> {
|
|
30
|
+
return filePath.toLowerCase().split(/[\\/]+/).filter((s) => s.length > 0);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function hasPrimitiveDirHint(filePath: string): string | undefined {
|
|
34
|
+
const lower = filePath.toLowerCase();
|
|
35
|
+
for (const composed of COMPOSED_HINTS) {
|
|
36
|
+
if (composed.test(filePath)) return composed.source;
|
|
37
|
+
}
|
|
38
|
+
const segments = pathSegments(lower);
|
|
39
|
+
for (const segment of segments) {
|
|
40
|
+
if (PRIMITIVE_DIR_HINTS.includes(segment)) return segment;
|
|
41
|
+
}
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Path hint can suggest a specific canonical when the basename matches one
|
|
46
|
+
// (e.g. `src/components/ui/button.tsx` → Button). We deduplicate against the
|
|
47
|
+
// vocab index. If no specific canonical is implicated, we emit nothing —
|
|
48
|
+
// PATH_HINT alone is too weak to be useful without a target hypothesis.
|
|
49
|
+
function basenameCanonicals(filePath: string) {
|
|
50
|
+
const segments = pathSegments(filePath);
|
|
51
|
+
const basename = segments[segments.length - 1] ?? '';
|
|
52
|
+
// Strip common extensions.
|
|
53
|
+
const stem = basename.replace(/\.(tsx?|jsx?|vue|svelte)$/i, '');
|
|
54
|
+
// dasherized → PascalCase candidate.
|
|
55
|
+
const pascal = stem
|
|
56
|
+
.split(/[-_]/)
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.map((word) => word[0].toUpperCase() + word.slice(1))
|
|
59
|
+
.join('');
|
|
60
|
+
if (!pascal) return [];
|
|
61
|
+
return lookupSynonym(pascal).map((canonical) => ({ canonical, stem }));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const pathHint: SignalExtractor = (
|
|
65
|
+
ucf: UniversalComponentFact,
|
|
66
|
+
): SignalRecord[] => {
|
|
67
|
+
const dirHint = hasPrimitiveDirHint(ucf.filePath);
|
|
68
|
+
if (!dirHint) return [];
|
|
69
|
+
|
|
70
|
+
const candidates = basenameCanonicals(ucf.filePath);
|
|
71
|
+
|
|
72
|
+
// No specific canonical from basename → emit a generic hint per known
|
|
73
|
+
// canonical that matches the component name (so the signal reinforces an
|
|
74
|
+
// existing hypothesis rather than nominating arbitrary canonicals).
|
|
75
|
+
if (candidates.length === 0) {
|
|
76
|
+
const componentNameLookup = lookupSynonym(ucf.componentName);
|
|
77
|
+
if (componentNameLookup.length === 0) return [];
|
|
78
|
+
return componentNameLookup.map((canonical) => ({
|
|
79
|
+
type: 'PATH_HINT',
|
|
80
|
+
canonical,
|
|
81
|
+
weight: WEIGHT,
|
|
82
|
+
evidence: { dirHint, componentName: ucf.componentName },
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return candidates.map(({ canonical, stem }) => ({
|
|
87
|
+
type: 'PATH_HINT',
|
|
88
|
+
canonical,
|
|
89
|
+
weight: WEIGHT,
|
|
90
|
+
evidence: { dirHint, basename: stem },
|
|
91
|
+
}));
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export default pathHint;
|