@harness-engineering/graph 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -260,21 +260,26 @@ var GraphStore = class {
260
260
  return this.edgeMap.values();
261
261
  }
262
262
  getNeighbors(nodeId, direction = "both") {
263
- const neighborIds = /* @__PURE__ */ new Set();
263
+ const neighborIds = this.collectNeighborIds(nodeId, direction);
264
+ return this.resolveNodes(neighborIds);
265
+ }
266
+ collectNeighborIds(nodeId, direction) {
267
+ const ids = /* @__PURE__ */ new Set();
264
268
  if (direction === "outbound" || direction === "both") {
265
- const outEdges = this.edgesByFrom.get(nodeId) ?? [];
266
- for (const edge of outEdges) {
267
- neighborIds.add(edge.to);
269
+ for (const edge of this.edgesByFrom.get(nodeId) ?? []) {
270
+ ids.add(edge.to);
268
271
  }
269
272
  }
270
273
  if (direction === "inbound" || direction === "both") {
271
- const inEdges = this.edgesByTo.get(nodeId) ?? [];
272
- for (const edge of inEdges) {
273
- neighborIds.add(edge.from);
274
+ for (const edge of this.edgesByTo.get(nodeId) ?? []) {
275
+ ids.add(edge.from);
274
276
  }
275
277
  }
278
+ return ids;
279
+ }
280
+ resolveNodes(ids) {
276
281
  const results = [];
277
- for (const nid of neighborIds) {
282
+ for (const nid of ids) {
278
283
  const node = this.getNode(nid);
279
284
  if (node) results.push(node);
280
285
  }
@@ -1021,6 +1026,17 @@ var CodeIngestor = class {
1021
1026
  import { execFile } from "child_process";
1022
1027
  import { promisify } from "util";
1023
1028
  var execFileAsync = promisify(execFile);
1029
+ function finalizeCommit(current) {
1030
+ return {
1031
+ hash: current.hash,
1032
+ shortHash: current.shortHash,
1033
+ author: current.author,
1034
+ email: current.email,
1035
+ date: current.date,
1036
+ message: current.message,
1037
+ files: current.files
1038
+ };
1039
+ }
1024
1040
  var GitIngestor = class {
1025
1041
  constructor(store, gitRunner) {
1026
1042
  this.store = store;
@@ -1057,39 +1073,49 @@ var GitIngestor = class {
1057
1073
  }
1058
1074
  const commits = this.parseGitLog(output);
1059
1075
  for (const commit of commits) {
1060
- const nodeId = `commit:${commit.shortHash}`;
1061
- this.store.addNode({
1062
- id: nodeId,
1063
- type: "commit",
1064
- name: commit.message,
1065
- metadata: {
1066
- author: commit.author,
1067
- email: commit.email,
1068
- date: commit.date,
1069
- hash: commit.hash
1070
- }
1071
- });
1072
- nodesAdded++;
1073
- for (const file of commit.files) {
1074
- const fileNodeId = `file:${file}`;
1075
- const existingNode = this.store.getNode(fileNodeId);
1076
- if (existingNode) {
1077
- this.store.addEdge({
1078
- from: fileNodeId,
1079
- to: nodeId,
1080
- type: "triggered_by"
1081
- });
1082
- edgesAdded++;
1083
- }
1076
+ const counts = this.ingestCommit(commit);
1077
+ nodesAdded += counts.nodesAdded;
1078
+ edgesAdded += counts.edgesAdded;
1079
+ }
1080
+ edgesAdded += this.ingestCoChanges(commits);
1081
+ return {
1082
+ nodesAdded,
1083
+ nodesUpdated,
1084
+ edgesAdded,
1085
+ edgesUpdated,
1086
+ errors,
1087
+ durationMs: Date.now() - start
1088
+ };
1089
+ }
1090
+ ingestCommit(commit) {
1091
+ const nodeId = `commit:${commit.shortHash}`;
1092
+ this.store.addNode({
1093
+ id: nodeId,
1094
+ type: "commit",
1095
+ name: commit.message,
1096
+ metadata: {
1097
+ author: commit.author,
1098
+ email: commit.email,
1099
+ date: commit.date,
1100
+ hash: commit.hash
1101
+ }
1102
+ });
1103
+ let edgesAdded = 0;
1104
+ for (const file of commit.files) {
1105
+ const fileNodeId = `file:${file}`;
1106
+ if (this.store.getNode(fileNodeId)) {
1107
+ this.store.addEdge({ from: fileNodeId, to: nodeId, type: "triggered_by" });
1108
+ edgesAdded++;
1084
1109
  }
1085
1110
  }
1086
- const coChanges = this.computeCoChanges(commits);
1087
- for (const { fileA, fileB, count } of coChanges) {
1111
+ return { nodesAdded: 1, edgesAdded };
1112
+ }
1113
+ ingestCoChanges(commits) {
1114
+ let edgesAdded = 0;
1115
+ for (const { fileA, fileB, count } of this.computeCoChanges(commits)) {
1088
1116
  const fileAId = `file:${fileA}`;
1089
1117
  const fileBId = `file:${fileB}`;
1090
- const nodeA = this.store.getNode(fileAId);
1091
- const nodeB = this.store.getNode(fileBId);
1092
- if (nodeA && nodeB) {
1118
+ if (this.store.getNode(fileAId) && this.store.getNode(fileBId)) {
1093
1119
  this.store.addEdge({
1094
1120
  from: fileAId,
1095
1121
  to: fileBId,
@@ -1099,14 +1125,7 @@ var GitIngestor = class {
1099
1125
  edgesAdded++;
1100
1126
  }
1101
1127
  }
1102
- return {
1103
- nodesAdded,
1104
- nodesUpdated,
1105
- edgesAdded,
1106
- edgesUpdated,
1107
- errors,
1108
- durationMs: Date.now() - start
1109
- };
1128
+ return edgesAdded;
1110
1129
  }
1111
1130
  async runGit(rootDir, args) {
1112
1131
  if (this.gitRunner) {
@@ -1121,63 +1140,49 @@ var GitIngestor = class {
1121
1140
  const lines = output.split("\n");
1122
1141
  let current = null;
1123
1142
  for (const line of lines) {
1124
- const trimmed = line.trim();
1125
- if (!trimmed) {
1126
- if (current && current.hasFiles) {
1127
- commits.push({
1128
- hash: current.hash,
1129
- shortHash: current.shortHash,
1130
- author: current.author,
1131
- email: current.email,
1132
- date: current.date,
1133
- message: current.message,
1134
- files: current.files
1135
- });
1136
- current = null;
1137
- }
1138
- continue;
1143
+ current = this.processLogLine(line, current, commits);
1144
+ }
1145
+ if (current) {
1146
+ commits.push(finalizeCommit(current));
1147
+ }
1148
+ return commits;
1149
+ }
1150
+ /**
1151
+ * Process one line from git log output, updating the in-progress commit builder
1152
+ * and flushing completed commits into the accumulator.
1153
+ * Returns the updated current builder (null if flushed and not replaced).
1154
+ */
1155
+ processLogLine(line, current, commits) {
1156
+ const trimmed = line.trim();
1157
+ if (!trimmed) {
1158
+ if (current?.hasFiles) {
1159
+ commits.push(finalizeCommit(current));
1160
+ return null;
1139
1161
  }
1140
- const parts = trimmed.split("|");
1141
- if (parts.length >= 5 && /^[0-9a-f]{7,40}$/.test(parts[0])) {
1142
- if (current) {
1143
- commits.push({
1144
- hash: current.hash,
1145
- shortHash: current.shortHash,
1146
- author: current.author,
1147
- email: current.email,
1148
- date: current.date,
1149
- message: current.message,
1150
- files: current.files
1151
- });
1152
- }
1153
- current = {
1154
- hash: parts[0],
1155
- shortHash: parts[0].substring(0, 7),
1156
- author: parts[1],
1157
- email: parts[2],
1158
- date: parts[3],
1159
- message: parts.slice(4).join("|"),
1160
- // message may contain |
1161
- files: [],
1162
- hasFiles: false
1163
- };
1164
- } else if (current) {
1165
- current.files.push(trimmed);
1166
- current.hasFiles = true;
1162
+ return current;
1163
+ }
1164
+ const parts = trimmed.split("|");
1165
+ if (parts.length >= 5 && /^[0-9a-f]{7,40}$/.test(parts[0])) {
1166
+ if (current) {
1167
+ commits.push(finalizeCommit(current));
1167
1168
  }
1169
+ return {
1170
+ hash: parts[0],
1171
+ shortHash: parts[0].substring(0, 7),
1172
+ author: parts[1],
1173
+ email: parts[2],
1174
+ date: parts[3],
1175
+ message: parts.slice(4).join("|"),
1176
+ // message may contain |
1177
+ files: [],
1178
+ hasFiles: false
1179
+ };
1168
1180
  }
1169
1181
  if (current) {
1170
- commits.push({
1171
- hash: current.hash,
1172
- shortHash: current.shortHash,
1173
- author: current.author,
1174
- email: current.email,
1175
- date: current.date,
1176
- message: current.message,
1177
- files: current.files
1178
- });
1182
+ current.files.push(trimmed);
1183
+ current.hasFiles = true;
1179
1184
  }
1180
- return commits;
1185
+ return current;
1181
1186
  }
1182
1187
  computeCoChanges(commits) {
1183
1188
  const pairCounts = /* @__PURE__ */ new Map();
@@ -1321,50 +1326,25 @@ var KnowledgeIngestor = class {
1321
1326
  try {
1322
1327
  const content = await fs2.readFile(filePath, "utf-8");
1323
1328
  const filename = path3.basename(filePath, ".md");
1324
- const titleMatch = content.match(/^#\s+(.+)$/m);
1325
- const title = titleMatch ? titleMatch[1].trim() : filename;
1326
- const dateMatch = content.match(/\*\*Date:\*\*\s*(.+)/);
1327
- const statusMatch = content.match(/\*\*Status:\*\*\s*(.+)/);
1328
- const date = dateMatch ? dateMatch[1].trim() : void 0;
1329
- const status = statusMatch ? statusMatch[1].trim() : void 0;
1330
1329
  const nodeId = `adr:${filename}`;
1331
- this.store.addNode({
1332
- id: nodeId,
1333
- type: "adr",
1334
- name: title,
1335
- path: filePath,
1336
- metadata: { date, status }
1337
- });
1330
+ this.store.addNode(parseADRNode(nodeId, filePath, filename, content));
1338
1331
  nodesAdded++;
1339
1332
  edgesAdded += this.linkToCode(content, nodeId, "documents");
1340
1333
  } catch (err) {
1341
1334
  errors.push(`${filePath}: ${err instanceof Error ? err.message : String(err)}`);
1342
1335
  }
1343
1336
  }
1344
- return {
1345
- nodesAdded,
1346
- nodesUpdated: 0,
1347
- edgesAdded,
1348
- edgesUpdated: 0,
1349
- errors,
1350
- durationMs: Date.now() - start
1351
- };
1337
+ return buildResult(nodesAdded, edgesAdded, errors, start);
1352
1338
  }
1353
1339
  async ingestLearnings(projectPath) {
1354
1340
  const start = Date.now();
1355
1341
  const filePath = path3.join(projectPath, ".harness", "learnings.md");
1356
- let content;
1357
- try {
1358
- content = await fs2.readFile(filePath, "utf-8");
1359
- } catch {
1360
- return emptyResult(Date.now() - start);
1361
- }
1362
- const errors = [];
1342
+ const content = await readFileOrEmpty(filePath);
1343
+ if (content === null) return emptyResult(Date.now() - start);
1363
1344
  let nodesAdded = 0;
1364
1345
  let edgesAdded = 0;
1365
- const lines = content.split("\n");
1366
1346
  let currentDate;
1367
- for (const line of lines) {
1347
+ for (const line of content.split("\n")) {
1368
1348
  const headingMatch = line.match(/^##\s+(\S+)/);
1369
1349
  if (headingMatch) {
1370
1350
  currentDate = headingMatch[1];
@@ -1373,70 +1353,29 @@ var KnowledgeIngestor = class {
1373
1353
  const bulletMatch = line.match(/^-\s+(.+)/);
1374
1354
  if (!bulletMatch) continue;
1375
1355
  const text = bulletMatch[1];
1376
- const skillMatch = text.match(/\[skill:([^\]]+)\]/);
1377
- const outcomeMatch = text.match(/\[outcome:([^\]]+)\]/);
1378
- const skill = skillMatch ? skillMatch[1] : void 0;
1379
- const outcome = outcomeMatch ? outcomeMatch[1] : void 0;
1380
1356
  const nodeId = `learning:${hash(text)}`;
1381
- this.store.addNode({
1382
- id: nodeId,
1383
- type: "learning",
1384
- name: text,
1385
- metadata: { skill, outcome, date: currentDate }
1386
- });
1357
+ this.store.addNode(parseLearningNode(nodeId, text, currentDate));
1387
1358
  nodesAdded++;
1388
1359
  edgesAdded += this.linkToCode(text, nodeId, "applies_to");
1389
1360
  }
1390
- return {
1391
- nodesAdded,
1392
- nodesUpdated: 0,
1393
- edgesAdded,
1394
- edgesUpdated: 0,
1395
- errors,
1396
- durationMs: Date.now() - start
1397
- };
1361
+ return buildResult(nodesAdded, edgesAdded, [], start);
1398
1362
  }
1399
1363
  async ingestFailures(projectPath) {
1400
1364
  const start = Date.now();
1401
1365
  const filePath = path3.join(projectPath, ".harness", "failures.md");
1402
- let content;
1403
- try {
1404
- content = await fs2.readFile(filePath, "utf-8");
1405
- } catch {
1406
- return emptyResult(Date.now() - start);
1407
- }
1408
- const errors = [];
1366
+ const content = await readFileOrEmpty(filePath);
1367
+ if (content === null) return emptyResult(Date.now() - start);
1409
1368
  let nodesAdded = 0;
1410
1369
  let edgesAdded = 0;
1411
- const sections = content.split(/^##\s+/m).filter((s) => s.trim());
1412
- for (const section of sections) {
1413
- const dateMatch = section.match(/\*\*Date:\*\*\s*(.+)/);
1414
- const skillMatch = section.match(/\*\*Skill:\*\*\s*(.+)/);
1415
- const typeMatch = section.match(/\*\*Type:\*\*\s*(.+)/);
1416
- const descMatch = section.match(/\*\*Description:\*\*\s*(.+)/);
1417
- const date = dateMatch ? dateMatch[1].trim() : void 0;
1418
- const skill = skillMatch ? skillMatch[1].trim() : void 0;
1419
- const failureType = typeMatch ? typeMatch[1].trim() : void 0;
1420
- const description = descMatch ? descMatch[1].trim() : void 0;
1421
- if (!description) continue;
1422
- const nodeId = `failure:${hash(description)}`;
1423
- this.store.addNode({
1424
- id: nodeId,
1425
- type: "failure",
1426
- name: description,
1427
- metadata: { date, skill, type: failureType }
1428
- });
1370
+ for (const section of content.split(/^##\s+/m).filter((s) => s.trim())) {
1371
+ const parsed = parseFailureSection(section);
1372
+ if (!parsed) continue;
1373
+ const { description, node } = parsed;
1374
+ this.store.addNode(node);
1429
1375
  nodesAdded++;
1430
- edgesAdded += this.linkToCode(description, nodeId, "caused_by");
1376
+ edgesAdded += this.linkToCode(description, node.id, "caused_by");
1431
1377
  }
1432
- return {
1433
- nodesAdded,
1434
- nodesUpdated: 0,
1435
- edgesAdded,
1436
- edgesUpdated: 0,
1437
- errors,
1438
- durationMs: Date.now() - start
1439
- };
1378
+ return buildResult(nodesAdded, edgesAdded, [], start);
1440
1379
  }
1441
1380
  async ingestAll(projectPath, opts) {
1442
1381
  const start = Date.now();
@@ -1490,6 +1429,74 @@ var KnowledgeIngestor = class {
1490
1429
  return results;
1491
1430
  }
1492
1431
  };
1432
+ async function readFileOrEmpty(filePath) {
1433
+ try {
1434
+ return await fs2.readFile(filePath, "utf-8");
1435
+ } catch {
1436
+ return null;
1437
+ }
1438
+ }
1439
+ function buildResult(nodesAdded, edgesAdded, errors, start) {
1440
+ return {
1441
+ nodesAdded,
1442
+ nodesUpdated: 0,
1443
+ edgesAdded,
1444
+ edgesUpdated: 0,
1445
+ errors,
1446
+ durationMs: Date.now() - start
1447
+ };
1448
+ }
1449
+ function parseADRNode(nodeId, filePath, filename, content) {
1450
+ const titleMatch = content.match(/^#\s+(.+)$/m);
1451
+ const title = titleMatch ? titleMatch[1].trim() : filename;
1452
+ const dateMatch = content.match(/\*\*Date:\*\*\s*(.+)/);
1453
+ const statusMatch = content.match(/\*\*Status:\*\*\s*(.+)/);
1454
+ return {
1455
+ id: nodeId,
1456
+ type: "adr",
1457
+ name: title,
1458
+ path: filePath,
1459
+ metadata: {
1460
+ date: dateMatch ? dateMatch[1].trim() : void 0,
1461
+ status: statusMatch ? statusMatch[1].trim() : void 0
1462
+ }
1463
+ };
1464
+ }
1465
+ function parseLearningNode(nodeId, text, currentDate) {
1466
+ const skillMatch = text.match(/\[skill:([^\]]+)\]/);
1467
+ const outcomeMatch = text.match(/\[outcome:([^\]]+)\]/);
1468
+ return {
1469
+ id: nodeId,
1470
+ type: "learning",
1471
+ name: text,
1472
+ metadata: {
1473
+ skill: skillMatch ? skillMatch[1] : void 0,
1474
+ outcome: outcomeMatch ? outcomeMatch[1] : void 0,
1475
+ date: currentDate
1476
+ }
1477
+ };
1478
+ }
1479
+ function parseFailureSection(section) {
1480
+ const descMatch = section.match(/\*\*Description:\*\*\s*(.+)/);
1481
+ const description = descMatch ? descMatch[1].trim() : void 0;
1482
+ if (!description) return null;
1483
+ const dateMatch = section.match(/\*\*Date:\*\*\s*(.+)/);
1484
+ const skillMatch = section.match(/\*\*Skill:\*\*\s*(.+)/);
1485
+ const typeMatch = section.match(/\*\*Type:\*\*\s*(.+)/);
1486
+ return {
1487
+ description,
1488
+ node: {
1489
+ id: `failure:${hash(description)}`,
1490
+ type: "failure",
1491
+ name: description,
1492
+ metadata: {
1493
+ date: dateMatch ? dateMatch[1].trim() : void 0,
1494
+ skill: skillMatch ? skillMatch[1].trim() : void 0,
1495
+ type: typeMatch ? typeMatch[1].trim() : void 0
1496
+ }
1497
+ }
1498
+ };
1499
+ }
1493
1500
 
1494
1501
  // src/ingest/RequirementIngestor.ts
1495
1502
  import * as fs3 from "fs/promises";
@@ -1534,40 +1541,9 @@ var RequirementIngestor = class {
1534
1541
  return emptyResult(Date.now() - start);
1535
1542
  }
1536
1543
  for (const featureDir of featureDirs) {
1537
- const featureName = path4.basename(featureDir);
1538
- const specPath = path4.join(featureDir, "proposal.md").replaceAll("\\", "/");
1539
- let content;
1540
- try {
1541
- content = await fs3.readFile(specPath, "utf-8");
1542
- } catch {
1543
- continue;
1544
- }
1545
- try {
1546
- const specHash = hash(specPath);
1547
- const specNodeId = `file:${specPath}`;
1548
- this.store.addNode({
1549
- id: specNodeId,
1550
- type: "document",
1551
- name: path4.basename(specPath),
1552
- path: specPath,
1553
- metadata: { featureName }
1554
- });
1555
- const requirements = this.extractRequirements(content, specPath, specHash, featureName);
1556
- for (const req of requirements) {
1557
- this.store.addNode(req.node);
1558
- nodesAdded++;
1559
- this.store.addEdge({
1560
- from: req.node.id,
1561
- to: specNodeId,
1562
- type: "specifies"
1563
- });
1564
- edgesAdded++;
1565
- edgesAdded += this.linkByPathPattern(req.node.id, featureName);
1566
- edgesAdded += this.linkByKeywordOverlap(req.node.id, req.node.name);
1567
- }
1568
- } catch (err) {
1569
- errors.push(`${specPath}: ${err instanceof Error ? err.message : String(err)}`);
1570
- }
1544
+ const counts = await this.ingestFeatureDir(featureDir, errors);
1545
+ nodesAdded += counts.nodesAdded;
1546
+ edgesAdded += counts.edgesAdded;
1571
1547
  }
1572
1548
  return {
1573
1549
  nodesAdded,
@@ -1578,6 +1554,48 @@ var RequirementIngestor = class {
1578
1554
  durationMs: Date.now() - start
1579
1555
  };
1580
1556
  }
1557
+ async ingestFeatureDir(featureDir, errors) {
1558
+ const featureName = path4.basename(featureDir);
1559
+ const specPath = path4.join(featureDir, "proposal.md").replaceAll("\\", "/");
1560
+ let content;
1561
+ try {
1562
+ content = await fs3.readFile(specPath, "utf-8");
1563
+ } catch {
1564
+ return { nodesAdded: 0, edgesAdded: 0 };
1565
+ }
1566
+ try {
1567
+ return this.ingestSpec(specPath, content, featureName);
1568
+ } catch (err) {
1569
+ errors.push(`${specPath}: ${err instanceof Error ? err.message : String(err)}`);
1570
+ return { nodesAdded: 0, edgesAdded: 0 };
1571
+ }
1572
+ }
1573
+ ingestSpec(specPath, content, featureName) {
1574
+ const specHash = hash(specPath);
1575
+ const specNodeId = `file:${specPath}`;
1576
+ this.store.addNode({
1577
+ id: specNodeId,
1578
+ type: "document",
1579
+ name: path4.basename(specPath),
1580
+ path: specPath,
1581
+ metadata: { featureName }
1582
+ });
1583
+ const requirements = this.extractRequirements(content, specPath, specHash, featureName);
1584
+ let nodesAdded = 0;
1585
+ let edgesAdded = 0;
1586
+ for (const req of requirements) {
1587
+ const counts = this.ingestRequirement(req.node, specNodeId, featureName);
1588
+ nodesAdded += counts.nodesAdded;
1589
+ edgesAdded += counts.edgesAdded;
1590
+ }
1591
+ return { nodesAdded, edgesAdded };
1592
+ }
1593
+ ingestRequirement(node, specNodeId, featureName) {
1594
+ this.store.addNode(node);
1595
+ this.store.addEdge({ from: node.id, to: specNodeId, type: "specifies" });
1596
+ const edgesAdded = 1 + this.linkByPathPattern(node.id, featureName) + this.linkByKeywordOverlap(node.id, node.name);
1597
+ return { nodesAdded: 1, edgesAdded };
1598
+ }
1581
1599
  /**
1582
1600
  * Parse markdown content and extract numbered items from recognized sections.
1583
1601
  */
@@ -1589,54 +1607,80 @@ var RequirementIngestor = class {
1589
1607
  let globalIndex = 0;
1590
1608
  for (let i = 0; i < lines.length; i++) {
1591
1609
  const line = lines[i];
1592
- const headingMatch = line.match(SECTION_HEADING_RE);
1593
- if (headingMatch) {
1594
- const heading = headingMatch[1].trim();
1595
- const isReqSection = REQUIREMENT_SECTIONS.some(
1596
- (s) => heading.toLowerCase() === s.toLowerCase()
1597
- );
1598
- if (isReqSection) {
1599
- currentSection = heading;
1600
- inRequirementSection = true;
1601
- } else {
1602
- inRequirementSection = false;
1610
+ const sectionResult = this.processHeadingLine(line, inRequirementSection);
1611
+ if (sectionResult !== null) {
1612
+ inRequirementSection = sectionResult.inRequirementSection;
1613
+ if (sectionResult.currentSection !== void 0) {
1614
+ currentSection = sectionResult.currentSection;
1603
1615
  }
1604
1616
  continue;
1605
1617
  }
1606
1618
  if (!inRequirementSection) continue;
1607
1619
  const itemMatch = line.match(NUMBERED_ITEM_RE);
1608
1620
  if (!itemMatch) continue;
1609
- const index = parseInt(itemMatch[1], 10);
1610
- const text = itemMatch[2].trim();
1611
- const rawText = line.trim();
1612
- const lineNumber = i + 1;
1613
1621
  globalIndex++;
1614
- const nodeId = `req:${specHash}:${globalIndex}`;
1615
- const earsPattern = detectEarsPattern(text);
1616
- results.push({
1617
- node: {
1618
- id: nodeId,
1619
- type: "requirement",
1620
- name: text,
1621
- path: specPath,
1622
- location: {
1623
- fileId: `file:${specPath}`,
1624
- startLine: lineNumber,
1625
- endLine: lineNumber
1626
- },
1627
- metadata: {
1628
- specPath,
1629
- index,
1630
- section: currentSection,
1631
- rawText,
1632
- earsPattern,
1633
- featureName
1634
- }
1635
- }
1636
- });
1622
+ results.push(
1623
+ this.buildRequirementNode(
1624
+ line,
1625
+ itemMatch,
1626
+ i + 1,
1627
+ specPath,
1628
+ specHash,
1629
+ globalIndex,
1630
+ featureName,
1631
+ currentSection
1632
+ )
1633
+ );
1637
1634
  }
1638
1635
  return results;
1639
1636
  }
1637
+ /**
1638
+ * Check if a line is a section heading and return updated section state,
1639
+ * or return null if the line is not a heading.
1640
+ */
1641
+ processHeadingLine(line, _inRequirementSection) {
1642
+ const headingMatch = line.match(SECTION_HEADING_RE);
1643
+ if (!headingMatch) return null;
1644
+ const heading = headingMatch[1].trim();
1645
+ const isReqSection = REQUIREMENT_SECTIONS.some(
1646
+ (s) => heading.toLowerCase() === s.toLowerCase()
1647
+ );
1648
+ if (isReqSection) {
1649
+ return { inRequirementSection: true, currentSection: heading };
1650
+ }
1651
+ return { inRequirementSection: false };
1652
+ }
1653
+ /**
1654
+ * Build a requirement GraphNode from a matched numbered-item line.
1655
+ */
1656
+ buildRequirementNode(line, itemMatch, lineNumber, specPath, specHash, globalIndex, featureName, currentSection) {
1657
+ const index = parseInt(itemMatch[1], 10);
1658
+ const text = itemMatch[2].trim();
1659
+ const rawText = line.trim();
1660
+ const nodeId = `req:${specHash}:${globalIndex}`;
1661
+ const earsPattern = detectEarsPattern(text);
1662
+ return {
1663
+ node: {
1664
+ id: nodeId,
1665
+ type: "requirement",
1666
+ name: text,
1667
+ path: specPath,
1668
+ location: {
1669
+ fileId: `file:${specPath}`,
1670
+ startLine: lineNumber,
1671
+ endLine: lineNumber
1672
+ },
1673
+ metadata: {
1674
+ specPath,
1675
+ index,
1676
+ section: currentSection,
1677
+ rawText,
1678
+ earsPattern,
1679
+ featureName
1680
+ }
1681
+ }
1682
+ };
1683
+ }
1640
1684
  /**
1641
1685
  * Convention-based linking: match requirement to code/test files
1642
1686
  * by feature name in their path.
@@ -1840,15 +1884,18 @@ function buildIngestResult(nodesAdded, edgesAdded, errors, start) {
1840
1884
  durationMs: Date.now() - start
1841
1885
  };
1842
1886
  }
1887
+ function appendJqlClause(jql, clause) {
1888
+ return jql ? `${jql} AND ${clause}` : clause;
1889
+ }
1843
1890
  function buildJql(config) {
1844
1891
  const project2 = config.project;
1845
1892
  let jql = project2 ? `project=${project2}` : "";
1846
1893
  const filters = config.filters;
1847
1894
  if (filters?.status?.length) {
1848
- jql += `${jql ? " AND " : ""}status IN (${filters.status.map((s) => `"${s}"`).join(",")})`;
1895
+ jql = appendJqlClause(jql, `status IN (${filters.status.map((s) => `"${s}"`).join(",")})`);
1849
1896
  }
1850
1897
  if (filters?.labels?.length) {
1851
- jql += `${jql ? " AND " : ""}labels IN (${filters.labels.map((l) => `"${l}"`).join(",")})`;
1898
+ jql = appendJqlClause(jql, `labels IN (${filters.labels.map((l) => `"${l}"`).join(",")})`);
1852
1899
  }
1853
1900
  return jql;
1854
1901
  }
@@ -1861,8 +1908,6 @@ var JiraConnector = class {
1861
1908
  }
1862
1909
  async ingest(store, config) {
1863
1910
  const start = Date.now();
1864
- let nodesAdded = 0;
1865
- let edgesAdded = 0;
1866
1911
  const apiKeyEnv = config.apiKeyEnv ?? "JIRA_API_KEY";
1867
1912
  const apiKey = process.env[apiKeyEnv];
1868
1913
  if (!apiKey) {
@@ -1884,38 +1929,39 @@ var JiraConnector = class {
1884
1929
  );
1885
1930
  }
1886
1931
  const jql = buildJql(config);
1887
- const headers = {
1888
- Authorization: `Basic ${apiKey}`,
1889
- "Content-Type": "application/json"
1890
- };
1932
+ const headers = { Authorization: `Basic ${apiKey}`, "Content-Type": "application/json" };
1891
1933
  try {
1892
- let startAt = 0;
1893
- const maxResults = 50;
1894
- let total = Infinity;
1895
- while (startAt < total) {
1896
- const url = `${baseUrl}/rest/api/2/search?jql=${encodeURIComponent(jql)}&startAt=${startAt}&maxResults=${maxResults}`;
1897
- const response = await this.httpClient(url, { headers });
1898
- if (!response.ok) {
1899
- return buildIngestResult(nodesAdded, edgesAdded, ["Jira API request failed"], start);
1900
- }
1901
- const data = await response.json();
1902
- total = data.total;
1903
- for (const issue of data.issues) {
1904
- const counts = this.processIssue(store, issue);
1905
- nodesAdded += counts.nodesAdded;
1906
- edgesAdded += counts.edgesAdded;
1907
- }
1908
- startAt += maxResults;
1909
- }
1934
+ const counts = await this.fetchAllIssues(store, baseUrl, jql, headers);
1935
+ return buildIngestResult(counts.nodesAdded, counts.edgesAdded, [], start);
1910
1936
  } catch (err) {
1911
1937
  return buildIngestResult(
1912
- nodesAdded,
1913
- edgesAdded,
1938
+ 0,
1939
+ 0,
1914
1940
  [`Jira API error: ${err instanceof Error ? err.message : String(err)}`],
1915
1941
  start
1916
1942
  );
1917
1943
  }
1918
- return buildIngestResult(nodesAdded, edgesAdded, [], start);
1944
+ }
1945
+ async fetchAllIssues(store, baseUrl, jql, headers) {
1946
+ let nodesAdded = 0;
1947
+ let edgesAdded = 0;
1948
+ let startAt = 0;
1949
+ const maxResults = 50;
1950
+ let total = Infinity;
1951
+ while (startAt < total) {
1952
+ const url = `${baseUrl}/rest/api/2/search?jql=${encodeURIComponent(jql)}&startAt=${startAt}&maxResults=${maxResults}`;
1953
+ const response = await this.httpClient(url, { headers });
1954
+ if (!response.ok) throw new Error("Jira API request failed");
1955
+ const data = await response.json();
1956
+ total = data.total;
1957
+ for (const issue of data.issues) {
1958
+ const counts = this.processIssue(store, issue);
1959
+ nodesAdded += counts.nodesAdded;
1960
+ edgesAdded += counts.edgesAdded;
1961
+ }
1962
+ startAt += maxResults;
1963
+ }
1964
+ return { nodesAdded, edgesAdded };
1919
1965
  }
1920
1966
  processIssue(store, issue) {
1921
1967
  const nodeId = `issue:jira:${issue.key}`;
@@ -2036,6 +2082,16 @@ var SlackConnector = class {
2036
2082
  };
2037
2083
 
2038
2084
  // src/ingest/connectors/ConfluenceConnector.ts
2085
+ function missingApiKeyResult(envVar, start) {
2086
+ return {
2087
+ nodesAdded: 0,
2088
+ nodesUpdated: 0,
2089
+ edgesAdded: 0,
2090
+ edgesUpdated: 0,
2091
+ errors: [`Missing API key: environment variable "${envVar}" is not set`],
2092
+ durationMs: Date.now() - start
2093
+ };
2094
+ }
2039
2095
  var ConfluenceConnector = class {
2040
2096
  name = "confluence";
2041
2097
  source = "confluence";
@@ -2046,40 +2102,34 @@ var ConfluenceConnector = class {
2046
2102
  async ingest(store, config) {
2047
2103
  const start = Date.now();
2048
2104
  const errors = [];
2049
- let nodesAdded = 0;
2050
- let edgesAdded = 0;
2051
2105
  const apiKeyEnv = config.apiKeyEnv ?? "CONFLUENCE_API_KEY";
2052
2106
  const apiKey = process.env[apiKeyEnv];
2053
2107
  if (!apiKey) {
2054
- return {
2055
- nodesAdded: 0,
2056
- nodesUpdated: 0,
2057
- edgesAdded: 0,
2058
- edgesUpdated: 0,
2059
- errors: [`Missing API key: environment variable "${apiKeyEnv}" is not set`],
2060
- durationMs: Date.now() - start
2061
- };
2108
+ return missingApiKeyResult(apiKeyEnv, start);
2062
2109
  }
2063
2110
  const baseUrlEnv = config.baseUrlEnv ?? "CONFLUENCE_BASE_URL";
2064
2111
  const baseUrl = process.env[baseUrlEnv] ?? "";
2065
2112
  const spaceKey = config.spaceKey ?? "";
2066
- try {
2067
- const result = await this.fetchAllPages(store, baseUrl, apiKey, spaceKey);
2068
- nodesAdded = result.nodesAdded;
2069
- edgesAdded = result.edgesAdded;
2070
- errors.push(...result.errors);
2071
- } catch (err) {
2072
- errors.push(`Confluence fetch error: ${err instanceof Error ? err.message : String(err)}`);
2073
- }
2113
+ const counts = await this.fetchAllPagesHandled(store, baseUrl, apiKey, spaceKey, errors);
2074
2114
  return {
2075
- nodesAdded,
2115
+ nodesAdded: counts.nodesAdded,
2076
2116
  nodesUpdated: 0,
2077
- edgesAdded,
2117
+ edgesAdded: counts.edgesAdded,
2078
2118
  edgesUpdated: 0,
2079
2119
  errors,
2080
2120
  durationMs: Date.now() - start
2081
2121
  };
2082
2122
  }
2123
+ async fetchAllPagesHandled(store, baseUrl, apiKey, spaceKey, errors) {
2124
+ try {
2125
+ const result = await this.fetchAllPages(store, baseUrl, apiKey, spaceKey);
2126
+ errors.push(...result.errors);
2127
+ return { nodesAdded: result.nodesAdded, edgesAdded: result.edgesAdded };
2128
+ } catch (err) {
2129
+ errors.push(`Confluence fetch error: ${err instanceof Error ? err.message : String(err)}`);
2130
+ return { nodesAdded: 0, edgesAdded: 0 };
2131
+ }
2132
+ }
2083
2133
  async fetchAllPages(store, baseUrl, apiKey, spaceKey) {
2084
2134
  const errors = [];
2085
2135
  let nodesAdded = 0;
@@ -2124,6 +2174,61 @@ var ConfluenceConnector = class {
2124
2174
  };
2125
2175
 
2126
2176
  // src/ingest/connectors/CIConnector.ts
2177
+ function emptyResult2(errors, start) {
2178
+ return {
2179
+ nodesAdded: 0,
2180
+ nodesUpdated: 0,
2181
+ edgesAdded: 0,
2182
+ edgesUpdated: 0,
2183
+ errors,
2184
+ durationMs: Date.now() - start
2185
+ };
2186
+ }
2187
+ function ingestRun(store, run) {
2188
+ const buildId = `build:${run.id}`;
2189
+ const safeName = sanitizeExternalText(run.name, 200);
2190
+ let nodesAdded = 0;
2191
+ let edgesAdded = 0;
2192
+ store.addNode({
2193
+ id: buildId,
2194
+ type: "build",
2195
+ name: `${safeName} #${run.id}`,
2196
+ metadata: {
2197
+ source: "github-actions",
2198
+ status: run.status,
2199
+ conclusion: run.conclusion,
2200
+ branch: run.head_branch,
2201
+ sha: run.head_sha,
2202
+ url: run.html_url,
2203
+ createdAt: run.created_at
2204
+ }
2205
+ });
2206
+ nodesAdded++;
2207
+ const commitNode = store.getNode(`commit:${run.head_sha}`);
2208
+ if (commitNode) {
2209
+ store.addEdge({ from: buildId, to: commitNode.id, type: "triggered_by" });
2210
+ edgesAdded++;
2211
+ }
2212
+ if (run.conclusion === "failure") {
2213
+ const testResultId = `test_result:${run.id}`;
2214
+ store.addNode({
2215
+ id: testResultId,
2216
+ type: "test_result",
2217
+ name: `Failed: ${safeName} #${run.id}`,
2218
+ metadata: {
2219
+ source: "github-actions",
2220
+ buildId: String(run.id),
2221
+ conclusion: "failure",
2222
+ branch: run.head_branch,
2223
+ sha: run.head_sha
2224
+ }
2225
+ });
2226
+ nodesAdded++;
2227
+ store.addEdge({ from: testResultId, to: buildId, type: "failed_in" });
2228
+ edgesAdded++;
2229
+ }
2230
+ return { nodesAdded, edgesAdded };
2231
+ }
2127
2232
  var CIConnector = class {
2128
2233
  name = "ci";
2129
2234
  source = "github-actions";
@@ -2134,22 +2239,29 @@ var CIConnector = class {
2134
2239
  async ingest(store, config) {
2135
2240
  const start = Date.now();
2136
2241
  const errors = [];
2137
- let nodesAdded = 0;
2138
- let edgesAdded = 0;
2139
2242
  const apiKeyEnv = config.apiKeyEnv ?? "GITHUB_TOKEN";
2140
2243
  const apiKey = process.env[apiKeyEnv];
2141
2244
  if (!apiKey) {
2142
- return {
2143
- nodesAdded: 0,
2144
- nodesUpdated: 0,
2145
- edgesAdded: 0,
2146
- edgesUpdated: 0,
2147
- errors: [`Missing API key: environment variable "${apiKeyEnv}" is not set`],
2148
- durationMs: Date.now() - start
2149
- };
2245
+ return emptyResult2(
2246
+ [`Missing API key: environment variable "${apiKeyEnv}" is not set`],
2247
+ start
2248
+ );
2150
2249
  }
2151
2250
  const repo = config.repo ?? "";
2152
2251
  const maxRuns = config.maxRuns ?? 10;
2252
+ const counts = await this.fetchAndIngestRuns(store, repo, maxRuns, apiKey, errors);
2253
+ return {
2254
+ nodesAdded: counts.nodesAdded,
2255
+ nodesUpdated: 0,
2256
+ edgesAdded: counts.edgesAdded,
2257
+ edgesUpdated: 0,
2258
+ errors,
2259
+ durationMs: Date.now() - start
2260
+ };
2261
+ }
2262
+ async fetchAndIngestRuns(store, repo, maxRuns, apiKey, errors) {
2263
+ let nodesAdded = 0;
2264
+ let edgesAdded = 0;
2153
2265
  try {
2154
2266
  const url = `https://api.github.com/repos/${repo}/actions/runs?per_page=${maxRuns}`;
2155
2267
  const response = await this.httpClient(url, {
@@ -2157,71 +2269,20 @@ var CIConnector = class {
2157
2269
  });
2158
2270
  if (!response.ok) {
2159
2271
  errors.push(`GitHub Actions API error: status ${response.status}`);
2160
- return {
2161
- nodesAdded: 0,
2162
- nodesUpdated: 0,
2163
- edgesAdded: 0,
2164
- edgesUpdated: 0,
2165
- errors,
2166
- durationMs: Date.now() - start
2167
- };
2272
+ return { nodesAdded, edgesAdded };
2168
2273
  }
2169
2274
  const data = await response.json();
2170
2275
  for (const run of data.workflow_runs) {
2171
- const buildId = `build:${run.id}`;
2172
- const safeName = sanitizeExternalText(run.name, 200);
2173
- store.addNode({
2174
- id: buildId,
2175
- type: "build",
2176
- name: `${safeName} #${run.id}`,
2177
- metadata: {
2178
- source: "github-actions",
2179
- status: run.status,
2180
- conclusion: run.conclusion,
2181
- branch: run.head_branch,
2182
- sha: run.head_sha,
2183
- url: run.html_url,
2184
- createdAt: run.created_at
2185
- }
2186
- });
2187
- nodesAdded++;
2188
- const commitNode = store.getNode(`commit:${run.head_sha}`);
2189
- if (commitNode) {
2190
- store.addEdge({ from: buildId, to: commitNode.id, type: "triggered_by" });
2191
- edgesAdded++;
2192
- }
2193
- if (run.conclusion === "failure") {
2194
- const testResultId = `test_result:${run.id}`;
2195
- store.addNode({
2196
- id: testResultId,
2197
- type: "test_result",
2198
- name: `Failed: ${safeName} #${run.id}`,
2199
- metadata: {
2200
- source: "github-actions",
2201
- buildId: String(run.id),
2202
- conclusion: "failure",
2203
- branch: run.head_branch,
2204
- sha: run.head_sha
2205
- }
2206
- });
2207
- nodesAdded++;
2208
- store.addEdge({ from: testResultId, to: buildId, type: "failed_in" });
2209
- edgesAdded++;
2210
- }
2276
+ const counts = ingestRun(store, run);
2277
+ nodesAdded += counts.nodesAdded;
2278
+ edgesAdded += counts.edgesAdded;
2211
2279
  }
2212
- } catch (err) {
2213
- errors.push(
2214
- `GitHub Actions fetch error: ${err instanceof Error ? err.message : String(err)}`
2215
- );
2216
- }
2217
- return {
2218
- nodesAdded,
2219
- nodesUpdated: 0,
2220
- edgesAdded,
2221
- edgesUpdated: 0,
2222
- errors,
2223
- durationMs: Date.now() - start
2224
- };
2280
+ } catch (err) {
2281
+ errors.push(
2282
+ `GitHub Actions fetch error: ${err instanceof Error ? err.message : String(err)}`
2283
+ );
2284
+ }
2285
+ return { nodesAdded, edgesAdded };
2225
2286
  }
2226
2287
  };
2227
2288
 
@@ -2291,16 +2352,29 @@ var FusionLayer = class {
2291
2352
  return [];
2292
2353
  }
2293
2354
  const allNodes = this.store.findNodes({});
2355
+ const semanticScores = this.buildSemanticScores(queryEmbedding, allNodes.length);
2356
+ const { kwWeight, semWeight } = this.resolveWeights(semanticScores.size > 0);
2357
+ const results = this.scoreNodes(allNodes, keywords, semanticScores, kwWeight, semWeight);
2358
+ results.sort((a, b) => b.score - a.score);
2359
+ return results.slice(0, topK);
2360
+ }
2361
+ buildSemanticScores(queryEmbedding, nodeCount) {
2294
2362
  const semanticScores = /* @__PURE__ */ new Map();
2295
2363
  if (queryEmbedding && this.vectorStore) {
2296
- const vectorResults = this.vectorStore.search(queryEmbedding, allNodes.length);
2364
+ const vectorResults = this.vectorStore.search(queryEmbedding, nodeCount);
2297
2365
  for (const vr of vectorResults) {
2298
2366
  semanticScores.set(vr.id, vr.score);
2299
2367
  }
2300
2368
  }
2301
- const hasSemanticScores = semanticScores.size > 0;
2302
- const kwWeight = hasSemanticScores ? this.keywordWeight : 1;
2303
- const semWeight = hasSemanticScores ? this.semanticWeight : 0;
2369
+ return semanticScores;
2370
+ }
2371
+ resolveWeights(hasSemanticScores) {
2372
+ return {
2373
+ kwWeight: hasSemanticScores ? this.keywordWeight : 1,
2374
+ semWeight: hasSemanticScores ? this.semanticWeight : 0
2375
+ };
2376
+ }
2377
+ scoreNodes(allNodes, keywords, semanticScores, kwWeight, semWeight) {
2304
2378
  const results = [];
2305
2379
  for (const node of allNodes) {
2306
2380
  const kwScore = this.keywordScore(keywords, node);
@@ -2311,15 +2385,11 @@ var FusionLayer = class {
2311
2385
  nodeId: node.id,
2312
2386
  node,
2313
2387
  score: fusedScore,
2314
- signals: {
2315
- keyword: kwScore,
2316
- semantic: semScore
2317
- }
2388
+ signals: { keyword: kwScore, semantic: semScore }
2318
2389
  });
2319
2390
  }
2320
2391
  }
2321
- results.sort((a, b) => b.score - a.score);
2322
- return results.slice(0, topK);
2392
+ return results;
2323
2393
  }
2324
2394
  extractKeywords(query) {
2325
2395
  const tokens = query.toLowerCase().split(/[\s\-_.,:;!?()[\]{}"'`/\\|@#$%^&*+=<>~]+/).filter((t) => t.length >= 2).filter((t) => !STOP_WORDS.has(t));
@@ -2374,37 +2444,50 @@ var GraphEntropyAdapter = class {
2374
2444
  const missingTargets = [];
2375
2445
  let freshEdges = 0;
2376
2446
  for (const edge of documentsEdges) {
2377
- const codeNode = this.store.getNode(edge.to);
2378
- if (!codeNode) {
2447
+ const result = this.classifyDocEdge(edge);
2448
+ if (result.kind === "missing") {
2379
2449
  missingTargets.push(edge.to);
2380
- continue;
2450
+ } else if (result.kind === "fresh") {
2451
+ freshEdges++;
2452
+ } else {
2453
+ staleEdges.push(result.entry);
2381
2454
  }
2382
- const docNode = this.store.getNode(edge.from);
2383
- const codeLastModified = codeNode.lastModified;
2384
- const docLastModified = docNode?.lastModified;
2385
- if (codeLastModified && docLastModified) {
2386
- if (codeLastModified > docLastModified) {
2387
- staleEdges.push({
2455
+ }
2456
+ return { staleEdges, missingTargets, freshEdges };
2457
+ }
2458
+ classifyDocEdge(edge) {
2459
+ const codeNode = this.store.getNode(edge.to);
2460
+ if (!codeNode) {
2461
+ return { kind: "missing" };
2462
+ }
2463
+ const docNode = this.store.getNode(edge.from);
2464
+ const codeLastModified = codeNode.lastModified;
2465
+ const docLastModified = docNode?.lastModified;
2466
+ if (codeLastModified && docLastModified) {
2467
+ if (codeLastModified > docLastModified) {
2468
+ return {
2469
+ kind: "stale",
2470
+ entry: {
2388
2471
  docNodeId: edge.from,
2389
2472
  codeNodeId: edge.to,
2390
2473
  edgeType: edge.type,
2391
2474
  codeLastModified,
2392
2475
  docLastModified
2393
- });
2394
- } else {
2395
- freshEdges++;
2396
- }
2397
- } else {
2398
- staleEdges.push({
2399
- docNodeId: edge.from,
2400
- codeNodeId: edge.to,
2401
- edgeType: edge.type,
2402
- codeLastModified,
2403
- docLastModified
2404
- });
2476
+ }
2477
+ };
2405
2478
  }
2479
+ return { kind: "fresh" };
2406
2480
  }
2407
- return { staleEdges, missingTargets, freshEdges };
2481
+ return {
2482
+ kind: "stale",
2483
+ entry: {
2484
+ docNodeId: edge.from,
2485
+ codeNodeId: edge.to,
2486
+ edgeType: edge.type,
2487
+ codeLastModified,
2488
+ docLastModified
2489
+ }
2490
+ };
2408
2491
  }
2409
2492
  /**
2410
2493
  * BFS from entry points to find reachable vs unreachable code nodes.
@@ -2661,36 +2744,12 @@ var GraphAnomalyAdapter = class {
2661
2744
  store;
2662
2745
  detect(options) {
2663
2746
  const threshold = options?.threshold != null && options.threshold > 0 ? options.threshold : DEFAULT_THRESHOLD;
2664
- const requestedMetrics = options?.metrics ?? [...DEFAULT_METRICS];
2665
- const warnings = [];
2666
- const metricsToAnalyze = [];
2667
- for (const m of requestedMetrics) {
2668
- if (RECOGNIZED_METRICS.has(m)) {
2669
- metricsToAnalyze.push(m);
2670
- } else {
2671
- warnings.push(m);
2672
- }
2673
- }
2674
- const allOutliers = [];
2675
- const analyzedNodeIds = /* @__PURE__ */ new Set();
2676
- const couplingMetrics = ["fanIn", "fanOut", "transitiveDepth"];
2677
- const needsCoupling = metricsToAnalyze.some((m) => couplingMetrics.includes(m));
2678
- const needsComplexity = metricsToAnalyze.includes("hotspotScore");
2679
- const cachedCouplingData = needsCoupling ? new GraphCouplingAdapter(this.store).computeCouplingData() : void 0;
2680
- const cachedHotspotData = needsComplexity ? new GraphComplexityAdapter(this.store).computeComplexityHotspots() : void 0;
2681
- for (const metric of metricsToAnalyze) {
2682
- const entries = this.collectMetricValues(metric, cachedCouplingData, cachedHotspotData);
2683
- for (const e of entries) {
2684
- analyzedNodeIds.add(e.nodeId);
2685
- }
2686
- const outliers = this.computeZScoreOutliers(entries, metric, threshold);
2687
- allOutliers.push(...outliers);
2688
- }
2689
- allOutliers.sort((a, b) => b.zScore - a.zScore);
2747
+ const { metricsToAnalyze, warnings } = this.filterMetrics(
2748
+ options?.metrics ?? [...DEFAULT_METRICS]
2749
+ );
2750
+ const { allOutliers, analyzedNodeIds } = this.computeAllOutliers(metricsToAnalyze, threshold);
2690
2751
  const articulationPoints = this.findArticulationPoints();
2691
- const outlierNodeIds = new Set(allOutliers.map((o) => o.nodeId));
2692
- const apNodeIds = new Set(articulationPoints.map((ap) => ap.nodeId));
2693
- const overlapping = [...outlierNodeIds].filter((id) => apNodeIds.has(id));
2752
+ const overlapping = this.computeOverlap(allOutliers, articulationPoints);
2694
2753
  return {
2695
2754
  statisticalOutliers: allOutliers,
2696
2755
  articulationPoints,
@@ -2706,6 +2765,38 @@ var GraphAnomalyAdapter = class {
2706
2765
  }
2707
2766
  };
2708
2767
  }
2768
+ filterMetrics(requested) {
2769
+ const metricsToAnalyze = [];
2770
+ const warnings = [];
2771
+ for (const m of requested) {
2772
+ if (RECOGNIZED_METRICS.has(m)) {
2773
+ metricsToAnalyze.push(m);
2774
+ } else {
2775
+ warnings.push(m);
2776
+ }
2777
+ }
2778
+ return { metricsToAnalyze, warnings };
2779
+ }
2780
+ computeAllOutliers(metricsToAnalyze, threshold) {
2781
+ const couplingMetrics = ["fanIn", "fanOut", "transitiveDepth"];
2782
+ const needsCoupling = metricsToAnalyze.some((m) => couplingMetrics.includes(m));
2783
+ const cachedCouplingData = needsCoupling ? new GraphCouplingAdapter(this.store).computeCouplingData() : void 0;
2784
+ const cachedHotspotData = metricsToAnalyze.includes("hotspotScore") ? new GraphComplexityAdapter(this.store).computeComplexityHotspots() : void 0;
2785
+ const allOutliers = [];
2786
+ const analyzedNodeIds = /* @__PURE__ */ new Set();
2787
+ for (const metric of metricsToAnalyze) {
2788
+ const entries = this.collectMetricValues(metric, cachedCouplingData, cachedHotspotData);
2789
+ for (const e of entries) analyzedNodeIds.add(e.nodeId);
2790
+ allOutliers.push(...this.computeZScoreOutliers(entries, metric, threshold));
2791
+ }
2792
+ allOutliers.sort((a, b) => b.zScore - a.zScore);
2793
+ return { allOutliers, analyzedNodeIds };
2794
+ }
2795
+ computeOverlap(outliers, articulationPoints) {
2796
+ const outlierNodeIds = new Set(outliers.map((o) => o.nodeId));
2797
+ const apNodeIds = new Set(articulationPoints.map((ap) => ap.nodeId));
2798
+ return [...outlierNodeIds].filter((id) => apNodeIds.has(id));
2799
+ }
2709
2800
  collectMetricValues(metric, cachedCouplingData, cachedHotspotData) {
2710
2801
  const entries = [];
2711
2802
  if (metric === "cyclomaticComplexity") {
@@ -3261,37 +3352,54 @@ var EntityExtractor = class {
3261
3352
  result.push(entity);
3262
3353
  }
3263
3354
  };
3264
- const quotedConsumed = /* @__PURE__ */ new Set();
3355
+ const quotedConsumed = this.extractQuoted(trimmed, add);
3356
+ const casingConsumed = this.extractCasing(trimmed, quotedConsumed, add);
3357
+ const pathConsumed = this.extractPaths(trimmed, add);
3358
+ this.extractNouns(trimmed, buildConsumedSet(quotedConsumed, casingConsumed, pathConsumed), add);
3359
+ return result;
3360
+ }
3361
+ /** Strategy 1: Quoted strings. Returns the set of consumed tokens. */
3362
+ extractQuoted(trimmed, add) {
3363
+ const consumed = /* @__PURE__ */ new Set();
3265
3364
  for (const match of trimmed.matchAll(QUOTED_RE)) {
3266
3365
  const inner = match[1].trim();
3267
3366
  if (inner.length > 0) {
3268
3367
  add(inner);
3269
- quotedConsumed.add(inner);
3368
+ consumed.add(inner);
3270
3369
  }
3271
3370
  }
3272
- const casingConsumed = /* @__PURE__ */ new Set();
3371
+ return consumed;
3372
+ }
3373
+ /** Strategy 2: PascalCase/camelCase tokens. Returns the set of consumed tokens. */
3374
+ extractCasing(trimmed, quotedConsumed, add) {
3375
+ const consumed = /* @__PURE__ */ new Set();
3273
3376
  for (const match of trimmed.matchAll(PASCAL_OR_CAMEL_RE)) {
3274
3377
  const token = match[0];
3275
3378
  if (!quotedConsumed.has(token)) {
3276
3379
  add(token);
3277
- casingConsumed.add(token);
3380
+ consumed.add(token);
3278
3381
  }
3279
3382
  }
3280
- const pathConsumed = /* @__PURE__ */ new Set();
3383
+ return consumed;
3384
+ }
3385
+ /** Strategy 3: File paths. Returns the set of consumed tokens. */
3386
+ extractPaths(trimmed, add) {
3387
+ const consumed = /* @__PURE__ */ new Set();
3281
3388
  for (const match of trimmed.matchAll(FILE_PATH_RE)) {
3282
3389
  const path7 = match[0];
3283
3390
  add(path7);
3284
- pathConsumed.add(path7);
3391
+ consumed.add(path7);
3285
3392
  }
3286
- const allConsumed = buildConsumedSet(quotedConsumed, casingConsumed, pathConsumed);
3287
- const words = trimmed.split(/\s+/);
3288
- for (const raw of words) {
3393
+ return consumed;
3394
+ }
3395
+ /** Strategy 4: Remaining significant nouns after stop-word and intent-keyword removal. */
3396
+ extractNouns(trimmed, allConsumed, add) {
3397
+ for (const raw of trimmed.split(/\s+/)) {
3289
3398
  const cleaned = raw.replace(/^[^a-zA-Z0-9]+|[^a-zA-Z0-9]+$/g, "");
3290
3399
  if (cleaned.length === 0) continue;
3291
3400
  if (isSkippableWord(cleaned, allConsumed)) continue;
3292
3401
  add(cleaned);
3293
3402
  }
3294
- return result;
3295
3403
  }
3296
3404
  };
3297
3405
 
@@ -3708,36 +3816,41 @@ var ENTITY_REQUIRED_INTENTS = /* @__PURE__ */ new Set(["impact", "relationships"
3708
3816
  var classifier = new IntentClassifier();
3709
3817
  var extractor = new EntityExtractor();
3710
3818
  var formatter = new ResponseFormatter();
3819
+ function lowConfidenceResult(intent, confidence) {
3820
+ return {
3821
+ intent,
3822
+ intentConfidence: confidence,
3823
+ entities: [],
3824
+ summary: "I'm not sure what you're asking. Try rephrasing your question.",
3825
+ data: null,
3826
+ suggestions: [
3827
+ 'Try "what breaks if I change <name>?" for impact analysis',
3828
+ 'Try "where is <name>?" to find entities',
3829
+ 'Try "what calls <name>?" for relationships',
3830
+ 'Try "what is <name>?" for explanations',
3831
+ 'Try "what looks wrong?" for anomaly detection'
3832
+ ]
3833
+ };
3834
+ }
3835
+ function noEntityResult(intent, confidence) {
3836
+ return {
3837
+ intent,
3838
+ intentConfidence: confidence,
3839
+ entities: [],
3840
+ summary: "Could not find any matching nodes in the graph for your query. Try using exact class names, function names, or file paths.",
3841
+ data: null
3842
+ };
3843
+ }
3711
3844
  async function askGraph(store, question) {
3712
3845
  const fusion = new FusionLayer(store);
3713
3846
  const resolver = new EntityResolver(store, fusion);
3714
3847
  const classification = classifier.classify(question);
3715
3848
  if (classification.confidence < 0.3) {
3716
- return {
3717
- intent: classification.intent,
3718
- intentConfidence: classification.confidence,
3719
- entities: [],
3720
- summary: "I'm not sure what you're asking. Try rephrasing your question.",
3721
- data: null,
3722
- suggestions: [
3723
- 'Try "what breaks if I change <name>?" for impact analysis',
3724
- 'Try "where is <name>?" to find entities',
3725
- 'Try "what calls <name>?" for relationships',
3726
- 'Try "what is <name>?" for explanations',
3727
- 'Try "what looks wrong?" for anomaly detection'
3728
- ]
3729
- };
3849
+ return lowConfidenceResult(classification.intent, classification.confidence);
3730
3850
  }
3731
- const rawEntities = extractor.extract(question);
3732
- const entities = resolver.resolve(rawEntities);
3851
+ const entities = resolver.resolve(extractor.extract(question));
3733
3852
  if (ENTITY_REQUIRED_INTENTS.has(classification.intent) && entities.length === 0) {
3734
- return {
3735
- intent: classification.intent,
3736
- intentConfidence: classification.confidence,
3737
- entities: [],
3738
- summary: "Could not find any matching nodes in the graph for your query. Try using exact class names, function names, or file paths.",
3739
- data: null
3740
- };
3853
+ return noEntityResult(classification.intent, classification.confidence);
3741
3854
  }
3742
3855
  let data;
3743
3856
  try {
@@ -3751,67 +3864,59 @@ async function askGraph(store, question) {
3751
3864
  data: null
3752
3865
  };
3753
3866
  }
3754
- const summary = formatter.format(classification.intent, entities, data, question);
3755
3867
  return {
3756
3868
  intent: classification.intent,
3757
3869
  intentConfidence: classification.confidence,
3758
3870
  entities,
3759
- summary,
3871
+ summary: formatter.format(classification.intent, entities, data, question),
3760
3872
  data
3761
3873
  };
3762
3874
  }
3875
+ function buildContextBlocks(cql, rootIds, searchResults) {
3876
+ return rootIds.map((rootId) => {
3877
+ const expanded = cql.execute({ rootNodeIds: [rootId], maxDepth: 2 });
3878
+ const match = searchResults.find((r) => r.nodeId === rootId);
3879
+ return {
3880
+ rootNode: rootId,
3881
+ score: match?.score ?? 1,
3882
+ nodes: expanded.nodes,
3883
+ edges: expanded.edges
3884
+ };
3885
+ });
3886
+ }
3887
+ function executeImpact(store, cql, entities, question) {
3888
+ const rootId = entities[0].nodeId;
3889
+ const lower = question.toLowerCase();
3890
+ if (lower.includes("blast radius") || lower.includes("cascade")) {
3891
+ return new CascadeSimulator(store).simulate(rootId);
3892
+ }
3893
+ const result = cql.execute({ rootNodeIds: [rootId], bidirectional: true, maxDepth: 3 });
3894
+ return groupNodesByImpact(result.nodes, rootId);
3895
+ }
3896
+ function executeExplain(cql, entities, question, fusion) {
3897
+ const searchResults = fusion.search(question, 10);
3898
+ const rootIds = entities.length > 0 ? [entities[0].nodeId] : searchResults.slice(0, 3).map((r) => r.nodeId);
3899
+ return { searchResults, context: buildContextBlocks(cql, rootIds, searchResults) };
3900
+ }
3763
3901
  function executeOperation(store, intent, entities, question, fusion) {
3764
3902
  const cql = new ContextQL(store);
3765
3903
  switch (intent) {
3766
- case "impact": {
3767
- const rootId = entities[0].nodeId;
3768
- const lowerQuestion = question.toLowerCase();
3769
- if (lowerQuestion.includes("blast radius") || lowerQuestion.includes("cascade")) {
3770
- const simulator = new CascadeSimulator(store);
3771
- return simulator.simulate(rootId);
3772
- }
3773
- const result = cql.execute({
3774
- rootNodeIds: [rootId],
3775
- bidirectional: true,
3776
- maxDepth: 3
3777
- });
3778
- return groupNodesByImpact(result.nodes, rootId);
3779
- }
3780
- case "find": {
3904
+ case "impact":
3905
+ return executeImpact(store, cql, entities, question);
3906
+ case "find":
3781
3907
  return fusion.search(question, 10);
3782
- }
3783
3908
  case "relationships": {
3784
- const rootId = entities[0].nodeId;
3785
3909
  const result = cql.execute({
3786
- rootNodeIds: [rootId],
3910
+ rootNodeIds: [entities[0].nodeId],
3787
3911
  bidirectional: true,
3788
3912
  maxDepth: 1
3789
3913
  });
3790
3914
  return { nodes: result.nodes, edges: result.edges };
3791
3915
  }
3792
- case "explain": {
3793
- const searchResults = fusion.search(question, 10);
3794
- const contextBlocks = [];
3795
- const rootIds = entities.length > 0 ? [entities[0].nodeId] : searchResults.slice(0, 3).map((r) => r.nodeId);
3796
- for (const rootId of rootIds) {
3797
- const expanded = cql.execute({
3798
- rootNodeIds: [rootId],
3799
- maxDepth: 2
3800
- });
3801
- const matchingResult = searchResults.find((r) => r.nodeId === rootId);
3802
- contextBlocks.push({
3803
- rootNode: rootId,
3804
- score: matchingResult?.score ?? 1,
3805
- nodes: expanded.nodes,
3806
- edges: expanded.edges
3807
- });
3808
- }
3809
- return { searchResults, context: contextBlocks };
3810
- }
3811
- case "anomaly": {
3812
- const adapter = new GraphAnomalyAdapter(store);
3813
- return adapter.detect();
3814
- }
3916
+ case "explain":
3917
+ return executeExplain(cql, entities, question, fusion);
3918
+ case "anomaly":
3919
+ return new GraphAnomalyAdapter(store).detect();
3815
3920
  default:
3816
3921
  return null;
3817
3922
  }
@@ -3832,12 +3937,14 @@ var CODE_NODE_TYPES5 = /* @__PURE__ */ new Set([
3832
3937
  "method",
3833
3938
  "variable"
3834
3939
  ]);
3940
+ function countMetadataChars(node) {
3941
+ return node.metadata ? JSON.stringify(node.metadata).length : 0;
3942
+ }
3943
+ function countBaseChars(node) {
3944
+ return (node.name?.length ?? 0) + (node.path?.length ?? 0) + (node.type?.length ?? 0);
3945
+ }
3835
3946
  function estimateNodeTokens(node) {
3836
- let chars = (node.name?.length ?? 0) + (node.path?.length ?? 0) + (node.type?.length ?? 0);
3837
- if (node.metadata) {
3838
- chars += JSON.stringify(node.metadata).length;
3839
- }
3840
- return Math.ceil(chars / 4);
3947
+ return Math.ceil((countBaseChars(node) + countMetadataChars(node)) / 4);
3841
3948
  }
3842
3949
  var Assembler = class {
3843
3950
  store;
@@ -3918,47 +4025,55 @@ var Assembler = class {
3918
4025
  }
3919
4026
  return { keptNodes, tokenEstimate, truncated };
3920
4027
  }
3921
- /**
3922
- * Compute a token budget allocation across node types.
3923
- */
3924
- computeBudget(totalTokens, phase) {
3925
- const allNodes = this.store.findNodes({});
4028
+ countNodesByType() {
3926
4029
  const typeCounts = {};
3927
- for (const node of allNodes) {
4030
+ for (const node of this.store.findNodes({})) {
3928
4031
  typeCounts[node.type] = (typeCounts[node.type] ?? 0) + 1;
3929
4032
  }
4033
+ return typeCounts;
4034
+ }
4035
+ computeModuleDensity() {
3930
4036
  const density = {};
3931
- const moduleNodes = this.store.findNodes({ type: "module" });
3932
- for (const mod of moduleNodes) {
3933
- const outEdges = this.store.getEdges({ from: mod.id });
3934
- const inEdges = this.store.getEdges({ to: mod.id });
3935
- density[mod.name] = outEdges.length + inEdges.length;
4037
+ for (const mod of this.store.findNodes({ type: "module" })) {
4038
+ const out = this.store.getEdges({ from: mod.id }).length;
4039
+ const inn = this.store.getEdges({ to: mod.id }).length;
4040
+ density[mod.name] = out + inn;
3936
4041
  }
3937
- const boostTypes = phase ? PHASE_NODE_TYPES[phase] : void 0;
3938
- const boostFactor = 2;
3939
- let weightedTotal = 0;
4042
+ return density;
4043
+ }
4044
+ computeTypeWeights(typeCounts, boostTypes) {
3940
4045
  const weights = {};
4046
+ let weightedTotal = 0;
3941
4047
  for (const [type, count] of Object.entries(typeCounts)) {
3942
- const isBoosted = boostTypes?.includes(type);
3943
- const weight = count * (isBoosted ? boostFactor : 1);
4048
+ const weight = count * (boostTypes?.includes(type) ? 2 : 1);
3944
4049
  weights[type] = weight;
3945
4050
  weightedTotal += weight;
3946
4051
  }
4052
+ return { weights, weightedTotal };
4053
+ }
4054
+ allocateProportionally(weights, weightedTotal, totalTokens) {
3947
4055
  const allocations = {};
3948
- if (weightedTotal > 0) {
3949
- let allocated = 0;
3950
- const types = Object.keys(weights);
3951
- for (let i = 0; i < types.length; i++) {
3952
- const type = types[i];
3953
- if (i === types.length - 1) {
3954
- allocations[type] = totalTokens - allocated;
3955
- } else {
3956
- const share = Math.round(weights[type] / weightedTotal * totalTokens);
3957
- allocations[type] = share;
3958
- allocated += share;
3959
- }
4056
+ if (weightedTotal === 0) return allocations;
4057
+ let allocated = 0;
4058
+ const types = Object.keys(weights);
4059
+ for (let i = 0; i < types.length; i++) {
4060
+ const type = types[i];
4061
+ if (i === types.length - 1) {
4062
+ allocations[type] = totalTokens - allocated;
4063
+ } else {
4064
+ const share = Math.round(weights[type] / weightedTotal * totalTokens);
4065
+ allocations[type] = share;
4066
+ allocated += share;
3960
4067
  }
3961
4068
  }
4069
+ return allocations;
4070
+ }
4071
+ computeBudget(totalTokens, phase) {
4072
+ const typeCounts = this.countNodesByType();
4073
+ const density = this.computeModuleDensity();
4074
+ const boostTypes = phase ? PHASE_NODE_TYPES[phase] : void 0;
4075
+ const { weights, weightedTotal } = this.computeTypeWeights(typeCounts, boostTypes);
4076
+ const allocations = this.allocateProportionally(weights, weightedTotal, totalTokens);
3962
4077
  return { total: totalTokens, allocations, density };
3963
4078
  }
3964
4079
  /**
@@ -3989,49 +4104,43 @@ var Assembler = class {
3989
4104
  filePaths: Array.from(filePathSet)
3990
4105
  };
3991
4106
  }
3992
- /**
3993
- * Generate a markdown repository map from graph structure.
3994
- */
3995
- generateMap() {
3996
- const moduleNodes = this.store.findNodes({ type: "module" });
3997
- const modulesWithEdgeCount = moduleNodes.map((mod) => {
3998
- const outEdges = this.store.getEdges({ from: mod.id });
3999
- const inEdges = this.store.getEdges({ to: mod.id });
4000
- return { module: mod, edgeCount: outEdges.length + inEdges.length };
4107
+ buildModuleLines() {
4108
+ const modulesWithEdgeCount = this.store.findNodes({ type: "module" }).map((mod) => {
4109
+ const edgeCount = this.store.getEdges({ from: mod.id }).length + this.store.getEdges({ to: mod.id }).length;
4110
+ return { module: mod, edgeCount };
4001
4111
  });
4002
4112
  modulesWithEdgeCount.sort((a, b) => b.edgeCount - a.edgeCount);
4003
- const lines = ["# Repository Structure", ""];
4004
- if (modulesWithEdgeCount.length > 0) {
4005
- lines.push("## Modules", "");
4006
- for (const { module: mod, edgeCount } of modulesWithEdgeCount) {
4007
- lines.push(`### ${mod.name} (${edgeCount} connections)`);
4008
- lines.push("");
4009
- const containsEdges = this.store.getEdges({ from: mod.id, type: "contains" });
4010
- for (const edge of containsEdges) {
4011
- const fileNode = this.store.getNode(edge.to);
4012
- if (fileNode && fileNode.type === "file") {
4013
- const symbolEdges = this.store.getEdges({ from: fileNode.id, type: "contains" });
4014
- lines.push(`- ${fileNode.path ?? fileNode.name} (${symbolEdges.length} symbols)`);
4015
- }
4113
+ if (modulesWithEdgeCount.length === 0) return [];
4114
+ const lines = ["## Modules", ""];
4115
+ for (const { module: mod, edgeCount } of modulesWithEdgeCount) {
4116
+ lines.push(`### ${mod.name} (${edgeCount} connections)`, "");
4117
+ for (const edge of this.store.getEdges({ from: mod.id, type: "contains" })) {
4118
+ const fileNode = this.store.getNode(edge.to);
4119
+ if (fileNode?.type === "file") {
4120
+ const symbols = this.store.getEdges({ from: fileNode.id, type: "contains" }).length;
4121
+ lines.push(`- ${fileNode.path ?? fileNode.name} (${symbols} symbols)`);
4016
4122
  }
4017
- lines.push("");
4018
4123
  }
4124
+ lines.push("");
4019
4125
  }
4020
- const fileNodes = this.store.findNodes({ type: "file" });
4021
- const nonBarrelFiles = fileNodes.filter((n) => !n.name.startsWith("index."));
4022
- const filesWithOutDegree = nonBarrelFiles.map((f) => {
4023
- const outEdges = this.store.getEdges({ from: f.id });
4024
- return { file: f, outDegree: outEdges.length };
4025
- });
4126
+ return lines;
4127
+ }
4128
+ buildEntryPointLines() {
4129
+ const filesWithOutDegree = this.store.findNodes({ type: "file" }).filter((n) => !n.name.startsWith("index.")).map((f) => ({ file: f, outDegree: this.store.getEdges({ from: f.id }).length }));
4026
4130
  filesWithOutDegree.sort((a, b) => b.outDegree - a.outDegree);
4027
4131
  const entryPoints = filesWithOutDegree.filter((f) => f.outDegree > 0).slice(0, 5);
4028
- if (entryPoints.length > 0) {
4029
- lines.push("## Entry Points", "");
4030
- for (const { file, outDegree } of entryPoints) {
4031
- lines.push(`- ${file.path ?? file.name} (${outDegree} outbound edges)`);
4032
- }
4033
- lines.push("");
4132
+ if (entryPoints.length === 0) return [];
4133
+ const lines = ["## Entry Points", ""];
4134
+ for (const { file, outDegree } of entryPoints) {
4135
+ lines.push(`- ${file.path ?? file.name} (${outDegree} outbound edges)`);
4034
4136
  }
4137
+ lines.push("");
4138
+ return lines;
4139
+ }
4140
+ generateMap() {
4141
+ const lines = ["# Repository Structure", ""];
4142
+ lines.push(...this.buildModuleLines());
4143
+ lines.push(...this.buildEntryPointLines());
4035
4144
  return lines.join("\n");
4036
4145
  }
4037
4146
  /**
@@ -4165,10 +4274,15 @@ var GraphConstraintAdapter = class {
4165
4274
  }
4166
4275
  store;
4167
4276
  computeDependencyGraph() {
4168
- const fileNodes = this.store.findNodes({ type: "file" });
4169
- const nodes = fileNodes.map((n) => n.path ?? n.id);
4170
- const importsEdges = this.store.getEdges({ type: "imports" });
4171
- const edges = importsEdges.map((e) => {
4277
+ const nodes = this.collectFileNodePaths();
4278
+ const edges = this.collectImportEdges();
4279
+ return { nodes, edges };
4280
+ }
4281
+ collectFileNodePaths() {
4282
+ return this.store.findNodes({ type: "file" }).map((n) => n.path ?? n.id);
4283
+ }
4284
+ collectImportEdges() {
4285
+ return this.store.getEdges({ type: "imports" }).map((e) => {
4172
4286
  const fromNode = this.store.getNode(e.from);
4173
4287
  const toNode = this.store.getNode(e.to);
4174
4288
  const fromPath = fromNode?.path ?? e.from;
@@ -4177,7 +4291,6 @@ var GraphConstraintAdapter = class {
4177
4291
  const line = e.metadata?.line ?? 0;
4178
4292
  return { from: fromPath, to: toPath, importType, line };
4179
4293
  });
4180
- return { nodes, edges };
4181
4294
  }
4182
4295
  computeLayerViolations(layers, rootDir) {
4183
4296
  const { edges } = this.computeDependencyGraph();
@@ -4471,65 +4584,53 @@ var GraphFeedbackAdapter = class {
4471
4584
  const affectedDocs = [];
4472
4585
  let impactScope = 0;
4473
4586
  for (const filePath of changedFiles) {
4474
- const fileNodes = this.store.findNodes({ path: filePath });
4475
- if (fileNodes.length === 0) continue;
4476
- const fileNode = fileNodes[0];
4477
- const inboundImports = this.store.getEdges({ to: fileNode.id, type: "imports" });
4478
- for (const edge of inboundImports) {
4479
- const importerNode = this.store.getNode(edge.from);
4480
- if (importerNode?.path && /test/i.test(importerNode.path)) {
4481
- affectedTests.push({
4482
- testFile: importerNode.path,
4483
- coversFile: filePath
4484
- });
4485
- }
4486
- impactScope++;
4487
- }
4488
- const docsEdges = this.store.getEdges({ to: fileNode.id, type: "documents" });
4489
- for (const edge of docsEdges) {
4490
- const docNode = this.store.getNode(edge.from);
4491
- if (docNode) {
4492
- affectedDocs.push({
4493
- docFile: docNode.path ?? docNode.name,
4494
- documentsFile: filePath
4495
- });
4496
- }
4497
- }
4587
+ const fileNode = this.store.findNodes({ path: filePath })[0];
4588
+ if (!fileNode) continue;
4589
+ const counts = this.collectFileImpact(fileNode.id, filePath, affectedTests, affectedDocs);
4590
+ impactScope += counts.impactScope;
4498
4591
  }
4499
4592
  return { affectedTests, affectedDocs, impactScope };
4500
4593
  }
4501
- computeHarnessCheckData() {
4502
- const nodeCount = this.store.nodeCount;
4503
- const edgeCount = this.store.edgeCount;
4504
- const violatesEdges = this.store.getEdges({ type: "violates" });
4505
- const constraintViolations = violatesEdges.length;
4506
- const fileNodes = this.store.findNodes({ type: "file" });
4507
- let undocumentedFiles = 0;
4508
- for (const node of fileNodes) {
4509
- const docsEdges = this.store.getEdges({ to: node.id, type: "documents" });
4510
- if (docsEdges.length === 0) {
4511
- undocumentedFiles++;
4594
+ collectFileImpact(fileNodeId, filePath, affectedTests, affectedDocs) {
4595
+ const inboundImports = this.store.getEdges({ to: fileNodeId, type: "imports" });
4596
+ for (const edge of inboundImports) {
4597
+ const importerNode = this.store.getNode(edge.from);
4598
+ if (importerNode?.path && /test/i.test(importerNode.path)) {
4599
+ affectedTests.push({ testFile: importerNode.path, coversFile: filePath });
4512
4600
  }
4513
4601
  }
4514
- let unreachableNodes = 0;
4515
- for (const node of fileNodes) {
4516
- const inboundImports = this.store.getEdges({ to: node.id, type: "imports" });
4517
- if (inboundImports.length === 0) {
4518
- const isEntryPoint = node.name === "index.ts" || node.path !== void 0 && node.path.endsWith("/index.ts") || node.metadata?.entryPoint === true;
4519
- if (!isEntryPoint) {
4520
- unreachableNodes++;
4521
- }
4602
+ const docsEdges = this.store.getEdges({ to: fileNodeId, type: "documents" });
4603
+ for (const edge of docsEdges) {
4604
+ const docNode = this.store.getNode(edge.from);
4605
+ if (docNode) {
4606
+ affectedDocs.push({ docFile: docNode.path ?? docNode.name, documentsFile: filePath });
4522
4607
  }
4523
4608
  }
4609
+ return { impactScope: inboundImports.length };
4610
+ }
4611
+ computeHarnessCheckData() {
4612
+ const fileNodes = this.store.findNodes({ type: "file" });
4524
4613
  return {
4525
4614
  graphExists: true,
4526
- nodeCount,
4527
- edgeCount,
4528
- constraintViolations,
4529
- undocumentedFiles,
4530
- unreachableNodes
4615
+ nodeCount: this.store.nodeCount,
4616
+ edgeCount: this.store.edgeCount,
4617
+ constraintViolations: this.store.getEdges({ type: "violates" }).length,
4618
+ undocumentedFiles: this.countUndocumentedFiles(fileNodes),
4619
+ unreachableNodes: this.countUnreachableNodes(fileNodes)
4531
4620
  };
4532
4621
  }
4622
+ countUndocumentedFiles(fileNodes) {
4623
+ return fileNodes.filter(
4624
+ (node) => this.store.getEdges({ to: node.id, type: "documents" }).length === 0
4625
+ ).length;
4626
+ }
4627
+ countUnreachableNodes(fileNodes) {
4628
+ return fileNodes.filter((node) => {
4629
+ if (this.store.getEdges({ to: node.id, type: "imports" }).length > 0) return false;
4630
+ const isEntryPoint = node.name === "index.ts" || node.path !== void 0 && node.path.endsWith("/index.ts") || node.metadata?.entryPoint === true;
4631
+ return !isEntryPoint;
4632
+ }).length;
4633
+ }
4533
4634
  };
4534
4635
 
4535
4636
  // src/independence/TaskIndependenceAnalyzer.ts
@@ -4546,47 +4647,46 @@ var TaskIndependenceAnalyzer = class {
4546
4647
  this.validate(tasks);
4547
4648
  const useGraph = this.store != null && depth > 0;
4548
4649
  const analysisLevel = useGraph ? "graph-expanded" : "file-only";
4650
+ const { originalFiles, expandedFiles } = this.buildFileSets(tasks, useGraph, depth, edgeTypes);
4651
+ const taskIds = tasks.map((t) => t.id);
4652
+ const pairs = this.computeAllPairs(taskIds, originalFiles, expandedFiles);
4653
+ const groups = this.buildGroups(taskIds, pairs);
4654
+ const verdict = this.generateVerdict(taskIds, groups, analysisLevel);
4655
+ return { tasks: taskIds, analysisLevel, depth, pairs, groups, verdict };
4656
+ }
4657
+ // --- Private methods ---
4658
+ buildFileSets(tasks, useGraph, depth, edgeTypes) {
4549
4659
  const originalFiles = /* @__PURE__ */ new Map();
4550
4660
  const expandedFiles = /* @__PURE__ */ new Map();
4551
4661
  for (const task of tasks) {
4552
- const origSet = new Set(task.files);
4553
- originalFiles.set(task.id, origSet);
4554
- if (useGraph) {
4555
- const expanded = this.expandViaGraph(task.files, depth, edgeTypes);
4556
- expandedFiles.set(task.id, expanded);
4557
- } else {
4558
- expandedFiles.set(task.id, /* @__PURE__ */ new Map());
4559
- }
4662
+ originalFiles.set(task.id, new Set(task.files));
4663
+ expandedFiles.set(
4664
+ task.id,
4665
+ useGraph ? this.expandViaGraph(task.files, depth, edgeTypes) : /* @__PURE__ */ new Map()
4666
+ );
4560
4667
  }
4561
- const taskIds = tasks.map((t) => t.id);
4668
+ return { originalFiles, expandedFiles };
4669
+ }
4670
+ computeAllPairs(taskIds, originalFiles, expandedFiles) {
4562
4671
  const pairs = [];
4563
4672
  for (let i = 0; i < taskIds.length; i++) {
4564
4673
  for (let j = i + 1; j < taskIds.length; j++) {
4565
4674
  const idA = taskIds[i];
4566
4675
  const idB = taskIds[j];
4567
- const pair = this.computePairOverlap(
4568
- idA,
4569
- idB,
4570
- originalFiles.get(idA),
4571
- originalFiles.get(idB),
4572
- expandedFiles.get(idA),
4573
- expandedFiles.get(idB)
4676
+ pairs.push(
4677
+ this.computePairOverlap(
4678
+ idA,
4679
+ idB,
4680
+ originalFiles.get(idA),
4681
+ originalFiles.get(idB),
4682
+ expandedFiles.get(idA),
4683
+ expandedFiles.get(idB)
4684
+ )
4574
4685
  );
4575
- pairs.push(pair);
4576
4686
  }
4577
4687
  }
4578
- const groups = this.buildGroups(taskIds, pairs);
4579
- const verdict = this.generateVerdict(taskIds, groups, analysisLevel);
4580
- return {
4581
- tasks: taskIds,
4582
- analysisLevel,
4583
- depth,
4584
- pairs,
4585
- groups,
4586
- verdict
4587
- };
4688
+ return pairs;
4588
4689
  }
4589
- // --- Private methods ---
4590
4690
  validate(tasks) {
4591
4691
  if (tasks.length < 2) {
4592
4692
  throw new Error("At least 2 tasks are required for independence analysis");
@@ -4739,27 +4839,62 @@ var ConflictPredictor = class {
4739
4839
  predict(params) {
4740
4840
  const analyzer = new TaskIndependenceAnalyzer(this.store);
4741
4841
  const result = analyzer.analyze(params);
4842
+ const { churnMap, couplingMap, churnThreshold, couplingThreshold } = this.buildMetricMaps();
4843
+ const conflicts = this.classifyConflicts(
4844
+ result.pairs,
4845
+ churnMap,
4846
+ couplingMap,
4847
+ churnThreshold,
4848
+ couplingThreshold
4849
+ );
4850
+ const taskIds = result.tasks;
4851
+ const groups = this.buildHighSeverityGroups(taskIds, conflicts);
4852
+ const regrouped = !this.groupsEqual(result.groups, groups);
4853
+ const { highCount, mediumCount, lowCount } = this.countBySeverity(conflicts);
4854
+ const verdict = this.generateVerdict(
4855
+ taskIds,
4856
+ groups,
4857
+ result.analysisLevel,
4858
+ highCount,
4859
+ mediumCount,
4860
+ lowCount,
4861
+ regrouped
4862
+ );
4863
+ return {
4864
+ tasks: taskIds,
4865
+ analysisLevel: result.analysisLevel,
4866
+ depth: result.depth,
4867
+ conflicts,
4868
+ groups,
4869
+ summary: { high: highCount, medium: mediumCount, low: lowCount, regrouped },
4870
+ verdict
4871
+ };
4872
+ }
4873
+ // --- Private helpers ---
4874
+ buildMetricMaps() {
4742
4875
  const churnMap = /* @__PURE__ */ new Map();
4743
4876
  const couplingMap = /* @__PURE__ */ new Map();
4744
- let churnThreshold = Infinity;
4745
- let couplingThreshold = Infinity;
4746
- if (this.store != null) {
4747
- const complexityResult = new GraphComplexityAdapter(this.store).computeComplexityHotspots();
4748
- for (const hotspot of complexityResult.hotspots) {
4749
- const existing = churnMap.get(hotspot.file);
4750
- if (existing === void 0 || hotspot.changeFrequency > existing) {
4751
- churnMap.set(hotspot.file, hotspot.changeFrequency);
4752
- }
4753
- }
4754
- const couplingResult = new GraphCouplingAdapter(this.store).computeCouplingData();
4755
- for (const fileData of couplingResult.files) {
4756
- couplingMap.set(fileData.file, fileData.fanIn + fileData.fanOut);
4877
+ if (this.store == null) {
4878
+ return { churnMap, couplingMap, churnThreshold: Infinity, couplingThreshold: Infinity };
4879
+ }
4880
+ const complexityResult = new GraphComplexityAdapter(this.store).computeComplexityHotspots();
4881
+ for (const hotspot of complexityResult.hotspots) {
4882
+ const existing = churnMap.get(hotspot.file);
4883
+ if (existing === void 0 || hotspot.changeFrequency > existing) {
4884
+ churnMap.set(hotspot.file, hotspot.changeFrequency);
4757
4885
  }
4758
- churnThreshold = this.computePercentile(Array.from(churnMap.values()), 80);
4759
- couplingThreshold = this.computePercentile(Array.from(couplingMap.values()), 80);
4760
4886
  }
4887
+ const couplingResult = new GraphCouplingAdapter(this.store).computeCouplingData();
4888
+ for (const fileData of couplingResult.files) {
4889
+ couplingMap.set(fileData.file, fileData.fanIn + fileData.fanOut);
4890
+ }
4891
+ const churnThreshold = this.computePercentile(Array.from(churnMap.values()), 80);
4892
+ const couplingThreshold = this.computePercentile(Array.from(couplingMap.values()), 80);
4893
+ return { churnMap, couplingMap, churnThreshold, couplingThreshold };
4894
+ }
4895
+ classifyConflicts(pairs, churnMap, couplingMap, churnThreshold, couplingThreshold) {
4761
4896
  const conflicts = [];
4762
- for (const pair of result.pairs) {
4897
+ for (const pair of pairs) {
4763
4898
  if (pair.independent) continue;
4764
4899
  const { severity, reason, mitigation } = this.classifyPair(
4765
4900
  pair.taskA,
@@ -4779,9 +4914,9 @@ var ConflictPredictor = class {
4779
4914
  overlaps: pair.overlaps
4780
4915
  });
4781
4916
  }
4782
- const taskIds = result.tasks;
4783
- const groups = this.buildHighSeverityGroups(taskIds, conflicts);
4784
- const regrouped = !this.groupsEqual(result.groups, groups);
4917
+ return conflicts;
4918
+ }
4919
+ countBySeverity(conflicts) {
4785
4920
  let highCount = 0;
4786
4921
  let mediumCount = 0;
4787
4922
  let lowCount = 0;
@@ -4790,68 +4925,57 @@ var ConflictPredictor = class {
4790
4925
  else if (c.severity === "medium") mediumCount++;
4791
4926
  else lowCount++;
4792
4927
  }
4793
- const verdict = this.generateVerdict(
4794
- taskIds,
4795
- groups,
4796
- result.analysisLevel,
4797
- highCount,
4798
- mediumCount,
4799
- lowCount,
4800
- regrouped
4801
- );
4928
+ return { highCount, mediumCount, lowCount };
4929
+ }
4930
+ classifyTransitiveOverlap(taskA, taskB, overlap, churnMap, couplingMap, churnThreshold, couplingThreshold) {
4931
+ const churn = churnMap.get(overlap.file);
4932
+ const coupling = couplingMap.get(overlap.file);
4933
+ const via = overlap.via ?? "unknown";
4934
+ if (churn !== void 0 && churn >= churnThreshold && churnThreshold !== Infinity) {
4935
+ return {
4936
+ severity: "medium",
4937
+ reason: `Transitive overlap on high-churn file ${overlap.file} (via ${via})`,
4938
+ mitigation: `Review: ${overlap.file} changes frequently \u2014 coordinate edits between ${taskA} and ${taskB}`
4939
+ };
4940
+ }
4941
+ if (coupling !== void 0 && coupling >= couplingThreshold && couplingThreshold !== Infinity) {
4942
+ return {
4943
+ severity: "medium",
4944
+ reason: `Transitive overlap on highly-coupled file ${overlap.file} (via ${via})`,
4945
+ mitigation: `Review: ${overlap.file} has high coupling \u2014 coordinate edits between ${taskA} and ${taskB}`
4946
+ };
4947
+ }
4802
4948
  return {
4803
- tasks: taskIds,
4804
- analysisLevel: result.analysisLevel,
4805
- depth: result.depth,
4806
- conflicts,
4807
- groups,
4808
- summary: {
4809
- high: highCount,
4810
- medium: mediumCount,
4811
- low: lowCount,
4812
- regrouped
4813
- },
4814
- verdict
4949
+ severity: "low",
4950
+ reason: `Transitive overlap on ${overlap.file} (via ${via}) \u2014 low risk`,
4951
+ mitigation: `Info: transitive overlap unlikely to cause conflicts`
4815
4952
  };
4816
4953
  }
4817
- // --- Private helpers ---
4818
4954
  classifyPair(taskA, taskB, overlaps, churnMap, couplingMap, churnThreshold, couplingThreshold) {
4819
4955
  let maxSeverity = "low";
4820
4956
  let primaryReason = "";
4821
4957
  let primaryMitigation = "";
4822
4958
  for (const overlap of overlaps) {
4823
- let overlapSeverity;
4824
- let reason;
4825
- let mitigation;
4826
- if (overlap.type === "direct") {
4827
- overlapSeverity = "high";
4828
- reason = `Both tasks write to ${overlap.file}`;
4829
- mitigation = `Serialize: run ${taskA} before ${taskB}`;
4830
- } else {
4831
- const churn = churnMap.get(overlap.file);
4832
- const coupling = couplingMap.get(overlap.file);
4833
- const via = overlap.via ?? "unknown";
4834
- if (churn !== void 0 && churn >= churnThreshold && churnThreshold !== Infinity) {
4835
- overlapSeverity = "medium";
4836
- reason = `Transitive overlap on high-churn file ${overlap.file} (via ${via})`;
4837
- mitigation = `Review: ${overlap.file} changes frequently \u2014 coordinate edits between ${taskA} and ${taskB}`;
4838
- } else if (coupling !== void 0 && coupling >= couplingThreshold && couplingThreshold !== Infinity) {
4839
- overlapSeverity = "medium";
4840
- reason = `Transitive overlap on highly-coupled file ${overlap.file} (via ${via})`;
4841
- mitigation = `Review: ${overlap.file} has high coupling \u2014 coordinate edits between ${taskA} and ${taskB}`;
4842
- } else {
4843
- overlapSeverity = "low";
4844
- reason = `Transitive overlap on ${overlap.file} (via ${via}) \u2014 low risk`;
4845
- mitigation = `Info: transitive overlap unlikely to cause conflicts`;
4846
- }
4847
- }
4848
- if (this.severityRank(overlapSeverity) > this.severityRank(maxSeverity)) {
4849
- maxSeverity = overlapSeverity;
4850
- primaryReason = reason;
4851
- primaryMitigation = mitigation;
4959
+ const classified = overlap.type === "direct" ? {
4960
+ severity: "high",
4961
+ reason: `Both tasks write to ${overlap.file}`,
4962
+ mitigation: `Serialize: run ${taskA} before ${taskB}`
4963
+ } : this.classifyTransitiveOverlap(
4964
+ taskA,
4965
+ taskB,
4966
+ overlap,
4967
+ churnMap,
4968
+ couplingMap,
4969
+ churnThreshold,
4970
+ couplingThreshold
4971
+ );
4972
+ if (this.severityRank(classified.severity) > this.severityRank(maxSeverity)) {
4973
+ maxSeverity = classified.severity;
4974
+ primaryReason = classified.reason;
4975
+ primaryMitigation = classified.mitigation;
4852
4976
  } else if (primaryReason === "") {
4853
- primaryReason = reason;
4854
- primaryMitigation = mitigation;
4977
+ primaryReason = classified.reason;
4978
+ primaryMitigation = classified.mitigation;
4855
4979
  }
4856
4980
  }
4857
4981
  return { severity: maxSeverity, reason: primaryReason, mitigation: primaryMitigation };
@@ -4974,7 +5098,7 @@ var ConflictPredictor = class {
4974
5098
  };
4975
5099
 
4976
5100
  // src/index.ts
4977
- var VERSION = "0.4.0";
5101
+ var VERSION = "0.4.1";
4978
5102
  export {
4979
5103
  Assembler,
4980
5104
  CIConnector,