@urmzd/github-insights 2.0.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/.gitattributes +28 -0
- package/.github/dependabot.yml +6 -0
- package/.github/pull_request_template.md +14 -0
- package/.github/workflows/ci.yml +93 -0
- package/.github/workflows/release.yml +59 -0
- package/.nvmrc +1 -0
- package/.pre-commit-config.yaml +5 -0
- package/AGENTS.md +69 -0
- package/CHANGELOG.md +260 -0
- package/CONTRIBUTING.md +87 -0
- package/LICENSE +190 -0
- package/README.md +188 -0
- package/action.yml +45 -0
- package/biome.json +40 -0
- package/examples/classic/README.md +9 -0
- package/examples/classic/index.svg +14 -0
- package/examples/classic/metrics-calendar.svg +14 -0
- package/examples/classic/metrics-complexity.svg +14 -0
- package/examples/classic/metrics-contributions.svg +14 -0
- package/examples/classic/metrics-expertise.svg +14 -0
- package/examples/classic/metrics-languages.svg +14 -0
- package/examples/classic/metrics-pulse.svg +14 -0
- package/examples/ecosystem/README.md +59 -0
- package/examples/ecosystem/index.svg +14 -0
- package/examples/ecosystem/metrics-calendar.svg +14 -0
- package/examples/ecosystem/metrics-complexity.svg +14 -0
- package/examples/ecosystem/metrics-contributions.svg +14 -0
- package/examples/ecosystem/metrics-expertise.svg +14 -0
- package/examples/ecosystem/metrics-languages.svg +14 -0
- package/examples/ecosystem/metrics-pulse.svg +14 -0
- package/examples/minimal/README.md +9 -0
- package/examples/minimal/index.svg +14 -0
- package/examples/minimal/metrics-calendar.svg +14 -0
- package/examples/minimal/metrics-complexity.svg +14 -0
- package/examples/minimal/metrics-contributions.svg +14 -0
- package/examples/minimal/metrics-expertise.svg +14 -0
- package/examples/minimal/metrics-languages.svg +14 -0
- package/examples/minimal/metrics-pulse.svg +14 -0
- package/examples/modern/README.md +111 -0
- package/examples/modern/index.svg +14 -0
- package/examples/modern/metrics-calendar.svg +14 -0
- package/examples/modern/metrics-complexity.svg +14 -0
- package/examples/modern/metrics-contributions.svg +14 -0
- package/examples/modern/metrics-expertise.svg +14 -0
- package/examples/modern/metrics-languages.svg +14 -0
- package/examples/modern/metrics-pulse.svg +14 -0
- package/llms.txt +24 -0
- package/metrics/index.svg +14 -0
- package/metrics/metrics-calendar.svg +14 -0
- package/metrics/metrics-complexity.svg +14 -0
- package/metrics/metrics-contributions.svg +14 -0
- package/metrics/metrics-domains.svg +14 -0
- package/metrics/metrics-expertise.svg +14 -0
- package/metrics/metrics-languages.svg +14 -0
- package/metrics/metrics-pulse.svg +14 -0
- package/metrics/metrics-tech-stack.svg +14 -0
- package/package.json +35 -0
- package/skills/github-insights/SKILL.md +237 -0
- package/sr.yaml +16 -0
- package/src/__fixtures__/repos.ts +84 -0
- package/src/api.ts +729 -0
- package/src/components/bar-chart.test.tsx +38 -0
- package/src/components/bar-chart.tsx +54 -0
- package/src/components/contribution-calendar.test.tsx +44 -0
- package/src/components/contribution-calendar.tsx +94 -0
- package/src/components/contribution-cards.test.tsx +36 -0
- package/src/components/contribution-cards.tsx +58 -0
- package/src/components/donut-chart.test.tsx +36 -0
- package/src/components/donut-chart.tsx +102 -0
- package/src/components/full-svg.test.tsx +54 -0
- package/src/components/full-svg.tsx +59 -0
- package/src/components/project-cards.test.tsx +46 -0
- package/src/components/project-cards.tsx +66 -0
- package/src/components/section.test.tsx +69 -0
- package/src/components/section.tsx +79 -0
- package/src/components/stat-cards.test.tsx +32 -0
- package/src/components/stat-cards.tsx +57 -0
- package/src/components/style-defs.test.tsx +26 -0
- package/src/components/style-defs.tsx +27 -0
- package/src/components/tech-highlights.test.tsx +63 -0
- package/src/components/tech-highlights.tsx +109 -0
- package/src/config.test.ts +127 -0
- package/src/config.ts +103 -0
- package/src/index.ts +363 -0
- package/src/jsx-factory.test.tsx +86 -0
- package/src/jsx-factory.ts +46 -0
- package/src/jsx.d.ts +6 -0
- package/src/metrics.test.ts +669 -0
- package/src/metrics.ts +365 -0
- package/src/parsers.test.ts +247 -0
- package/src/parsers.ts +146 -0
- package/src/readme.test.ts +189 -0
- package/src/readme.ts +70 -0
- package/src/svg-utils.test.ts +66 -0
- package/src/svg-utils.ts +33 -0
- package/src/templates.test.ts +412 -0
- package/src/templates.ts +296 -0
- package/src/theme.ts +33 -0
- package/src/types.ts +235 -0
- package/teasr.toml +14 -0
- package/tsconfig.json +21 -0
- package/vitest.config.ts +12 -0
package/src/metrics.ts
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { renderContributionCalendar } from "./components/contribution-calendar.js";
|
|
2
|
+
import { renderContributionCards } from "./components/contribution-cards.js";
|
|
3
|
+
import { renderDonutChart } from "./components/donut-chart.js";
|
|
4
|
+
import { renderProjectCards } from "./components/project-cards.js";
|
|
5
|
+
import { renderStatCards } from "./components/stat-cards.js";
|
|
6
|
+
import { renderTechHighlights } from "./components/tech-highlights.js";
|
|
7
|
+
import { parseManifest } from "./parsers.js";
|
|
8
|
+
import type {
|
|
9
|
+
ContributionData,
|
|
10
|
+
LanguageItem,
|
|
11
|
+
ManifestMap,
|
|
12
|
+
ProjectItem,
|
|
13
|
+
ProjectStatus,
|
|
14
|
+
RepoClassificationInput,
|
|
15
|
+
RepoClassificationOutput,
|
|
16
|
+
RepoNode,
|
|
17
|
+
SectionDef,
|
|
18
|
+
TechHighlight,
|
|
19
|
+
} from "./types.js";
|
|
20
|
+
|
|
21
|
+
// ── Category Sets ───────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
const EXCLUDED_LANGUAGES = new Set(["Jupyter Notebook"]);
|
|
24
|
+
|
|
25
|
+
// ── Section keys ────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export const SECTION_KEYS: Record<string, string> = {
|
|
28
|
+
pulse: "metrics-pulse.svg",
|
|
29
|
+
languages: "metrics-languages.svg",
|
|
30
|
+
expertise: "metrics-expertise.svg",
|
|
31
|
+
projects: "metrics-complexity.svg",
|
|
32
|
+
contributions: "metrics-contributions.svg",
|
|
33
|
+
calendar: "metrics-calendar.svg",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// ── Aggregation ─────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
export const aggregateLanguages = (repos: RepoNode[]): LanguageItem[] => {
|
|
39
|
+
const langBytes = new Map<string, number>();
|
|
40
|
+
const langColors = new Map<string, string>();
|
|
41
|
+
|
|
42
|
+
for (const repo of repos) {
|
|
43
|
+
for (const edge of repo.languages?.edges || []) {
|
|
44
|
+
const name = edge.node.name;
|
|
45
|
+
if (EXCLUDED_LANGUAGES.has(name)) continue;
|
|
46
|
+
langBytes.set(name, (langBytes.get(name) || 0) + edge.size);
|
|
47
|
+
if (!langColors.has(name)) langColors.set(name, edge.node.color);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const total = [...langBytes.values()].reduce((a, b) => a + b, 0);
|
|
52
|
+
return [...langBytes.entries()]
|
|
53
|
+
.sort((a, b) => b[1] - a[1])
|
|
54
|
+
.slice(0, 10)
|
|
55
|
+
.map(([name, bytes]) => ({
|
|
56
|
+
name,
|
|
57
|
+
value: bytes,
|
|
58
|
+
percent: ((bytes / total) * 100).toFixed(1),
|
|
59
|
+
color: langColors.get(name) || "#8b949e",
|
|
60
|
+
}));
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// ── Dependency & Topic Collection ────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
export const collectAllDependencies = (
|
|
66
|
+
repos: RepoNode[],
|
|
67
|
+
manifests: ManifestMap,
|
|
68
|
+
): string[] => {
|
|
69
|
+
const seen = new Set<string>();
|
|
70
|
+
for (const repo of repos) {
|
|
71
|
+
const files = manifests.get(repo.name) || {};
|
|
72
|
+
for (const [filename, text] of Object.entries(files)) {
|
|
73
|
+
for (const dep of parseManifest(filename, text)) {
|
|
74
|
+
seen.add(dep);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return [...seen].sort();
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const collectAllTopics = (repos: RepoNode[]): string[] => {
|
|
82
|
+
const seen = new Set<string>();
|
|
83
|
+
for (const repo of repos) {
|
|
84
|
+
for (const node of repo.repositoryTopics?.nodes || []) {
|
|
85
|
+
seen.add(node.topic.name);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return [...seen].sort();
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// ── Project complexity scoring ──────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
const repoLanguages = (repo: RepoNode): string[] =>
|
|
94
|
+
repo.languages.edges.map((e) => e.node.name);
|
|
95
|
+
|
|
96
|
+
export const complexityScore = (repo: RepoNode): number => {
|
|
97
|
+
const langCount = repo.languages.edges.length;
|
|
98
|
+
const sizeMb = repo.diskUsage / 1024;
|
|
99
|
+
const stars = repo.stargazerCount;
|
|
100
|
+
const topics = repo.repositoryTopics.nodes.length;
|
|
101
|
+
|
|
102
|
+
// Weighted sum: language diversity matters most, then code size, then
|
|
103
|
+
// social proof (stars) and topic breadth as tie-breakers.
|
|
104
|
+
return (
|
|
105
|
+
langCount * 10 +
|
|
106
|
+
Math.min(sizeMb, 50) * 2 +
|
|
107
|
+
Math.log2(stars + 1) * 3 +
|
|
108
|
+
topics * 2
|
|
109
|
+
);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const toProjectItem = (repo: RepoNode): ProjectItem => ({
|
|
113
|
+
name: repo.name,
|
|
114
|
+
url: repo.url,
|
|
115
|
+
description: repo.description || "",
|
|
116
|
+
stars: repo.stargazerCount,
|
|
117
|
+
languageCount: repo.languages.edges.length,
|
|
118
|
+
codeSize: repo.diskUsage,
|
|
119
|
+
languages: repoLanguages(repo),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ── Top Projects by Stars ───────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
export const getTopProjectsByStars = (repos: RepoNode[]): ProjectItem[] =>
|
|
125
|
+
[...repos]
|
|
126
|
+
.sort((a, b) => b.stargazerCount - a.stargazerCount)
|
|
127
|
+
.slice(0, 5)
|
|
128
|
+
.map(toProjectItem);
|
|
129
|
+
|
|
130
|
+
// ── Top Projects by Complexity ─────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
export const getTopProjectsByComplexity = (
|
|
133
|
+
repos: RepoNode[],
|
|
134
|
+
): ProjectItem[] => {
|
|
135
|
+
const sorted = [...repos].sort(
|
|
136
|
+
(a, b) => complexityScore(b) - complexityScore(a),
|
|
137
|
+
);
|
|
138
|
+
for (const repo of sorted) {
|
|
139
|
+
console.info(
|
|
140
|
+
`[complexity] ${repo.name}: ${complexityScore(repo).toFixed(1)} (${repo.languages.edges.length} langs, ${repo.diskUsage}KB)`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
return sorted.map(toProjectItem);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// ── Project classification ──────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
const ACTIVE_COMMIT_THRESHOLD = 5;
|
|
149
|
+
|
|
150
|
+
export const buildClassificationInputs = (
|
|
151
|
+
repos: RepoNode[],
|
|
152
|
+
contributionData: ContributionData,
|
|
153
|
+
): RepoClassificationInput[] => {
|
|
154
|
+
const commitMap = new Map<string, number>();
|
|
155
|
+
for (const entry of contributionData.commitContributionsByRepository || []) {
|
|
156
|
+
commitMap.set(entry.repository.name, entry.contributions.totalCount);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return repos.map((repo) => ({
|
|
160
|
+
name: repo.name,
|
|
161
|
+
description: repo.description || "",
|
|
162
|
+
stars: repo.stargazerCount,
|
|
163
|
+
diskUsageKb: repo.diskUsage,
|
|
164
|
+
languages: repo.languages.edges.map((e) => e.node.name),
|
|
165
|
+
commitsLastYear: commitMap.get(repo.name) || 0,
|
|
166
|
+
createdAt: repo.createdAt,
|
|
167
|
+
pushedAt: repo.pushedAt,
|
|
168
|
+
topicCount: repo.repositoryTopics.nodes.length,
|
|
169
|
+
}));
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const heuristicStatus = (
|
|
173
|
+
commits: number,
|
|
174
|
+
createdAt?: string,
|
|
175
|
+
): ProjectStatus => {
|
|
176
|
+
const isYoung = createdAt
|
|
177
|
+
? Date.now() - new Date(createdAt).getTime() < 6 * 30 * 24 * 60 * 60 * 1000
|
|
178
|
+
: false;
|
|
179
|
+
|
|
180
|
+
if (isYoung && commits >= ACTIVE_COMMIT_THRESHOLD) return "active";
|
|
181
|
+
if (commits > 0) return "maintained";
|
|
182
|
+
return "inactive";
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
export const splitProjectsByRecency = (
|
|
186
|
+
repos: RepoNode[],
|
|
187
|
+
contributionData: ContributionData,
|
|
188
|
+
aiClassifications?: RepoClassificationOutput[],
|
|
189
|
+
): {
|
|
190
|
+
active: ProjectItem[];
|
|
191
|
+
maintained: ProjectItem[];
|
|
192
|
+
inactive: ProjectItem[];
|
|
193
|
+
} => {
|
|
194
|
+
const commitMap = new Map<string, number>();
|
|
195
|
+
for (const entry of contributionData.commitContributionsByRepository || []) {
|
|
196
|
+
commitMap.set(entry.repository.name, entry.contributions.totalCount);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const aiMap = new Map<string, RepoClassificationOutput>();
|
|
200
|
+
if (aiClassifications) {
|
|
201
|
+
for (const c of aiClassifications) {
|
|
202
|
+
aiMap.set(c.name, c);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const activeRepos: RepoNode[] = [];
|
|
207
|
+
const maintainedRepos: RepoNode[] = [];
|
|
208
|
+
const inactiveRepos: RepoNode[] = [];
|
|
209
|
+
|
|
210
|
+
for (const repo of repos) {
|
|
211
|
+
const commits = commitMap.get(repo.name) || 0;
|
|
212
|
+
const aiEntry = aiMap.get(repo.name);
|
|
213
|
+
const status = aiEntry?.status || heuristicStatus(commits, repo.createdAt);
|
|
214
|
+
const source = aiEntry ? "ai" : "heuristic";
|
|
215
|
+
|
|
216
|
+
if (status === "active") {
|
|
217
|
+
activeRepos.push(repo);
|
|
218
|
+
} else if (status === "maintained") {
|
|
219
|
+
maintainedRepos.push(repo);
|
|
220
|
+
} else {
|
|
221
|
+
inactiveRepos.push(repo);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
console.info(
|
|
225
|
+
`[${status.padEnd(10)}] ${repo.name} (${commits} commits, complexity=${complexityScore(repo).toFixed(1)}, source=${source})`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
console.info(
|
|
230
|
+
`Split: ${activeRepos.length} active, ${maintainedRepos.length} maintained, ${inactiveRepos.length} inactive`,
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
const sortByComplexity = (a: RepoNode, b: RepoNode) =>
|
|
234
|
+
complexityScore(b) - complexityScore(a);
|
|
235
|
+
|
|
236
|
+
const toProjectItemWithSummary = (repo: RepoNode): ProjectItem => ({
|
|
237
|
+
...toProjectItem(repo),
|
|
238
|
+
summary: aiMap.get(repo.name)?.summary || undefined,
|
|
239
|
+
category: aiMap.get(repo.name)?.category || undefined,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const active: ProjectItem[] = activeRepos
|
|
243
|
+
.sort(sortByComplexity)
|
|
244
|
+
.map(toProjectItemWithSummary);
|
|
245
|
+
const maintained: ProjectItem[] = maintainedRepos
|
|
246
|
+
.sort(sortByComplexity)
|
|
247
|
+
.map(toProjectItemWithSummary);
|
|
248
|
+
const inactive: ProjectItem[] = inactiveRepos
|
|
249
|
+
.sort(sortByComplexity)
|
|
250
|
+
.map(toProjectItemWithSummary);
|
|
251
|
+
|
|
252
|
+
return { active, maintained, inactive };
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
// ── Section definitions ─────────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
export const buildSections = ({
|
|
258
|
+
languages,
|
|
259
|
+
techHighlights,
|
|
260
|
+
projects,
|
|
261
|
+
contributionData,
|
|
262
|
+
}: {
|
|
263
|
+
languages: LanguageItem[];
|
|
264
|
+
techHighlights: TechHighlight[];
|
|
265
|
+
projects: ProjectItem[];
|
|
266
|
+
contributionData: ContributionData;
|
|
267
|
+
}): SectionDef[] => {
|
|
268
|
+
const sections: SectionDef[] = [];
|
|
269
|
+
|
|
270
|
+
// 1. At a Glance
|
|
271
|
+
sections.push({
|
|
272
|
+
filename: "metrics-pulse.svg",
|
|
273
|
+
title: "At a Glance",
|
|
274
|
+
subtitle: "Contribution activity over the past year",
|
|
275
|
+
renderBody: (y: number) => {
|
|
276
|
+
const stats = [
|
|
277
|
+
{
|
|
278
|
+
label: "COMMITS",
|
|
279
|
+
value:
|
|
280
|
+
contributionData.contributions.totalCommitContributions.toLocaleString(),
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
label: "PRS",
|
|
284
|
+
value:
|
|
285
|
+
contributionData.contributions.totalPullRequestContributions.toLocaleString(),
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
label: "REVIEWS",
|
|
289
|
+
value:
|
|
290
|
+
contributionData.contributions.totalPullRequestReviewContributions.toLocaleString(),
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
label: "REPOS",
|
|
294
|
+
value:
|
|
295
|
+
contributionData.contributions.totalRepositoriesWithContributedCommits.toLocaleString(),
|
|
296
|
+
},
|
|
297
|
+
];
|
|
298
|
+
return renderStatCards(stats, y);
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// 2. Languages
|
|
303
|
+
sections.push({
|
|
304
|
+
filename: "metrics-languages.svg",
|
|
305
|
+
title: "Languages",
|
|
306
|
+
subtitle: "By bytes of code across all public repos",
|
|
307
|
+
renderBody: (y: number) => renderDonutChart(languages, y),
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// 3. Expertise
|
|
311
|
+
if (techHighlights.length > 0) {
|
|
312
|
+
sections.push({
|
|
313
|
+
filename: "metrics-expertise.svg",
|
|
314
|
+
title: "Expertise",
|
|
315
|
+
subtitle:
|
|
316
|
+
"Curated from dependencies, topics, and languages via AI analysis",
|
|
317
|
+
renderBody: (y: number) => renderTechHighlights(techHighlights, y),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// 4. Signature Projects
|
|
322
|
+
sections.push({
|
|
323
|
+
filename: "metrics-complexity.svg",
|
|
324
|
+
title: "Signature Projects",
|
|
325
|
+
subtitle: "Top projects by technical complexity",
|
|
326
|
+
renderBody: (y: number) => renderProjectCards(projects, y),
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// 5. Contribution Calendar
|
|
330
|
+
if (contributionData.contributionCalendar) {
|
|
331
|
+
const calendarData = contributionData.contributionCalendar;
|
|
332
|
+
sections.push({
|
|
333
|
+
filename: "metrics-calendar.svg",
|
|
334
|
+
title: "Contribution Calendar",
|
|
335
|
+
subtitle: `${calendarData.totalContributions.toLocaleString()} contributions in the last year`,
|
|
336
|
+
renderBody: (y: number) => renderContributionCalendar(calendarData, y),
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// 6. Open Source Contributions
|
|
341
|
+
if (contributionData.externalRepos.nodes.length > 0) {
|
|
342
|
+
sections.push({
|
|
343
|
+
filename: "metrics-contributions.svg",
|
|
344
|
+
title: "Open Source Contributions",
|
|
345
|
+
subtitle: "External repositories contributed to (all time)",
|
|
346
|
+
renderBody: (y: number) => {
|
|
347
|
+
const repos = contributionData.externalRepos.nodes.slice(0, 5);
|
|
348
|
+
const highlights = repos.map((r) => ({
|
|
349
|
+
project: r.nameWithOwner,
|
|
350
|
+
detail: [
|
|
351
|
+
r.stargazerCount > 0
|
|
352
|
+
? `\u2605 ${r.stargazerCount.toLocaleString()}`
|
|
353
|
+
: null,
|
|
354
|
+
r.primaryLanguage?.name,
|
|
355
|
+
]
|
|
356
|
+
.filter(Boolean)
|
|
357
|
+
.join(" \u00b7 "),
|
|
358
|
+
}));
|
|
359
|
+
return renderContributionCards(highlights, y);
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return sections;
|
|
365
|
+
};
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
CargoParser,
|
|
4
|
+
GoModParser,
|
|
5
|
+
NodePackageParser,
|
|
6
|
+
PARSERS,
|
|
7
|
+
PyprojectParser,
|
|
8
|
+
parseManifest,
|
|
9
|
+
RequirementsTxtParser,
|
|
10
|
+
} from "./parsers.js";
|
|
11
|
+
|
|
12
|
+
describe("NodePackageParser", () => {
|
|
13
|
+
it("returns both deps and devDeps", () => {
|
|
14
|
+
const json = JSON.stringify({
|
|
15
|
+
dependencies: { react: "^18.0.0", next: "^14.0.0" },
|
|
16
|
+
devDependencies: { vitest: "^2.0.0" },
|
|
17
|
+
});
|
|
18
|
+
expect(NodePackageParser.parseDependencies(json)).toEqual([
|
|
19
|
+
"react",
|
|
20
|
+
"next",
|
|
21
|
+
"vitest",
|
|
22
|
+
]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("returns only dependencies when devDependencies is missing", () => {
|
|
26
|
+
const json = JSON.stringify({ dependencies: { express: "^4.0.0" } });
|
|
27
|
+
expect(NodePackageParser.parseDependencies(json)).toEqual(["express"]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("returns only devDependencies when dependencies is missing", () => {
|
|
31
|
+
const json = JSON.stringify({ devDependencies: { jest: "^29.0.0" } });
|
|
32
|
+
expect(NodePackageParser.parseDependencies(json)).toEqual(["jest"]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns [] for invalid JSON", () => {
|
|
36
|
+
expect(NodePackageParser.parseDependencies("not json")).toEqual([]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("returns [] for empty object", () => {
|
|
40
|
+
expect(NodePackageParser.parseDependencies("{}")).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("CargoParser", () => {
|
|
45
|
+
it("parses [dependencies] and [dev-dependencies]", () => {
|
|
46
|
+
const toml = `[dependencies]
|
|
47
|
+
serde = "1.0"
|
|
48
|
+
tokio = { version = "1", features = ["full"] }
|
|
49
|
+
|
|
50
|
+
[dev-dependencies]
|
|
51
|
+
criterion = "0.5"
|
|
52
|
+
`;
|
|
53
|
+
expect(CargoParser.parseDependencies(toml)).toEqual([
|
|
54
|
+
"serde",
|
|
55
|
+
"tokio",
|
|
56
|
+
"criterion",
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("stops collecting when hitting a non-deps section", () => {
|
|
61
|
+
const toml = `[dependencies]
|
|
62
|
+
serde = "1.0"
|
|
63
|
+
|
|
64
|
+
[package]
|
|
65
|
+
name = "myapp"
|
|
66
|
+
version = "0.1.0"
|
|
67
|
+
`;
|
|
68
|
+
expect(CargoParser.parseDependencies(toml)).toEqual(["serde"]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("ignores comments", () => {
|
|
72
|
+
const toml = `[dependencies]
|
|
73
|
+
# this is a comment
|
|
74
|
+
serde = "1.0"
|
|
75
|
+
`;
|
|
76
|
+
expect(CargoParser.parseDependencies(toml)).toEqual(["serde"]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("handles inline table syntax", () => {
|
|
80
|
+
const toml = `[dependencies]
|
|
81
|
+
tokio = { version = "1", features = ["full"] }
|
|
82
|
+
`;
|
|
83
|
+
expect(CargoParser.parseDependencies(toml)).toEqual(["tokio"]);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("GoModParser", () => {
|
|
88
|
+
it("extracts last path segment from require block", () => {
|
|
89
|
+
const gomod = `module example.com/myapp
|
|
90
|
+
|
|
91
|
+
go 1.21
|
|
92
|
+
|
|
93
|
+
require (
|
|
94
|
+
github.com/gin-gonic/gin v1.9.1
|
|
95
|
+
github.com/stretchr/testify v1.8.4
|
|
96
|
+
)
|
|
97
|
+
`;
|
|
98
|
+
expect(GoModParser.parseDependencies(gomod)).toEqual(["gin", "testify"]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("ignores comments in require block", () => {
|
|
102
|
+
const gomod = `module example.com/myapp
|
|
103
|
+
|
|
104
|
+
require (
|
|
105
|
+
// indirect dependency
|
|
106
|
+
github.com/gin-gonic/gin v1.9.1
|
|
107
|
+
)
|
|
108
|
+
`;
|
|
109
|
+
expect(GoModParser.parseDependencies(gomod)).toEqual(["gin"]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("returns [] when no require block", () => {
|
|
113
|
+
expect(
|
|
114
|
+
GoModParser.parseDependencies("module example.com/myapp\n\ngo 1.21\n"),
|
|
115
|
+
).toEqual([]);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("PyprojectParser", () => {
|
|
120
|
+
it("parses PEP 621 dependencies array", () => {
|
|
121
|
+
const toml = `[project]
|
|
122
|
+
name = "myapp"
|
|
123
|
+
dependencies = [
|
|
124
|
+
"fastapi>=0.100.0",
|
|
125
|
+
"uvicorn[standard]>=0.23.0",
|
|
126
|
+
]
|
|
127
|
+
`;
|
|
128
|
+
expect(PyprojectParser.parseDependencies(toml)).toEqual([
|
|
129
|
+
"fastapi",
|
|
130
|
+
"uvicorn",
|
|
131
|
+
]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("parses Poetry [tool.poetry.dependencies]", () => {
|
|
135
|
+
const toml = `[tool.poetry.dependencies]
|
|
136
|
+
python = "^3.11"
|
|
137
|
+
django = "^4.2"
|
|
138
|
+
celery = "^5.3"
|
|
139
|
+
`;
|
|
140
|
+
expect(PyprojectParser.parseDependencies(toml)).toEqual([
|
|
141
|
+
"django",
|
|
142
|
+
"celery",
|
|
143
|
+
]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("strips version specifiers", () => {
|
|
147
|
+
const toml = `[project]
|
|
148
|
+
dependencies = [
|
|
149
|
+
"requests>=2.28,<3",
|
|
150
|
+
"pydantic~=2.0",
|
|
151
|
+
]
|
|
152
|
+
`;
|
|
153
|
+
const result = PyprojectParser.parseDependencies(toml);
|
|
154
|
+
expect(result).toEqual(["requests", "pydantic"]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("excludes python from Poetry deps", () => {
|
|
158
|
+
const toml = `[tool.poetry.dependencies]
|
|
159
|
+
python = "^3.11"
|
|
160
|
+
flask = "^2.3"
|
|
161
|
+
`;
|
|
162
|
+
expect(PyprojectParser.parseDependencies(toml)).toEqual(["flask"]);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("RequirementsTxtParser", () => {
|
|
167
|
+
it("strips version specifiers", () => {
|
|
168
|
+
const txt = `requests>=2.28.0
|
|
169
|
+
flask==2.3.3
|
|
170
|
+
numpy~=1.24
|
|
171
|
+
`;
|
|
172
|
+
expect(RequirementsTxtParser.parseDependencies(txt)).toEqual([
|
|
173
|
+
"requests",
|
|
174
|
+
"flask",
|
|
175
|
+
"numpy",
|
|
176
|
+
]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("ignores comments and blank lines", () => {
|
|
180
|
+
const txt = `# comment
|
|
181
|
+
requests
|
|
182
|
+
|
|
183
|
+
flask
|
|
184
|
+
`;
|
|
185
|
+
expect(RequirementsTxtParser.parseDependencies(txt)).toEqual([
|
|
186
|
+
"requests",
|
|
187
|
+
"flask",
|
|
188
|
+
]);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("ignores flags like -r and -e", () => {
|
|
192
|
+
const txt = `-r base.txt
|
|
193
|
+
-e git+https://example.com
|
|
194
|
+
requests
|
|
195
|
+
`;
|
|
196
|
+
expect(RequirementsTxtParser.parseDependencies(txt)).toEqual(["requests"]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("returns [] for empty input", () => {
|
|
200
|
+
expect(RequirementsTxtParser.parseDependencies("")).toEqual([]);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe("PARSERS", () => {
|
|
205
|
+
it("covers all expected filenames", () => {
|
|
206
|
+
const allFilenames = PARSERS.flatMap((p) => p.filenames).sort();
|
|
207
|
+
expect(allFilenames).toEqual([
|
|
208
|
+
"Cargo.toml",
|
|
209
|
+
"go.mod",
|
|
210
|
+
"package.json",
|
|
211
|
+
"pyproject.toml",
|
|
212
|
+
"requirements.txt",
|
|
213
|
+
]);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("parseManifest", () => {
|
|
218
|
+
it("dispatches to NodePackageParser for package.json", () => {
|
|
219
|
+
const json = JSON.stringify({ dependencies: { react: "^18" } });
|
|
220
|
+
expect(parseManifest("package.json", json)).toEqual(["react"]);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("dispatches to CargoParser for Cargo.toml", () => {
|
|
224
|
+
const toml = '[dependencies]\nserde = "1.0"\n';
|
|
225
|
+
expect(parseManifest("Cargo.toml", toml)).toEqual(["serde"]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("dispatches to GoModParser for go.mod", () => {
|
|
229
|
+
const gomod = "module x\nrequire (\n\tgithub.com/foo/bar v1.0\n)\n";
|
|
230
|
+
expect(parseManifest("go.mod", gomod)).toEqual(["bar"]);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("dispatches to PyprojectParser for pyproject.toml", () => {
|
|
234
|
+
const toml = '[project]\ndependencies = [\n "flask",\n]\n';
|
|
235
|
+
expect(parseManifest("pyproject.toml", toml)).toEqual(["flask"]);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("dispatches to RequirementsTxtParser for requirements.txt", () => {
|
|
239
|
+
expect(parseManifest("requirements.txt", "requests\n")).toEqual([
|
|
240
|
+
"requests",
|
|
241
|
+
]);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("returns [] for unknown filename", () => {
|
|
245
|
+
expect(parseManifest("Makefile", "all: build")).toEqual([]);
|
|
246
|
+
});
|
|
247
|
+
});
|