@letta-ai/letta-code 0.30.13 → 0.30.14

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.
Files changed (58) hide show
  1. package/dist/channels-slack.js +106 -26
  2. package/dist/channels-slack.js.map +5 -4
  3. package/dist/gateway-core.js +12 -2
  4. package/dist/gateway-core.js.map +3 -3
  5. package/dist/mcp-client.js +2 -2
  6. package/dist/mcp-client.js.map +1 -1
  7. package/dist/types/channels/message-channel-executor.d.ts +4 -0
  8. package/dist/types/channels/message-channel-executor.d.ts.map +1 -1
  9. package/dist/types/channels/slack/bot-policy.d.ts +25 -0
  10. package/dist/types/channels/slack/bot-policy.d.ts.map +1 -0
  11. package/dist/types/channels/slack/ingress-policy.d.ts +32 -1
  12. package/dist/types/channels/slack/ingress-policy.d.ts.map +1 -1
  13. package/dist/types/channels-slack.d.ts +3 -2
  14. package/dist/types/channels-slack.d.ts.map +1 -1
  15. package/dist/types/gateway-core.d.ts +1 -1
  16. package/dist/types/gateway-core.d.ts.map +1 -1
  17. package/dist/types/permissions/analyzer.d.ts.map +1 -1
  18. package/dist/types/permissions/canonical.d.ts +1 -0
  19. package/dist/types/permissions/canonical.d.ts.map +1 -1
  20. package/dist/types/permissions/checker.d.ts.map +1 -1
  21. package/dist/types/tools/impl/bash.d.ts +7 -0
  22. package/dist/types/tools/impl/bash.d.ts.map +1 -1
  23. package/dist/types/tools/impl/enter-worktree.d.ts +16 -1
  24. package/dist/types/tools/impl/enter-worktree.d.ts.map +1 -1
  25. package/dist/types/tools/impl/exit-worktree.d.ts +21 -0
  26. package/dist/types/tools/impl/exit-worktree.d.ts.map +1 -0
  27. package/dist/types/tools/impl/kill-bash.d.ts.map +1 -1
  28. package/dist/types/tools/impl/monitor-event-stream.d.ts +23 -0
  29. package/dist/types/tools/impl/monitor-event-stream.d.ts.map +1 -0
  30. package/dist/types/tools/impl/monitor.d.ts +30 -0
  31. package/dist/types/tools/impl/monitor.d.ts.map +1 -0
  32. package/dist/types/tools/impl/process_manager.d.ts +8 -0
  33. package/dist/types/tools/impl/process_manager.d.ts.map +1 -1
  34. package/dist/types/tools/impl/shell-runner.d.ts +1 -0
  35. package/dist/types/tools/impl/shell-runner.d.ts.map +1 -1
  36. package/dist/types/tools/impl/worktree-git.d.ts +26 -0
  37. package/dist/types/tools/impl/worktree-git.d.ts.map +1 -0
  38. package/dist/types/tools/manager.d.ts.map +1 -1
  39. package/dist/types/tools/tool-definitions.d.ts +2 -0
  40. package/dist/types/tools/tool-definitions.d.ts.map +1 -1
  41. package/dist/types/tools/tool-permissions.d.ts +7 -0
  42. package/dist/types/tools/tool-permissions.d.ts.map +1 -0
  43. package/dist/types/types/background-process-protocol.d.ts +29 -0
  44. package/dist/types/types/background-process-protocol.d.ts.map +1 -0
  45. package/dist/types/types/protocol_v2.d.ts +2 -19
  46. package/dist/types/types/protocol_v2.d.ts.map +1 -1
  47. package/dist/types/utils/task-notifications.d.ts +5 -0
  48. package/dist/types/utils/task-notifications.d.ts.map +1 -1
  49. package/dist/types/websocket/listener/background-process-snapshot.d.ts.map +1 -1
  50. package/dist/types/websocket/listener/types.d.ts +2 -0
  51. package/dist/types/websocket/listener/types.d.ts.map +1 -1
  52. package/letta.js +3682 -2358
  53. package/package.json +1 -1
  54. package/scripts/isolated-unit-tests.json +5 -0
  55. package/scripts/run-unit-tests.cjs +83 -2
  56. package/scripts/source-file-size-baseline.json +3 -4
  57. package/scripts/unit-test-impact.cjs +578 -0
  58. package/scripts/unit-test-impact.test.cjs +328 -0
@@ -0,0 +1,578 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { execFileSync } = require("node:child_process");
4
+ const { existsSync, readFileSync, readdirSync, statSync } = require("node:fs");
5
+ const path = require("node:path");
6
+ const ts = require("typescript");
7
+
8
+ // Each top-level src directory owns its tests. A pull request runs a directory's
9
+ // tests when it changes that directory or a local file imported directly by it.
10
+ // The scan stops there on purpose: broad registration modules make a transitive
11
+ // import walk connect most of the repository, including unrelated channel tests
12
+ // for shell-only changes. Unknown paths and unresolved imports run every test.
13
+
14
+ const ROOT_FAMILY = "root";
15
+ const INTEGRATION_FAMILY = "integration-tests";
16
+ const TEST_FILE_PATTERN = /\.test\.tsx?$/u;
17
+ const SOURCE_FILE_PATTERN = /\.(?:c|m)?[jt]sx?$/u;
18
+ const LOCAL_IMPORT_PATTERN = /^(?:@\/|\.\.?\/)/u;
19
+
20
+ const NO_UNIT_TEST_PATHS = new Set([
21
+ "AI_POLICY.md",
22
+ "CONTRIBUTING.md",
23
+ "LICENSE",
24
+ "README.md",
25
+ ".github/pull_request_template.md",
26
+ "assets/letta-code-demo.gif",
27
+ "scripts/source-file-size-baseline.json",
28
+ ]);
29
+
30
+ const FULL_UNIT_TEST_PATHS = new Set([
31
+ ".github/workflows/ci.yml",
32
+ "build.js",
33
+ "bun.lock",
34
+ "bunfig.toml",
35
+ "package.json",
36
+ "scripts/check-test-coverage.cjs",
37
+ "scripts/isolated-unit-tests.json",
38
+ "scripts/run-unit-tests.cjs",
39
+ "scripts/unit-test-impact.cjs",
40
+ "scripts/unit-test-impact.test.cjs",
41
+ "tsconfig.json",
42
+ "tsconfig.types.json",
43
+ ]);
44
+
45
+ const NO_UNIT_TEST_PREFIXES = [
46
+ ".github/ISSUE_TEMPLATE/",
47
+ ".github/pr-assets/",
48
+ "docs/",
49
+ ];
50
+
51
+ const PATH_FAMILY_ANCHORS = [
52
+ // These files are loaded through filesystem paths rather than imports.
53
+ { path: "assets/tutor-profile.png", families: ["agent"] },
54
+ { prefix: "docs/examples/mods/", families: ["cli", "mods"] },
55
+ { prefix: "src/cli/app/", families: [ROOT_FAMILY] },
56
+ ];
57
+
58
+ function normalizePath(filePath) {
59
+ return filePath.replaceAll("\\", "/").replace(/^\.\//u, "");
60
+ }
61
+
62
+ function sourceFamily(filePath) {
63
+ const normalized = normalizePath(filePath);
64
+ if (!normalized.startsWith("src/")) return null;
65
+ const relativePath = normalized.slice("src/".length);
66
+ const slashIndex = relativePath.indexOf("/");
67
+ return slashIndex === -1 ? ROOT_FAMILY : relativePath.slice(0, slashIndex);
68
+ }
69
+
70
+ function isTestFile(filePath) {
71
+ return TEST_FILE_PATTERN.test(normalizePath(filePath));
72
+ }
73
+
74
+ function collectFiles(directory, predicate) {
75
+ if (!existsSync(directory)) return [];
76
+ const files = [];
77
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
78
+ const entryPath = path.join(directory, entry.name);
79
+ if (entry.isDirectory()) {
80
+ files.push(...collectFiles(entryPath, predicate));
81
+ } else if (entry.isFile() && predicate(entryPath)) {
82
+ files.push(entryPath);
83
+ }
84
+ }
85
+ return files;
86
+ }
87
+
88
+ function resolveExistingPath(basePath) {
89
+ const candidates = [
90
+ basePath,
91
+ `${basePath}.ts`,
92
+ `${basePath}.tsx`,
93
+ `${basePath}.js`,
94
+ `${basePath}.jsx`,
95
+ `${basePath}.json`,
96
+ `${basePath}.md`,
97
+ `${basePath}.mdx`,
98
+ `${basePath}.txt`,
99
+ path.join(basePath, "index.ts"),
100
+ path.join(basePath, "index.tsx"),
101
+ path.join(basePath, "index.js"),
102
+ path.join(basePath, "index.jsx"),
103
+ ];
104
+ return candidates.find((candidate) => {
105
+ try {
106
+ return statSync(candidate).isFile();
107
+ } catch {
108
+ return false;
109
+ }
110
+ });
111
+ }
112
+
113
+ function resolveLocalImport(params) {
114
+ const { containingFile, moduleSpecifier, rootDir, compilerOptions, cache } =
115
+ params;
116
+ const resolved = ts.resolveModuleName(
117
+ moduleSpecifier,
118
+ containingFile,
119
+ compilerOptions,
120
+ ts.sys,
121
+ cache,
122
+ ).resolvedModule?.resolvedFileName;
123
+ if (resolved) return resolved;
124
+
125
+ const basePath = moduleSpecifier.startsWith("@/")
126
+ ? path.join(rootDir, "src", moduleSpecifier.slice(2))
127
+ : path.resolve(path.dirname(containingFile), moduleSpecifier);
128
+ return resolveExistingPath(basePath) ?? null;
129
+ }
130
+
131
+ function collectAstReferences(sourceText, fileName) {
132
+ const sourceFile = ts.createSourceFile(
133
+ fileName,
134
+ sourceText,
135
+ ts.ScriptTarget.Latest,
136
+ false,
137
+ );
138
+ const moduleSpecifiers = [];
139
+ const fileReferences = [];
140
+ const dynamicFileReaders = new Set();
141
+ for (const statement of sourceFile.statements) {
142
+ if (
143
+ !ts.isFunctionDeclaration(statement) ||
144
+ !statement.name ||
145
+ !statement.body
146
+ ) {
147
+ continue;
148
+ }
149
+ const pathParameter = statement.parameters[0]?.name;
150
+ if (!pathParameter || !ts.isIdentifier(pathParameter)) continue;
151
+ let readsParameterFromModuleUrl = false;
152
+ const findDynamicFileUrl = (node) => {
153
+ if (
154
+ ts.isNewExpression(node) &&
155
+ ts.isIdentifier(node.expression) &&
156
+ node.expression.text === "URL" &&
157
+ node.arguments?.[0] &&
158
+ ts.isIdentifier(node.arguments[0]) &&
159
+ node.arguments[0].text === pathParameter.text &&
160
+ node.arguments?.[1]?.getText(sourceFile) === "import.meta.url"
161
+ ) {
162
+ readsParameterFromModuleUrl = true;
163
+ return;
164
+ }
165
+ ts.forEachChild(node, findDynamicFileUrl);
166
+ };
167
+ findDynamicFileUrl(statement.body);
168
+ if (readsParameterFromModuleUrl) {
169
+ dynamicFileReaders.add(statement.name.text);
170
+ }
171
+ }
172
+
173
+ const visit = (node) => {
174
+ if (
175
+ ts.isCallExpression(node) &&
176
+ ts.isPropertyAccessExpression(node.expression) &&
177
+ node.expression.expression.getText(sourceFile) === "mock" &&
178
+ node.expression.name.text === "module"
179
+ ) {
180
+ const moduleArgument = node.arguments[0];
181
+ if (moduleArgument && ts.isStringLiteralLike(moduleArgument)) {
182
+ moduleSpecifiers.push(moduleArgument.text);
183
+ }
184
+ }
185
+ if (
186
+ ts.isNewExpression(node) &&
187
+ ts.isIdentifier(node.expression) &&
188
+ node.expression.text === "URL"
189
+ ) {
190
+ const pathArgument = node.arguments?.[0];
191
+ const baseArgument = node.arguments?.[1];
192
+ if (
193
+ pathArgument &&
194
+ ts.isStringLiteralLike(pathArgument) &&
195
+ baseArgument?.getText(sourceFile) === "import.meta.url"
196
+ ) {
197
+ fileReferences.push(pathArgument.text);
198
+ }
199
+ }
200
+ if (
201
+ ts.isCallExpression(node) &&
202
+ ts.isIdentifier(node.expression) &&
203
+ dynamicFileReaders.has(node.expression.text)
204
+ ) {
205
+ const pathArgument = node.arguments[0];
206
+ if (pathArgument && ts.isStringLiteralLike(pathArgument)) {
207
+ fileReferences.push(pathArgument.text);
208
+ }
209
+ }
210
+ ts.forEachChild(node, visit);
211
+ };
212
+ visit(sourceFile);
213
+ return { moduleSpecifiers, fileReferences };
214
+ }
215
+
216
+ function buildFamilyImpactIndex(rootDir = process.cwd()) {
217
+ const sourceRoot = path.join(rootDir, "src");
218
+ const configPath = path.join(rootDir, "tsconfig.json");
219
+ const config = ts.readConfigFile(configPath, ts.sys.readFile);
220
+ if (config.error) {
221
+ throw new Error(`Unable to read ${configPath}`);
222
+ }
223
+ const parsedConfig = ts.parseJsonConfigFileContent(
224
+ config.config,
225
+ ts.sys,
226
+ rootDir,
227
+ );
228
+ const cache = ts.createModuleResolutionCache(
229
+ rootDir,
230
+ (fileName) => fileName,
231
+ parsedConfig.options,
232
+ );
233
+ const familyDependencies = new Map();
234
+ const fileDependencies = new Map();
235
+ const unresolvedImports = [];
236
+ const sourceFiles = collectFiles(sourceRoot, (filePath) =>
237
+ SOURCE_FILE_PATTERN.test(filePath),
238
+ );
239
+
240
+ for (const absolutePath of sourceFiles) {
241
+ const relativePath = normalizePath(path.relative(rootDir, absolutePath));
242
+ const family = sourceFamily(relativePath);
243
+ if (!family || family === INTEGRATION_FAMILY) continue;
244
+ let dependencies = familyDependencies.get(family);
245
+ if (!dependencies) {
246
+ dependencies = new Set();
247
+ familyDependencies.set(family, dependencies);
248
+ }
249
+ const directFileDependencies = new Set();
250
+ fileDependencies.set(relativePath, directFileDependencies);
251
+
252
+ const sourceText = readFileSync(absolutePath, "utf8");
253
+ const preprocessed = ts.preProcessFile(sourceText, true, true);
254
+ const moduleSpecifiers = new Set(
255
+ preprocessed.importedFiles.map((entry) => entry.fileName),
256
+ );
257
+ let fileReferences = [];
258
+ if (sourceText.includes("mock.module") || sourceText.includes("new URL(")) {
259
+ const astReferences = collectAstReferences(sourceText, absolutePath);
260
+ fileReferences = astReferences.fileReferences;
261
+ for (const moduleSpecifier of astReferences.moduleSpecifiers) {
262
+ moduleSpecifiers.add(moduleSpecifier);
263
+ }
264
+ }
265
+
266
+ for (const moduleSpecifier of moduleSpecifiers) {
267
+ if (!LOCAL_IMPORT_PATTERN.test(moduleSpecifier)) continue;
268
+ const resolved = resolveLocalImport({
269
+ containingFile: absolutePath,
270
+ moduleSpecifier,
271
+ rootDir,
272
+ compilerOptions: parsedConfig.options,
273
+ cache,
274
+ });
275
+ if (!resolved) {
276
+ unresolvedImports.push({ file: relativePath, moduleSpecifier });
277
+ continue;
278
+ }
279
+ const dependencyPath = normalizePath(path.relative(rootDir, resolved));
280
+ if (dependencyPath.startsWith("../") || path.isAbsolute(dependencyPath)) {
281
+ continue;
282
+ }
283
+ directFileDependencies.add(dependencyPath);
284
+ if (sourceFamily(dependencyPath) !== family) {
285
+ dependencies.add(dependencyPath);
286
+ }
287
+ }
288
+
289
+ for (const fileReference of new Set(fileReferences)) {
290
+ if (!LOCAL_IMPORT_PATTERN.test(fileReference)) continue;
291
+ const basePath = path.resolve(path.dirname(absolutePath), fileReference);
292
+ let resolvedPaths = [];
293
+ try {
294
+ const baseStat = statSync(basePath);
295
+ if (baseStat.isDirectory()) {
296
+ resolvedPaths = collectFiles(basePath, (filePath) =>
297
+ SOURCE_FILE_PATTERN.test(filePath),
298
+ );
299
+ } else if (baseStat.isFile()) {
300
+ resolvedPaths = [basePath];
301
+ }
302
+ } catch {
303
+ const resolved = resolveLocalImport({
304
+ containingFile: absolutePath,
305
+ moduleSpecifier: fileReference,
306
+ rootDir,
307
+ compilerOptions: parsedConfig.options,
308
+ cache,
309
+ });
310
+ if (resolved) resolvedPaths = [resolved];
311
+ }
312
+ for (const resolvedPath of resolvedPaths) {
313
+ const dependencyPath = normalizePath(
314
+ path.relative(rootDir, resolvedPath),
315
+ );
316
+ directFileDependencies.add(dependencyPath);
317
+ if (sourceFamily(dependencyPath) !== family) {
318
+ dependencies.add(dependencyPath);
319
+ }
320
+ }
321
+ }
322
+ }
323
+
324
+ return { familyDependencies, fileDependencies, unresolvedImports };
325
+ }
326
+
327
+ function changedPath(change) {
328
+ return normalizePath(change.path ?? change);
329
+ }
330
+
331
+ function fullRunReason(change) {
332
+ const filePath = changedPath(change);
333
+ const status = typeof change === "string" ? "M" : (change.status ?? "M");
334
+ if (status.startsWith("R")) {
335
+ const previousPath =
336
+ typeof change === "string" || !change.previousPath
337
+ ? null
338
+ : normalizePath(change.previousPath);
339
+ if (
340
+ previousPath &&
341
+ hasNoUnitTestImpact(previousPath) &&
342
+ hasNoUnitTestImpact(filePath)
343
+ ) {
344
+ return null;
345
+ }
346
+ return `${previousPath ?? filePath} was renamed`;
347
+ }
348
+ if (status.startsWith("D")) {
349
+ return hasNoUnitTestImpact(filePath) ? null : `${filePath} was deleted`;
350
+ }
351
+ if (hasNoUnitTestImpact(filePath)) return null;
352
+ if (FULL_UNIT_TEST_PATHS.has(filePath)) {
353
+ return `${filePath} can affect every unit test`;
354
+ }
355
+ if (filePath.startsWith("scripts/")) {
356
+ return `${filePath} is unclassified test or build infrastructure`;
357
+ }
358
+ if (
359
+ filePath.startsWith(".github/workflows/") ||
360
+ filePath.startsWith("vendor/")
361
+ ) {
362
+ return `${filePath} is unclassified workflow or runtime infrastructure`;
363
+ }
364
+ return null;
365
+ }
366
+
367
+ function hasNoUnitTestImpact(filePath) {
368
+ const normalized = normalizePath(filePath);
369
+ return (
370
+ NO_UNIT_TEST_PATHS.has(normalized) ||
371
+ NO_UNIT_TEST_PREFIXES.some((prefix) => normalized.startsWith(prefix))
372
+ );
373
+ }
374
+
375
+ function planUnitTests(params) {
376
+ const { changedFiles, allTestFiles, impactIndex } = params;
377
+ const normalizedTests = allTestFiles.map(normalizePath);
378
+ const runAll = (reason) => ({
379
+ mode: "full",
380
+ reason,
381
+ selectedFamilies: [],
382
+ selectedTests: normalizedTests,
383
+ omittedTests: [],
384
+ });
385
+
386
+ if (changedFiles.length === 0) {
387
+ return runAll("no changed files were provided");
388
+ }
389
+ if (impactIndex.unresolvedImports.length > 0) {
390
+ const first = impactIndex.unresolvedImports[0];
391
+ return runAll(
392
+ `local import ${first.file} -> ${first.moduleSpecifier} could not be resolved`,
393
+ );
394
+ }
395
+ for (const change of changedFiles) {
396
+ const reason = fullRunReason(change);
397
+ if (reason) return runAll(reason);
398
+ }
399
+
400
+ const reasonsByFamily = new Map();
401
+ const addFamily = (family, reason) => {
402
+ if (!family || family === INTEGRATION_FAMILY) return;
403
+ const reasons = reasonsByFamily.get(family) ?? new Set();
404
+ reasons.add(reason);
405
+ reasonsByFamily.set(family, reasons);
406
+ };
407
+
408
+ let sawSourceChange = false;
409
+ for (const change of changedFiles) {
410
+ const filePath = changedPath(change);
411
+ const family = sourceFamily(filePath);
412
+ if (family && family !== INTEGRATION_FAMILY) {
413
+ sawSourceChange = true;
414
+ addFamily(family, `${filePath} is owned by ${family}`);
415
+
416
+ if (family === ROOT_FAMILY) {
417
+ for (const dependency of impactIndex.fileDependencies?.get(filePath) ??
418
+ []) {
419
+ const dependencyFamily = sourceFamily(dependency);
420
+ if (!dependencyFamily || dependencyFamily === ROOT_FAMILY) continue;
421
+ addFamily(
422
+ dependencyFamily,
423
+ `${filePath} imports ${dependencyFamily} directly`,
424
+ );
425
+ }
426
+ }
427
+ }
428
+
429
+ let anchoredFamilyFound = false;
430
+ for (const anchor of PATH_FAMILY_ANCHORS) {
431
+ const anchorPath = anchor.path ?? anchor.prefix;
432
+ if (
433
+ !anchorPath ||
434
+ (anchor.path
435
+ ? filePath !== anchor.path
436
+ : !filePath.startsWith(anchorPath))
437
+ ) {
438
+ continue;
439
+ }
440
+ anchoredFamilyFound = true;
441
+ sawSourceChange = true;
442
+ for (const anchoredFamily of anchor.families) {
443
+ addFamily(
444
+ anchoredFamily,
445
+ `${filePath} matches the ${anchorPath} test group`,
446
+ );
447
+ }
448
+ }
449
+
450
+ let directConsumerFound = false;
451
+ for (const [
452
+ consumerFamily,
453
+ dependencies,
454
+ ] of impactIndex.familyDependencies) {
455
+ if (dependencies.has(filePath)) {
456
+ directConsumerFound = true;
457
+ sawSourceChange = true;
458
+ addFamily(
459
+ consumerFamily,
460
+ `${consumerFamily} imports ${filePath} directly`,
461
+ );
462
+ }
463
+ }
464
+
465
+ if (family === INTEGRATION_FAMILY) continue;
466
+ if (
467
+ !family &&
468
+ !directConsumerFound &&
469
+ !anchoredFamilyFound &&
470
+ !hasNoUnitTestImpact(filePath)
471
+ ) {
472
+ return runAll(`${filePath} is not classified for unit-test selection`);
473
+ }
474
+ }
475
+
476
+ const selectedFamilies = [...reasonsByFamily.keys()].sort();
477
+ const selectedFamilySet = new Set(selectedFamilies);
478
+ const selectedTests = normalizedTests.filter((testPath) => {
479
+ const family = sourceFamily(testPath);
480
+ return family ? selectedFamilySet.has(family) : false;
481
+ });
482
+
483
+ if (sawSourceChange && selectedTests.length === 0) {
484
+ return runAll("source changes selected no unit tests");
485
+ }
486
+
487
+ const selectedTestSet = new Set(selectedTests);
488
+ return {
489
+ mode: "selected",
490
+ reason:
491
+ selectedTests.length === 0
492
+ ? "changed files do not affect unit tests"
493
+ : `selected ${selectedFamilies.join(", ")}`,
494
+ selectedFamilies: selectedFamilies.map((family) => ({
495
+ family,
496
+ reasons: [...(reasonsByFamily.get(family) ?? [])].sort(),
497
+ })),
498
+ selectedTests,
499
+ omittedTests: normalizedTests.filter((test) => !selectedTestSet.has(test)),
500
+ };
501
+ }
502
+
503
+ function getGitChangedFiles(baseSha, headSha, rootDir = process.cwd()) {
504
+ const output = execFileSync(
505
+ "git",
506
+ [
507
+ "diff",
508
+ "--name-status",
509
+ "--find-renames",
510
+ "--diff-filter=ACDMRTUXB",
511
+ baseSha,
512
+ headSha,
513
+ ],
514
+ { cwd: rootDir, encoding: "utf8" },
515
+ );
516
+ return output
517
+ .trim()
518
+ .split("\n")
519
+ .filter(Boolean)
520
+ .map((line) => {
521
+ const [status, firstPath, secondPath] = line.split("\t");
522
+ return {
523
+ status,
524
+ path: normalizePath(secondPath ?? firstPath),
525
+ previousPath: secondPath ? normalizePath(firstPath) : undefined,
526
+ };
527
+ });
528
+ }
529
+
530
+ function readPullRequestShas(eventPath) {
531
+ const event = JSON.parse(readFileSync(eventPath, "utf8"));
532
+ const baseSha = event.pull_request?.base?.sha;
533
+ const headSha =
534
+ event.pull_request?.merge_commit_sha ?? event.pull_request?.head?.sha;
535
+ if (typeof baseSha !== "string" || typeof headSha !== "string") {
536
+ throw new Error("Pull request event is missing base/head SHAs");
537
+ }
538
+ return { baseSha, headSha };
539
+ }
540
+
541
+ module.exports = {
542
+ ROOT_FAMILY,
543
+ buildFamilyImpactIndex,
544
+ getGitChangedFiles,
545
+ isTestFile,
546
+ normalizePath,
547
+ planUnitTests,
548
+ readPullRequestShas,
549
+ sourceFamily,
550
+ };
551
+
552
+ if (require.main === module) {
553
+ const baseIndex = process.argv.indexOf("--base");
554
+ const headIndex = process.argv.indexOf("--head");
555
+ if (baseIndex === -1 || headIndex === -1) {
556
+ console.error("Usage: unit-test-impact.cjs --base <sha> --head <sha>");
557
+ process.exit(2);
558
+ }
559
+ const rootDir = process.cwd();
560
+ const changedFiles = getGitChangedFiles(
561
+ process.argv[baseIndex + 1],
562
+ process.argv[headIndex + 1],
563
+ rootDir,
564
+ );
565
+ const allTestFiles = collectFiles(path.join(rootDir, "src"), (filePath) =>
566
+ isTestFile(filePath),
567
+ )
568
+ .map((filePath) => normalizePath(path.relative(rootDir, filePath)))
569
+ .filter((filePath) => sourceFamily(filePath) !== INTEGRATION_FAMILY)
570
+ .concat("scripts/unit-test-impact.test.cjs")
571
+ .sort();
572
+ const plan = planUnitTests({
573
+ changedFiles,
574
+ allTestFiles,
575
+ impactIndex: buildFamilyImpactIndex(rootDir),
576
+ });
577
+ process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`);
578
+ }