doppelgangers 0.0.1
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/.husky/pre-commit +21 -0
- package/README.md +87 -0
- package/biome.json +27 -0
- package/dist/build.d.ts +10 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +951 -0
- package/dist/build.js.map +1 -0
- package/dist/embed.d.ts +11 -0
- package/dist/embed.d.ts.map +1 -0
- package/dist/embed.js +147 -0
- package/dist/embed.js.map +1 -0
- package/dist/triage.d.ts +3 -0
- package/dist/triage.d.ts.map +1 -0
- package/dist/triage.js +220 -0
- package/dist/triage.js.map +1 -0
- package/package.json +36 -0
- package/src/build.ts +995 -0
- package/src/embed.ts +182 -0
- package/src/triage.ts +256 -0
- package/tsconfig.json +21 -0
package/src/embed.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import OpenAI from "openai";
|
|
3
|
+
import path from "path";
|
|
4
|
+
|
|
5
|
+
export interface EmbedOptions {
|
|
6
|
+
input: string;
|
|
7
|
+
output: string;
|
|
8
|
+
model: string;
|
|
9
|
+
batchSize: number;
|
|
10
|
+
maxChars: number;
|
|
11
|
+
bodyChars: number;
|
|
12
|
+
resume: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface Item {
|
|
16
|
+
url: string;
|
|
17
|
+
number?: number;
|
|
18
|
+
title: string;
|
|
19
|
+
body: string | null;
|
|
20
|
+
state?: string;
|
|
21
|
+
type?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface EmbeddingRecord {
|
|
25
|
+
url: string;
|
|
26
|
+
number?: number;
|
|
27
|
+
title: string;
|
|
28
|
+
body: string;
|
|
29
|
+
state?: string;
|
|
30
|
+
type?: string;
|
|
31
|
+
embedding: number[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
35
|
+
|
|
36
|
+
const buildText = (title: string, body: string | null, maxChars: number): string => {
|
|
37
|
+
const bodyText = (body || "").replace(/\r\n/g, "\n").trim();
|
|
38
|
+
const combined = `${title}\n\n${bodyText}`.trim();
|
|
39
|
+
return combined.slice(0, maxChars);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const buildSnippet = (body: string | null, bodyChars: number): string => {
|
|
43
|
+
if (!body) return "";
|
|
44
|
+
return body.replace(/\s+/g, " ").trim().slice(0, bodyChars);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export async function embed(options: EmbedOptions): Promise<void> {
|
|
48
|
+
if (!process.env.OPENAI_API_KEY) {
|
|
49
|
+
console.error("OPENAI_API_KEY is required");
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const inputPath = path.resolve(options.input);
|
|
54
|
+
const outputPath = path.resolve(options.output);
|
|
55
|
+
|
|
56
|
+
const items: Item[] = JSON.parse(fs.readFileSync(inputPath, "utf8"));
|
|
57
|
+
|
|
58
|
+
const existing = new Map<string, EmbeddingRecord>();
|
|
59
|
+
if (options.resume && fs.existsSync(outputPath)) {
|
|
60
|
+
const lines = fs.readFileSync(outputPath, "utf8").split("\n").filter(Boolean);
|
|
61
|
+
for (const line of lines) {
|
|
62
|
+
try {
|
|
63
|
+
const item: EmbeddingRecord = JSON.parse(line);
|
|
64
|
+
if (item.url) existing.set(item.url, item);
|
|
65
|
+
} catch {
|
|
66
|
+
// skip invalid lines
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
|
72
|
+
|
|
73
|
+
const outputDir = path.dirname(outputPath);
|
|
74
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
75
|
+
const outputStream = fs.createWriteStream(outputPath, { flags: options.resume ? "a" : "w" });
|
|
76
|
+
|
|
77
|
+
const pending = items.filter((item) => item?.url && !existing.has(item.url));
|
|
78
|
+
const total = pending.length;
|
|
79
|
+
let processed = 0;
|
|
80
|
+
const skipped = items.length - pending.length;
|
|
81
|
+
|
|
82
|
+
let batchInputs: string[] = [];
|
|
83
|
+
let batchMeta: { url: string; number?: number; title: string; body: string; state?: string; type?: string }[] = [];
|
|
84
|
+
|
|
85
|
+
const createEmbeddings = async (inputs: string[], attempt = 1): Promise<number[][]> => {
|
|
86
|
+
try {
|
|
87
|
+
const response = await client.embeddings.create({
|
|
88
|
+
model: options.model,
|
|
89
|
+
input: inputs,
|
|
90
|
+
});
|
|
91
|
+
return response.data.map((item) => item.embedding);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (attempt >= 5) throw error;
|
|
94
|
+
const delay = 1000 * attempt;
|
|
95
|
+
console.warn(`Embedding request failed, retrying in ${delay}ms`, (error as Error).message);
|
|
96
|
+
await sleep(delay);
|
|
97
|
+
return createEmbeddings(inputs, attempt + 1);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const flushBatch = async () => {
|
|
102
|
+
if (!batchInputs.length) return;
|
|
103
|
+
const embeddings = await createEmbeddings(batchInputs);
|
|
104
|
+
for (let i = 0; i < embeddings.length; i += 1) {
|
|
105
|
+
const meta = batchMeta[i];
|
|
106
|
+
const record: EmbeddingRecord = {
|
|
107
|
+
url: meta.url,
|
|
108
|
+
number: meta.number,
|
|
109
|
+
title: meta.title,
|
|
110
|
+
body: meta.body,
|
|
111
|
+
state: meta.state,
|
|
112
|
+
type: meta.type,
|
|
113
|
+
embedding: embeddings[i],
|
|
114
|
+
};
|
|
115
|
+
outputStream.write(`${JSON.stringify(record)}\n`);
|
|
116
|
+
processed += 1;
|
|
117
|
+
if (processed % 50 === 0 || processed === total) {
|
|
118
|
+
console.log(`Embedded ${processed}/${total} items`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
batchInputs = [];
|
|
122
|
+
batchMeta = [];
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
for (const item of pending) {
|
|
126
|
+
const title = item.title || "";
|
|
127
|
+
const body = item.body || "";
|
|
128
|
+
const text = buildText(title, body, options.maxChars);
|
|
129
|
+
batchInputs.push(text || title || item.url);
|
|
130
|
+
batchMeta.push({
|
|
131
|
+
url: item.url,
|
|
132
|
+
number: item.number,
|
|
133
|
+
title,
|
|
134
|
+
body: buildSnippet(body, options.bodyChars),
|
|
135
|
+
state: item.state,
|
|
136
|
+
type: item.type,
|
|
137
|
+
});
|
|
138
|
+
if (batchInputs.length >= options.batchSize) {
|
|
139
|
+
await flushBatch();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
await flushBatch();
|
|
144
|
+
outputStream.end();
|
|
145
|
+
|
|
146
|
+
console.log(`Done. Embedded ${processed}/${total} PRs, skipped ${skipped}.`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// CLI entry point
|
|
150
|
+
if (process.argv[1]?.endsWith("embed.js") || process.argv[1]?.endsWith("embed.ts")) {
|
|
151
|
+
const args = process.argv.slice(2);
|
|
152
|
+
const options: EmbedOptions = {
|
|
153
|
+
input: "prs.json",
|
|
154
|
+
output: "embeddings.jsonl",
|
|
155
|
+
model: "text-embedding-3-small",
|
|
156
|
+
batchSize: 100,
|
|
157
|
+
maxChars: 4000,
|
|
158
|
+
bodyChars: 2000,
|
|
159
|
+
resume: true,
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
163
|
+
const arg = args[i];
|
|
164
|
+
if (arg === "--input") {
|
|
165
|
+
options.input = args[++i];
|
|
166
|
+
} else if (arg === "--output") {
|
|
167
|
+
options.output = args[++i];
|
|
168
|
+
} else if (arg === "--model") {
|
|
169
|
+
options.model = args[++i];
|
|
170
|
+
} else if (arg === "--batch") {
|
|
171
|
+
options.batchSize = Number(args[++i]);
|
|
172
|
+
} else if (arg === "--max-chars") {
|
|
173
|
+
options.maxChars = Number(args[++i]);
|
|
174
|
+
} else if (arg === "--body-chars") {
|
|
175
|
+
options.bodyChars = Number(args[++i]);
|
|
176
|
+
} else if (arg === "--no-resume") {
|
|
177
|
+
options.resume = false;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
embed(options);
|
|
182
|
+
}
|
package/src/triage.ts
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn } from "child_process";
|
|
4
|
+
import fs from "fs";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import readline from "readline";
|
|
7
|
+
import { type BuildOptions, build } from "./build.js";
|
|
8
|
+
import { type EmbedOptions, embed } from "./embed.js";
|
|
9
|
+
|
|
10
|
+
type ItemState = "open" | "closed" | "all";
|
|
11
|
+
type ItemType = "pr" | "issue" | "all";
|
|
12
|
+
|
|
13
|
+
interface TriageOptions {
|
|
14
|
+
repo: string | null;
|
|
15
|
+
state: ItemState;
|
|
16
|
+
type: ItemType;
|
|
17
|
+
output: string;
|
|
18
|
+
embeddings: string;
|
|
19
|
+
html: string;
|
|
20
|
+
model: string;
|
|
21
|
+
batch: number;
|
|
22
|
+
maxChars: number;
|
|
23
|
+
bodyChars: number;
|
|
24
|
+
neighbors: number;
|
|
25
|
+
minDist: number;
|
|
26
|
+
search: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseRepo(repo: string): { owner: string; name: string } | null {
|
|
30
|
+
const trimmed = repo.replace(/\s+/g, "");
|
|
31
|
+
const match = trimmed.match(/github\.com[/:]([^/]+)\/([^/.]+)(?:\.git)?/i);
|
|
32
|
+
if (match) {
|
|
33
|
+
return { owner: match[1], name: match[2] };
|
|
34
|
+
}
|
|
35
|
+
if (trimmed.includes("/")) {
|
|
36
|
+
const [owner, name] = trimmed.split("/");
|
|
37
|
+
if (owner && name) return { owner, name };
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface FetchResult {
|
|
43
|
+
total: number;
|
|
44
|
+
prs: number;
|
|
45
|
+
issues: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function fetchItems(
|
|
49
|
+
owner: string,
|
|
50
|
+
name: string,
|
|
51
|
+
state: ItemState,
|
|
52
|
+
type: ItemType,
|
|
53
|
+
outputPath: string,
|
|
54
|
+
): Promise<FetchResult> {
|
|
55
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
56
|
+
|
|
57
|
+
const repoLabel = `${owner}/${name}`;
|
|
58
|
+
const items: object[] = [];
|
|
59
|
+
let prCount = 0;
|
|
60
|
+
let issueCount = 0;
|
|
61
|
+
|
|
62
|
+
const fetchEndpoint = async (endpoint: string, itemType: "pr" | "issue") => {
|
|
63
|
+
const jqFilter =
|
|
64
|
+
itemType === "issue"
|
|
65
|
+
? '.[] | select(.pull_request == null) | {url: .html_url, number: .number, title: .title, body: .body, state: .state, type: "issue"}'
|
|
66
|
+
: '.[] | {url: .html_url, number: .number, title: .title, body: .body, state: .state, type: "pr"}';
|
|
67
|
+
|
|
68
|
+
const gh = spawn("gh", ["api", "--paginate", endpoint, "--jq", jqFilter], {
|
|
69
|
+
stdio: ["ignore", "pipe", "inherit"],
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const rl = readline.createInterface({
|
|
73
|
+
input: gh.stdout!,
|
|
74
|
+
crlfDelay: Number.POSITIVE_INFINITY,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const readPromise = (async () => {
|
|
78
|
+
for await (const line of rl) {
|
|
79
|
+
const trimmed = line.trim();
|
|
80
|
+
if (!trimmed) continue;
|
|
81
|
+
try {
|
|
82
|
+
const item = JSON.parse(trimmed);
|
|
83
|
+
items.push(item);
|
|
84
|
+
if (itemType === "pr") prCount++;
|
|
85
|
+
else issueCount++;
|
|
86
|
+
const total = prCount + issueCount;
|
|
87
|
+
if (total % 200 === 0) {
|
|
88
|
+
console.log(`[${repoLabel}] Fetched ${prCount} PRs, ${issueCount} issues`);
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
// skip invalid JSON
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
})();
|
|
95
|
+
|
|
96
|
+
const exitPromise = new Promise<void>((resolve, reject) => {
|
|
97
|
+
gh.on("close", (code) => {
|
|
98
|
+
if (code === 0) resolve();
|
|
99
|
+
else reject(new Error(`gh api ${endpoint} exited with code ${code}`));
|
|
100
|
+
});
|
|
101
|
+
gh.on("error", reject);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
await Promise.all([readPromise, exitPromise]);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
if (type === "pr" || type === "all") {
|
|
108
|
+
const prEndpoint = `/repos/${owner}/${name}/pulls?state=${state}&per_page=100`;
|
|
109
|
+
await fetchEndpoint(prEndpoint, "pr");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (type === "issue" || type === "all") {
|
|
113
|
+
const issueEndpoint = `/repos/${owner}/${name}/issues?state=${state}&per_page=100`;
|
|
114
|
+
await fetchEndpoint(issueEndpoint, "issue");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
fs.writeFileSync(outputPath, JSON.stringify(items, null, 2));
|
|
118
|
+
|
|
119
|
+
return { total: items.length, prs: prCount, issues: issueCount };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function main() {
|
|
123
|
+
const args = process.argv.slice(2);
|
|
124
|
+
const options: TriageOptions = {
|
|
125
|
+
repo: null,
|
|
126
|
+
state: "open",
|
|
127
|
+
type: "all",
|
|
128
|
+
output: "prs.json",
|
|
129
|
+
embeddings: "embeddings.jsonl",
|
|
130
|
+
html: "triage.html",
|
|
131
|
+
model: "text-embedding-3-small",
|
|
132
|
+
batch: 100,
|
|
133
|
+
maxChars: 4000,
|
|
134
|
+
bodyChars: 2000,
|
|
135
|
+
neighbors: 15,
|
|
136
|
+
minDist: 0.1,
|
|
137
|
+
search: false,
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
141
|
+
const arg = args[i];
|
|
142
|
+
if (arg === "--repo") {
|
|
143
|
+
options.repo = args[++i];
|
|
144
|
+
} else if (arg === "--state") {
|
|
145
|
+
const val = args[++i];
|
|
146
|
+
if (val !== "open" && val !== "closed" && val !== "all") {
|
|
147
|
+
console.error("--state must be open, closed, or all");
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
options.state = val;
|
|
151
|
+
} else if (arg === "--type") {
|
|
152
|
+
const val = args[++i];
|
|
153
|
+
if (val !== "pr" && val !== "issue" && val !== "all") {
|
|
154
|
+
console.error("--type must be pr, issue, or all");
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
options.type = val;
|
|
158
|
+
} else if (arg === "--output") {
|
|
159
|
+
options.output = args[++i];
|
|
160
|
+
} else if (arg === "--embeddings") {
|
|
161
|
+
options.embeddings = args[++i];
|
|
162
|
+
} else if (arg === "--html") {
|
|
163
|
+
options.html = args[++i];
|
|
164
|
+
} else if (arg === "--model") {
|
|
165
|
+
options.model = args[++i];
|
|
166
|
+
} else if (arg === "--batch") {
|
|
167
|
+
options.batch = Number(args[++i]);
|
|
168
|
+
} else if (arg === "--max-chars") {
|
|
169
|
+
options.maxChars = Number(args[++i]);
|
|
170
|
+
} else if (arg === "--body-chars") {
|
|
171
|
+
options.bodyChars = Number(args[++i]);
|
|
172
|
+
} else if (arg === "--neighbors") {
|
|
173
|
+
options.neighbors = Number(args[++i]);
|
|
174
|
+
} else if (arg === "--min-dist") {
|
|
175
|
+
options.minDist = Number(args[++i]);
|
|
176
|
+
} else if (arg === "--search") {
|
|
177
|
+
options.search = true;
|
|
178
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
179
|
+
console.log(`
|
|
180
|
+
doppelgangers - Find duplicate PRs through embedding visualization
|
|
181
|
+
|
|
182
|
+
Usage:
|
|
183
|
+
doppelgangers --repo <owner/repo>
|
|
184
|
+
|
|
185
|
+
Options:
|
|
186
|
+
--repo <url|owner/repo> GitHub repository (required)
|
|
187
|
+
--state <state> Item state: open, closed, or all (default: open)
|
|
188
|
+
--type <type> Item type: pr, issue, or all (default: all)
|
|
189
|
+
--output <path> Output path for items JSON (default: prs.json)
|
|
190
|
+
--embeddings <path> Output path for embeddings (default: embeddings.jsonl)
|
|
191
|
+
--html <path> Output path for HTML viewer (default: triage.html)
|
|
192
|
+
--model <model> OpenAI embedding model (default: text-embedding-3-small)
|
|
193
|
+
--batch <n> Batch size for embeddings (default: 100)
|
|
194
|
+
--max-chars <n> Max chars for embedding input (default: 4000)
|
|
195
|
+
--body-chars <n> Max chars for body snippet (default: 2000)
|
|
196
|
+
--neighbors <n> UMAP neighbors (default: 15)
|
|
197
|
+
--min-dist <n> UMAP min distance (default: 0.1)
|
|
198
|
+
--search Include embeddings for semantic search (increases file size)
|
|
199
|
+
|
|
200
|
+
Environment:
|
|
201
|
+
OPENAI_API_KEY Required for embedding generation
|
|
202
|
+
`);
|
|
203
|
+
process.exit(0);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!options.repo) {
|
|
208
|
+
console.error("--repo is required. Use --help for usage.");
|
|
209
|
+
process.exit(1);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const repoInfo = parseRepo(options.repo);
|
|
213
|
+
if (!repoInfo) {
|
|
214
|
+
console.error("Could not parse repo. Use https://github.com/org/repo or org/repo");
|
|
215
|
+
process.exit(1);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const { owner, name } = repoInfo;
|
|
219
|
+
const typeLabel = options.type === "all" ? "PRs and issues" : options.type === "pr" ? "PRs" : "issues";
|
|
220
|
+
console.log(`Fetching ${options.state} ${typeLabel} for ${owner}/${name}`);
|
|
221
|
+
|
|
222
|
+
const outputPath = path.resolve(options.output);
|
|
223
|
+
const result = await fetchItems(owner, name, options.state, options.type, outputPath);
|
|
224
|
+
console.log(`Wrote ${outputPath} (${result.prs} PRs, ${result.issues} issues)`);
|
|
225
|
+
|
|
226
|
+
const embedOptions: EmbedOptions = {
|
|
227
|
+
input: outputPath,
|
|
228
|
+
output: path.resolve(options.embeddings),
|
|
229
|
+
model: options.model,
|
|
230
|
+
batchSize: options.batch,
|
|
231
|
+
maxChars: options.maxChars,
|
|
232
|
+
bodyChars: options.bodyChars,
|
|
233
|
+
resume: false,
|
|
234
|
+
};
|
|
235
|
+
await embed(embedOptions);
|
|
236
|
+
|
|
237
|
+
const embeddingsPath = path.resolve(options.embeddings);
|
|
238
|
+
const projectionsPath = embeddingsPath.replace(/\.[^.]+$/, "-projections.json");
|
|
239
|
+
|
|
240
|
+
const buildOptions: BuildOptions = {
|
|
241
|
+
input: embeddingsPath,
|
|
242
|
+
output: path.resolve(options.html),
|
|
243
|
+
projections: projectionsPath,
|
|
244
|
+
neighbors: options.neighbors,
|
|
245
|
+
minDist: options.minDist,
|
|
246
|
+
includeEmbeddings: options.search,
|
|
247
|
+
};
|
|
248
|
+
build(buildOptions);
|
|
249
|
+
|
|
250
|
+
console.log(`Done. Open ${path.resolve(options.html)}`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
main().catch((error) => {
|
|
254
|
+
console.error(error);
|
|
255
|
+
process.exit(1);
|
|
256
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "Node16",
|
|
5
|
+
"lib": ["ES2022"],
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"forceConsistentCasingInFileNames": true,
|
|
10
|
+
"declaration": true,
|
|
11
|
+
"declarationMap": true,
|
|
12
|
+
"sourceMap": true,
|
|
13
|
+
"moduleResolution": "Node16",
|
|
14
|
+
"resolveJsonModule": true,
|
|
15
|
+
"outDir": "./dist",
|
|
16
|
+
"rootDir": "./src",
|
|
17
|
+
"types": ["node"]
|
|
18
|
+
},
|
|
19
|
+
"include": ["src/**/*"],
|
|
20
|
+
"exclude": ["node_modules", "dist"]
|
|
21
|
+
}
|