@dcoder-x/plugin-shared 0.1.4 → 0.1.6

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.
@@ -10,6 +10,8 @@ const parser_1 = require("@babel/parser");
10
10
  const traverse_1 = __importDefault(require("@babel/traverse"));
11
11
  const InteractionGraphExtractor_1 = require("./InteractionGraphExtractor");
12
12
  const ComponentContextResolver_1 = require("./ComponentContextResolver");
13
+ const HtmlTagGuards_1 = require("../injection/HtmlTagGuards");
14
+ const IdStrategy_1 = require("../injection/IdStrategy");
13
15
  class ComponentExtractor {
14
16
  constructor(source, routes) {
15
17
  this.source = source;
@@ -257,21 +259,32 @@ class ComponentExtractor {
257
259
  const tagName = nodePath.node.name?.name;
258
260
  if (!tagName)
259
261
  return;
260
- const isInteractive = ['button', 'input', 'select', 'textarea', 'a', 'form'].includes(tagName) ||
261
- nodePath.node.attributes.some((attr) => attr.type === 'JSXAttribute' && attr.name?.name === 'onClick');
262
- if (!isInteractive)
262
+ // Navigation components (Link, NavLink) are included for flow graph building
263
+ // but flagged so the selector manifest skips them
264
+ const isNavigationComponent = (tagName === 'Link' || tagName === 'NavLink' || tagName === 'RouterLink') &&
265
+ nodePath.node.attributes.some((a) => a.name?.name === 'href' || a.name?.name === 'to');
266
+ // Only intrinsic HTML tags get data-clippy-id injected; skip React components
267
+ if (!(0, HtmlTagGuards_1.isIntrinsicTag)(tagName) && !isNavigationComponent)
268
+ return;
269
+ const INTERACTIVE_TAGS = new Set(['button', 'input', 'select', 'textarea', 'a', 'form', 'label']);
270
+ if (!isNavigationComponent && !INTERACTIVE_TAGS.has(tagName))
263
271
  return;
264
272
  const staticProps = extractStaticProps(nodePath.node.attributes);
265
273
  const nearbyText = extractNearbyText(nodePath);
274
+ const label = deriveLabel(tagName, staticProps, nearbyText) ?? undefined;
275
+ const componentName = (0, IdStrategy_1.findEnclosingComponentName)(nodePath) ?? undefined;
266
276
  elements.push({
267
277
  tag: tagName,
268
278
  filePath,
269
279
  route,
270
280
  staticProps,
271
281
  nearbyText,
282
+ label,
283
+ component: componentName,
272
284
  semanticRole: inferSemanticRole(tagName, staticProps, nearbyText),
273
285
  interactions: inferInteractions(tagName, staticProps),
274
286
  loc: { line: nodePath.node.loc?.start?.line ?? 0 },
287
+ isNavigationLink: isNavigationComponent,
275
288
  });
276
289
  },
277
290
  });
@@ -331,18 +344,64 @@ function extractStaticProps(attributes) {
331
344
  return props;
332
345
  }
333
346
  function extractNearbyText(nodePath) {
334
- const texts = [];
335
347
  const parent = nodePath.parentPath?.node;
336
- if (parent?.children) {
337
- for (const child of parent.children) {
338
- if (child.type === 'JSXText') {
339
- const text = child.value.trim();
340
- if (text)
341
- texts.push(text);
348
+ if (!parent?.children)
349
+ return [];
350
+ return extractTextFromChildren(parent.children, 0);
351
+ }
352
+ function extractTextFromChildren(children, depth) {
353
+ if (depth > 3)
354
+ return [];
355
+ const texts = [];
356
+ for (const child of children) {
357
+ if (child.type === 'JSXText') {
358
+ const t = child.value.trim();
359
+ if (t)
360
+ texts.push(t);
361
+ }
362
+ else if (child.type === 'JSXExpressionContainer') {
363
+ if (child.expression?.type === 'StringLiteral') {
364
+ texts.push(child.expression.value);
365
+ }
366
+ else if (child.expression?.type === 'TemplateLiteral' &&
367
+ child.expression.quasis?.length === 1) {
368
+ const raw = child.expression.quasis[0].value.raw.trim();
369
+ if (raw)
370
+ texts.push(raw);
371
+ }
372
+ }
373
+ else if (child.type === 'JSXElement') {
374
+ const childTag = child.openingElement?.name?.name || '';
375
+ // Skip icon-like leaf components — they contribute no readable text
376
+ if (!/^(Icon|Svg|Loader|Spinner|Arrow|Check|Plus|Minus|Close|X)[A-Z]?/i.test(childTag)) {
377
+ texts.push(...extractTextFromChildren(child.children || [], depth + 1));
342
378
  }
343
379
  }
344
380
  }
345
- return texts;
381
+ return texts.filter(Boolean);
382
+ }
383
+ function deriveLabel(tag, props, nearbyText) {
384
+ // Forms: child text is a concatenation of every field label + button inside the form.
385
+ // That is not a useful description. Use aria-label if present, otherwise take only
386
+ // the last short text node (typically the submit button label).
387
+ if (tag === 'form') {
388
+ if (props['aria-label'])
389
+ return props['aria-label'];
390
+ const lastShort = [...nearbyText].reverse().find((t) => t.length >= 2 && t.length <= 30);
391
+ return lastShort || null;
392
+ }
393
+ if (props['aria-label'])
394
+ return props['aria-label'];
395
+ if (props['title'])
396
+ return props['title'];
397
+ const joined = nearbyText.join(' ').trim();
398
+ if (joined && joined.length <= 60)
399
+ return joined;
400
+ if (props['placeholder'])
401
+ return props['placeholder'];
402
+ if (props['name'])
403
+ return props['name'];
404
+ return null;
346
405
  }
347
406
  function inferSemanticRole(tag, props, nearbyText) {
348
407
  const label = props['aria-label'] || props['placeholder'] || nearbyText[0] || '';
@@ -7,6 +7,25 @@ export declare class FlowInferrer {
7
7
  infer(): InferredFlow[];
8
8
  private buildEdgeGraph;
9
9
  private detectLinearFlows;
10
+ /**
11
+ * Fix 3b: A coherent chain stays within the same top-level route domain.
12
+ * A chain that jumps from /auth/* to / or from /dashboard/* to /auth/signup
13
+ * is an artifact of footer/layout navigation links, not a real user flow.
14
+ */
15
+ private isCoherentChain;
10
16
  private detectInteractionFlows;
11
17
  private buildChain;
18
+ private generateIntentPatterns;
19
+ /**
20
+ * Returns true if a string is a usable user intent phrase.
21
+ * Rejects blocklisted words, PascalCase component names, very short strings,
22
+ * and URL-like strings.
23
+ */
24
+ private isValidIntentPattern;
25
+ /**
26
+ * Fix 4: Remove intent patterns that appear across more than 30% of all flows.
27
+ * These are structural navigation phrases (sidebar, footer links) that ended up
28
+ * attached to many unrelated flows rather than genuine user intents.
29
+ */
30
+ private deduplicateCrossFlowNoise;
12
31
  }
@@ -1,6 +1,41 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.FlowInferrer = void 0;
4
+ const SEMANTIC_INTENT_PATTERNS = {
5
+ 'login': ['log in', 'sign in', 'login'],
6
+ 'signup': ['sign up', 'create account', 'register', 'get started'],
7
+ 'register': ['register', 'sign up', 'create account'],
8
+ 'forgot-password': ['forgot password', 'reset password', 'recover account'],
9
+ 'reset-password': ['reset my password', 'set new password', 'change password'],
10
+ 'verify': ['verify email', 'verify account', 'confirm email'],
11
+ 'otp': ['enter code', 'verify code', 'enter otp'],
12
+ 'billing': ['upgrade plan', 'manage billing', 'change subscription', 'view billing'],
13
+ 'payment-method': ['add payment method', 'update card', 'change payment'],
14
+ 'settings': ['settings', 'account settings', 'preferences'],
15
+ 'team': ['invite team member', 'manage team', 'add team member'],
16
+ 'forms': ['view forms', 'manage forms', 'create form', 'my forms'],
17
+ 'builder': ['build form', 'form builder', 'edit form'],
18
+ 'submissions': ['view submissions', 'form submissions', 'see responses'],
19
+ 'analytics': ['view analytics', 'form analytics', 'see stats'],
20
+ 'templates': ['view templates', 'browse templates', 'use template'],
21
+ 'studio': ['template studio', 'create template'],
22
+ 'transactions': ['view transactions', 'transaction history', 'payment history'],
23
+ 'documents': ['view documents', 'open document', 'my documents'],
24
+ 'integrations': ['integrations', 'connect integration', 'manage integrations'],
25
+ 'developer': ['developer settings', 'api keys', 'developer dashboard'],
26
+ 'automations': ['automations', 'manage automations', 'view automations'],
27
+ 'campaigns': ['campaigns', 'view campaigns', 'manage campaigns'],
28
+ 'website': ['website', 'manage website'],
29
+ 'workflows': ['workflows', 'manage workflows'],
30
+ 'invite': ['accept invite', 'join team'],
31
+ };
32
+ // Fix 4: words that are never meaningful user intent phrases
33
+ const INTENT_BLOCKLIST = new Set([
34
+ 'back', 'next', 'continue', 'click', 'button', 'here', 'more',
35
+ 'loader2', 'loader', 'spinner', 'close', 'x', 'menu', 'open',
36
+ 'cancel', 'ok', 'yes', 'no', 'loading', 'view all', 'learn more',
37
+ 'read more', 'see more', 'link', 'skip', 'done', 'go',
38
+ ]);
4
39
  class FlowInferrer {
5
40
  constructor(routes, elements, components) {
6
41
  this.routes = routes;
@@ -11,24 +46,39 @@ class FlowInferrer {
11
46
  const edges = this.buildEdgeGraph();
12
47
  const routeFlows = this.detectLinearFlows(edges);
13
48
  const interactionFlows = this.detectInteractionFlows();
14
- return [...routeFlows, ...interactionFlows];
49
+ const combined = [...routeFlows, ...interactionFlows];
50
+ // Fix 4: remove patterns that appear in too many flows — they are structural
51
+ // navigation chrome (sidebars, footers) rather than user intent signals
52
+ return this.deduplicateCrossFlowNoise(combined);
15
53
  }
54
+ // ---------------------------------------------------------------------------
55
+ // Edge graph
56
+ // ---------------------------------------------------------------------------
16
57
  buildEdgeGraph() {
17
58
  const edges = [];
18
59
  const routePaths = new Set(this.routes.map((r) => r.path));
19
60
  for (const el of this.elements) {
61
+ const isNav = el.tag === 'a' || el.isNavigationLink === true;
20
62
  const href = el.staticProps['href'] || el.staticProps['to'];
21
- if (el.tag === 'a' && href && routePaths.has(href)) {
22
- edges.push({
23
- from: el.route,
24
- to: href,
25
- trigger: el.staticProps['aria-label'] || el.nearbyText[0] || 'link',
26
- confidence: 0.85,
27
- });
28
- }
63
+ if (!isNav || !href)
64
+ continue;
65
+ if (!routePaths.has(href))
66
+ continue;
67
+ const triggerText = el.staticProps['aria-label'] ||
68
+ el.label ||
69
+ el.nearbyText[0] ||
70
+ 'link';
71
+ // Fix 3a: demote confidence for elements from layout/footer/header files.
72
+ // These links appear on every page and corrupt flow chains when followed.
73
+ const isLayoutSource = /footer|layout|header|navbar|sidebar|navigation/i.test(el.filePath || '');
74
+ const confidence = isLayoutSource ? 0.3 : 0.85;
75
+ edges.push({ from: el.route, to: href, trigger: triggerText, confidence });
29
76
  }
30
77
  return edges;
31
78
  }
79
+ // ---------------------------------------------------------------------------
80
+ // Linear flow detection
81
+ // ---------------------------------------------------------------------------
32
82
  detectLinearFlows(edges) {
33
83
  const adjacency = new Map();
34
84
  for (const edge of edges) {
@@ -42,7 +92,11 @@ class FlowInferrer {
42
92
  if (visited.has(route.path))
43
93
  continue;
44
94
  const chain = this.buildChain(route.path, adjacency, new Set());
45
- if (chain.length >= 2) {
95
+ if (chain.length >= 2 && this.isCoherentChain(chain)) {
96
+ const chainEdges = edges.filter((e) => {
97
+ const s = new Set(chain);
98
+ return s.has(e.from) && s.has(e.to);
99
+ });
46
100
  flows.push({
47
101
  id: `flow_${flows.length + 1}`,
48
102
  name: chain
@@ -50,29 +104,47 @@ class FlowInferrer {
50
104
  .join(' -> '),
51
105
  page: chain[0],
52
106
  steps: chain,
53
- edges: edges.filter((e) => {
54
- const s = new Set(chain);
55
- return s.has(e.from) && s.has(e.to);
56
- }),
107
+ edges: chainEdges,
108
+ intentPatterns: this.generateIntentPatterns(chain, chainEdges),
57
109
  });
58
110
  chain.forEach((p) => visited.add(p));
59
111
  }
60
112
  }
61
113
  return flows;
62
114
  }
115
+ /**
116
+ * Fix 3b: A coherent chain stays within the same top-level route domain.
117
+ * A chain that jumps from /auth/* to / or from /dashboard/* to /auth/signup
118
+ * is an artifact of footer/layout navigation links, not a real user flow.
119
+ */
120
+ isCoherentChain(chain) {
121
+ const firstSegment = (p) => p.split('/').filter(Boolean)[0] ?? 'root';
122
+ const domain = firstSegment(chain[0]);
123
+ return chain.every((r) => firstSegment(r) === domain);
124
+ }
125
+ // ---------------------------------------------------------------------------
126
+ // Interaction flow detection
127
+ // ---------------------------------------------------------------------------
63
128
  detectInteractionFlows() {
64
- if (!this.components || this.components.length === 0) {
129
+ if (!this.components || this.components.length === 0)
65
130
  return [];
66
- }
67
131
  const flows = [];
68
132
  for (const component of this.components) {
69
133
  for (const interaction of component.interactions || []) {
70
- const routePath = component.route || '/';
71
134
  if (!interaction.trigger?.event)
72
135
  continue;
136
+ if (!interaction.effect || interaction.effect.waitStrategy === 'none')
137
+ continue;
138
+ const routePath = component.route || '/';
139
+ const effectTarget = interaction.effect.selector ||
140
+ (interaction.effect.rendersWhenTrue
141
+ ? `[data-clippy-component='${interaction.effect.rendersWhenTrue}']`
142
+ : null);
143
+ if (!effectTarget)
144
+ continue;
73
145
  flows.push({
74
146
  id: `flow_${flows.length + 1}`,
75
- name: `${routePath} ${interaction.trigger.event}`,
147
+ name: interaction.effect.rendersWhenTrue || interaction.trigger.event,
76
148
  page: routePath,
77
149
  steps: [routePath],
78
150
  edges: [
@@ -83,11 +155,15 @@ class FlowInferrer {
83
155
  confidence: 0.65,
84
156
  },
85
157
  ],
158
+ intentPatterns: [],
86
159
  });
87
160
  }
88
161
  }
89
162
  return flows;
90
163
  }
164
+ // ---------------------------------------------------------------------------
165
+ // Chain builder
166
+ // ---------------------------------------------------------------------------
91
167
  buildChain(start, adjacency, seen) {
92
168
  if (seen.has(start))
93
169
  return [];
@@ -98,5 +174,67 @@ class FlowInferrer {
98
174
  const best = [...next].sort((a, b) => b.confidence - a.confidence)[0];
99
175
  return [start, ...this.buildChain(best.to, adjacency, seen)];
100
176
  }
177
+ // ---------------------------------------------------------------------------
178
+ // Intent pattern generation (Fix 4)
179
+ // ---------------------------------------------------------------------------
180
+ generateIntentPatterns(chain, edges) {
181
+ const patterns = new Set();
182
+ // 1. Route-segment semantic lookup
183
+ for (const routePath of chain) {
184
+ const segments = routePath.split('/').filter(Boolean);
185
+ for (const segment of segments) {
186
+ const clean = segment.replace(/[\[\]$:]/g, '').toLowerCase();
187
+ const known = SEMANTIC_INTENT_PATTERNS[clean];
188
+ if (known)
189
+ known.forEach((p) => patterns.add(p));
190
+ }
191
+ }
192
+ // 2. Link/button trigger text from the edges — apply noise filter
193
+ for (const edge of edges) {
194
+ const trigger = edge.trigger?.trim();
195
+ if (trigger && this.isValidIntentPattern(trigger)) {
196
+ patterns.add(trigger.toLowerCase());
197
+ }
198
+ }
199
+ return Array.from(patterns).filter(Boolean);
200
+ }
201
+ /**
202
+ * Returns true if a string is a usable user intent phrase.
203
+ * Rejects blocklisted words, PascalCase component names, very short strings,
204
+ * and URL-like strings.
205
+ */
206
+ isValidIntentPattern(pattern) {
207
+ if (!pattern || pattern.length < 4)
208
+ return false;
209
+ const lower = pattern.toLowerCase();
210
+ if (INTENT_BLOCKLIST.has(lower))
211
+ return false;
212
+ // Reject PascalCase strings — these are component names leaking through
213
+ if (/^[A-Z][a-zA-Z0-9]+$/.test(pattern))
214
+ return false;
215
+ // Reject paths and URLs
216
+ if (pattern.startsWith('/') || pattern.startsWith('http'))
217
+ return false;
218
+ return true;
219
+ }
220
+ /**
221
+ * Fix 4: Remove intent patterns that appear across more than 30% of all flows.
222
+ * These are structural navigation phrases (sidebar, footer links) that ended up
223
+ * attached to many unrelated flows rather than genuine user intents.
224
+ */
225
+ deduplicateCrossFlowNoise(flows) {
226
+ if (flows.length === 0)
227
+ return flows;
228
+ const threshold = Math.max(3, Math.ceil(flows.length * 0.3));
229
+ const counts = new Map();
230
+ flows.forEach((f) => (f.intentPatterns ?? []).forEach((p) => counts.set(p, (counts.get(p) ?? 0) + 1)));
231
+ const globalNoise = new Set([...counts.entries()].filter(([, n]) => n > threshold).map(([p]) => p));
232
+ if (globalNoise.size === 0)
233
+ return flows;
234
+ return flows.map((f) => ({
235
+ ...f,
236
+ intentPatterns: (f.intentPatterns ?? []).filter((p) => !globalNoise.has(p)),
237
+ }));
238
+ }
101
239
  }
102
240
  exports.FlowInferrer = FlowInferrer;
@@ -2,7 +2,7 @@ import type { NodePath } from '@babel/traverse';
2
2
  import type { ComponentInteraction } from '../types';
3
3
  /**
4
4
  * Extracts interaction graphs from React component ASTs.
5
- * Maps event handlers -> state mutations -> conditional renders and effects.
5
+ * Maps event handlers -> state mutations -> conditional renders, navigation, and async effects.
6
6
  */
7
7
  export declare class InteractionGraphExtractor {
8
8
  private contextResolver;
@@ -10,25 +10,19 @@ export declare class InteractionGraphExtractor {
10
10
  * Extract all interactions (trigger -> effect mappings) from a component.
11
11
  */
12
12
  extractInteractions(componentPath: NodePath<any>, componentName: string): ComponentInteraction[];
13
+ private buildInteractionFromSetters;
13
14
  /**
14
- * Extract trigger/effect pair from a JSX element with an event handler.
15
+ * Detect router.push() / router.replace() / navigate() calls inside a handler.
16
+ * Returns the destination route string if found, otherwise null.
15
17
  */
16
- private extractTriggerAndEffect;
18
+ private resolveRouterNavigation;
19
+ private findHandlerBody;
20
+ private extractRouterCallFromNode;
17
21
  private resolveStateSettersForAttribute;
18
22
  private collectSettersFromNode;
19
- /**
20
- * Find conditional renders/effects that depend on a state variable.
21
- * Looks for logical expressions and ternaries using the state var.
22
- */
23
23
  private findConditionalEffect;
24
24
  private findConditionalEffectInAncestors;
25
25
  private findConditionalEffectInComponent;
26
- /**
27
- * Check if a node references a specific state variable by name.
28
- */
29
26
  private nodeReferencesState;
30
- /**
31
- * Extract component name from a JSX element or identifier.
32
- */
33
27
  private extractComponentNameFromNode;
34
28
  }