auditor-lambda 0.3.12 → 0.3.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 (61) hide show
  1. package/README.md +20 -24
  2. package/audit-code-wrapper-lib.mjs +52 -53
  3. package/dist/cli.js +43 -6
  4. package/dist/coverage.js +3 -1
  5. package/dist/extractors/disposition.js +8 -1
  6. package/dist/extractors/graph.d.ts +3 -1
  7. package/dist/extractors/graph.js +1147 -67
  8. package/dist/extractors/graphManifestEdges.d.ts +14 -0
  9. package/dist/extractors/graphManifestEdges.js +1158 -0
  10. package/dist/extractors/graphPathUtils.d.ts +5 -0
  11. package/dist/extractors/graphPathUtils.js +75 -0
  12. package/dist/extractors/pathPatterns.d.ts +1 -0
  13. package/dist/extractors/pathPatterns.js +3 -0
  14. package/dist/io/artifacts.d.ts +10 -1
  15. package/dist/io/artifacts.js +23 -3
  16. package/dist/orchestrator/internalExecutors.d.ts +4 -0
  17. package/dist/orchestrator/internalExecutors.js +35 -6
  18. package/dist/orchestrator/reviewPackets.js +1003 -31
  19. package/dist/orchestrator/syntaxResolutionExecutor.js +34 -0
  20. package/dist/types/externalAnalyzer.d.ts +9 -0
  21. package/dist/types/graph.d.ts +3 -0
  22. package/dist/types/reviewPlanning.d.ts +39 -0
  23. package/docs/contracts.md +215 -0
  24. package/docs/development.md +210 -0
  25. package/docs/handoff.md +204 -0
  26. package/docs/history.md +40 -0
  27. package/docs/operator-guide.md +189 -0
  28. package/docs/product.md +185 -0
  29. package/docs/release.md +131 -0
  30. package/package.json +1 -1
  31. package/schemas/audit_plan_metrics.schema.json +347 -0
  32. package/schemas/external_analyzer_results.schema.json +35 -0
  33. package/schemas/graph_bundle.schema.json +47 -2
  34. package/schemas/review_packets.schema.json +160 -0
  35. package/skills/audit-code/SKILL.md +7 -3
  36. package/skills/audit-code/audit-code.prompt.md +4 -1
  37. package/docs/agent-integrations.md +0 -317
  38. package/docs/agent-roles.md +0 -69
  39. package/docs/architecture.md +0 -90
  40. package/docs/artifacts.md +0 -36
  41. package/docs/bootstrap-install.md +0 -139
  42. package/docs/contract.md +0 -54
  43. package/docs/dispatch-implementation-plan.md +0 -302
  44. package/docs/field-trial-bug-report.md +0 -237
  45. package/docs/github-copilot.md +0 -66
  46. package/docs/model-selection.md +0 -97
  47. package/docs/next-steps.md +0 -202
  48. package/docs/packaging.md +0 -120
  49. package/docs/pipeline.md +0 -152
  50. package/docs/product-direction.md +0 -154
  51. package/docs/production-launch-bar.md +0 -92
  52. package/docs/production-readiness.md +0 -58
  53. package/docs/releasing.md +0 -145
  54. package/docs/remediation-baseline.md +0 -75
  55. package/docs/repo-layout.md +0 -30
  56. package/docs/run-flow.md +0 -56
  57. package/docs/session-config.md +0 -319
  58. package/docs/supervisor.md +0 -100
  59. package/docs/usage.md +0 -215
  60. package/docs/windows-setup.md +0 -146
  61. package/docs/workflow-refactor-brief.md +0 -124
@@ -4,6 +4,48 @@ const DEFAULT_MAX_TASKS_PER_PACKET = 0;
4
4
  const DEFAULT_TARGET_PACKET_LINES = 8000;
5
5
  const ESTIMATED_TOKENS_PER_LINE = 4;
6
6
  const ESTIMATED_PACKET_PROMPT_TOKENS = 900;
7
+ const PACKET_EXPANSION_MIN_CONFIDENCE = 0.65;
8
+ const HIGH_FAN_DEGREE_THRESHOLD = 12;
9
+ const HIGH_FAN_EXPANSION_CONFIDENCE = 0.99;
10
+ const MAX_PACKET_KEY_EDGES = 8;
11
+ const MAX_PACKET_BOUNDARY_FILES = 12;
12
+ const MAX_WEAK_PACKET_SAMPLES = 12;
13
+ const MAX_WEAK_PACKET_SAMPLE_FILES = 8;
14
+ const WEAK_PACKET_GAP_ORDER = [
15
+ "missing_internal_edges",
16
+ "unexplained_files",
17
+ "partial_cohesion",
18
+ ];
19
+ const MAX_ENTRYPOINT_FLOW_BRIDGE_HOPS = 3;
20
+ const MAX_ENTRYPOINT_FLOW_BRANCHES = 8;
21
+ const SUBSYSTEM_CLUSTER_CONFIDENCE = 0.7;
22
+ const PACKAGE_OWNERSHIP_CLUSTER_CONFIDENCE = 0.68;
23
+ const MODULE_OWNERSHIP_CLUSTER_CONFIDENCE = 0.66;
24
+ const ANALYZER_OWNERSHIP_EDGE_KIND = "analyzer-ownership-root-link";
25
+ const MAX_SUBSYSTEM_CLUSTER_GROUPS = 4;
26
+ const MAX_SUBSYSTEM_CLUSTER_TASKS = 8;
27
+ const MAX_SUBSYSTEM_CLUSTER_FILES = 8;
28
+ const MODULE_OWNERSHIP_EDGE_KINDS = new Set([
29
+ "typescript-project-reference-link",
30
+ "go-workspace-module-link",
31
+ "cargo-workspace-member-link",
32
+ "maven-module-link",
33
+ ANALYZER_OWNERSHIP_EDGE_KIND,
34
+ ]);
35
+ const BROAD_ANALYZER_OWNERSHIP_ROOTS = new Set([
36
+ "src",
37
+ "lib",
38
+ "app",
39
+ "apps",
40
+ "packages",
41
+ "services",
42
+ "crates",
43
+ "modules",
44
+ "test",
45
+ "tests",
46
+ "spec",
47
+ "specs",
48
+ ]);
7
49
  function priorityRank(priority) {
8
50
  switch (priority) {
9
51
  case "high":
@@ -36,6 +78,16 @@ function packetGroupingKey(task) {
36
78
  const scope = criticalFlowTag ?? task.unit_id;
37
79
  return `${scope}\0${taskFileSignature(task)}`;
38
80
  }
81
+ function buildTaskGroups(tasks) {
82
+ const groups = new Map();
83
+ for (const task of tasks) {
84
+ const key = packetGroupingKey(task);
85
+ const group = groups.get(key) ?? [];
86
+ group.push(task);
87
+ groups.set(key, group);
88
+ }
89
+ return groups;
90
+ }
39
91
  function normalizeGraphPath(path) {
40
92
  return path.replace(/\\/g, "/").replace(/^\.\//, "").toLowerCase();
41
93
  }
@@ -59,18 +111,680 @@ function collectGraphEdges(graphBundle) {
59
111
  if (typeof item.from !== "string" || typeof item.to !== "string") {
60
112
  continue;
61
113
  }
62
- edges.push({
114
+ const edge = {
63
115
  from: item.from,
64
116
  to: item.to,
65
117
  kind: typeof item.kind === "string" ? item.kind : undefined,
66
- });
118
+ };
119
+ if (item.direction === "directed" || item.direction === "undirected") {
120
+ edge.direction = item.direction;
121
+ }
122
+ if (typeof item.confidence === "number" && Number.isFinite(item.confidence)) {
123
+ edge.confidence = Math.min(1, Math.max(0, item.confidence));
124
+ }
125
+ if (typeof item.reason === "string" && item.reason.trim().length > 0) {
126
+ edge.reason = item.reason.trim();
127
+ }
128
+ edges.push(edge);
67
129
  }
68
130
  }
69
131
  return edges;
70
132
  }
71
- function isPacketExpansionEdge(edge) {
133
+ function graphEdgeConfidence(edge) {
134
+ if (typeof edge.confidence === "number" && Number.isFinite(edge.confidence)) {
135
+ return Math.min(1, Math.max(0, edge.confidence));
136
+ }
137
+ if (edge.kind === "heuristic-container-edge") {
138
+ return 0.25;
139
+ }
140
+ if (edge.kind?.startsWith("heuristic-")) {
141
+ return 0.5;
142
+ }
143
+ return 0.8;
144
+ }
145
+ function isConcreteGraphEdge(edge) {
72
146
  return edge.kind !== "heuristic-container-edge";
73
147
  }
148
+ function buildGraphDegreeIndex(edges) {
149
+ const fanIn = new Map();
150
+ const fanOut = new Map();
151
+ for (const edge of edges) {
152
+ if (!isConcreteGraphEdge(edge)) {
153
+ continue;
154
+ }
155
+ const from = normalizeGraphPath(edge.from);
156
+ const to = normalizeGraphPath(edge.to);
157
+ fanOut.set(from, (fanOut.get(from) ?? 0) + 1);
158
+ fanIn.set(to, (fanIn.get(to) ?? 0) + 1);
159
+ }
160
+ return { fanIn, fanOut };
161
+ }
162
+ function isPacketExpansionEdge(edge, degreeIndex) {
163
+ if (!isConcreteGraphEdge(edge)) {
164
+ return false;
165
+ }
166
+ const confidence = graphEdgeConfidence(edge);
167
+ if (confidence < PACKET_EXPANSION_MIN_CONFIDENCE) {
168
+ return false;
169
+ }
170
+ const fromFanOut = degreeIndex.fanOut.get(normalizeGraphPath(edge.from)) ?? 0;
171
+ const toFanIn = degreeIndex.fanIn.get(normalizeGraphPath(edge.to)) ?? 0;
172
+ const highFanEdge = fromFanOut > HIGH_FAN_DEGREE_THRESHOLD ||
173
+ toFanIn > HIGH_FAN_DEGREE_THRESHOLD;
174
+ return !highFanEdge || confidence >= HIGH_FAN_EXPANSION_CONFIDENCE;
175
+ }
176
+ function buildFileToGroupKeys(groups) {
177
+ const fileToGroupKeys = new Map();
178
+ for (const [key, tasks] of groups) {
179
+ for (const path of new Set(tasks.flatMap((task) => task.file_paths))) {
180
+ const normalized = normalizeGraphPath(path);
181
+ const existing = fileToGroupKeys.get(normalized) ?? new Set();
182
+ existing.add(key);
183
+ fileToGroupKeys.set(normalized, existing);
184
+ }
185
+ }
186
+ return fileToGroupKeys;
187
+ }
188
+ function collectEntrypointFlowRoots(graphEdges, graphBundle) {
189
+ const roots = new Set();
190
+ const routes = Array.isArray(graphBundle?.graphs.routes)
191
+ ? graphBundle.graphs.routes
192
+ : [];
193
+ for (const route of routes) {
194
+ if (isRecord(route) && typeof route.handler === "string") {
195
+ roots.add(normalizeGraphPath(route.handler));
196
+ }
197
+ }
198
+ for (const edge of graphEdges) {
199
+ if (edge.kind === "route-handler-link") {
200
+ roots.add(normalizeGraphPath(edge.from));
201
+ roots.add(normalizeGraphPath(edge.to));
202
+ }
203
+ else if (edge.kind === "package-entrypoint-link") {
204
+ roots.add(normalizeGraphPath(edge.to));
205
+ }
206
+ }
207
+ return roots;
208
+ }
209
+ function buildRepresentativePathIndex(groups, graphEdges, graphBundle) {
210
+ const representatives = new Map();
211
+ const addPath = (path) => {
212
+ const normalized = normalizeGraphPath(path);
213
+ if (!representatives.has(normalized)) {
214
+ representatives.set(normalized, path);
215
+ }
216
+ };
217
+ for (const tasks of groups.values()) {
218
+ for (const path of tasks.flatMap((task) => task.file_paths)) {
219
+ addPath(path);
220
+ }
221
+ }
222
+ for (const edge of graphEdges) {
223
+ addPath(edge.from);
224
+ addPath(edge.to);
225
+ }
226
+ const routes = Array.isArray(graphBundle?.graphs.routes)
227
+ ? graphBundle.graphs.routes
228
+ : [];
229
+ for (const route of routes) {
230
+ if (isRecord(route) && typeof route.handler === "string") {
231
+ addPath(route.handler);
232
+ }
233
+ }
234
+ return representatives;
235
+ }
236
+ function uniqueTaskFilePaths(tasks) {
237
+ return [...new Set(tasks.flatMap((task) => task.file_paths))].sort((a, b) => a.localeCompare(b));
238
+ }
239
+ function groupsOverlap(a, b) {
240
+ for (const key of a) {
241
+ if (b.has(key)) {
242
+ return true;
243
+ }
244
+ }
245
+ return false;
246
+ }
247
+ function buildGraphConnectedComponentIndex(groups, graphEdges) {
248
+ const groupKeys = [...groups.keys()];
249
+ const parent = new Map(groupKeys.map((key) => [key, key]));
250
+ const fileToGroupKeys = buildFileToGroupKeys(groups);
251
+ const degreeIndex = buildGraphDegreeIndex(graphEdges);
252
+ function find(key) {
253
+ const current = parent.get(key) ?? key;
254
+ if (current === key)
255
+ return key;
256
+ const root = find(current);
257
+ parent.set(key, root);
258
+ return root;
259
+ }
260
+ function union(a, b) {
261
+ const rootA = find(a);
262
+ const rootB = find(b);
263
+ if (rootA === rootB)
264
+ return;
265
+ const [keep, move] = rootA.localeCompare(rootB) <= 0 ? [rootA, rootB] : [rootB, rootA];
266
+ parent.set(move, keep);
267
+ }
268
+ for (const keys of fileToGroupKeys.values()) {
269
+ const [first, ...rest] = [...keys].sort((a, b) => a.localeCompare(b));
270
+ if (!first)
271
+ continue;
272
+ for (const key of rest) {
273
+ union(first, key);
274
+ }
275
+ }
276
+ for (const edge of graphEdges) {
277
+ if (!isPacketExpansionEdge(edge, degreeIndex)) {
278
+ continue;
279
+ }
280
+ const fromGroups = fileToGroupKeys.get(normalizeGraphPath(edge.from));
281
+ const toGroups = fileToGroupKeys.get(normalizeGraphPath(edge.to));
282
+ if (!fromGroups || !toGroups) {
283
+ continue;
284
+ }
285
+ for (const fromKey of fromGroups) {
286
+ for (const toKey of toGroups) {
287
+ union(fromKey, toKey);
288
+ }
289
+ }
290
+ }
291
+ return new Map(groupKeys.map((key) => [key, find(key)]));
292
+ }
293
+ function subsystemRootForPath(path) {
294
+ const segments = normalizeGraphPath(path).split("/").filter(Boolean);
295
+ const directories = segments.slice(0, -1);
296
+ if (directories.length < 2) {
297
+ return undefined;
298
+ }
299
+ const namespace = directories[0];
300
+ const depth = namespace === "apps" || namespace === "packages"
301
+ ? 4
302
+ : namespace === "src" ||
303
+ namespace === "lib" ||
304
+ namespace === "app" ||
305
+ namespace === "tests" ||
306
+ namespace === "test" ||
307
+ namespace === "spec"
308
+ ? 3
309
+ : 2;
310
+ if (directories.length < depth) {
311
+ return undefined;
312
+ }
313
+ return directories.slice(0, depth).join("/");
314
+ }
315
+ function subsystemRootForTasks(tasks) {
316
+ const rootsForFiles = uniqueTaskFilePaths(tasks).map(subsystemRootForPath);
317
+ if (rootsForFiles.some((root) => root === undefined)) {
318
+ return undefined;
319
+ }
320
+ const roots = new Set(rootsForFiles);
321
+ return roots.size === 1 ? [...roots][0] : undefined;
322
+ }
323
+ function buildBoundedClusterEdges(params) {
324
+ const groupToComponent = buildGraphConnectedComponentIndex(params.groups, params.graphEdges);
325
+ const clusters = new Map();
326
+ for (const [key, tasks] of params.groups) {
327
+ const root = params.rootForTasks(tasks);
328
+ if (!root) {
329
+ continue;
330
+ }
331
+ const filePaths = uniqueTaskFilePaths(tasks);
332
+ const cluster = clusters.get(root) ?? [];
333
+ cluster.push({
334
+ component: groupToComponent.get(key) ?? key,
335
+ tasks,
336
+ filePaths,
337
+ representativePath: filePaths[0] ?? root,
338
+ });
339
+ clusters.set(root, cluster);
340
+ }
341
+ const clusterEdges = [];
342
+ for (const [root, entries] of [...clusters.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
343
+ const components = new Map();
344
+ for (const entry of entries) {
345
+ const component = components.get(entry.component) ?? {
346
+ taskCount: 0,
347
+ filePaths: new Set(),
348
+ representativePath: entry.representativePath,
349
+ };
350
+ component.taskCount += entry.tasks.length;
351
+ for (const filePath of entry.filePaths) {
352
+ component.filePaths.add(filePath);
353
+ }
354
+ if (entry.representativePath.localeCompare(component.representativePath) < 0) {
355
+ component.representativePath = entry.representativePath;
356
+ }
357
+ components.set(entry.component, component);
358
+ }
359
+ const componentEntries = [...components.values()].sort((a, b) => a.representativePath.localeCompare(b.representativePath));
360
+ if (componentEntries.length < 2 ||
361
+ componentEntries.length > MAX_SUBSYSTEM_CLUSTER_GROUPS) {
362
+ continue;
363
+ }
364
+ const allFiles = new Set(componentEntries.flatMap((entry) => [...entry.filePaths]));
365
+ const totalTasks = componentEntries.reduce((sum, entry) => sum + entry.taskCount, 0);
366
+ const clusterTasks = entries.flatMap((entry) => entry.tasks);
367
+ const totalLines = [...allFiles].reduce((sum, path) => {
368
+ const owner = clusterTasks.find((task) => task.file_paths.includes(path));
369
+ return sum + (owner ? lineCountForPath(owner, path, params.lineIndex) : 0);
370
+ }, 0);
371
+ if (allFiles.size > MAX_SUBSYSTEM_CLUSTER_FILES ||
372
+ totalTasks > MAX_SUBSYSTEM_CLUSTER_TASKS ||
373
+ totalLines > (params.targetPacketLines ?? DEFAULT_TARGET_PACKET_LINES)) {
374
+ continue;
375
+ }
376
+ for (let index = 1; index < componentEntries.length; index++) {
377
+ const previous = componentEntries[index - 1];
378
+ const current = componentEntries[index];
379
+ clusterEdges.push({
380
+ from: previous.representativePath,
381
+ to: current.representativePath,
382
+ kind: params.edgeKind,
383
+ direction: "undirected",
384
+ confidence: params.edgeConfidence,
385
+ reason: params.reasonForCluster(root, allFiles.size),
386
+ });
387
+ }
388
+ }
389
+ return clusterEdges.sort(compareGraphEdges);
390
+ }
391
+ function buildSubsystemClusterEdges(groups, graphEdges, lineIndex, targetPacketLines = DEFAULT_TARGET_PACKET_LINES) {
392
+ return buildBoundedClusterEdges({
393
+ groups,
394
+ graphEdges,
395
+ rootForTasks: subsystemRootForTasks,
396
+ edgeKind: "subsystem-cluster-link",
397
+ edgeConfidence: SUBSYSTEM_CLUSTER_CONFIDENCE,
398
+ reasonForCluster: (root, fileCount) => `Bounded subsystem cluster '${root}' groups ${fileCount} file(s) without stronger graph evidence.`,
399
+ lineIndex,
400
+ targetPacketLines,
401
+ });
402
+ }
403
+ function packageManifestRoot(path) {
404
+ const segments = normalizeGraphPath(path).split("/").filter(Boolean);
405
+ if (segments.at(-1) !== "package.json" || segments.length < 2) {
406
+ return undefined;
407
+ }
408
+ return segments.slice(0, -1).join("/");
409
+ }
410
+ function isTypescriptProjectConfigPath(path) {
411
+ const basename = normalizeGraphPath(path).split("/").at(-1);
412
+ if (!basename) {
413
+ return false;
414
+ }
415
+ return (basename === "tsconfig.json" ||
416
+ (basename.startsWith("tsconfig.") && basename.endsWith(".json")));
417
+ }
418
+ function isGoModuleManifestPath(path) {
419
+ return normalizeGraphPath(path).split("/").at(-1) === "go.mod";
420
+ }
421
+ function isCargoManifestPath(path) {
422
+ return normalizeGraphPath(path).split("/").at(-1) === "cargo.toml";
423
+ }
424
+ function isMavenPomPath(path) {
425
+ return normalizeGraphPath(path).split("/").at(-1) === "pom.xml";
426
+ }
427
+ function typescriptProjectRoot(path) {
428
+ const segments = normalizeGraphPath(path).split("/").filter(Boolean);
429
+ if (!isTypescriptProjectConfigPath(path) || segments.length < 2) {
430
+ return undefined;
431
+ }
432
+ return segments.slice(0, -1).join("/");
433
+ }
434
+ function goModuleRoot(path) {
435
+ const segments = normalizeGraphPath(path).split("/").filter(Boolean);
436
+ if (!isGoModuleManifestPath(path) || segments.length < 2) {
437
+ return undefined;
438
+ }
439
+ return segments.slice(0, -1).join("/");
440
+ }
441
+ function cargoModuleRoot(path) {
442
+ const segments = normalizeGraphPath(path).split("/").filter(Boolean);
443
+ if (!isCargoManifestPath(path) || segments.length < 2) {
444
+ return undefined;
445
+ }
446
+ return segments.slice(0, -1).join("/");
447
+ }
448
+ function mavenModuleRoot(path) {
449
+ const segments = normalizeGraphPath(path).split("/").filter(Boolean);
450
+ if (!isMavenPomPath(path) || segments.length < 2) {
451
+ return undefined;
452
+ }
453
+ return segments.slice(0, -1).join("/");
454
+ }
455
+ function moduleConfigRoot(path) {
456
+ return (typescriptProjectRoot(path) ??
457
+ goModuleRoot(path) ??
458
+ cargoModuleRoot(path) ??
459
+ mavenModuleRoot(path));
460
+ }
461
+ function analyzerOwnershipRoot(path) {
462
+ const root = normalizeGraphPath(path).replace(/\/+$/, "");
463
+ if (root.length === 0 ||
464
+ root === "." ||
465
+ root === ".." ||
466
+ root.startsWith("../") ||
467
+ root.startsWith("/")) {
468
+ return undefined;
469
+ }
470
+ const segments = root.split("/").filter(Boolean);
471
+ if (segments.length === 1 &&
472
+ BROAD_ANALYZER_OWNERSHIP_ROOTS.has(segments[0])) {
473
+ return undefined;
474
+ }
475
+ return root;
476
+ }
477
+ function collectPackageOwnershipRoots(groups, graphEdges) {
478
+ const roots = new Set();
479
+ const addRoot = (path) => {
480
+ const root = packageManifestRoot(path);
481
+ if (root) {
482
+ roots.add(root);
483
+ }
484
+ };
485
+ for (const tasks of groups.values()) {
486
+ for (const path of tasks.flatMap((task) => task.file_paths)) {
487
+ addRoot(path);
488
+ }
489
+ }
490
+ for (const edge of graphEdges) {
491
+ addRoot(edge.from);
492
+ addRoot(edge.to);
493
+ }
494
+ return roots;
495
+ }
496
+ function ownershipRootForPath(path, ownershipRoots) {
497
+ const normalized = normalizeGraphPath(path);
498
+ let bestMatch;
499
+ for (const root of ownershipRoots) {
500
+ if (normalized === `${root}/package.json` ||
501
+ normalized === `${root}/tsconfig.json` ||
502
+ normalized.startsWith(`${root}/`)) {
503
+ if (!bestMatch || root.length > bestMatch.length) {
504
+ bestMatch = root;
505
+ }
506
+ }
507
+ }
508
+ return bestMatch;
509
+ }
510
+ function packageOwnershipRootForTasks(tasks, packageRoots) {
511
+ if (packageRoots.size === 0) {
512
+ return undefined;
513
+ }
514
+ const rootsForFiles = uniqueTaskFilePaths(tasks).map((path) => ownershipRootForPath(path, packageRoots));
515
+ if (rootsForFiles.some((root) => root === undefined)) {
516
+ return undefined;
517
+ }
518
+ const roots = new Set(rootsForFiles);
519
+ return roots.size === 1 ? [...roots][0] : undefined;
520
+ }
521
+ function buildPackageOwnershipClusterEdges(groups, graphEdges, lineIndex, targetPacketLines = DEFAULT_TARGET_PACKET_LINES) {
522
+ const packageRoots = collectPackageOwnershipRoots(groups, graphEdges);
523
+ if (packageRoots.size === 0) {
524
+ return [];
525
+ }
526
+ return buildBoundedClusterEdges({
527
+ groups,
528
+ graphEdges,
529
+ rootForTasks: (tasks) => packageOwnershipRootForTasks(tasks, packageRoots),
530
+ edgeKind: "package-ownership-link",
531
+ edgeConfidence: PACKAGE_OWNERSHIP_CLUSTER_CONFIDENCE,
532
+ reasonForCluster: (root, fileCount) => `Package ownership root '${root}' groups ${fileCount} file(s) across bounded package subdirectories.`,
533
+ lineIndex,
534
+ targetPacketLines,
535
+ });
536
+ }
537
+ function collectModuleOwnershipRoots(groups, graphEdges) {
538
+ const roots = new Map();
539
+ const addRoot = (path) => {
540
+ const root = moduleConfigRoot(path);
541
+ if (root) {
542
+ roots.set(root, roots.get(root) ?? "project configuration");
543
+ }
544
+ };
545
+ const addAnalyzerRoot = (path) => {
546
+ const root = analyzerOwnershipRoot(path);
547
+ if (root) {
548
+ roots.set(root, roots.get(root) ?? "analyzer ownership hint");
549
+ }
550
+ };
551
+ for (const tasks of groups.values()) {
552
+ for (const path of tasks.flatMap((task) => task.file_paths)) {
553
+ addRoot(path);
554
+ }
555
+ }
556
+ for (const edge of graphEdges) {
557
+ if (edge.kind === ANALYZER_OWNERSHIP_EDGE_KIND) {
558
+ addAnalyzerRoot(edge.from);
559
+ continue;
560
+ }
561
+ if (!MODULE_OWNERSHIP_EDGE_KINDS.has(edge.kind ?? "")) {
562
+ continue;
563
+ }
564
+ addRoot(edge.from);
565
+ addRoot(edge.to);
566
+ }
567
+ return roots;
568
+ }
569
+ function moduleOwnershipRootForTasks(tasks, moduleRoots) {
570
+ if (moduleRoots.size === 0) {
571
+ return undefined;
572
+ }
573
+ const rootsForFiles = uniqueTaskFilePaths(tasks).map((path) => ownershipRootForPath(path, moduleRoots));
574
+ if (rootsForFiles.some((root) => root === undefined)) {
575
+ return undefined;
576
+ }
577
+ const roots = new Set(rootsForFiles);
578
+ return roots.size === 1 ? [...roots][0] : undefined;
579
+ }
580
+ function buildModuleOwnershipClusterEdges(groups, graphEdges, lineIndex, targetPacketLines = DEFAULT_TARGET_PACKET_LINES) {
581
+ const moduleRoots = collectModuleOwnershipRoots(groups, graphEdges);
582
+ if (moduleRoots.size === 0) {
583
+ return [];
584
+ }
585
+ const moduleRootSet = new Set(moduleRoots.keys());
586
+ return buildBoundedClusterEdges({
587
+ groups,
588
+ graphEdges,
589
+ rootForTasks: (tasks) => moduleOwnershipRootForTasks(tasks, moduleRootSet),
590
+ edgeKind: "module-ownership-link",
591
+ edgeConfidence: MODULE_OWNERSHIP_CLUSTER_CONFIDENCE,
592
+ reasonForCluster: (root, fileCount) => {
593
+ const source = moduleRoots.get(root) ?? "project configuration";
594
+ return source === "analyzer ownership hint"
595
+ ? `Module ownership root '${root}' from analyzer ownership hint groups ${fileCount} file(s) across bounded subdirectories.`
596
+ : `Module ownership root '${root}' from project configuration groups ${fileCount} file(s) across bounded subdirectories.`;
597
+ },
598
+ lineIndex,
599
+ targetPacketLines,
600
+ });
601
+ }
602
+ function buildEntrypointFlowBridgeEdges(groups, graphEdges, graphBundle) {
603
+ const roots = collectEntrypointFlowRoots(graphEdges, graphBundle);
604
+ if (roots.size === 0) {
605
+ return [];
606
+ }
607
+ const fileToGroupKeys = buildFileToGroupKeys(groups);
608
+ const degreeIndex = buildGraphDegreeIndex(graphEdges);
609
+ const representatives = buildRepresentativePathIndex(groups, graphEdges, graphBundle);
610
+ const adjacency = new Map();
611
+ for (const edge of graphEdges) {
612
+ if (edge.direction === "undirected" ||
613
+ !isPacketExpansionEdge(edge, degreeIndex)) {
614
+ continue;
615
+ }
616
+ const from = normalizeGraphPath(edge.from);
617
+ const edges = adjacency.get(from) ?? [];
618
+ edges.push(edge);
619
+ adjacency.set(from, edges);
620
+ }
621
+ for (const edges of adjacency.values()) {
622
+ edges.sort(compareGraphEdges);
623
+ }
624
+ const bridgeEdges = new Map();
625
+ const displayPath = (normalized) => representatives.get(normalized) ?? normalized;
626
+ for (const root of [...roots].sort((a, b) => a.localeCompare(b))) {
627
+ const rootGroups = fileToGroupKeys.get(root);
628
+ if (!rootGroups) {
629
+ continue;
630
+ }
631
+ const queue = [
632
+ { node: root, path: [root], edges: [] },
633
+ ];
634
+ const visited = new Set([root]);
635
+ while (queue.length > 0) {
636
+ const current = queue.shift();
637
+ if (!current || current.edges.length >= MAX_ENTRYPOINT_FLOW_BRIDGE_HOPS) {
638
+ continue;
639
+ }
640
+ const outgoing = adjacency.get(current.node) ?? [];
641
+ if (outgoing.length > MAX_ENTRYPOINT_FLOW_BRANCHES) {
642
+ continue;
643
+ }
644
+ for (const edge of outgoing) {
645
+ const target = normalizeGraphPath(edge.to);
646
+ if (current.path.includes(target)) {
647
+ continue;
648
+ }
649
+ const nextEdges = [...current.edges, edge];
650
+ const nextPath = [...current.path, target];
651
+ const targetGroups = fileToGroupKeys.get(target);
652
+ if (targetGroups &&
653
+ nextEdges.length > 1 &&
654
+ !groupsOverlap(rootGroups, targetGroups)) {
655
+ const from = displayPath(root);
656
+ const to = displayPath(target);
657
+ const intermediates = nextPath.slice(1, -1).map(displayPath);
658
+ const confidence = Math.min(...nextEdges.map(graphEdgeConfidence));
659
+ const bridgeEdge = {
660
+ from,
661
+ to,
662
+ kind: "entrypoint-flow-link",
663
+ direction: "directed",
664
+ confidence,
665
+ reason: intermediates.length > 0
666
+ ? `Entrypoint flow from '${from}' reaches '${to}' via ${intermediates.join(" -> ")}.`
667
+ : `Entrypoint flow from '${from}' reaches '${to}'.`,
668
+ };
669
+ bridgeEdges.set(`${from}\0${to}\0${bridgeEdge.kind}`, bridgeEdge);
670
+ }
671
+ if (!targetGroups &&
672
+ nextEdges.length < MAX_ENTRYPOINT_FLOW_BRIDGE_HOPS &&
673
+ !visited.has(target)) {
674
+ visited.add(target);
675
+ queue.push({
676
+ node: target,
677
+ path: nextPath,
678
+ edges: nextEdges,
679
+ });
680
+ }
681
+ }
682
+ }
683
+ }
684
+ return [...bridgeEdges.values()].sort(compareGraphEdges);
685
+ }
686
+ function buildPlanningGraphEdges(groups, graphEdges, graphBundle, lineIndex, targetPacketLines = DEFAULT_TARGET_PACKET_LINES) {
687
+ const bridgeEdges = buildEntrypointFlowBridgeEdges(groups, graphEdges, graphBundle);
688
+ const graphWithBridges = bridgeEdges.length > 0 ? [...graphEdges, ...bridgeEdges] : graphEdges;
689
+ const subsystemEdges = buildSubsystemClusterEdges(groups, graphWithBridges, lineIndex, targetPacketLines);
690
+ const graphWithSubsystems = subsystemEdges.length > 0
691
+ ? [...graphWithBridges, ...subsystemEdges]
692
+ : graphWithBridges;
693
+ const packageOwnershipEdges = buildPackageOwnershipClusterEdges(groups, graphWithSubsystems, lineIndex, targetPacketLines);
694
+ const graphWithPackageOwnership = packageOwnershipEdges.length > 0
695
+ ? [...graphWithSubsystems, ...packageOwnershipEdges]
696
+ : graphWithSubsystems;
697
+ const moduleOwnershipEdges = buildModuleOwnershipClusterEdges(groups, graphWithPackageOwnership, lineIndex, targetPacketLines);
698
+ return moduleOwnershipEdges.length > 0
699
+ ? [...graphWithPackageOwnership, ...moduleOwnershipEdges]
700
+ : graphWithPackageOwnership;
701
+ }
702
+ function compareGraphEdges(a, b) {
703
+ const confidenceDelta = graphEdgeConfidence(b) - graphEdgeConfidence(a);
704
+ if (confidenceDelta !== 0)
705
+ return confidenceDelta;
706
+ return (a.from.localeCompare(b.from) ||
707
+ a.to.localeCompare(b.to) ||
708
+ (a.kind ?? "").localeCompare(b.kind ?? ""));
709
+ }
710
+ function reviewPacketGraphEdge(edge) {
711
+ const result = {
712
+ from: edge.from,
713
+ to: edge.to,
714
+ confidence: graphEdgeConfidence(edge),
715
+ };
716
+ if (edge.kind)
717
+ result.kind = edge.kind;
718
+ if (edge.reason)
719
+ result.reason = edge.reason;
720
+ return result;
721
+ }
722
+ function roundQuality(value) {
723
+ return Math.round(value * 1000) / 1000;
724
+ }
725
+ function packetEntrypoints(filePaths, graphBundle) {
726
+ const fileSet = new Set(filePaths.map(normalizeGraphPath));
727
+ const routes = Array.isArray(graphBundle?.graphs.routes)
728
+ ? graphBundle.graphs.routes
729
+ : [];
730
+ return routes
731
+ .filter((route) => isRecord(route) &&
732
+ typeof route.handler === "string" &&
733
+ typeof route.path === "string" &&
734
+ fileSet.has(normalizeGraphPath(route.handler)))
735
+ .map((route) => {
736
+ const method = typeof route.method === "string" ? `${route.method} ` : "";
737
+ return `${method}${route.path} -> ${route.handler}`;
738
+ })
739
+ .sort((a, b) => a.localeCompare(b));
740
+ }
741
+ function buildPacketGraphContext(filePaths, graphEdges, graphBundle) {
742
+ const fileSet = new Set(filePaths.map(normalizeGraphPath));
743
+ const internalEdges = [];
744
+ const boundaryFiles = new Set();
745
+ let boundaryEdgeCount = 0;
746
+ for (const edge of graphEdges) {
747
+ if (!isConcreteGraphEdge(edge)) {
748
+ continue;
749
+ }
750
+ const fromInPacket = fileSet.has(normalizeGraphPath(edge.from));
751
+ const toInPacket = fileSet.has(normalizeGraphPath(edge.to));
752
+ if (fromInPacket && toInPacket) {
753
+ internalEdges.push(edge);
754
+ }
755
+ else if (fromInPacket !== toInPacket) {
756
+ boundaryEdgeCount += 1;
757
+ boundaryFiles.add(fromInPacket ? edge.to : edge.from);
758
+ }
759
+ }
760
+ const internallyConnectedFiles = new Set();
761
+ for (const edge of internalEdges) {
762
+ internallyConnectedFiles.add(normalizeGraphPath(edge.from));
763
+ internallyConnectedFiles.add(normalizeGraphPath(edge.to));
764
+ }
765
+ const unexplainedFileCount = filePaths.length <= 1
766
+ ? 0
767
+ : filePaths.filter((path) => !internallyConnectedFiles.has(normalizeGraphPath(path))).length;
768
+ const cohesionScore = filePaths.length <= 1
769
+ ? 1
770
+ : Math.min(1, internalEdges.length / (filePaths.length - 1));
771
+ return {
772
+ keyEdges: internalEdges
773
+ .sort(compareGraphEdges)
774
+ .slice(0, MAX_PACKET_KEY_EDGES)
775
+ .map(reviewPacketGraphEdge),
776
+ boundaryFiles: [...boundaryFiles]
777
+ .sort((a, b) => a.localeCompare(b))
778
+ .slice(0, MAX_PACKET_BOUNDARY_FILES),
779
+ entrypoints: packetEntrypoints(filePaths, graphBundle),
780
+ quality: {
781
+ cohesion_score: roundQuality(cohesionScore),
782
+ internal_edge_count: internalEdges.length,
783
+ boundary_edge_count: boundaryEdgeCount,
784
+ unexplained_file_count: unexplainedFileCount,
785
+ },
786
+ };
787
+ }
74
788
  function sanitizeSegment(value) {
75
789
  const sanitized = value
76
790
  .replace(/[^a-zA-Z0-9_-]+/g, "-")
@@ -134,10 +848,11 @@ function chunkPacketTasks(tasks, options) {
134
848
  }
135
849
  return chunks;
136
850
  }
137
- function mergeGraphConnectedGroups(groups, graphBundle) {
851
+ function mergeGraphConnectedGroups(groups, graphEdges) {
138
852
  const groupKeys = [...groups.keys()];
139
853
  const parent = new Map(groupKeys.map((key) => [key, key]));
140
- const fileToGroupKeys = new Map();
854
+ const fileToGroupKeys = buildFileToGroupKeys(groups);
855
+ const degreeIndex = buildGraphDegreeIndex(graphEdges);
141
856
  function find(key) {
142
857
  const current = parent.get(key) ?? key;
143
858
  if (current === key)
@@ -154,14 +869,6 @@ function mergeGraphConnectedGroups(groups, graphBundle) {
154
869
  const [keep, move] = rootA.localeCompare(rootB) <= 0 ? [rootA, rootB] : [rootB, rootA];
155
870
  parent.set(move, keep);
156
871
  }
157
- for (const [key, tasks] of groups) {
158
- for (const path of new Set(tasks.flatMap((task) => task.file_paths))) {
159
- const normalized = normalizeGraphPath(path);
160
- const existing = fileToGroupKeys.get(normalized) ?? new Set();
161
- existing.add(key);
162
- fileToGroupKeys.set(normalized, existing);
163
- }
164
- }
165
872
  for (const keys of fileToGroupKeys.values()) {
166
873
  const [first, ...rest] = [...keys].sort((a, b) => a.localeCompare(b));
167
874
  if (!first)
@@ -170,8 +877,8 @@ function mergeGraphConnectedGroups(groups, graphBundle) {
170
877
  union(first, key);
171
878
  }
172
879
  }
173
- for (const edge of collectGraphEdges(graphBundle)) {
174
- if (!isPacketExpansionEdge(edge)) {
880
+ for (const edge of graphEdges) {
881
+ if (!isPacketExpansionEdge(edge, degreeIndex)) {
175
882
  continue;
176
883
  }
177
884
  const fromGroups = fileToGroupKeys.get(normalizeGraphPath(edge.from));
@@ -194,8 +901,9 @@ function mergeGraphConnectedGroups(groups, graphBundle) {
194
901
  }
195
902
  return [...merged.values()];
196
903
  }
197
- function buildPacket(tasks, packetIndex, lineIndex) {
904
+ function buildPacket(tasks, packetIndex, lineIndex, graphEdges = [], graphBundle) {
198
905
  const filePaths = [...new Set(tasks.flatMap((task) => task.file_paths))].sort((a, b) => a.localeCompare(b));
906
+ const graphContext = buildPacketGraphContext(filePaths, graphEdges, graphBundle);
199
907
  const fileLineCounts = Object.fromEntries(filePaths.map((path) => {
200
908
  const owner = tasks.find((task) => task.file_paths.includes(path));
201
909
  return [path, owner ? lineCountForPath(owner, path, lineIndex) : 0];
@@ -208,6 +916,14 @@ function buildPacket(tasks, packetIndex, lineIndex) {
208
916
  const tags = [
209
917
  ...new Set(tasks.flatMap((task) => task.tags ?? [])),
210
918
  ].sort((a, b) => a.localeCompare(b));
919
+ const baseRationale = tasks.length === 1
920
+ ? tasks[0].rationale
921
+ : `Review ${filePaths.length} related file(s) across ${lenses.length} lens(es): ${lenses.join(", ")}.`;
922
+ const graphRationale = graphContext.keyEdges.length > 0
923
+ ? ` Key graph edges explain ${graphContext.keyEdges.length} internal relationship(s).`
924
+ : graphContext.boundaryFiles.length > 0
925
+ ? ` Boundary context is available for ${graphContext.boundaryFiles.length} adjacent file(s).`
926
+ : "";
211
927
  return {
212
928
  packet_id: packetIdFor(tasks, packetIndex),
213
929
  task_ids: tasks.map((task) => task.task_id),
@@ -219,25 +935,27 @@ function buildPacket(tasks, packetIndex, lineIndex) {
219
935
  total_lines: totalLines,
220
936
  priority,
221
937
  tags: tags.length > 0 ? tags : undefined,
222
- rationale: tasks.length === 1
223
- ? tasks[0].rationale
224
- : `Review ${filePaths.length} related file(s) across ${lenses.length} lens(es): ${lenses.join(", ")}.`,
938
+ entrypoints: graphContext.entrypoints.length > 0
939
+ ? graphContext.entrypoints
940
+ : undefined,
941
+ key_edges: graphContext.keyEdges.length > 0 ? graphContext.keyEdges : undefined,
942
+ boundary_files: graphContext.boundaryFiles.length > 0
943
+ ? graphContext.boundaryFiles
944
+ : undefined,
945
+ quality: graphContext.quality,
946
+ rationale: `${baseRationale}${graphRationale}`,
225
947
  estimated_tokens: ESTIMATED_PACKET_PROMPT_TOKENS + totalLines * ESTIMATED_TOKENS_PER_LINE,
226
948
  };
227
949
  }
228
- export function buildReviewPackets(tasks, options = {}) {
950
+ function buildReviewPacketPlanningData(tasks, options = {}) {
229
951
  const maxTasksPerPacket = options.maxTasksPerPacket ?? DEFAULT_MAX_TASKS_PER_PACKET;
230
952
  const targetPacketLines = options.targetPacketLines ?? DEFAULT_TARGET_PACKET_LINES;
231
- const groups = new Map();
232
- for (const task of tasks) {
233
- const key = packetGroupingKey(task);
234
- const group = groups.get(key) ?? [];
235
- group.push(task);
236
- groups.set(key, group);
237
- }
953
+ const graphEdges = collectGraphEdges(options.graphBundle);
954
+ const groups = buildTaskGroups(tasks);
955
+ const planningGraphEdges = buildPlanningGraphEdges(groups, graphEdges, options.graphBundle, options.lineIndex, targetPacketLines);
238
956
  const packets = [];
239
957
  let packetIndex = 0;
240
- const groupedTasks = mergeGraphConnectedGroups(groups, options.graphBundle).sort((a, b) => {
958
+ const groupedTasks = mergeGraphConnectedGroups(groups, planningGraphEdges).sort((a, b) => {
241
959
  const aPriority = Math.max(...a.map((task) => priorityRank(task.priority)));
242
960
  const bPriority = Math.max(...b.map((task) => priorityRank(task.priority)));
243
961
  if (aPriority !== bPriority)
@@ -250,11 +968,19 @@ export function buildReviewPackets(tasks, options = {}) {
250
968
  maxTasksPerPacket,
251
969
  targetPacketLines,
252
970
  })) {
253
- packets.push(buildPacket(chunk, packetIndex, options.lineIndex));
971
+ packets.push(buildPacket(chunk, packetIndex, options.lineIndex, planningGraphEdges, options.graphBundle));
254
972
  packetIndex += 1;
255
973
  }
256
974
  }
257
- return packets.sort(comparePackets);
975
+ return {
976
+ graphEdges,
977
+ groups,
978
+ planningGraphEdges,
979
+ packets: packets.sort(comparePackets),
980
+ };
981
+ }
982
+ export function buildReviewPackets(tasks, options = {}) {
983
+ return buildReviewPacketPlanningData(tasks, options).packets;
258
984
  }
259
985
  export function orderTasksForPacketReview(tasks, options = {}) {
260
986
  const taskById = new Map(tasks.map((task) => [task.task_id, task]));
@@ -262,8 +988,253 @@ export function orderTasksForPacketReview(tasks, options = {}) {
262
988
  .map((taskId) => taskById.get(taskId))
263
989
  .filter((task) => task !== undefined));
264
990
  }
991
+ function countHighDegreeTaskFiles(degreeMap, taskFiles) {
992
+ let count = 0;
993
+ for (const [path, degree] of degreeMap) {
994
+ if (degree > HIGH_FAN_DEGREE_THRESHOLD && taskFiles.has(path)) {
995
+ count += 1;
996
+ }
997
+ }
998
+ return count;
999
+ }
1000
+ function edgeKindKey(edge) {
1001
+ const kind = edge.kind?.trim();
1002
+ return kind && kind.length > 0 ? kind : "unknown";
1003
+ }
1004
+ function edgeIdentity(edge) {
1005
+ return [
1006
+ normalizeGraphPath(edge.from),
1007
+ normalizeGraphPath(edge.to),
1008
+ edgeKindKey(edge),
1009
+ ].join("\0");
1010
+ }
1011
+ function incrementEdgeKindCount(counts, edge) {
1012
+ const key = edgeKindKey(edge);
1013
+ counts[key] = (counts[key] ?? 0) + 1;
1014
+ }
1015
+ function sortCountRecord(counts) {
1016
+ return Object.fromEntries(Object.entries(counts).sort((a, b) => a[0].localeCompare(b[0])));
1017
+ }
1018
+ function buildTaskToGroupKey(groups) {
1019
+ const taskToGroupKey = new Map();
1020
+ for (const [groupKey, tasks] of groups) {
1021
+ for (const task of tasks) {
1022
+ taskToGroupKey.set(task.task_id, groupKey);
1023
+ }
1024
+ }
1025
+ return taskToGroupKey;
1026
+ }
1027
+ function buildGroupToPacketIds(packets, groups) {
1028
+ const taskToGroupKey = buildTaskToGroupKey(groups);
1029
+ const groupToPacketIds = new Map();
1030
+ for (const packet of packets) {
1031
+ const packetGroupKeys = new Set(packet.task_ids
1032
+ .map((taskId) => taskToGroupKey.get(taskId))
1033
+ .filter((groupKey) => groupKey !== undefined));
1034
+ for (const groupKey of packetGroupKeys) {
1035
+ const packetIds = groupToPacketIds.get(groupKey) ?? new Set();
1036
+ packetIds.add(packet.packet_id);
1037
+ groupToPacketIds.set(groupKey, packetIds);
1038
+ }
1039
+ }
1040
+ return groupToPacketIds;
1041
+ }
1042
+ function setsOverlap(a, b) {
1043
+ if (!a || !b) {
1044
+ return false;
1045
+ }
1046
+ for (const value of a) {
1047
+ if (b.has(value)) {
1048
+ return true;
1049
+ }
1050
+ }
1051
+ return false;
1052
+ }
1053
+ function countMergeEdgeKinds(packets, groups, planningGraphEdges) {
1054
+ const counts = {};
1055
+ const seen = new Set();
1056
+ const fileToGroupKeys = buildFileToGroupKeys(groups);
1057
+ const groupToPacketIds = buildGroupToPacketIds(packets, groups);
1058
+ const degreeIndex = buildGraphDegreeIndex(planningGraphEdges);
1059
+ for (const edge of planningGraphEdges) {
1060
+ if (!isPacketExpansionEdge(edge, degreeIndex)) {
1061
+ continue;
1062
+ }
1063
+ const fromGroups = fileToGroupKeys.get(normalizeGraphPath(edge.from));
1064
+ const toGroups = fileToGroupKeys.get(normalizeGraphPath(edge.to));
1065
+ if (!fromGroups || !toGroups) {
1066
+ continue;
1067
+ }
1068
+ let mergedDistinctGroups = false;
1069
+ for (const fromKey of fromGroups) {
1070
+ for (const toKey of toGroups) {
1071
+ if (fromKey !== toKey &&
1072
+ setsOverlap(groupToPacketIds.get(fromKey), groupToPacketIds.get(toKey))) {
1073
+ mergedDistinctGroups = true;
1074
+ break;
1075
+ }
1076
+ }
1077
+ if (mergedDistinctGroups) {
1078
+ break;
1079
+ }
1080
+ }
1081
+ const identity = edgeIdentity(edge);
1082
+ if (mergedDistinctGroups && !seen.has(identity)) {
1083
+ seen.add(identity);
1084
+ incrementEdgeKindCount(counts, edge);
1085
+ }
1086
+ }
1087
+ return sortCountRecord(counts);
1088
+ }
1089
+ function buildFileToPacketIds(packets) {
1090
+ const fileToPacketIds = new Map();
1091
+ for (const packet of packets) {
1092
+ for (const path of packet.file_paths) {
1093
+ const normalized = normalizeGraphPath(path);
1094
+ const packetIds = fileToPacketIds.get(normalized) ?? new Set();
1095
+ packetIds.add(packet.packet_id);
1096
+ fileToPacketIds.set(normalized, packetIds);
1097
+ }
1098
+ }
1099
+ return fileToPacketIds;
1100
+ }
1101
+ function countBoundaryOnlyEdgeKinds(packets, planningGraphEdges) {
1102
+ const counts = {};
1103
+ const seen = new Set();
1104
+ const fileToPacketIds = buildFileToPacketIds(packets);
1105
+ for (const edge of planningGraphEdges) {
1106
+ if (!isConcreteGraphEdge(edge)) {
1107
+ continue;
1108
+ }
1109
+ const fromPacketIds = fileToPacketIds.get(normalizeGraphPath(edge.from));
1110
+ const toPacketIds = fileToPacketIds.get(normalizeGraphPath(edge.to));
1111
+ if (!fromPacketIds && !toPacketIds) {
1112
+ continue;
1113
+ }
1114
+ if (setsOverlap(fromPacketIds, toPacketIds)) {
1115
+ continue;
1116
+ }
1117
+ const identity = edgeIdentity(edge);
1118
+ if (!seen.has(identity)) {
1119
+ seen.add(identity);
1120
+ incrementEdgeKindCount(counts, edge);
1121
+ }
1122
+ }
1123
+ return sortCountRecord(counts);
1124
+ }
1125
+ function isWeaklyExplainedPacket(packet) {
1126
+ return (packet.file_paths.length > 1 &&
1127
+ (packet.quality.internal_edge_count === 0 ||
1128
+ packet.quality.cohesion_score < 1 ||
1129
+ packet.quality.unexplained_file_count > 0));
1130
+ }
1131
+ function weaklyExplainedPackets(packets) {
1132
+ return packets.filter(isWeaklyExplainedPacket);
1133
+ }
1134
+ function weaklyExplainedPacketIds(weakPackets) {
1135
+ return weakPackets
1136
+ .map((packet) => packet.packet_id)
1137
+ .sort((a, b) => a.localeCompare(b));
1138
+ }
1139
+ function weakPacketPrimaryGap(packet) {
1140
+ if (packet.quality.internal_edge_count === 0) {
1141
+ return "missing_internal_edges";
1142
+ }
1143
+ if (packet.quality.unexplained_file_count > 0) {
1144
+ return "unexplained_files";
1145
+ }
1146
+ return "partial_cohesion";
1147
+ }
1148
+ function weaklyExplainedGapCounts(weakPackets) {
1149
+ const counts = {
1150
+ missing_internal_edges: 0,
1151
+ unexplained_files: 0,
1152
+ partial_cohesion: 0,
1153
+ };
1154
+ for (const packet of weakPackets) {
1155
+ counts[weakPacketPrimaryGap(packet)] += 1;
1156
+ }
1157
+ return Object.fromEntries(WEAK_PACKET_GAP_ORDER.map((gap) => [gap, counts[gap]]));
1158
+ }
1159
+ function fileExtensionBucket(path) {
1160
+ const basename = normalizeGraphPath(path).split("/").at(-1) ?? "";
1161
+ const extensionStart = basename.lastIndexOf(".");
1162
+ if (extensionStart <= 0 || extensionStart === basename.length - 1) {
1163
+ return "no_extension";
1164
+ }
1165
+ return basename.slice(extensionStart).toLowerCase();
1166
+ }
1167
+ function weaklyExplainedFileExtensionCounts(weakPackets) {
1168
+ const counts = {};
1169
+ const seenPaths = new Set();
1170
+ for (const packet of weakPackets) {
1171
+ for (const path of packet.file_paths) {
1172
+ const normalized = normalizeGraphPath(path);
1173
+ if (seenPaths.has(normalized)) {
1174
+ continue;
1175
+ }
1176
+ seenPaths.add(normalized);
1177
+ const extension = fileExtensionBucket(path);
1178
+ counts[extension] = (counts[extension] ?? 0) + 1;
1179
+ }
1180
+ }
1181
+ return sortCountRecord(counts);
1182
+ }
1183
+ function weaklyExplainedPacketSamples(weakPackets) {
1184
+ return weakPackets
1185
+ .sort((a, b) => b.quality.unexplained_file_count - a.quality.unexplained_file_count ||
1186
+ a.quality.cohesion_score - b.quality.cohesion_score ||
1187
+ b.file_paths.length - a.file_paths.length ||
1188
+ a.packet_id.localeCompare(b.packet_id))
1189
+ .slice(0, MAX_WEAK_PACKET_SAMPLES)
1190
+ .map((packet) => ({
1191
+ packet_id: packet.packet_id,
1192
+ primary_gap: weakPacketPrimaryGap(packet),
1193
+ file_count: packet.file_paths.length,
1194
+ sample_file_paths: packet.file_paths.slice(0, MAX_WEAK_PACKET_SAMPLE_FILES),
1195
+ cohesion_score: packet.quality.cohesion_score,
1196
+ internal_edge_count: packet.quality.internal_edge_count,
1197
+ boundary_edge_count: packet.quality.boundary_edge_count,
1198
+ unexplained_file_count: packet.quality.unexplained_file_count,
1199
+ }));
1200
+ }
1201
+ function buildPacketQualityMetrics(packets, tasks, graphEdges, planningGraphEdges, groups) {
1202
+ const packetTaskIds = new Set(packets.flatMap((packet) => packet.task_ids));
1203
+ const orphanTaskCount = tasks.filter((task) => !packetTaskIds.has(task.task_id)).length;
1204
+ const degreeIndex = buildGraphDegreeIndex(graphEdges);
1205
+ const taskFiles = new Set(tasks.flatMap((task) => task.file_paths.map(normalizeGraphPath)));
1206
+ const largestUnexplainedPacket = packets.reduce((largest, packet) => !largest ||
1207
+ packet.quality.unexplained_file_count >
1208
+ largest.quality.unexplained_file_count
1209
+ ? packet
1210
+ : largest, undefined);
1211
+ const largestUnexplainedFiles = largestUnexplainedPacket?.quality.unexplained_file_count ?? 0;
1212
+ const weakPackets = weaklyExplainedPackets(packets);
1213
+ const weakPacketIds = weaklyExplainedPacketIds(weakPackets);
1214
+ const weakPacketSamples = weaklyExplainedPacketSamples(weakPackets);
1215
+ return {
1216
+ average_cohesion_score: packets.length > 0
1217
+ ? roundQuality(packets.reduce((sum, packet) => sum + packet.quality.cohesion_score, 0) / packets.length)
1218
+ : 0,
1219
+ boundary_crossing_count: packets.reduce((sum, packet) => sum + packet.quality.boundary_edge_count, 0),
1220
+ merge_edge_kind_counts: countMergeEdgeKinds(packets, groups, planningGraphEdges),
1221
+ boundary_edge_kind_counts: countBoundaryOnlyEdgeKinds(packets, planningGraphEdges),
1222
+ orphan_task_count: orphanTaskCount,
1223
+ high_fan_in_file_count: countHighDegreeTaskFiles(degreeIndex.fanIn, taskFiles),
1224
+ high_fan_out_file_count: countHighDegreeTaskFiles(degreeIndex.fanOut, taskFiles),
1225
+ weakly_explained_gap_counts: weaklyExplainedGapCounts(weakPackets),
1226
+ weakly_explained_file_extension_counts: weaklyExplainedFileExtensionCounts(weakPackets),
1227
+ weakly_explained_packet_count: weakPacketIds.length,
1228
+ weakly_explained_packet_ids: weakPacketIds,
1229
+ weakly_explained_packet_samples: weakPacketSamples,
1230
+ largest_unexplained_packet_id: largestUnexplainedFiles > 0
1231
+ ? largestUnexplainedPacket?.packet_id
1232
+ : undefined,
1233
+ largest_unexplained_packet_files: largestUnexplainedFiles,
1234
+ };
1235
+ }
265
1236
  export function buildAuditPlanMetrics(tasks, options = {}) {
266
- const packets = buildReviewPackets(tasks, options);
1237
+ const { graphEdges, groups, packets, planningGraphEdges } = buildReviewPacketPlanningData(tasks, options);
267
1238
  const taskLineCounts = tasks.map((task) => taskLineCount(task, options.lineIndex));
268
1239
  const totalTaskLines = taskLineCounts.reduce((sum, value) => sum + value, 0);
269
1240
  const totalPacketLines = packets.reduce((sum, packet) => sum + packet.total_lines, 0);
@@ -300,6 +1271,7 @@ export function buildAuditPlanMetrics(tasks, options = {}) {
300
1271
  largest_packet_id: largestPacket?.packet_id,
301
1272
  lens_task_counts: lensTaskCounts,
302
1273
  priority_task_counts: priorityTaskCounts,
1274
+ packet_quality: buildPacketQualityMetrics(packets, tasks, graphEdges, planningGraphEdges, groups),
303
1275
  packet_size: {
304
1276
  single_task_packets: packets.filter((packet) => packet.task_ids.length === 1).length,
305
1277
  multi_task_packets: packets.filter((packet) => packet.task_ids.length > 1).length,