@yannelli/paseo-linear-plugin 1.0.0 → 1.1.0-beta.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/OVERVIEW.md +19 -1
- package/README.md +77 -6
- package/client/access.tsx +66 -0
- package/client/agent-options.ts +12 -2
- package/client/agent-preferences.ts +112 -0
- package/client/browser.tsx +12 -9
- package/client/graph-labels.ts +137 -0
- package/client/launch-choices.ts +18 -5
- package/client/launch-plan.ts +88 -39
- package/client/launch.tsx +9 -4
- package/client/live-agents.tsx +407 -0
- package/client/live-cursor.tsx +291 -0
- package/client/live-feed.tsx +151 -0
- package/client/live-graph.tsx +434 -0
- package/client/live-header.tsx +312 -0
- package/client/live-issues.tsx +345 -0
- package/client/live-map.tsx +345 -0
- package/client/live-panel.tsx +444 -0
- package/client/live-timeline.ts +341 -0
- package/client/pill.tsx +393 -0
- package/client/project-init.tsx +168 -0
- package/client/settings-access.tsx +56 -0
- package/client/settings-live.tsx +121 -0
- package/client/settings-projects.tsx +9 -0
- package/index.client.tsx +39 -1
- package/index.server.ts +24 -4
- package/package.json +1 -1
- package/server/agent-runs.ts +299 -0
- package/server/handlers.ts +12 -1
- package/server/init.ts +64 -0
- package/server/links.ts +153 -0
- package/server/live.ts +334 -0
- package/server/stores.ts +119 -0
- package/server/sync.ts +127 -0
- package/shared/activity.ts +308 -0
- package/shared/graph-geometry.ts +122 -0
- package/shared/graph-model.ts +319 -0
- package/shared/links.ts +252 -0
- package/shared/live.ts +200 -0
- package/shared/map-model.ts +192 -0
- package/shared/project-setup.ts +55 -0
- package/shared/prompts.ts +16 -1
- package/shared/settings.ts +57 -1
- package/shared/shell-activity.ts +309 -0
- package/shared/shell-lexer.ts +180 -0
- package/shared/subagents.ts +106 -0
- package/shared/todo-sync.ts +158 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import type { AgentFocus } from "./activity";
|
|
2
|
+
import type { LabelSide, Placed } from "./graph-geometry";
|
|
3
|
+
import type { FileLink, LinkKind } from "./links";
|
|
4
|
+
import type { MapTile } from "./map-model";
|
|
5
|
+
|
|
6
|
+
// The Linear Live graph: files are vertices and the links between them, such as imports, are
|
|
7
|
+
// edges. Files sit near others in their folder and take their sub-issue's color; the issue and
|
|
8
|
+
// its sub-issues join the files the plan gave them. Pure, so tests can check the layout.
|
|
9
|
+
|
|
10
|
+
export type GraphNodeKind = "issue" | "subissue" | "file";
|
|
11
|
+
export type GraphEdgeKind = "owns" | LinkKind;
|
|
12
|
+
|
|
13
|
+
export interface GraphNode {
|
|
14
|
+
id: string;
|
|
15
|
+
kind: GraphNodeKind;
|
|
16
|
+
label: string;
|
|
17
|
+
/** Issue key whose color the node takes. */
|
|
18
|
+
owner: string | null;
|
|
19
|
+
/** Repo path, for file nodes. */
|
|
20
|
+
path: string | null;
|
|
21
|
+
tile: MapTile | null;
|
|
22
|
+
/** Up to two folder levels, which files of one neighborhood share. */
|
|
23
|
+
folder: string | null;
|
|
24
|
+
/** Files left out to keep the graph readable. */
|
|
25
|
+
folded: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface GraphEdge {
|
|
29
|
+
id: string;
|
|
30
|
+
from: string;
|
|
31
|
+
to: string;
|
|
32
|
+
kind: GraphEdgeKind;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface Graph {
|
|
36
|
+
root: string;
|
|
37
|
+
nodes: GraphNode[];
|
|
38
|
+
edges: GraphEdge[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface GraphInput {
|
|
42
|
+
issue: string;
|
|
43
|
+
children: readonly string[];
|
|
44
|
+
tiles: readonly MapTile[];
|
|
45
|
+
links?: readonly FileLink[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const MAX_UNTOUCHED_PER_OWNER = 6;
|
|
49
|
+
export const MAX_GRAPH_FILES = 120;
|
|
50
|
+
const STRENGTH: Record<GraphEdgeKind, number> = { import: 4, link: 3, mention: 2, owns: 1 };
|
|
51
|
+
|
|
52
|
+
export const issueNodeId = (key: string) => `issue:${key.toUpperCase()}`;
|
|
53
|
+
export const subIssueNodeId = (key: string) => `sub:${key.toUpperCase()}`;
|
|
54
|
+
export const fileNodeId = (path: string) => `file:${path}`;
|
|
55
|
+
|
|
56
|
+
export function folderOf(path: string): string {
|
|
57
|
+
const parts = path.split("/").slice(0, -1);
|
|
58
|
+
return parts.length ? parts.slice(0, 2).join("/") : ".";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The files the graph shows: every touched file, then predicted ones up to a cap per issue. */
|
|
62
|
+
export function graphFiles(input: GraphInput): { tiles: MapTile[]; folded: Map<string, number> } {
|
|
63
|
+
const children = new Set(input.children.map((key) => key.toUpperCase()));
|
|
64
|
+
const parent = input.issue.toUpperCase();
|
|
65
|
+
const ownerOf = (tile: MapTile) => (tile.owner && children.has(tile.owner.toUpperCase()) ? tile.owner.toUpperCase() : parent);
|
|
66
|
+
const tiles = input.tiles.filter((tile) => !tile.ghost);
|
|
67
|
+
const chosen = tiles.filter((tile) => tile.touch !== "none").slice(0, MAX_GRAPH_FILES);
|
|
68
|
+
const folded = new Map<string, number>();
|
|
69
|
+
const perOwner = new Map<string, number>();
|
|
70
|
+
for (const tile of tiles) {
|
|
71
|
+
if (tile.touch !== "none" || !tile.onMap) continue;
|
|
72
|
+
const owner = ownerOf(tile);
|
|
73
|
+
const count = perOwner.get(owner) ?? 0;
|
|
74
|
+
if (count >= MAX_UNTOUCHED_PER_OWNER || chosen.length >= MAX_GRAPH_FILES) {
|
|
75
|
+
folded.set(owner, (folded.get(owner) ?? 0) + 1);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
perOwner.set(owner, count + 1);
|
|
79
|
+
chosen.push(tile);
|
|
80
|
+
}
|
|
81
|
+
return { tiles: chosen, folded };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function buildGraph(input: GraphInput): Graph {
|
|
85
|
+
const parentKey = input.issue.toUpperCase();
|
|
86
|
+
const root = issueNodeId(parentKey);
|
|
87
|
+
const children = new Set(input.children.map((key) => key.toUpperCase()));
|
|
88
|
+
const nodes = new Map<string, GraphNode>();
|
|
89
|
+
const edges = new Map<string, GraphEdge>();
|
|
90
|
+
const { tiles, folded } = graphFiles(input);
|
|
91
|
+
const add = (node: GraphNode) => nodes.set(node.id, node);
|
|
92
|
+
const join = (from: string, to: string, kind: GraphEdgeKind) => {
|
|
93
|
+
if (from === to || !nodes.has(from) || !nodes.has(to)) return;
|
|
94
|
+
const [a, b] = from < to ? [from, to] : [to, from];
|
|
95
|
+
const id = `${a}>${b}`;
|
|
96
|
+
const known = edges.get(id);
|
|
97
|
+
if (!known || STRENGTH[kind] > STRENGTH[known.kind]) edges.set(id, { id, from, to, kind });
|
|
98
|
+
};
|
|
99
|
+
const hub = (key: string) => (children.has(key) ? subIssueNodeId(key) : root);
|
|
100
|
+
add({ id: root, kind: "issue", label: parentKey, owner: parentKey, path: null, tile: null, folder: null, folded: folded.get(parentKey) ?? 0 });
|
|
101
|
+
for (const key of children) {
|
|
102
|
+
add({ id: subIssueNodeId(key), kind: "subissue", label: key, owner: key, path: null, tile: null, folder: null, folded: folded.get(key) ?? 0 });
|
|
103
|
+
join(root, subIssueNodeId(key), "owns");
|
|
104
|
+
}
|
|
105
|
+
for (const tile of tiles) {
|
|
106
|
+
const id = fileNodeId(tile.path);
|
|
107
|
+
const label = tile.path.split("/").pop() ?? tile.path;
|
|
108
|
+
add({ id, kind: "file", label, owner: tile.owner, path: tile.path, tile, folder: folderOf(tile.path), folded: 0 });
|
|
109
|
+
if (tile.onMap && tile.owner) join(hub(tile.owner.toUpperCase()), id, "owns");
|
|
110
|
+
}
|
|
111
|
+
for (const link of input.links ?? []) join(fileNodeId(link.from), fileNodeId(link.to), link.kind);
|
|
112
|
+
return { root, nodes: [...nodes.values()], edges: [...edges.values()] };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Changes only when a node or an edge comes or goes; then the layout runs again. */
|
|
116
|
+
export function graphKey(graph: Graph): string {
|
|
117
|
+
const ids = graph.nodes.map((node) => node.id).sort();
|
|
118
|
+
const links = graph.edges.map((edge) => edge.id).sort();
|
|
119
|
+
return `${ids.join("|")}#${links.join("|")}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const MAX_HOPS = 6;
|
|
123
|
+
|
|
124
|
+
/** The shortest walk between two nodes along edges, or a straight hop when there is none. */
|
|
125
|
+
export function graphPath(graph: Graph, from: string, to: string): string[] {
|
|
126
|
+
const ids = new Set(graph.nodes.map((node) => node.id));
|
|
127
|
+
if (!ids.has(to)) return [];
|
|
128
|
+
if (!ids.has(from) || from === to) return [to];
|
|
129
|
+
const next = new Map<string, string[]>();
|
|
130
|
+
for (const edge of graph.edges) {
|
|
131
|
+
next.set(edge.from, [...(next.get(edge.from) ?? []), edge.to]);
|
|
132
|
+
next.set(edge.to, [...(next.get(edge.to) ?? []), edge.from]);
|
|
133
|
+
}
|
|
134
|
+
const came = new Map<string, string>([[from, from]]);
|
|
135
|
+
const queue = [from];
|
|
136
|
+
for (let index = 0; index < queue.length && !came.has(to); index += 1) {
|
|
137
|
+
const at = queue[index] as string;
|
|
138
|
+
for (const neighbor of [...(next.get(at) ?? [])].sort()) {
|
|
139
|
+
if (came.has(neighbor)) continue;
|
|
140
|
+
came.set(neighbor, at);
|
|
141
|
+
queue.push(neighbor);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (!came.has(to)) return [from, to];
|
|
145
|
+
const path = [to];
|
|
146
|
+
while (path[0] !== from) path.unshift(came.get(path[0] as string) as string);
|
|
147
|
+
return path.length - 1 > MAX_HOPS ? [from, to] : path;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The node an agent works on: its file or sub-issue when the graph has it, else the issue. */
|
|
151
|
+
export function focusNode(graph: Graph, focus: AgentFocus | null): string {
|
|
152
|
+
if (!focus) return graph.root;
|
|
153
|
+
const id = focus.kind === "issue" ? subIssueNodeId(focus.key) : fileNodeId(focus.path);
|
|
154
|
+
return graph.nodes.some((node) => node.id === id) ? id : graph.root;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function mulberry32(seed: number) {
|
|
158
|
+
let state = seed >>> 0;
|
|
159
|
+
return () => {
|
|
160
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
161
|
+
let value = state;
|
|
162
|
+
value = Math.imul(value ^ (value >>> 15), value | 1);
|
|
163
|
+
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
|
|
164
|
+
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function hash(text: string): number {
|
|
169
|
+
let value = 2166136261;
|
|
170
|
+
for (let index = 0; index < text.length; index += 1) value = Math.imul(value ^ text.charCodeAt(index), 16777619);
|
|
171
|
+
return value >>> 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Edge length as a share of the ideal spacing, and how hard the edge pulls.
|
|
175
|
+
const SPRINGS: Record<GraphEdgeKind, { length: number; weight: number }> = {
|
|
176
|
+
import: { length: 0.6, weight: 1 },
|
|
177
|
+
link: { length: 0.7, weight: 0.7 },
|
|
178
|
+
mention: { length: 0.9, weight: 0.35 },
|
|
179
|
+
owns: { length: 1, weight: 0.3 },
|
|
180
|
+
};
|
|
181
|
+
const GRAVITY = 0.05;
|
|
182
|
+
const COHESION = 0.35;
|
|
183
|
+
const ANCHOR = 6;
|
|
184
|
+
|
|
185
|
+
function sideOf(point: { x: number; y: number }, center: { x: number; y: number }): LabelSide {
|
|
186
|
+
if (point.x > center.x + 30) return "right";
|
|
187
|
+
if (point.x < center.x - 30) return "left";
|
|
188
|
+
return point.y > center.y ? "below" : "above";
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Force layout: nodes push apart, edges pull together, files drift toward their folder's middle,
|
|
192
|
+
// and the issue stays fixed in the center. A warm start holds nodes near where they were.
|
|
193
|
+
export function layoutGraph(
|
|
194
|
+
graph: Graph,
|
|
195
|
+
width: number,
|
|
196
|
+
height: number,
|
|
197
|
+
previous: ReadonlyMap<string, { x: number; y: number }> = new Map(),
|
|
198
|
+
): Map<string, Placed> {
|
|
199
|
+
const random = mulberry32(hash(graphKey(graph)));
|
|
200
|
+
const center = { x: width / 2, y: height / 2 };
|
|
201
|
+
const padX = Math.min(96, width * 0.14);
|
|
202
|
+
const padY = 40;
|
|
203
|
+
const clamp = (x: number, y: number) => ({
|
|
204
|
+
x: Math.min(width - padX, Math.max(padX, x)),
|
|
205
|
+
y: Math.min(height - padY, Math.max(padY, y)),
|
|
206
|
+
});
|
|
207
|
+
const nodes = graph.nodes;
|
|
208
|
+
const k = Math.sqrt((width * height) / Math.max(1, nodes.length)) * 0.72;
|
|
209
|
+
const folders = [...new Set(nodes.flatMap((node) => (node.folder ? [node.folder] : [])))].sort();
|
|
210
|
+
const position = new Map<string, { x: number; y: number }>();
|
|
211
|
+
let warm = 0;
|
|
212
|
+
for (const node of nodes) {
|
|
213
|
+
const before = previous.get(node.id);
|
|
214
|
+
if (node.id === graph.root) position.set(node.id, { ...center });
|
|
215
|
+
else if (before) {
|
|
216
|
+
position.set(node.id, clamp(before.x, before.y));
|
|
217
|
+
warm += 1;
|
|
218
|
+
} else {
|
|
219
|
+
// Each folder starts in its own direction, so neighborhoods form quickly.
|
|
220
|
+
const slot = node.folder ? folders.indexOf(node.folder) : folders.length + (hash(node.id) % 7);
|
|
221
|
+
const angle = (slot / Math.max(1, folders.length + 1)) * Math.PI * 2 + random() * 0.6;
|
|
222
|
+
const reach = (node.kind === "file" ? 0.55 + random() * 0.35 : 0.3) * Math.min(width, height) * 0.5;
|
|
223
|
+
position.set(node.id, clamp(center.x + Math.cos(angle) * reach * (width / height), center.y + Math.sin(angle) * reach));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const coldStart = warm < nodes.length / 2;
|
|
227
|
+
const iterations = coldStart ? 300 : 90;
|
|
228
|
+
let temperature = coldStart ? Math.min(width, height) / 5 : k / 4;
|
|
229
|
+
const cooling = temperature / (iterations + 1);
|
|
230
|
+
const short = Math.min(width, height);
|
|
231
|
+
const pull = { x: (GRAVITY * k * short) / width, y: (GRAVITY * k * short) / height };
|
|
232
|
+
const ids = nodes.map((node) => node.id);
|
|
233
|
+
for (let step = 0; step < iterations; step += 1) {
|
|
234
|
+
const moves = new Map(ids.map((id) => [id, { x: 0, y: 0 }]));
|
|
235
|
+
for (let a = 0; a < ids.length; a += 1) {
|
|
236
|
+
const pa = position.get(ids[a] as string) as { x: number; y: number };
|
|
237
|
+
const ma = moves.get(ids[a] as string) as { x: number; y: number };
|
|
238
|
+
for (let b = a + 1; b < ids.length; b += 1) {
|
|
239
|
+
const pb = position.get(ids[b] as string) as { x: number; y: number };
|
|
240
|
+
const mb = moves.get(ids[b] as string) as { x: number; y: number };
|
|
241
|
+
const dx = pa.x - pb.x || (random() - 0.5) * 0.01;
|
|
242
|
+
const dy = pa.y - pb.y || (random() - 0.5) * 0.01;
|
|
243
|
+
const distance = Math.max(0.5, Math.hypot(dx, dy));
|
|
244
|
+
const force = (k * k) / distance;
|
|
245
|
+
ma.x += (dx / distance) * force;
|
|
246
|
+
ma.y += (dy / distance) * force;
|
|
247
|
+
mb.x -= (dx / distance) * force;
|
|
248
|
+
mb.y -= (dy / distance) * force;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
for (const edge of graph.edges) {
|
|
252
|
+
const pa = position.get(edge.from);
|
|
253
|
+
const pb = position.get(edge.to);
|
|
254
|
+
if (!pa || !pb) continue;
|
|
255
|
+
const spring = SPRINGS[edge.kind];
|
|
256
|
+
const dx = pa.x - pb.x;
|
|
257
|
+
const dy = pa.y - pb.y;
|
|
258
|
+
const distance = Math.max(0.5, Math.hypot(dx, dy));
|
|
259
|
+
const force = (spring.weight * distance * distance) / (k * spring.length);
|
|
260
|
+
const ma = moves.get(edge.from) as { x: number; y: number };
|
|
261
|
+
const mb = moves.get(edge.to) as { x: number; y: number };
|
|
262
|
+
ma.x -= (dx / distance) * force;
|
|
263
|
+
ma.y -= (dy / distance) * force;
|
|
264
|
+
mb.x += (dx / distance) * force;
|
|
265
|
+
mb.y += (dy / distance) * force;
|
|
266
|
+
}
|
|
267
|
+
const middles = new Map<string, { x: number; y: number; count: number }>();
|
|
268
|
+
for (const node of nodes) {
|
|
269
|
+
if (!node.folder) continue;
|
|
270
|
+
const point = position.get(node.id) as { x: number; y: number };
|
|
271
|
+
const sum = middles.get(node.folder) ?? { x: 0, y: 0, count: 0 };
|
|
272
|
+
middles.set(node.folder, { x: sum.x + point.x, y: sum.y + point.y, count: sum.count + 1 });
|
|
273
|
+
}
|
|
274
|
+
for (const node of nodes) {
|
|
275
|
+
if (node.id === graph.root) continue;
|
|
276
|
+
const point = position.get(node.id) as { x: number; y: number };
|
|
277
|
+
const move = moves.get(node.id) as { x: number; y: number };
|
|
278
|
+
move.x += (center.x - point.x) * pull.x;
|
|
279
|
+
move.y += (center.y - point.y) * pull.y;
|
|
280
|
+
const middle = node.folder ? middles.get(node.folder) : undefined;
|
|
281
|
+
if (middle && middle.count > 1) {
|
|
282
|
+
const dx = middle.x / middle.count - point.x;
|
|
283
|
+
const dy = middle.y / middle.count - point.y;
|
|
284
|
+
const distance = Math.hypot(dx, dy);
|
|
285
|
+
move.x += (dx * distance * COHESION) / k;
|
|
286
|
+
move.y += (dy * distance * COHESION) / k;
|
|
287
|
+
}
|
|
288
|
+
const home = coldStart ? undefined : previous.get(node.id);
|
|
289
|
+
if (home) {
|
|
290
|
+
move.x += (home.x - point.x) * ANCHOR;
|
|
291
|
+
move.y += (home.y - point.y) * ANCHOR;
|
|
292
|
+
}
|
|
293
|
+
const length = Math.max(0.01, Math.hypot(move.x, move.y));
|
|
294
|
+
const limit = Math.min(length, temperature);
|
|
295
|
+
position.set(node.id, clamp(point.x + (move.x / length) * limit, point.y + (move.y / length) * limit));
|
|
296
|
+
}
|
|
297
|
+
temperature = Math.max(0.5, temperature - cooling);
|
|
298
|
+
}
|
|
299
|
+
return new Map(
|
|
300
|
+
nodes.map((node) => {
|
|
301
|
+
const point = position.get(node.id) as { x: number; y: number };
|
|
302
|
+
return [node.id, { ...point, side: node.kind === "file" ? sideOf(point, center) : "below" }];
|
|
303
|
+
}),
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Where each folder's files gather, for folders with enough files to name. */
|
|
308
|
+
export function folderCenters(graph: Graph, positions: ReadonlyMap<string, { x: number; y: number }>, minimum = 3) {
|
|
309
|
+
const sums = new Map<string, { x: number; y: number; top: number; count: number }>();
|
|
310
|
+
for (const node of graph.nodes) {
|
|
311
|
+
const at = node.folder ? positions.get(node.id) : undefined;
|
|
312
|
+
if (!at || !node.folder) continue;
|
|
313
|
+
const sum = sums.get(node.folder) ?? { x: 0, y: 0, top: Number.POSITIVE_INFINITY, count: 0 };
|
|
314
|
+
sums.set(node.folder, { x: sum.x + at.x, y: sum.y + at.y, top: Math.min(sum.top, at.y), count: sum.count + 1 });
|
|
315
|
+
}
|
|
316
|
+
return [...sums.entries()]
|
|
317
|
+
.filter(([, sum]) => sum.count >= minimum)
|
|
318
|
+
.map(([folder, sum]) => ({ folder, x: sum.x / sum.count, y: sum.y / sum.count, top: sum.top, count: sum.count }));
|
|
319
|
+
}
|
package/shared/links.ts
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// Links between repository files, for the Linear Live graph: code imports in common languages,
|
|
2
|
+
// links in Markdown, HTML, and CSS, entry points in package files, and repo paths named in
|
|
3
|
+
// config and scripts. Pure: the daemon reads the files and checks which candidates exist.
|
|
4
|
+
|
|
5
|
+
export type LinkKind = "import" | "link" | "mention";
|
|
6
|
+
|
|
7
|
+
export interface FileLink {
|
|
8
|
+
from: string;
|
|
9
|
+
to: string;
|
|
10
|
+
kind: LinkKind;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A reference found in a file, with the repo paths it may point to, best first. */
|
|
14
|
+
export interface Reference {
|
|
15
|
+
kind: LinkKind;
|
|
16
|
+
candidates: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface LinkContext {
|
|
20
|
+
/** Path aliases from tsconfig or jsconfig, such as "@/" to "src/". */
|
|
21
|
+
aliases: readonly { prefix: string; targets: readonly string[] }[];
|
|
22
|
+
/** Workspace packages by name, with their folder and entry file. */
|
|
23
|
+
packages: ReadonlyMap<string, { dir: string; entry: string | null }>;
|
|
24
|
+
/** The module path from go.mod. */
|
|
25
|
+
goModule: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const EMPTY_CONTEXT: LinkContext = { aliases: [], packages: new Map(), goModule: null };
|
|
29
|
+
|
|
30
|
+
const SCRIPT_EXTENSIONS = [".ts", ".tsx", ".d.ts", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".json", ".vue", ".svelte"];
|
|
31
|
+
/** A Go import names a package folder; this suffix asks the daemon for the folder's Go files. */
|
|
32
|
+
export const GO_PACKAGE = "/*.go";
|
|
33
|
+
|
|
34
|
+
function extension(file: string): string {
|
|
35
|
+
const name = file.split("/").pop() ?? file;
|
|
36
|
+
const dot = name.lastIndexOf(".");
|
|
37
|
+
return dot > 0 ? name.slice(dot).toLowerCase() : "";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function dirname(file: string): string {
|
|
41
|
+
const index = file.lastIndexOf("/");
|
|
42
|
+
return index < 0 ? "" : file.slice(0, index);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Joins and normalizes a repo path; null when it leaves the repository. */
|
|
46
|
+
export function joinPath(base: string, target: string): string | null {
|
|
47
|
+
const parts: string[] = [];
|
|
48
|
+
for (const part of `${base}/${target}`.split("/")) {
|
|
49
|
+
if (part === "" || part === ".") continue;
|
|
50
|
+
if (part === "..") {
|
|
51
|
+
if (parts.length === 0) return null;
|
|
52
|
+
parts.pop();
|
|
53
|
+
} else parts.push(part);
|
|
54
|
+
}
|
|
55
|
+
return parts.length ? parts.join("/") : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function all(pattern: RegExp, text: string, group = 1): string[] {
|
|
59
|
+
return [...text.matchAll(pattern)].map((match) => match[group] ?? "").filter(Boolean);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// A script specifier may leave out the extension, name a folder with an index file, or name
|
|
63
|
+
// the .js file that a .ts file compiles to.
|
|
64
|
+
function scriptCandidates(base: string): string[] {
|
|
65
|
+
const list = [base, ...SCRIPT_EXTENSIONS.map((ext) => base + ext), ...SCRIPT_EXTENSIONS.map((ext) => `${base}/index${ext}`)];
|
|
66
|
+
const compiled = /\.(m|c)?jsx?$/.exec(base);
|
|
67
|
+
if (compiled) {
|
|
68
|
+
const stem = base.slice(0, -compiled[0].length);
|
|
69
|
+
list.unshift(`${stem}.ts`, `${stem}.tsx`, `${stem}.${compiled[1] ?? ""}ts`);
|
|
70
|
+
}
|
|
71
|
+
return list;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function scriptSpecifier(from: string, spec: string, context: LinkContext): string[] {
|
|
75
|
+
if (spec.startsWith(".")) {
|
|
76
|
+
const base = joinPath(dirname(from), spec);
|
|
77
|
+
return base ? scriptCandidates(base) : [];
|
|
78
|
+
}
|
|
79
|
+
for (const alias of context.aliases) {
|
|
80
|
+
if (!spec.startsWith(alias.prefix)) continue;
|
|
81
|
+
return alias.targets.flatMap((target) => {
|
|
82
|
+
const base = joinPath(target, spec.slice(alias.prefix.length));
|
|
83
|
+
return base ? scriptCandidates(base) : [];
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
for (const [name, pkg] of context.packages) {
|
|
87
|
+
if (spec === name && pkg.entry) return scriptCandidates(pkg.entry);
|
|
88
|
+
if (spec.startsWith(`${name}/`)) {
|
|
89
|
+
const base = joinPath(pkg.dir, spec.slice(name.length + 1));
|
|
90
|
+
return base ? [...scriptCandidates(base), ...scriptCandidates(joinPath(pkg.dir, `src/${spec.slice(name.length + 1)}`) ?? base)] : [];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function scriptImports(text: string): string[] {
|
|
97
|
+
return [
|
|
98
|
+
...all(/\b(?:import|export)\s+(?:type\s+)?[^'"`;]*?\bfrom\s*["']([^"']+)["']/g, text),
|
|
99
|
+
...all(/\bimport\s*["']([^"']+)["']/g, text),
|
|
100
|
+
...all(/\b(?:require|import)\s*\(\s*["']([^"']+)["']\s*\)/g, text),
|
|
101
|
+
];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function pythonImports(from: string, text: string): string[][] {
|
|
105
|
+
const results: string[][] = [];
|
|
106
|
+
const module = (base: string, name: string) => {
|
|
107
|
+
const stem = joinPath(base, name.replace(/\./g, "/"));
|
|
108
|
+
return stem ? [`${stem}.py`, `${stem}.pyi`, `${stem}/__init__.py`] : [];
|
|
109
|
+
};
|
|
110
|
+
for (const match of text.matchAll(/^\s*from\s+(\.*)([\w.]*)\s+import\s+([\w*, ()]+)/gm)) {
|
|
111
|
+
const [, dots = "", name = "", names = ""] = match;
|
|
112
|
+
if (dots) {
|
|
113
|
+
let base = dirname(from);
|
|
114
|
+
for (let level = 1; level < dots.length; level += 1) base = dirname(base);
|
|
115
|
+
if (name) results.push(module(base, name));
|
|
116
|
+
else for (const item of names.replace(/[()]/g, "").split(",")) results.push(module(base, item.trim()));
|
|
117
|
+
} else results.push([...module("", name), ...module("src", name)]);
|
|
118
|
+
}
|
|
119
|
+
for (const match of text.matchAll(/^\s*import\s+([\w.]+(?:\s*,\s*[\w.]+)*)/gm)) {
|
|
120
|
+
for (const name of (match[1] ?? "").split(",")) results.push([...module("", name.trim()), ...module("src", name.trim())]);
|
|
121
|
+
}
|
|
122
|
+
return results;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function goImports(text: string, context: LinkContext): string[][] {
|
|
126
|
+
if (!context.goModule) return [];
|
|
127
|
+
const prefix = `${context.goModule}/`;
|
|
128
|
+
const specs = [...all(/^\s*import\s+(?:\w+\s+)?"([^"]+)"/gm, text)];
|
|
129
|
+
for (const block of all(/^\s*import\s*\(([\s\S]*?)\)/gm, text)) specs.push(...all(/"([^"]+)"/g, block));
|
|
130
|
+
return specs.filter((spec) => spec.startsWith(prefix)).map((spec) => [`${spec.slice(prefix.length)}${GO_PACKAGE}`]);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function rustImports(from: string, text: string): string[][] {
|
|
134
|
+
const name = from.split("/").pop() ?? "";
|
|
135
|
+
const own = /^(mod|lib|main)\.rs$/.test(name) ? dirname(from) : joinPath(dirname(from), name.replace(/\.rs$/, ""));
|
|
136
|
+
const results = all(/^\s*(?:pub\s+)?mod\s+(\w+)\s*;/gm, text).map((mod) => [`${own ?? ""}/${mod}.rs`, `${own ?? ""}/${mod}/mod.rs`].map((file) => file.replace(/^\//, "")));
|
|
137
|
+
for (const spec of all(/^\s*(?:pub\s+)?use\s+crate::([\w:]+)/gm, text)) {
|
|
138
|
+
const parts = spec.split("::");
|
|
139
|
+
for (let size = parts.length; size > 0; size -= 1) {
|
|
140
|
+
const stem = `src/${parts.slice(0, size).join("/")}`;
|
|
141
|
+
results.push([`${stem}.rs`, `${stem}/mod.rs`]);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return results;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Repo paths named in config, scripts, and prose: a word with a slash and an extension, or a
|
|
148
|
+
// relative path. Each is tried from the file's folder and from the repository root.
|
|
149
|
+
function mentions(from: string, text: string): string[][] {
|
|
150
|
+
const words = all(/(?:^|[\s"'`(=:,[])((?:\.{1,2}\/)?[\w@.-]+(?:\/[\w@.-]+)+\.[A-Za-z][\w]{0,7})(?=$|[\s"'`),:;\]#?])/gm, text);
|
|
151
|
+
return [...new Set(words)].map((word) => {
|
|
152
|
+
const local = joinPath(dirname(from), word);
|
|
153
|
+
const rooted = word.startsWith(".") ? null : joinPath("", word);
|
|
154
|
+
return [local, rooted].filter((candidate): candidate is string => candidate !== null);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function markdownLinks(from: string, text: string): string[][] {
|
|
159
|
+
return all(/\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)/g, text)
|
|
160
|
+
.map((target) => target.split("#")[0] ?? "")
|
|
161
|
+
.filter((target) => target && !/^[a-z][\w+.-]*:/i.test(target))
|
|
162
|
+
.map((target) => {
|
|
163
|
+
const resolved = target.startsWith("/") ? joinPath("", target) : joinPath(dirname(from), decodeURI(target));
|
|
164
|
+
return resolved ? [resolved, `${resolved}/README.md`] : [];
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function packageEntries(from: string, text: string): string[][] {
|
|
169
|
+
let pkg: Record<string, unknown>;
|
|
170
|
+
try {
|
|
171
|
+
pkg = JSON.parse(text) as Record<string, unknown>;
|
|
172
|
+
} catch {
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
const strings: string[] = [];
|
|
176
|
+
const collect = (value: unknown) => {
|
|
177
|
+
if (typeof value === "string") strings.push(value);
|
|
178
|
+
else if (value && typeof value === "object") for (const inner of Object.values(value)) collect(inner);
|
|
179
|
+
};
|
|
180
|
+
for (const key of ["main", "module", "types", "typings", "bin", "exports", "browser"]) collect(pkg[key]);
|
|
181
|
+
const dir = dirname(from);
|
|
182
|
+
const entries = strings.filter((value) => /^\.?\.?\/?[\w@.-]/.test(value) && !value.includes("*"));
|
|
183
|
+
const scripts = mentions(from, Object.values((pkg.scripts as Record<string, string>) ?? {}).join("\n"));
|
|
184
|
+
return [...entries.map((value) => [joinPath(dir, value)].filter((entry): entry is string => entry !== null)), ...scripts];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The references in one file, with candidate paths for each. */
|
|
188
|
+
export function fileReferences(from: string, text: string, context: LinkContext = EMPTY_CONTEXT): Reference[] {
|
|
189
|
+
const ext = extension(from);
|
|
190
|
+
const name = from.split("/").pop() ?? "";
|
|
191
|
+
const refs: Reference[] = [];
|
|
192
|
+
const add = (kind: LinkKind, lists: string[][]) => {
|
|
193
|
+
for (const candidates of lists) if (candidates.length) refs.push({ kind, candidates });
|
|
194
|
+
};
|
|
195
|
+
if ([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".svelte", ".astro"].includes(ext)) {
|
|
196
|
+
add("import", scriptImports(text).map((spec) => scriptSpecifier(from, spec, context)));
|
|
197
|
+
} else if (ext === ".py" || ext === ".pyi") add("import", pythonImports(from, text));
|
|
198
|
+
else if (ext === ".go") add("import", goImports(text, context));
|
|
199
|
+
else if (ext === ".rs") add("import", rustImports(from, text));
|
|
200
|
+
else if (ext === ".rb") {
|
|
201
|
+
add("import", all(/\brequire_relative\s*\(?\s*["']([^"']+)["']/g, text).map((spec) => {
|
|
202
|
+
const base = joinPath(dirname(from), spec);
|
|
203
|
+
return base ? [base.endsWith(".rb") ? base : `${base}.rb`] : [];
|
|
204
|
+
}));
|
|
205
|
+
} else if ([".css", ".scss", ".sass", ".less"].includes(ext)) {
|
|
206
|
+
add("link", all(/@(?:import|use|forward)\s+(?:url\()?["']([^"']+)["']/g, text).map((spec) => {
|
|
207
|
+
const base = joinPath(dirname(from), spec);
|
|
208
|
+
if (!base) return [];
|
|
209
|
+
const partial = `${dirname(base)}/_${base.split("/").pop()}`.replace(/^\//, "");
|
|
210
|
+
return [base, `${base}.css`, `${base}.scss`, `${partial}.scss`, `${base}.less`];
|
|
211
|
+
}));
|
|
212
|
+
} else if (ext === ".md" || ext === ".mdx") add("link", markdownLinks(from, text));
|
|
213
|
+
else if (ext === ".html" || ext === ".htm") {
|
|
214
|
+
add("link", all(/\b(?:src|href)\s*=\s*["']([^"'#?]+)["']/g, text)
|
|
215
|
+
.filter((target) => !/^[a-z][\w+.-]*:|^\/\//i.test(target))
|
|
216
|
+
.map((target) => [joinPath(target.startsWith("/") ? "" : dirname(from), target)].filter((value): value is string => value !== null)));
|
|
217
|
+
} else if (name === "package.json") add("link", packageEntries(from, text));
|
|
218
|
+
else if (/^[tj]sconfig(\..+)?\.json$/.test(name)) {
|
|
219
|
+
add("link", all(/"extends"\s*:\s*"(\.[^"]+)"/g, text).map((spec) => {
|
|
220
|
+
const base = joinPath(dirname(from), spec);
|
|
221
|
+
return base ? [base, `${base}.json`] : [];
|
|
222
|
+
}));
|
|
223
|
+
}
|
|
224
|
+
// Every text file can name repo paths, as a workflow names the script it runs.
|
|
225
|
+
add("mention", mentions(from, text));
|
|
226
|
+
return refs;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Reads tsconfig paths, such as {"@/*": ["./src/*"]}, as prefix aliases. */
|
|
230
|
+
export function aliasesFrom(config: unknown, configDir = ""): LinkContext["aliases"] {
|
|
231
|
+
const options = (config as { compilerOptions?: { baseUrl?: string; paths?: Record<string, string[]> } })?.compilerOptions;
|
|
232
|
+
const base = joinPath(configDir, options?.baseUrl ?? ".") ?? "";
|
|
233
|
+
return Object.entries(options?.paths ?? {}).flatMap(([pattern, targets]) => {
|
|
234
|
+
if (!pattern.endsWith("*") || !Array.isArray(targets)) return [];
|
|
235
|
+
const resolved = targets
|
|
236
|
+
.filter((target) => typeof target === "string" && target.endsWith("*"))
|
|
237
|
+
.map((target) => joinPath(base, target.slice(0, -1)) ?? "");
|
|
238
|
+
return [{ prefix: pattern.slice(0, -1), targets: resolved }];
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Strips comments and trailing commas, which tsconfig files allow. */
|
|
243
|
+
export function parseLooseJson(text: string): unknown {
|
|
244
|
+
const clean = text
|
|
245
|
+
.replace(/("(?:[^"\\]|\\.)*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (match, string: string | undefined) => string ?? "")
|
|
246
|
+
.replace(/,(\s*[}\]])/g, "$1");
|
|
247
|
+
try {
|
|
248
|
+
return JSON.parse(clean);
|
|
249
|
+
} catch {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
}
|