@beignet/cli 0.0.49 → 0.0.51

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 (64) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +54 -5
  3. package/dist/analysis/workspace.d.ts +1 -0
  4. package/dist/analysis/workspace.d.ts.map +1 -1
  5. package/dist/analysis/workspace.js +20 -10
  6. package/dist/analysis/workspace.js.map +1 -1
  7. package/dist/app-map-changes.d.ts +103 -0
  8. package/dist/app-map-changes.d.ts.map +1 -0
  9. package/dist/app-map-changes.js +949 -0
  10. package/dist/app-map-changes.js.map +1 -0
  11. package/dist/git-changes.d.ts +30 -0
  12. package/dist/git-changes.d.ts.map +1 -0
  13. package/dist/git-changes.js +367 -0
  14. package/dist/git-changes.js.map +1 -0
  15. package/dist/index.d.ts +2 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +58 -4
  18. package/dist/index.js.map +1 -1
  19. package/dist/inspect.js +413 -43
  20. package/dist/inspect.js.map +1 -1
  21. package/dist/lib.d.ts +3 -0
  22. package/dist/lib.d.ts.map +1 -1
  23. package/dist/lib.js +1 -0
  24. package/dist/lib.js.map +1 -1
  25. package/dist/make/shared.d.ts.map +1 -1
  26. package/dist/make/shared.js +31 -27
  27. package/dist/make/shared.js.map +1 -1
  28. package/dist/make.d.ts.map +1 -1
  29. package/dist/make.js +0 -2
  30. package/dist/make.js.map +1 -1
  31. package/dist/mcp.d.ts.map +1 -1
  32. package/dist/mcp.js +46 -6
  33. package/dist/mcp.js.map +1 -1
  34. package/dist/operational-process.d.ts +1 -0
  35. package/dist/operational-process.d.ts.map +1 -1
  36. package/dist/operational-process.js.map +1 -1
  37. package/dist/operational-runner.js +1 -0
  38. package/dist/operational-runner.js.map +1 -1
  39. package/dist/outbox.d.ts +1 -0
  40. package/dist/outbox.d.ts.map +1 -1
  41. package/dist/outbox.js +58 -12
  42. package/dist/outbox.js.map +1 -1
  43. package/dist/templates/agents.d.ts.map +1 -1
  44. package/dist/templates/agents.js +28 -3
  45. package/dist/templates/agents.js.map +1 -1
  46. package/dist/templates/base.d.ts.map +1 -1
  47. package/dist/templates/base.js +4 -1
  48. package/dist/templates/base.js.map +1 -1
  49. package/package.json +2 -2
  50. package/skills/app-structure/SKILL.md +30 -5
  51. package/src/analysis/workspace.ts +25 -9
  52. package/src/app-map-changes.ts +1462 -0
  53. package/src/git-changes.ts +511 -0
  54. package/src/index.ts +84 -4
  55. package/src/inspect.ts +586 -51
  56. package/src/lib.ts +21 -0
  57. package/src/make/shared.ts +31 -27
  58. package/src/make.ts +0 -2
  59. package/src/mcp.ts +65 -12
  60. package/src/operational-process.ts +1 -0
  61. package/src/operational-runner.ts +1 -0
  62. package/src/outbox.ts +63 -12
  63. package/src/templates/agents.ts +28 -3
  64. package/src/templates/base.ts +4 -1
@@ -0,0 +1,1462 @@
1
+ import { readFile, realpath } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import {
4
+ type LocalImportReference,
5
+ localImportReferences,
6
+ } from "./analysis/source-index.js";
7
+ import {
8
+ createAnalysisWorkspace,
9
+ listProjectFiles,
10
+ resolveLocalModulePath,
11
+ } from "./analysis/workspace.js";
12
+ import {
13
+ type AppMapEdge,
14
+ type AppMapEdgeKind,
15
+ type AppMapNode,
16
+ type AppMapNodeKind,
17
+ mapApp,
18
+ } from "./app-map.js";
19
+ import {
20
+ type GitChangeComparison,
21
+ type GitChangedFile,
22
+ type GitChangedFileStatus,
23
+ hashGitChanges,
24
+ readGitChangeSet,
25
+ } from "./git-changes.js";
26
+
27
+ const changedFileLimit = 200;
28
+ const impactedNodeLimit = 250;
29
+ const relationshipLimit = 400;
30
+ const gapLimit = 100;
31
+ const reasonLimitPerNode = 3;
32
+
33
+ const edgeImpactBehavior = {
34
+ owns: "context",
35
+ binds: "consumer",
36
+ executes: "consumer",
37
+ authorizes: "consumer",
38
+ emits: "consumer",
39
+ dispatches: "consumer",
40
+ sends: "consumer",
41
+ publishes: "consumer",
42
+ provides: "consumer",
43
+ registers: "consumer",
44
+ "listens-to": "consumer",
45
+ exposes: "consumer",
46
+ "depends-on": "consumer",
47
+ "contains-test": "context",
48
+ } as const satisfies Record<AppMapEdgeKind, "consumer" | "context">;
49
+
50
+ const containerExpansionKinds = {
51
+ feature: new Set<AppMapEdgeKind>(["owns"]),
52
+ entrypoint: new Set<AppMapEdgeKind>(["exposes", "registers"]),
53
+ openapi: new Set<AppMapEdgeKind>(["exposes"]),
54
+ registry: new Set<AppMapEdgeKind>(["registers"]),
55
+ "route-group": new Set<AppMapEdgeKind>(["registers"]),
56
+ } as const satisfies Partial<
57
+ Record<AppMapNodeKind, ReadonlySet<AppMapEdgeKind>>
58
+ >;
59
+
60
+ /** Relationship between one changed repository file and the selected app. */
61
+ export type AppChangeFileScope =
62
+ | "app"
63
+ | "governing"
64
+ | "outside"
65
+ | "workspace-dependency";
66
+
67
+ /** One Git change classified relative to the selected Beignet app. */
68
+ export type AppChangedFile = {
69
+ status: GitChangedFileStatus;
70
+ path: string;
71
+ oldPath?: string;
72
+ scope: AppChangeFileScope;
73
+ };
74
+
75
+ /** Why one mapped concept appears in a changed-app report. */
76
+ export type AppChangeImpact =
77
+ | "app-wide"
78
+ | "consumer"
79
+ | "context"
80
+ | "direct"
81
+ | "related";
82
+
83
+ /** Source-backed reason connecting a Git change to one mapped concept. */
84
+ export type AppChangeReason = {
85
+ kind: "app-wide" | "import" | "ownership" | "relationship" | "source";
86
+ changedFile: string;
87
+ message: string;
88
+ evidence?: {
89
+ file: string;
90
+ line?: number;
91
+ column?: number;
92
+ confidence: "exact" | "partial";
93
+ };
94
+ relationship?: {
95
+ kind: AppMapEdgeKind;
96
+ from: string;
97
+ to: string;
98
+ };
99
+ };
100
+
101
+ /** App-map node annotated with its strongest change-impact classification. */
102
+ export type AppChangeImpactedNode = AppMapNode & {
103
+ impact: AppChangeImpact;
104
+ reasons: AppChangeReason[];
105
+ reasonCount: number;
106
+ reasonsTruncated: boolean;
107
+ };
108
+
109
+ /** App-map edge used as semantic change-impact evidence. */
110
+ export type AppChangeRelationship = AppMapEdge & {
111
+ impact: "consumer" | "related";
112
+ };
113
+
114
+ /** Static-analysis blind spot retained in a changed-app report. */
115
+ export type AppChangeGap = {
116
+ code: string;
117
+ message: string;
118
+ /** Repository-relative file associated with this gap. */
119
+ file?: string;
120
+ };
121
+
122
+ /** Deterministically bounded report section with complete item counts. */
123
+ export type BoundedAppChangeSection<T> = {
124
+ items: T[];
125
+ returned: number;
126
+ total: number;
127
+ truncated: boolean;
128
+ };
129
+
130
+ /** Bounded gap examples plus exhaustive counts for every gap code. */
131
+ export type BoundedAppChangeGapSection =
132
+ BoundedAppChangeSection<AppChangeGap> & {
133
+ countsByCode: Record<string, number>;
134
+ };
135
+
136
+ /** Version 1 report mapping a Git change set to Beignet application concepts. */
137
+ export type AppChangeImpactResult = {
138
+ schemaVersion: 1;
139
+ targetDir: string;
140
+ repository: {
141
+ root: string;
142
+ workspaceRoot?: string;
143
+ comparison: GitChangeComparison;
144
+ repositoryChangeHash: string;
145
+ scopedChangeHash: string;
146
+ };
147
+ classification: {
148
+ appWide: boolean;
149
+ docsOnly: boolean;
150
+ };
151
+ summary: {
152
+ changedFiles: number;
153
+ scopedChangedFiles: number;
154
+ outsideChangedFiles: number;
155
+ impactedFeatures: number;
156
+ impactedNodes: number;
157
+ direct: number;
158
+ consumers: number;
159
+ related: number;
160
+ contexts: number;
161
+ appWide: number;
162
+ relationships: number;
163
+ gaps: number;
164
+ };
165
+ changedFiles: BoundedAppChangeSection<AppChangedFile>;
166
+ impactedNodes: BoundedAppChangeSection<AppChangeImpactedNode>;
167
+ relationships: BoundedAppChangeSection<AppChangeRelationship>;
168
+ gaps: BoundedAppChangeGapSection;
169
+ };
170
+
171
+ /** Options for mapping the current Git change set. */
172
+ export type AppMapChangesOptions = {
173
+ cwd?: string;
174
+ base?: string;
175
+ };
176
+
177
+ type PackageJson = {
178
+ name?: string;
179
+ workspaces?: string[] | { packages?: string[] };
180
+ dependencies?: Record<string, string>;
181
+ devDependencies?: Record<string, string>;
182
+ optionalDependencies?: Record<string, string>;
183
+ peerDependencies?: Record<string, string>;
184
+ };
185
+
186
+ type WorkspaceScope = {
187
+ workspaceRoot?: string;
188
+ dependencyDirectories: Set<string>;
189
+ gaps: AppChangeGap[];
190
+ };
191
+
192
+ type ImpactRecord = {
193
+ node: AppMapNode;
194
+ impact: AppChangeImpact;
195
+ reasons: AppChangeReason[];
196
+ };
197
+
198
+ /** Map the current Git change set to bounded, source-backed Beignet concepts. */
199
+ export async function mapAppChanges(
200
+ options: AppMapChangesOptions = {},
201
+ ): Promise<AppChangeImpactResult> {
202
+ const targetDir = path.resolve(options.cwd ?? process.cwd());
203
+ const git = await readGitChangeSet({ cwd: targetDir, base: options.base });
204
+ const canonicalTargetDir = await realpath(targetDir);
205
+ assertInsideRepository(canonicalTargetDir, git.repositoryRoot);
206
+ const appRepositoryPrefix = normalizePath(
207
+ path.relative(git.repositoryRoot, canonicalTargetDir),
208
+ );
209
+
210
+ const [appMap, workspace, workspaceScope] = await Promise.all([
211
+ mapApp({ cwd: targetDir, strict: true }),
212
+ createAnalysisWorkspace(targetDir),
213
+ resolveWorkspaceScope(git.repositoryRoot, canonicalTargetDir),
214
+ ]);
215
+ const governingFiles = new Set(
216
+ workspace.compilerConfigFiles
217
+ .map((file) =>
218
+ path.resolve(
219
+ canonicalTargetDir,
220
+ path.relative(workspace.targetDir, file),
221
+ ),
222
+ )
223
+ .filter((file) => isAbsoluteWithin(file, git.repositoryRoot))
224
+ .map((file) => normalizePath(path.relative(git.repositoryRoot, file))),
225
+ );
226
+ const changedFiles = classifyChangedFiles({
227
+ files: git.files,
228
+ governingFiles,
229
+ repositoryRoot: git.repositoryRoot,
230
+ targetDir: canonicalTargetDir,
231
+ workspaceScope,
232
+ });
233
+ const documentationRoots = [
234
+ normalizePath(path.relative(git.repositoryRoot, canonicalTargetDir)),
235
+ ...workspaceScope.dependencyDirectories,
236
+ ];
237
+ const isDocumentationChangePath = (file: string): boolean =>
238
+ isDocumentationPath(file, documentationRoots);
239
+ const scopedFiles = changedFiles.filter((file) => file.scope !== "outside");
240
+ const appWideFiles = scopedFiles.filter(
241
+ (file) =>
242
+ (file.scope === "governing" || file.scope === "workspace-dependency") &&
243
+ !changedFilePaths(file).every(isDocumentationChangePath),
244
+ );
245
+ const docsOnly =
246
+ scopedFiles.length > 0 &&
247
+ scopedFiles.every((file) =>
248
+ changedFilePaths(file).every(isDocumentationChangePath),
249
+ );
250
+ const appWide = appWideFiles.length > 0 && !docsOnly;
251
+ const impacts = new Map<string, ImpactRecord>();
252
+ const relationships: AppChangeRelationship[] = [];
253
+ const workspaceRelationshipRelevant =
254
+ changedFiles.some((file) => file.scope === "outside") ||
255
+ changedFiles.some(
256
+ (file) =>
257
+ file.scope === "governing" &&
258
+ path.posix.basename(file.path) === "package.json",
259
+ );
260
+ const gaps = workspaceScope.gaps.filter(isAppDependencyGap);
261
+ if (workspaceRelationshipRelevant) {
262
+ gaps.push(...workspaceScope.gaps.filter((gap) => !isAppDependencyGap(gap)));
263
+ }
264
+ const nodesById = new Map(appMap.nodes.map((node) => [node.id, node]));
265
+ const nodesByFile = groupNodesByFile(appMap.nodes);
266
+ const directNodeIds = new Set<string>();
267
+
268
+ if (appWide) {
269
+ const reasons = appWideChangeReasons(
270
+ appWideFiles,
271
+ isDocumentationChangePath,
272
+ );
273
+ for (const node of appMap.nodes) {
274
+ for (const reason of reasons) {
275
+ addImpact(impacts, node, "app-wide", reason);
276
+ }
277
+ }
278
+ }
279
+
280
+ const appSourceChanges = appRelativeSourceChanges(
281
+ changedFiles,
282
+ git.repositoryRoot,
283
+ canonicalTargetDir,
284
+ );
285
+ for (const change of appSourceChanges) {
286
+ const directNodes = nodesByFile.get(change.file) ?? [];
287
+ for (const node of directNodes) {
288
+ directNodeIds.add(node.id);
289
+ addImpact(impacts, node, "direct", {
290
+ kind: "source",
291
+ changedFile: change.repositoryPath,
292
+ message: `Source ${change.repositoryPath} changed directly.`,
293
+ evidence: {
294
+ file: node.source.file,
295
+ ...(node.source.line ? { line: node.source.line } : {}),
296
+ ...(node.source.column ? { column: node.source.column } : {}),
297
+ confidence: "exact",
298
+ },
299
+ });
300
+ }
301
+ }
302
+
303
+ await addImportConsumers({
304
+ workspace,
305
+ changes: appSourceChanges,
306
+ nodesByFile,
307
+ impacts,
308
+ });
309
+ addSemanticConsumers({
310
+ directNodeIds,
311
+ appMapEdges: appMap.edges,
312
+ nodesById,
313
+ impacts,
314
+ relationships,
315
+ });
316
+ addFeatureContext({
317
+ changes: appSourceChanges,
318
+ appMapEdges: appMap.edges,
319
+ nodes: appMap.nodes,
320
+ nodesById,
321
+ impacts,
322
+ });
323
+
324
+ addUnmappedChangeGaps({
325
+ changes: appSourceChanges,
326
+ impacts,
327
+ gaps,
328
+ });
329
+ addAppMapGaps(
330
+ appMap.unresolved,
331
+ appSourceChanges,
332
+ impacts,
333
+ gaps,
334
+ appRepositoryPrefix,
335
+ );
336
+ for (const file of changedFiles) {
337
+ if (file.status === "unmerged" || file.status === "unknown") {
338
+ gaps.push({
339
+ code: "git_change_status_unresolved",
340
+ file: file.path,
341
+ message: `Git reported ${file.status} status for ${file.path}; Beignet cannot classify its impact completely.`,
342
+ });
343
+ }
344
+ }
345
+
346
+ const sortedFiles = [...changedFiles].sort(compareAppChangedFiles);
347
+ const sortedImpacts = finalizeImpacts(impacts);
348
+ const sortedRelationships =
349
+ dedupeRelationships(relationships).sort(compareRelationships);
350
+ const sortedGaps = dedupeGaps(gaps).sort(compareGaps);
351
+ const impactedFeatures = new Set(
352
+ sortedImpacts
353
+ .map(
354
+ (item) =>
355
+ item.feature ?? (item.kind === "feature" ? item.name : undefined),
356
+ )
357
+ .filter((feature): feature is string => Boolean(feature)),
358
+ );
359
+ const repositoryChangeHash = await hashGitChanges(git);
360
+ const scopedRawFiles = git.files.filter((file) =>
361
+ scopedFiles.some(
362
+ (scoped) => scoped.path === file.path && scoped.oldPath === file.oldPath,
363
+ ),
364
+ );
365
+ const scopedChangeHash = await hashGitChanges(git, scopedRawFiles);
366
+
367
+ return {
368
+ schemaVersion: 1,
369
+ targetDir,
370
+ repository: {
371
+ root: git.repositoryRoot,
372
+ ...(workspaceScope.workspaceRoot
373
+ ? { workspaceRoot: workspaceScope.workspaceRoot }
374
+ : {}),
375
+ comparison: git.comparison,
376
+ repositoryChangeHash,
377
+ scopedChangeHash,
378
+ },
379
+ classification: { appWide, docsOnly },
380
+ summary: {
381
+ changedFiles: sortedFiles.length,
382
+ scopedChangedFiles: scopedFiles.length,
383
+ outsideChangedFiles: sortedFiles.length - scopedFiles.length,
384
+ impactedFeatures: impactedFeatures.size,
385
+ impactedNodes: sortedImpacts.length,
386
+ direct: countImpact(sortedImpacts, "direct"),
387
+ consumers: countImpact(sortedImpacts, "consumer"),
388
+ related: countImpact(sortedImpacts, "related"),
389
+ contexts: countImpact(sortedImpacts, "context"),
390
+ appWide: countImpact(sortedImpacts, "app-wide"),
391
+ relationships: sortedRelationships.length,
392
+ gaps: sortedGaps.length,
393
+ },
394
+ changedFiles: bounded(sortedFiles, changedFileLimit),
395
+ impactedNodes: bounded(sortedImpacts, impactedNodeLimit),
396
+ relationships: bounded(sortedRelationships, relationshipLimit),
397
+ gaps: boundedGaps(sortedGaps),
398
+ };
399
+ }
400
+
401
+ /** Format the report-only changed app map for a terminal. */
402
+ export function formatAppChangeImpact(result: AppChangeImpactResult): string {
403
+ const comparison =
404
+ result.repository.comparison.mode === "base"
405
+ ? `${terminalText(result.repository.comparison.base)} at ${shortId(result.repository.comparison.baseCommit)} (merge base ${shortId(result.repository.comparison.mergeBase)}) → HEAD/index/worktree`
406
+ : result.repository.comparison.head
407
+ ? `HEAD at ${shortId(result.repository.comparison.head)} → index/worktree`
408
+ : "empty Git tree → index/worktree";
409
+ const lines = [
410
+ `Beignet change map for ${terminalText(result.targetDir)}`,
411
+ `Comparison: ${comparison}`,
412
+ `Changes: ${result.summary.scopedChangedFiles} scoped, ${result.summary.outsideChangedFiles} outside app`,
413
+ `Impact: ${result.summary.direct} direct, ${result.summary.consumers} consumers, ${result.summary.related} related, ${result.summary.contexts} context, ${result.summary.appWide} app-wide`,
414
+ ];
415
+
416
+ if (result.classification.appWide) lines.push("Scope: app-wide");
417
+ if (result.classification.docsOnly) lines.push("Scope: documentation only");
418
+
419
+ const scopedChanges = result.changedFiles.items.filter(
420
+ (file) => file.scope !== "outside",
421
+ );
422
+ lines.push("", "Changed files");
423
+ if (scopedChanges.length === 0) {
424
+ lines.push(" No scoped changes found.");
425
+ } else {
426
+ for (const file of scopedChanges.slice(0, 30)) {
427
+ const rename = file.oldPath ? `${terminalText(file.oldPath)} → ` : "";
428
+ lines.push(
429
+ ` ${statusMark(file.status)} ${rename}${terminalText(file.path)} (${file.scope})`,
430
+ );
431
+ }
432
+ if (
433
+ scopedChanges.length > 30 ||
434
+ result.summary.scopedChangedFiles > scopedChanges.length
435
+ ) {
436
+ lines.push(
437
+ ` … showing ${Math.min(scopedChanges.length, 30)} of ${result.summary.scopedChangedFiles} scoped changes`,
438
+ );
439
+ }
440
+ }
441
+
442
+ lines.push("", "Potential impact");
443
+ if (result.impactedNodes.items.length === 0) {
444
+ lines.push(
445
+ " No mapped application concepts were connected to this change set.",
446
+ );
447
+ } else {
448
+ for (const item of result.impactedNodes.items.slice(0, 30)) {
449
+ lines.push(
450
+ ` ${item.kind.padEnd(18)} ${terminalText(item.id)} [${item.impact}]`,
451
+ );
452
+ const reason = item.reasons[0];
453
+ if (reason) lines.push(` ${terminalText(reason.message)}`);
454
+ }
455
+ if (result.impactedNodes.total > 30) {
456
+ lines.push(
457
+ ` … showing 30 of ${result.impactedNodes.total} impacted concepts`,
458
+ );
459
+ }
460
+ }
461
+
462
+ lines.push("", "Unresolved evidence");
463
+ if (result.gaps.total === 0) {
464
+ lines.push(" None");
465
+ } else {
466
+ for (const gap of result.gaps.items.slice(0, 20)) {
467
+ lines.push(
468
+ ` ${gap.code}${gap.file ? ` (${terminalText(gap.file)})` : ""}`,
469
+ );
470
+ lines.push(` ${terminalText(gap.message)}`);
471
+ }
472
+ if (result.gaps.total > 20) {
473
+ lines.push(` … showing 20 of ${result.gaps.total} gaps`);
474
+ }
475
+ }
476
+
477
+ lines.push(
478
+ "",
479
+ `Scoped hash: ${shortHash(result.repository.scopedChangeHash)}`,
480
+ "Run beignet map --changed --json for the bounded source-backed report.",
481
+ );
482
+ return lines.join("\n");
483
+ }
484
+
485
+ async function resolveWorkspaceScope(
486
+ repositoryRoot: string,
487
+ targetDir: string,
488
+ ): Promise<WorkspaceScope> {
489
+ const repositoryFiles = await listProjectFiles(repositoryRoot);
490
+ const packageFiles = repositoryFiles.filter(
491
+ (file) => path.posix.basename(file) === "package.json",
492
+ );
493
+ const packageFileSet = new Set(packageFiles);
494
+ const gaps: AppChangeGap[] = [];
495
+ const manifests = new Map<string, PackageJson>();
496
+ const attemptedManifests = new Set<string>();
497
+
498
+ const readManifest = async (
499
+ file: string,
500
+ ): Promise<PackageJson | undefined> => {
501
+ const existing = manifests.get(file);
502
+ if (existing) return existing;
503
+ if (attemptedManifests.has(file)) return undefined;
504
+ attemptedManifests.add(file);
505
+ try {
506
+ const manifest = await readPackageJson(path.join(repositoryRoot, file));
507
+ if (manifest) manifests.set(file, manifest);
508
+ return manifest;
509
+ } catch (error) {
510
+ gaps.push({
511
+ code: "workspace_manifest_unresolved",
512
+ file,
513
+ message: `Could not inspect workspace manifest ${file}: ${error instanceof Error ? error.message : String(error)}`,
514
+ });
515
+ return undefined;
516
+ }
517
+ };
518
+
519
+ const ancestorFiles = ancestorPackageFiles(repositoryRoot, targetDir).filter(
520
+ (file) => packageFileSet.has(file),
521
+ );
522
+ await Promise.all(ancestorFiles.map(readManifest));
523
+ const appPackageFile = normalizePath(
524
+ path.relative(repositoryRoot, path.join(targetDir, "package.json")),
525
+ );
526
+ const appPackage = manifests.get(appPackageFile);
527
+ if (!appPackage) return { dependencyDirectories: new Set(), gaps };
528
+
529
+ const workspaceManifest = ancestorFiles
530
+ .map((file) => ({ file, manifest: manifests.get(file) }))
531
+ .find(({ manifest }) => workspacePatterns(manifest).length > 0);
532
+ const workspaceRoot = workspaceManifest
533
+ ? path.join(repositoryRoot, path.posix.dirname(workspaceManifest.file))
534
+ : undefined;
535
+ const patterns = workspacePatterns(workspaceManifest?.manifest);
536
+ for (const pattern of patterns.filter(hasUnsupportedWorkspacePattern)) {
537
+ gaps.push({
538
+ code: "workspace_pattern_unresolved",
539
+ message: `Workspace pattern ${JSON.stringify(pattern)} uses syntax changed mapping cannot resolve statically.`,
540
+ });
541
+ }
542
+ const workspaceRelativeRoot = workspaceRoot
543
+ ? normalizePath(path.relative(repositoryRoot, workspaceRoot))
544
+ : "";
545
+ const workspacePackageFiles = workspaceRoot
546
+ ? packageFiles.filter((file) => {
547
+ const directory = normalizePath(path.posix.dirname(file));
548
+ const relative = normalizePath(
549
+ path.posix.relative(workspaceRelativeRoot, directory),
550
+ );
551
+ return patterns.some((pattern) =>
552
+ matchesWorkspacePattern(relative, pattern),
553
+ );
554
+ })
555
+ : [];
556
+ await Promise.all(workspacePackageFiles.map(readManifest));
557
+
558
+ const packages = new Map<
559
+ string,
560
+ { directory: string; file: string; manifest: PackageJson }
561
+ >();
562
+ const duplicatePackageNames = new Set<string>();
563
+ for (const file of workspacePackageFiles) {
564
+ const manifest = manifests.get(file);
565
+ if (!manifest) continue;
566
+ if (manifest.name) {
567
+ if (packages.has(manifest.name)) {
568
+ duplicatePackageNames.add(manifest.name);
569
+ packages.delete(manifest.name);
570
+ continue;
571
+ }
572
+ if (duplicatePackageNames.has(manifest.name)) continue;
573
+ packages.set(manifest.name, {
574
+ directory: normalizePath(path.posix.dirname(file)),
575
+ file,
576
+ manifest,
577
+ });
578
+ }
579
+ }
580
+
581
+ for (const name of [...duplicatePackageNames].sort()) {
582
+ gaps.push({
583
+ code: "workspace_package_ambiguous",
584
+ message: `Multiple repository packages declare the name ${JSON.stringify(name)}; direct dependency impact cannot be scoped uniquely.`,
585
+ });
586
+ }
587
+ const workspacePackages = new Map(
588
+ [...packages.entries()].filter(([, pkg]) => {
589
+ if (!workspaceRoot) return false;
590
+ const relative = normalizePath(
591
+ path.posix.relative(workspaceRelativeRoot, pkg.directory),
592
+ );
593
+ return patterns.some((pattern) =>
594
+ matchesWorkspacePattern(relative, pattern),
595
+ );
596
+ }),
597
+ );
598
+
599
+ const dependencyDirectories = new Set<string>();
600
+ for (const [name, version] of Object.entries(
601
+ packageDependencies(appPackage),
602
+ )) {
603
+ const local = workspacePackages.get(name);
604
+ if (local) {
605
+ dependencyDirectories.add(local.directory);
606
+ continue;
607
+ }
608
+ if (version.startsWith("file:") || version.startsWith("link:")) {
609
+ const requested = version.slice(version.indexOf(":") + 1);
610
+ const absolute = path.resolve(targetDir, requested);
611
+ const relative = normalizePath(path.relative(repositoryRoot, absolute));
612
+ if (relative === ".." || relative.startsWith("../")) {
613
+ gaps.push({
614
+ code: "local_dependency_outside_repository",
615
+ message: `Local dependency ${name} resolves outside the Git repository and cannot be scoped.`,
616
+ });
617
+ } else {
618
+ try {
619
+ const manifest = await readPackageJson(
620
+ path.join(absolute, "package.json"),
621
+ );
622
+ if (manifest) dependencyDirectories.add(relative);
623
+ else {
624
+ gaps.push({
625
+ code: "local_dependency_unresolved",
626
+ file: relative,
627
+ message: `Local dependency ${name} points to ${relative}, but no package.json was found there.`,
628
+ });
629
+ }
630
+ } catch (error) {
631
+ dependencyDirectories.add(relative);
632
+ gaps.push({
633
+ code: "local_dependency_manifest_unresolved",
634
+ file: normalizePath(path.posix.join(relative, "package.json")),
635
+ message: `Could not inspect local dependency ${name} at ${relative}: ${error instanceof Error ? error.message : String(error)}`,
636
+ });
637
+ }
638
+ }
639
+ } else if (version.startsWith("workspace:")) {
640
+ gaps.push({
641
+ code: "workspace_dependency_unresolved",
642
+ message: `Workspace dependency ${name} is declared but no matching local workspace package was found.`,
643
+ });
644
+ }
645
+ }
646
+
647
+ return {
648
+ ...(workspaceRoot ? { workspaceRoot } : {}),
649
+ dependencyDirectories,
650
+ gaps,
651
+ };
652
+ }
653
+
654
+ function classifyChangedFiles(args: {
655
+ files: GitChangedFile[];
656
+ governingFiles: ReadonlySet<string>;
657
+ repositoryRoot: string;
658
+ targetDir: string;
659
+ workspaceScope: WorkspaceScope;
660
+ }): AppChangedFile[] {
661
+ const targetPrefix = normalizePath(
662
+ path.relative(args.repositoryRoot, args.targetDir),
663
+ );
664
+ return args.files.map((file) => ({
665
+ ...file,
666
+ scope: highestPriorityScope(
667
+ changedFilePaths(file).map((value) =>
668
+ classifyChangedFile(
669
+ value,
670
+ args.governingFiles,
671
+ targetPrefix,
672
+ args.workspaceScope,
673
+ args.repositoryRoot,
674
+ ),
675
+ ),
676
+ ),
677
+ }));
678
+ }
679
+
680
+ function classifyChangedFile(
681
+ file: string,
682
+ governingFiles: ReadonlySet<string>,
683
+ targetPrefix: string,
684
+ workspaceScope: WorkspaceScope,
685
+ repositoryRoot: string,
686
+ ): AppChangeFileScope {
687
+ if (governingFiles.has(file)) return "governing";
688
+ const workspacePrefix = workspaceScope.workspaceRoot
689
+ ? normalizePath(path.relative(repositoryRoot, workspaceScope.workspaceRoot))
690
+ : undefined;
691
+ if (isGoverningFile(file, targetPrefix, workspacePrefix)) {
692
+ return "governing";
693
+ }
694
+ if (isWithin(file, targetPrefix)) return "app";
695
+ if (
696
+ [...workspaceScope.dependencyDirectories].some((directory) =>
697
+ isWithin(file, directory),
698
+ )
699
+ ) {
700
+ return "workspace-dependency";
701
+ }
702
+ return "outside";
703
+ }
704
+
705
+ function isGoverningFile(
706
+ file: string,
707
+ targetPrefix: string,
708
+ workspacePrefix: string | undefined,
709
+ ): boolean {
710
+ const basename = path.posix.basename(file);
711
+ const directory = normalizePath(path.posix.dirname(file));
712
+ const targetAncestors = pathAncestors(targetPrefix);
713
+ const isTargetOrAncestor =
714
+ isWithin(file, targetPrefix) || targetAncestors.includes(directory);
715
+ const isWorkspaceRoot =
716
+ workspacePrefix !== undefined && directory === workspacePrefix;
717
+ if (!isTargetOrAncestor && !isWorkspaceRoot) {
718
+ return false;
719
+ }
720
+ if (
721
+ [
722
+ "bun.lock",
723
+ "bun.lockb",
724
+ "package-lock.json",
725
+ "pnpm-lock.yaml",
726
+ "yarn.lock",
727
+ "package.json",
728
+ "turbo.json",
729
+ "bunfig.toml",
730
+ "biome.json",
731
+ "biome.jsonc",
732
+ "beignet.config.json",
733
+ ".npmrc",
734
+ ].includes(basename)
735
+ ) {
736
+ return true;
737
+ }
738
+ return (
739
+ /^(?:beignet|drizzle|eslint|next|postcss|tailwind|vite)\.config\.(?:cjs|cts|js|mjs|mts|ts)$/.test(
740
+ basename,
741
+ ) || /^tsconfig(?:\.[^.]+)?\.json$/.test(basename)
742
+ );
743
+ }
744
+
745
+ function appRelativeSourceChanges(
746
+ files: AppChangedFile[],
747
+ repositoryRoot: string,
748
+ canonicalTargetDir: string,
749
+ ): Array<{
750
+ file: string;
751
+ repositoryPath: string;
752
+ status: GitChangedFileStatus;
753
+ }> {
754
+ const targetPrefix = normalizePath(
755
+ path.relative(repositoryRoot, canonicalTargetDir),
756
+ );
757
+ const changes: Array<{
758
+ file: string;
759
+ repositoryPath: string;
760
+ status: GitChangedFileStatus;
761
+ }> = [];
762
+ for (const changed of files) {
763
+ if (changed.scope !== "app") continue;
764
+ for (const repositoryPath of changedFilePaths(changed)) {
765
+ if (!isWithin(repositoryPath, targetPrefix)) continue;
766
+ const file = targetPrefix
767
+ ? normalizePath(path.posix.relative(targetPrefix, repositoryPath))
768
+ : repositoryPath;
769
+ if (isDocumentationPath(file, [""])) continue;
770
+ changes.push({
771
+ file,
772
+ repositoryPath,
773
+ status: changed.status,
774
+ });
775
+ }
776
+ }
777
+ return uniqueBy(
778
+ changes,
779
+ (change) => `${change.repositoryPath}:${change.status}`,
780
+ );
781
+ }
782
+
783
+ async function addImportConsumers(args: {
784
+ workspace: Awaited<ReturnType<typeof createAnalysisWorkspace>>;
785
+ changes: Array<{ file: string; repositoryPath: string }>;
786
+ nodesByFile: Map<string, AppMapNode[]>;
787
+ impacts: Map<string, ImpactRecord>;
788
+ }): Promise<void> {
789
+ const reverseImports = new Map<
790
+ string,
791
+ Array<{
792
+ confidence: "exact" | "partial";
793
+ importer: string;
794
+ reference: LocalImportReference;
795
+ }>
796
+ >();
797
+ const sourceFiles = args.workspace.files.filter(isSourceFile);
798
+ await Promise.all(
799
+ sourceFiles.map(async (file) => {
800
+ for (const reference of await localImportReferences(
801
+ args.workspace,
802
+ file,
803
+ )) {
804
+ const target =
805
+ reference.resolvedFile ??
806
+ resolveLocalModulePath(args.workspace, file, reference.importPath);
807
+ if (!target) continue;
808
+ const confidence = reference.resolvedFile ? "exact" : "partial";
809
+ const keys = reference.resolvedFile
810
+ ? [resolvedModuleKey(target)]
811
+ : unresolvedModuleKeys(target);
812
+ for (const key of keys) {
813
+ const references = reverseImports.get(key) ?? [];
814
+ references.push({ confidence, importer: file, reference });
815
+ reverseImports.set(key, references);
816
+ }
817
+ }
818
+ }),
819
+ );
820
+
821
+ for (const references of reverseImports.values()) {
822
+ references.sort((left, right) =>
823
+ `${left.importer}:${left.reference.line}:${left.reference.column}`.localeCompare(
824
+ `${right.importer}:${right.reference.line}:${right.reference.column}`,
825
+ ),
826
+ );
827
+ }
828
+
829
+ for (const change of args.changes.filter((item) => isSourceFile(item.file))) {
830
+ const queue: Array<{
831
+ confidence: "exact" | "partial";
832
+ depth: number;
833
+ file: string;
834
+ }> = [{ confidence: "exact", file: change.file, depth: 0 }];
835
+ const visited = new Set([resolvedModuleKey(change.file)]);
836
+ for (let index = 0; index < queue.length; index += 1) {
837
+ const current = queue[index];
838
+ for (const key of moduleLookupKeys(current.file)) {
839
+ for (const {
840
+ confidence: edgeConfidence,
841
+ importer,
842
+ reference,
843
+ } of reverseImports.get(key) ?? []) {
844
+ const importerKey = resolvedModuleKey(importer);
845
+ if (visited.has(importerKey)) continue;
846
+ visited.add(importerKey);
847
+ const depth = current.depth + 1;
848
+ const confidence =
849
+ current.confidence === "partial" || edgeConfidence === "partial"
850
+ ? "partial"
851
+ : "exact";
852
+ for (const node of args.nodesByFile.get(importer) ?? []) {
853
+ addImpact(args.impacts, node, "consumer", {
854
+ kind: "import",
855
+ changedFile: change.repositoryPath,
856
+ message: importReasonMessage({
857
+ changedFile: change.file,
858
+ confidence,
859
+ depth,
860
+ importer,
861
+ }),
862
+ evidence: {
863
+ file: importer,
864
+ line: reference.line,
865
+ column: reference.column,
866
+ confidence,
867
+ },
868
+ });
869
+ }
870
+ queue.push({ confidence, file: importer, depth });
871
+ }
872
+ }
873
+ }
874
+ }
875
+ }
876
+
877
+ function importReasonMessage(args: {
878
+ changedFile: string;
879
+ confidence: "exact" | "partial";
880
+ depth: number;
881
+ importer: string;
882
+ }): string {
883
+ const dependency =
884
+ args.confidence === "exact" ? "depends on" : "may depend on";
885
+ const depth = args.depth > 1 ? ` through ${args.depth} local imports` : "";
886
+ const uncertainty =
887
+ args.confidence === "partial"
888
+ ? "; at least one import could not be resolved exactly"
889
+ : "";
890
+ return `${args.importer} ${dependency} changed source ${args.changedFile}${depth}${uncertainty}.`;
891
+ }
892
+
893
+ function addSemanticConsumers(args: {
894
+ directNodeIds: Set<string>;
895
+ appMapEdges: AppMapEdge[];
896
+ nodesById: Map<string, AppMapNode>;
897
+ impacts: Map<string, ImpactRecord>;
898
+ relationships: AppChangeRelationship[];
899
+ }): void {
900
+ for (const edge of args.appMapEdges) {
901
+ if (
902
+ args.directNodeIds.has(edge.to) &&
903
+ edgeImpactBehavior[edge.kind] === "consumer"
904
+ ) {
905
+ const consumer = args.nodesById.get(edge.from);
906
+ const changed = args.nodesById.get(edge.to);
907
+ const changedFile = changed
908
+ ? primaryChangeReason(args.impacts.get(changed.id))?.changedFile
909
+ : undefined;
910
+ if (consumer && changed && changedFile) {
911
+ addImpact(args.impacts, consumer, "consumer", {
912
+ kind: "relationship",
913
+ changedFile,
914
+ message: `${consumer.id} ${edge.kind} changed ${changed.id}.`,
915
+ evidence: edge.evidence,
916
+ relationship: { kind: edge.kind, from: edge.from, to: edge.to },
917
+ });
918
+ args.relationships.push({ ...edge, impact: "consumer" });
919
+ }
920
+ }
921
+
922
+ if (!args.directNodeIds.has(edge.from)) continue;
923
+ const container = args.nodesById.get(edge.from);
924
+ const expansionKinds = container
925
+ ? containerExpansionKinds[
926
+ container.kind as keyof typeof containerExpansionKinds
927
+ ]
928
+ : undefined;
929
+ if (!container || !expansionKinds?.has(edge.kind)) continue;
930
+ const related = args.nodesById.get(edge.to);
931
+ if (!related) continue;
932
+ addImpact(args.impacts, related, "related", {
933
+ kind: "relationship",
934
+ changedFile:
935
+ primaryChangeReason(args.impacts.get(container.id))?.changedFile ??
936
+ container.source.file,
937
+ message: `${related.id} is connected to the directly changed ${container.id} container.`,
938
+ evidence: edge.evidence,
939
+ relationship: { kind: edge.kind, from: edge.from, to: edge.to },
940
+ });
941
+ args.relationships.push({ ...edge, impact: "related" });
942
+ }
943
+ }
944
+
945
+ function addFeatureContext(args: {
946
+ changes: Array<{ file: string; repositoryPath: string }>;
947
+ appMapEdges: AppMapEdge[];
948
+ nodes: AppMapNode[];
949
+ nodesById: Map<string, AppMapNode>;
950
+ impacts: Map<string, ImpactRecord>;
951
+ }): void {
952
+ for (const change of args.changes) {
953
+ for (const feature of args.nodes.filter(
954
+ (node) => node.kind === "feature",
955
+ )) {
956
+ if (!isWithin(change.file, feature.source.file)) continue;
957
+ addImpact(args.impacts, feature, "context", {
958
+ kind: "ownership",
959
+ changedFile: change.repositoryPath,
960
+ message: `${change.file} belongs to feature ${feature.name}.`,
961
+ evidence: { file: change.file, confidence: "exact" },
962
+ });
963
+ }
964
+ }
965
+
966
+ const impactedIds = new Set(args.impacts.keys());
967
+ for (const edge of args.appMapEdges) {
968
+ if (
969
+ !impactedIds.has(edge.to) ||
970
+ (edge.kind !== "owns" && edge.kind !== "contains-test")
971
+ ) {
972
+ continue;
973
+ }
974
+ const feature = args.nodesById.get(edge.from);
975
+ const member = args.nodesById.get(edge.to);
976
+ const memberImpact = member ? args.impacts.get(member.id) : undefined;
977
+ if (!feature || !member || memberImpact?.impact === "app-wide") continue;
978
+ addImpact(args.impacts, feature, "context", {
979
+ kind: "ownership",
980
+ changedFile:
981
+ primaryChangeReason(memberImpact)?.changedFile ?? member.source.file,
982
+ message: `${feature.id} owns impacted ${member.id}.`,
983
+ evidence: edge.evidence,
984
+ relationship: { kind: edge.kind, from: edge.from, to: edge.to },
985
+ });
986
+ }
987
+ }
988
+
989
+ function addUnmappedChangeGaps(args: {
990
+ changes: Array<{
991
+ file: string;
992
+ repositoryPath: string;
993
+ status: GitChangedFileStatus;
994
+ }>;
995
+ impacts: Map<string, ImpactRecord>;
996
+ gaps: AppChangeGap[];
997
+ }): void {
998
+ const reasons = [...args.impacts.values()].flatMap(
999
+ (impact) => impact.reasons,
1000
+ );
1001
+ for (const change of args.changes) {
1002
+ const represented = reasons.some(
1003
+ (reason) => reason.changedFile === change.repositoryPath,
1004
+ );
1005
+ const sourceFile = isSourceFile(change.file);
1006
+ if (sourceFile && change.status === "deleted") {
1007
+ args.gaps.push({
1008
+ code: "deleted_source_unresolved",
1009
+ file: change.repositoryPath,
1010
+ message:
1011
+ "The declaration is absent from the current app map, so Beignet can report surviving importers and feature context but cannot reconstruct every previous semantic relationship.",
1012
+ });
1013
+ } else if (!represented) {
1014
+ args.gaps.push({
1015
+ code: sourceFile ? "changed_source_unmapped" : "changed_file_unmapped",
1016
+ file: change.repositoryPath,
1017
+ message: sourceFile
1018
+ ? "No mapped concept, feature, or surviving local importer could be connected to this changed source file."
1019
+ : "No mapped concept or feature context could be connected to this changed file.",
1020
+ });
1021
+ }
1022
+ }
1023
+ }
1024
+
1025
+ function addAppMapGaps(
1026
+ unresolved: Array<{
1027
+ code: string;
1028
+ message: string;
1029
+ source: { file: string };
1030
+ }>,
1031
+ changes: Array<{ file: string }>,
1032
+ impacts: Map<string, ImpactRecord>,
1033
+ gaps: AppChangeGap[],
1034
+ appRepositoryPrefix: string,
1035
+ ): void {
1036
+ const relevantFiles = new Set(
1037
+ [
1038
+ ...changes.map((change) => change.file),
1039
+ ...[...impacts.values()].flatMap((impact) => [
1040
+ impact.node.source.file,
1041
+ ...impact.reasons
1042
+ .map((reason) => reason.evidence?.file)
1043
+ .filter(Boolean),
1044
+ ]),
1045
+ ].filter((file): file is string => Boolean(file)),
1046
+ );
1047
+ for (const item of unresolved) {
1048
+ if (!relevantFiles.has(item.source.file)) continue;
1049
+ gaps.push({
1050
+ code: "app_map_unresolved_reference",
1051
+ file: normalizePath(
1052
+ path.posix.join(appRepositoryPrefix, item.source.file),
1053
+ ),
1054
+ message: `${item.code}: ${item.message}`,
1055
+ });
1056
+ }
1057
+ }
1058
+
1059
+ function addImpact(
1060
+ impacts: Map<string, ImpactRecord>,
1061
+ node: AppMapNode,
1062
+ impact: AppChangeImpact,
1063
+ reason: AppChangeReason,
1064
+ ): void {
1065
+ const existing = impacts.get(node.id);
1066
+ if (!existing) {
1067
+ impacts.set(node.id, { node, impact, reasons: [reason] });
1068
+ return;
1069
+ }
1070
+ if (impactRank(impact) > impactRank(existing.impact))
1071
+ existing.impact = impact;
1072
+ if (!existing.reasons.some((item) => reasonKey(item) === reasonKey(reason))) {
1073
+ existing.reasons.push(reason);
1074
+ }
1075
+ }
1076
+
1077
+ function finalizeImpacts(
1078
+ impacts: Map<string, ImpactRecord>,
1079
+ ): AppChangeImpactedNode[] {
1080
+ return [...impacts.values()]
1081
+ .map(({ node, impact, reasons }) => {
1082
+ const sortedReasons = [...reasons].sort(
1083
+ (left, right) =>
1084
+ reasonKindSort(left.kind) - reasonKindSort(right.kind) ||
1085
+ reasonKey(left).localeCompare(reasonKey(right)),
1086
+ );
1087
+ return {
1088
+ ...node,
1089
+ impact,
1090
+ reasons: sortedReasons.slice(0, reasonLimitPerNode),
1091
+ reasonCount: sortedReasons.length,
1092
+ reasonsTruncated: sortedReasons.length > reasonLimitPerNode,
1093
+ };
1094
+ })
1095
+ .sort((left, right) =>
1096
+ `${impactSort(left.impact)}:${left.kind}:${left.id}`.localeCompare(
1097
+ `${impactSort(right.impact)}:${right.kind}:${right.id}`,
1098
+ ),
1099
+ );
1100
+ }
1101
+
1102
+ function groupNodesByFile(nodes: AppMapNode[]): Map<string, AppMapNode[]> {
1103
+ const grouped = new Map<string, AppMapNode[]>();
1104
+ for (const node of nodes) {
1105
+ const values = grouped.get(node.source.file) ?? [];
1106
+ values.push(node);
1107
+ grouped.set(node.source.file, values);
1108
+ }
1109
+ return grouped;
1110
+ }
1111
+
1112
+ function packageDependencies(manifest: PackageJson): Record<string, string> {
1113
+ return {
1114
+ ...manifest.dependencies,
1115
+ ...manifest.devDependencies,
1116
+ ...manifest.optionalDependencies,
1117
+ ...manifest.peerDependencies,
1118
+ };
1119
+ }
1120
+
1121
+ function workspacePatterns(manifest: PackageJson | undefined): string[] {
1122
+ if (!manifest?.workspaces) return [];
1123
+ return Array.isArray(manifest.workspaces)
1124
+ ? manifest.workspaces
1125
+ : (manifest.workspaces.packages ?? []);
1126
+ }
1127
+
1128
+ function matchesWorkspacePattern(file: string, pattern: string): boolean {
1129
+ const source = normalizePath(pattern).replace(/^\.\//, "").replace(/\/$/, "");
1130
+ let expression = "";
1131
+ for (let index = 0; index < source.length; index += 1) {
1132
+ const character = source[index];
1133
+ if (character === "*" && source[index + 1] === "*") {
1134
+ expression += ".*";
1135
+ index += 1;
1136
+ } else if (character === "*") expression += "[^/]*";
1137
+ else if (character === "?") expression += "[^/]";
1138
+ else expression += character.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
1139
+ }
1140
+ return new RegExp(`^${expression}$`).test(file);
1141
+ }
1142
+
1143
+ function hasUnsupportedWorkspacePattern(pattern: string): boolean {
1144
+ return [...pattern].some((character) => "!{}[]".includes(character));
1145
+ }
1146
+
1147
+ function ancestorPackageFiles(
1148
+ repositoryRoot: string,
1149
+ targetDir: string,
1150
+ ): string[] {
1151
+ const files: string[] = [];
1152
+ let directory = targetDir;
1153
+ while (true) {
1154
+ files.push(
1155
+ normalizePath(
1156
+ path.relative(repositoryRoot, path.join(directory, "package.json")),
1157
+ ),
1158
+ );
1159
+ if (directory === repositoryRoot) break;
1160
+ const parent = path.dirname(directory);
1161
+ if (parent === directory || !isAbsoluteWithin(parent, repositoryRoot))
1162
+ break;
1163
+ directory = parent;
1164
+ }
1165
+ return files;
1166
+ }
1167
+
1168
+ async function readPackageJson(file: string): Promise<PackageJson | undefined> {
1169
+ try {
1170
+ return JSON.parse(await readFile(file, "utf8")) as PackageJson;
1171
+ } catch (error) {
1172
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
1173
+ throw new Error(
1174
+ `Could not read workspace manifest ${file}: ${String(error)}`,
1175
+ );
1176
+ }
1177
+ }
1178
+
1179
+ function bounded<T>(items: T[], limit: number): BoundedAppChangeSection<T> {
1180
+ return {
1181
+ items: items.slice(0, limit),
1182
+ returned: Math.min(items.length, limit),
1183
+ total: items.length,
1184
+ truncated: items.length > limit,
1185
+ };
1186
+ }
1187
+
1188
+ function boundedGaps(gaps: AppChangeGap[]): BoundedAppChangeGapSection {
1189
+ const section = bounded(gaps, gapLimit);
1190
+ const countsByCode: Record<string, number> = {};
1191
+ for (const gap of gaps) {
1192
+ countsByCode[gap.code] = (countsByCode[gap.code] ?? 0) + 1;
1193
+ }
1194
+ return { ...section, countsByCode };
1195
+ }
1196
+
1197
+ function dedupeRelationships(
1198
+ relationships: AppChangeRelationship[],
1199
+ ): AppChangeRelationship[] {
1200
+ return uniqueBy(
1201
+ relationships,
1202
+ (edge) => `${edge.impact}:${edge.kind}:${edge.from}:${edge.to}`,
1203
+ );
1204
+ }
1205
+
1206
+ function dedupeGaps(gaps: AppChangeGap[]): AppChangeGap[] {
1207
+ return uniqueBy(
1208
+ gaps,
1209
+ (gap) => `${gap.code}:${gap.file ?? ""}:${gap.message}`,
1210
+ );
1211
+ }
1212
+
1213
+ function uniqueBy<T>(items: T[], key: (item: T) => string): T[] {
1214
+ const seen = new Set<string>();
1215
+ return items.filter((item) => {
1216
+ const value = key(item);
1217
+ if (seen.has(value)) return false;
1218
+ seen.add(value);
1219
+ return true;
1220
+ });
1221
+ }
1222
+
1223
+ function compareAppChangedFiles(
1224
+ left: AppChangedFile,
1225
+ right: AppChangedFile,
1226
+ ): number {
1227
+ return `${scopeSort(left.scope)}:${left.path}:${left.oldPath ?? ""}`.localeCompare(
1228
+ `${scopeSort(right.scope)}:${right.path}:${right.oldPath ?? ""}`,
1229
+ );
1230
+ }
1231
+
1232
+ function compareRelationships(
1233
+ left: AppChangeRelationship,
1234
+ right: AppChangeRelationship,
1235
+ ): number {
1236
+ return `${left.impact}:${left.kind}:${left.from}:${left.to}`.localeCompare(
1237
+ `${right.impact}:${right.kind}:${right.from}:${right.to}`,
1238
+ );
1239
+ }
1240
+
1241
+ function compareGaps(left: AppChangeGap, right: AppChangeGap): number {
1242
+ return `${left.code}:${left.file ?? ""}:${left.message}`.localeCompare(
1243
+ `${right.code}:${right.file ?? ""}:${right.message}`,
1244
+ );
1245
+ }
1246
+
1247
+ function isAppDependencyGap(gap: AppChangeGap): boolean {
1248
+ return (
1249
+ gap.code.startsWith("local_dependency_") ||
1250
+ gap.code === "workspace_dependency_unresolved"
1251
+ );
1252
+ }
1253
+
1254
+ function countImpact(
1255
+ items: AppChangeImpactedNode[],
1256
+ impact: AppChangeImpact,
1257
+ ): number {
1258
+ return items.filter((item) => item.impact === impact).length;
1259
+ }
1260
+
1261
+ function reasonKey(reason: AppChangeReason): string {
1262
+ return `${reason.kind}:${reason.changedFile}:${reason.relationship?.kind ?? ""}:${reason.relationship?.from ?? ""}:${reason.relationship?.to ?? ""}:${reason.evidence?.file ?? ""}:${reason.evidence?.line ?? 0}:${reason.evidence?.column ?? 0}`;
1263
+ }
1264
+
1265
+ function reasonKindSort(kind: AppChangeReason["kind"]): number {
1266
+ return {
1267
+ source: 0,
1268
+ relationship: 1,
1269
+ import: 2,
1270
+ ownership: 3,
1271
+ "app-wide": 4,
1272
+ }[kind];
1273
+ }
1274
+
1275
+ function impactRank(impact: AppChangeImpact): number {
1276
+ return { "app-wide": 0, context: 1, related: 2, consumer: 3, direct: 4 }[
1277
+ impact
1278
+ ];
1279
+ }
1280
+
1281
+ function impactSort(impact: AppChangeImpact): number {
1282
+ return { direct: 0, consumer: 1, related: 2, context: 3, "app-wide": 4 }[
1283
+ impact
1284
+ ];
1285
+ }
1286
+
1287
+ function scopeSort(scope: AppChangeFileScope): number {
1288
+ return { governing: 0, app: 1, "workspace-dependency": 2, outside: 3 }[scope];
1289
+ }
1290
+
1291
+ function moduleStem(file: string): string {
1292
+ return normalizePath(file).replace(/\.(?:c|m)?[jt]sx?$/, "");
1293
+ }
1294
+
1295
+ function resolvedModuleKey(file: string): string {
1296
+ return `resolved:${normalizePath(file)}`;
1297
+ }
1298
+
1299
+ function unresolvedModuleKeys(file: string): string[] {
1300
+ const stem = moduleStem(file);
1301
+ return stem.endsWith("/index")
1302
+ ? [`unresolved:${stem}`]
1303
+ : [`unresolved:${stem}`, `unresolved:${stem}/index`];
1304
+ }
1305
+
1306
+ function moduleLookupKeys(file: string): string[] {
1307
+ return [resolvedModuleKey(file), ...unresolvedModuleKeys(file)];
1308
+ }
1309
+
1310
+ function changedFilePaths(
1311
+ file: Pick<AppChangedFile, "oldPath" | "path" | "status">,
1312
+ ): string[] {
1313
+ return file.status === "renamed" && file.oldPath
1314
+ ? [file.path, file.oldPath]
1315
+ : [file.path];
1316
+ }
1317
+
1318
+ function primaryChangeReason(
1319
+ impact: ImpactRecord | undefined,
1320
+ ): AppChangeReason | undefined {
1321
+ return (
1322
+ impact?.reasons.find((reason) => reason.kind === "source") ??
1323
+ impact?.reasons[0]
1324
+ );
1325
+ }
1326
+
1327
+ function highestPriorityScope(
1328
+ scopes: AppChangeFileScope[],
1329
+ ): AppChangeFileScope {
1330
+ return (
1331
+ [...scopes].sort((left, right) => scopeSort(left) - scopeSort(right))[0] ??
1332
+ "outside"
1333
+ );
1334
+ }
1335
+
1336
+ function isSourceFile(file: string): boolean {
1337
+ return /\.(?:c|m)?[jt]sx?$/.test(file) && !file.endsWith(".d.ts");
1338
+ }
1339
+
1340
+ function isDocumentationPath(file: string, roots: string[]): boolean {
1341
+ const normalized = normalizePath(file);
1342
+ const basename = path.posix.basename(normalized);
1343
+ if (
1344
+ /^(?:AGENTS|CLAUDE|README|CHANGELOG|CONTRIBUTING|SECURITY|CODE_OF_CONDUCT|LICENSE|NOTICE)$/i.test(
1345
+ basename,
1346
+ ) ||
1347
+ /^(?:AGENTS|CLAUDE|README|CHANGELOG|CONTRIBUTING|SECURITY|CODE_OF_CONDUCT|LICENSE|NOTICE)(?:\.[a-z0-9_-]+)?\.(?:md|mdx|txt)$/i.test(
1348
+ basename,
1349
+ )
1350
+ ) {
1351
+ return true;
1352
+ }
1353
+ return (
1354
+ /\.(?:md|mdx|txt)$/i.test(normalized) &&
1355
+ roots.some((root) => {
1356
+ if (!isWithin(normalized, root)) return false;
1357
+ const relative = root
1358
+ ? normalizePath(path.posix.relative(root, normalized))
1359
+ : normalized;
1360
+ return /^(?:docs|documentation)\//i.test(relative);
1361
+ })
1362
+ );
1363
+ }
1364
+
1365
+ function appWideChangeReasons(
1366
+ files: AppChangedFile[],
1367
+ isDocumentation: (file: string) => boolean,
1368
+ ): AppChangeReason[] {
1369
+ return (["governing", "workspace-dependency"] as const).flatMap((scope) => {
1370
+ const scoped = files.filter((file) => file.scope === scope);
1371
+ if (scoped.length === 0) return [];
1372
+ const first = scoped[0];
1373
+ const changedFile =
1374
+ changedFilePaths(first).find((file) => !isDocumentation(file)) ??
1375
+ first.path;
1376
+ const message =
1377
+ scope === "workspace-dependency"
1378
+ ? scoped.length === 1
1379
+ ? `Direct local dependency ${changedFile} changed.`
1380
+ : `${scoped.length} direct local dependency files changed, including ${changedFile}.`
1381
+ : scoped.length === 1
1382
+ ? `Governing file ${changedFile} changed.`
1383
+ : `${scoped.length} governing files changed, including ${changedFile}.`;
1384
+ return [{ kind: "app-wide" as const, changedFile, message }];
1385
+ });
1386
+ }
1387
+
1388
+ function isWithin(file: string, directory: string): boolean {
1389
+ if (!directory || directory === ".") return true;
1390
+ return file === directory || file.startsWith(`${directory}/`);
1391
+ }
1392
+
1393
+ function isAbsoluteWithin(file: string, directory: string): boolean {
1394
+ const relative = path.relative(directory, file);
1395
+ return (
1396
+ relative === "" ||
1397
+ (relative !== ".." && !relative.startsWith(`..${path.sep}`))
1398
+ );
1399
+ }
1400
+
1401
+ function assertInsideRepository(
1402
+ targetDir: string,
1403
+ repositoryRoot: string,
1404
+ ): void {
1405
+ if (!isAbsoluteWithin(targetDir, repositoryRoot)) {
1406
+ throw new Error(
1407
+ `App directory ${targetDir} is outside Git repository ${repositoryRoot}.`,
1408
+ );
1409
+ }
1410
+ }
1411
+
1412
+ function pathAncestors(relativePath: string): string[] {
1413
+ const values: string[] = [];
1414
+ let current = relativePath;
1415
+ while (true) {
1416
+ values.push(current || ".");
1417
+ if (!current || current === ".") break;
1418
+ const parent = path.posix.dirname(current);
1419
+ if (parent === current) break;
1420
+ current = parent === "." ? "" : parent;
1421
+ }
1422
+ return values;
1423
+ }
1424
+
1425
+ function normalizePath(file: string): string {
1426
+ const normalized = file.replaceAll("\\", "/");
1427
+ return normalized === "." ? "" : normalized;
1428
+ }
1429
+
1430
+ function statusMark(status: GitChangedFileStatus): string {
1431
+ return {
1432
+ added: "A",
1433
+ copied: "C",
1434
+ deleted: "D",
1435
+ modified: "M",
1436
+ renamed: "R",
1437
+ "type-changed": "T",
1438
+ unmerged: "U",
1439
+ unknown: "?",
1440
+ untracked: "?",
1441
+ }[status];
1442
+ }
1443
+
1444
+ function shortId(value: string): string {
1445
+ return value.slice(0, 8);
1446
+ }
1447
+
1448
+ function shortHash(value: string): string {
1449
+ return `${value.slice(0, 15)}…`;
1450
+ }
1451
+
1452
+ function terminalText(value: string): string {
1453
+ return JSON.stringify(value)
1454
+ .slice(1, -1)
1455
+ .replace(/[\u007f-\u009f\u2028\u2029\p{Cf}]/gu, (character) => {
1456
+ const codePoint = character.codePointAt(0);
1457
+ if (codePoint === undefined) return "";
1458
+ return codePoint <= 0xffff
1459
+ ? `\\u${codePoint.toString(16).padStart(4, "0")}`
1460
+ : `\\u{${codePoint.toString(16)}}`;
1461
+ });
1462
+ }