@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.js CHANGED
@@ -342,21 +342,26 @@ var GraphStore = class {
342
342
  return this.edgeMap.values();
343
343
  }
344
344
  getNeighbors(nodeId, direction = "both") {
345
- const neighborIds = /* @__PURE__ */ new Set();
345
+ const neighborIds = this.collectNeighborIds(nodeId, direction);
346
+ return this.resolveNodes(neighborIds);
347
+ }
348
+ collectNeighborIds(nodeId, direction) {
349
+ const ids = /* @__PURE__ */ new Set();
346
350
  if (direction === "outbound" || direction === "both") {
347
- const outEdges = this.edgesByFrom.get(nodeId) ?? [];
348
- for (const edge of outEdges) {
349
- neighborIds.add(edge.to);
351
+ for (const edge of this.edgesByFrom.get(nodeId) ?? []) {
352
+ ids.add(edge.to);
350
353
  }
351
354
  }
352
355
  if (direction === "inbound" || direction === "both") {
353
- const inEdges = this.edgesByTo.get(nodeId) ?? [];
354
- for (const edge of inEdges) {
355
- neighborIds.add(edge.from);
356
+ for (const edge of this.edgesByTo.get(nodeId) ?? []) {
357
+ ids.add(edge.from);
356
358
  }
357
359
  }
360
+ return ids;
361
+ }
362
+ resolveNodes(ids) {
358
363
  const results = [];
359
- for (const nid of neighborIds) {
364
+ for (const nid of ids) {
360
365
  const node = this.getNode(nid);
361
366
  if (node) results.push(node);
362
367
  }
@@ -1103,6 +1108,17 @@ var CodeIngestor = class {
1103
1108
  var import_node_child_process = require("child_process");
1104
1109
  var import_node_util = require("util");
1105
1110
  var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
1111
+ function finalizeCommit(current) {
1112
+ return {
1113
+ hash: current.hash,
1114
+ shortHash: current.shortHash,
1115
+ author: current.author,
1116
+ email: current.email,
1117
+ date: current.date,
1118
+ message: current.message,
1119
+ files: current.files
1120
+ };
1121
+ }
1106
1122
  var GitIngestor = class {
1107
1123
  constructor(store, gitRunner) {
1108
1124
  this.store = store;
@@ -1139,39 +1155,49 @@ var GitIngestor = class {
1139
1155
  }
1140
1156
  const commits = this.parseGitLog(output);
1141
1157
  for (const commit of commits) {
1142
- const nodeId = `commit:${commit.shortHash}`;
1143
- this.store.addNode({
1144
- id: nodeId,
1145
- type: "commit",
1146
- name: commit.message,
1147
- metadata: {
1148
- author: commit.author,
1149
- email: commit.email,
1150
- date: commit.date,
1151
- hash: commit.hash
1152
- }
1153
- });
1154
- nodesAdded++;
1155
- for (const file of commit.files) {
1156
- const fileNodeId = `file:${file}`;
1157
- const existingNode = this.store.getNode(fileNodeId);
1158
- if (existingNode) {
1159
- this.store.addEdge({
1160
- from: fileNodeId,
1161
- to: nodeId,
1162
- type: "triggered_by"
1163
- });
1164
- edgesAdded++;
1165
- }
1158
+ const counts = this.ingestCommit(commit);
1159
+ nodesAdded += counts.nodesAdded;
1160
+ edgesAdded += counts.edgesAdded;
1161
+ }
1162
+ edgesAdded += this.ingestCoChanges(commits);
1163
+ return {
1164
+ nodesAdded,
1165
+ nodesUpdated,
1166
+ edgesAdded,
1167
+ edgesUpdated,
1168
+ errors,
1169
+ durationMs: Date.now() - start
1170
+ };
1171
+ }
1172
+ ingestCommit(commit) {
1173
+ const nodeId = `commit:${commit.shortHash}`;
1174
+ this.store.addNode({
1175
+ id: nodeId,
1176
+ type: "commit",
1177
+ name: commit.message,
1178
+ metadata: {
1179
+ author: commit.author,
1180
+ email: commit.email,
1181
+ date: commit.date,
1182
+ hash: commit.hash
1183
+ }
1184
+ });
1185
+ let edgesAdded = 0;
1186
+ for (const file of commit.files) {
1187
+ const fileNodeId = `file:${file}`;
1188
+ if (this.store.getNode(fileNodeId)) {
1189
+ this.store.addEdge({ from: fileNodeId, to: nodeId, type: "triggered_by" });
1190
+ edgesAdded++;
1166
1191
  }
1167
1192
  }
1168
- const coChanges = this.computeCoChanges(commits);
1169
- for (const { fileA, fileB, count } of coChanges) {
1193
+ return { nodesAdded: 1, edgesAdded };
1194
+ }
1195
+ ingestCoChanges(commits) {
1196
+ let edgesAdded = 0;
1197
+ for (const { fileA, fileB, count } of this.computeCoChanges(commits)) {
1170
1198
  const fileAId = `file:${fileA}`;
1171
1199
  const fileBId = `file:${fileB}`;
1172
- const nodeA = this.store.getNode(fileAId);
1173
- const nodeB = this.store.getNode(fileBId);
1174
- if (nodeA && nodeB) {
1200
+ if (this.store.getNode(fileAId) && this.store.getNode(fileBId)) {
1175
1201
  this.store.addEdge({
1176
1202
  from: fileAId,
1177
1203
  to: fileBId,
@@ -1181,14 +1207,7 @@ var GitIngestor = class {
1181
1207
  edgesAdded++;
1182
1208
  }
1183
1209
  }
1184
- return {
1185
- nodesAdded,
1186
- nodesUpdated,
1187
- edgesAdded,
1188
- edgesUpdated,
1189
- errors,
1190
- durationMs: Date.now() - start
1191
- };
1210
+ return edgesAdded;
1192
1211
  }
1193
1212
  async runGit(rootDir, args) {
1194
1213
  if (this.gitRunner) {
@@ -1203,63 +1222,49 @@ var GitIngestor = class {
1203
1222
  const lines = output.split("\n");
1204
1223
  let current = null;
1205
1224
  for (const line of lines) {
1206
- const trimmed = line.trim();
1207
- if (!trimmed) {
1208
- if (current && current.hasFiles) {
1209
- commits.push({
1210
- hash: current.hash,
1211
- shortHash: current.shortHash,
1212
- author: current.author,
1213
- email: current.email,
1214
- date: current.date,
1215
- message: current.message,
1216
- files: current.files
1217
- });
1218
- current = null;
1219
- }
1220
- continue;
1225
+ current = this.processLogLine(line, current, commits);
1226
+ }
1227
+ if (current) {
1228
+ commits.push(finalizeCommit(current));
1229
+ }
1230
+ return commits;
1231
+ }
1232
+ /**
1233
+ * Process one line from git log output, updating the in-progress commit builder
1234
+ * and flushing completed commits into the accumulator.
1235
+ * Returns the updated current builder (null if flushed and not replaced).
1236
+ */
1237
+ processLogLine(line, current, commits) {
1238
+ const trimmed = line.trim();
1239
+ if (!trimmed) {
1240
+ if (current?.hasFiles) {
1241
+ commits.push(finalizeCommit(current));
1242
+ return null;
1221
1243
  }
1222
- const parts = trimmed.split("|");
1223
- if (parts.length >= 5 && /^[0-9a-f]{7,40}$/.test(parts[0])) {
1224
- if (current) {
1225
- commits.push({
1226
- hash: current.hash,
1227
- shortHash: current.shortHash,
1228
- author: current.author,
1229
- email: current.email,
1230
- date: current.date,
1231
- message: current.message,
1232
- files: current.files
1233
- });
1234
- }
1235
- current = {
1236
- hash: parts[0],
1237
- shortHash: parts[0].substring(0, 7),
1238
- author: parts[1],
1239
- email: parts[2],
1240
- date: parts[3],
1241
- message: parts.slice(4).join("|"),
1242
- // message may contain |
1243
- files: [],
1244
- hasFiles: false
1245
- };
1246
- } else if (current) {
1247
- current.files.push(trimmed);
1248
- current.hasFiles = true;
1244
+ return current;
1245
+ }
1246
+ const parts = trimmed.split("|");
1247
+ if (parts.length >= 5 && /^[0-9a-f]{7,40}$/.test(parts[0])) {
1248
+ if (current) {
1249
+ commits.push(finalizeCommit(current));
1249
1250
  }
1251
+ return {
1252
+ hash: parts[0],
1253
+ shortHash: parts[0].substring(0, 7),
1254
+ author: parts[1],
1255
+ email: parts[2],
1256
+ date: parts[3],
1257
+ message: parts.slice(4).join("|"),
1258
+ // message may contain |
1259
+ files: [],
1260
+ hasFiles: false
1261
+ };
1250
1262
  }
1251
1263
  if (current) {
1252
- commits.push({
1253
- hash: current.hash,
1254
- shortHash: current.shortHash,
1255
- author: current.author,
1256
- email: current.email,
1257
- date: current.date,
1258
- message: current.message,
1259
- files: current.files
1260
- });
1264
+ current.files.push(trimmed);
1265
+ current.hasFiles = true;
1261
1266
  }
1262
- return commits;
1267
+ return current;
1263
1268
  }
1264
1269
  computeCoChanges(commits) {
1265
1270
  const pairCounts = /* @__PURE__ */ new Map();
@@ -1403,50 +1408,25 @@ var KnowledgeIngestor = class {
1403
1408
  try {
1404
1409
  const content = await fs2.readFile(filePath, "utf-8");
1405
1410
  const filename = path3.basename(filePath, ".md");
1406
- const titleMatch = content.match(/^#\s+(.+)$/m);
1407
- const title = titleMatch ? titleMatch[1].trim() : filename;
1408
- const dateMatch = content.match(/\*\*Date:\*\*\s*(.+)/);
1409
- const statusMatch = content.match(/\*\*Status:\*\*\s*(.+)/);
1410
- const date = dateMatch ? dateMatch[1].trim() : void 0;
1411
- const status = statusMatch ? statusMatch[1].trim() : void 0;
1412
1411
  const nodeId = `adr:${filename}`;
1413
- this.store.addNode({
1414
- id: nodeId,
1415
- type: "adr",
1416
- name: title,
1417
- path: filePath,
1418
- metadata: { date, status }
1419
- });
1412
+ this.store.addNode(parseADRNode(nodeId, filePath, filename, content));
1420
1413
  nodesAdded++;
1421
1414
  edgesAdded += this.linkToCode(content, nodeId, "documents");
1422
1415
  } catch (err) {
1423
1416
  errors.push(`${filePath}: ${err instanceof Error ? err.message : String(err)}`);
1424
1417
  }
1425
1418
  }
1426
- return {
1427
- nodesAdded,
1428
- nodesUpdated: 0,
1429
- edgesAdded,
1430
- edgesUpdated: 0,
1431
- errors,
1432
- durationMs: Date.now() - start
1433
- };
1419
+ return buildResult(nodesAdded, edgesAdded, errors, start);
1434
1420
  }
1435
1421
  async ingestLearnings(projectPath) {
1436
1422
  const start = Date.now();
1437
1423
  const filePath = path3.join(projectPath, ".harness", "learnings.md");
1438
- let content;
1439
- try {
1440
- content = await fs2.readFile(filePath, "utf-8");
1441
- } catch {
1442
- return emptyResult(Date.now() - start);
1443
- }
1444
- const errors = [];
1424
+ const content = await readFileOrEmpty(filePath);
1425
+ if (content === null) return emptyResult(Date.now() - start);
1445
1426
  let nodesAdded = 0;
1446
1427
  let edgesAdded = 0;
1447
- const lines = content.split("\n");
1448
1428
  let currentDate;
1449
- for (const line of lines) {
1429
+ for (const line of content.split("\n")) {
1450
1430
  const headingMatch = line.match(/^##\s+(\S+)/);
1451
1431
  if (headingMatch) {
1452
1432
  currentDate = headingMatch[1];
@@ -1455,70 +1435,29 @@ var KnowledgeIngestor = class {
1455
1435
  const bulletMatch = line.match(/^-\s+(.+)/);
1456
1436
  if (!bulletMatch) continue;
1457
1437
  const text = bulletMatch[1];
1458
- const skillMatch = text.match(/\[skill:([^\]]+)\]/);
1459
- const outcomeMatch = text.match(/\[outcome:([^\]]+)\]/);
1460
- const skill = skillMatch ? skillMatch[1] : void 0;
1461
- const outcome = outcomeMatch ? outcomeMatch[1] : void 0;
1462
1438
  const nodeId = `learning:${hash(text)}`;
1463
- this.store.addNode({
1464
- id: nodeId,
1465
- type: "learning",
1466
- name: text,
1467
- metadata: { skill, outcome, date: currentDate }
1468
- });
1439
+ this.store.addNode(parseLearningNode(nodeId, text, currentDate));
1469
1440
  nodesAdded++;
1470
1441
  edgesAdded += this.linkToCode(text, nodeId, "applies_to");
1471
1442
  }
1472
- return {
1473
- nodesAdded,
1474
- nodesUpdated: 0,
1475
- edgesAdded,
1476
- edgesUpdated: 0,
1477
- errors,
1478
- durationMs: Date.now() - start
1479
- };
1443
+ return buildResult(nodesAdded, edgesAdded, [], start);
1480
1444
  }
1481
1445
  async ingestFailures(projectPath) {
1482
1446
  const start = Date.now();
1483
1447
  const filePath = path3.join(projectPath, ".harness", "failures.md");
1484
- let content;
1485
- try {
1486
- content = await fs2.readFile(filePath, "utf-8");
1487
- } catch {
1488
- return emptyResult(Date.now() - start);
1489
- }
1490
- const errors = [];
1448
+ const content = await readFileOrEmpty(filePath);
1449
+ if (content === null) return emptyResult(Date.now() - start);
1491
1450
  let nodesAdded = 0;
1492
1451
  let edgesAdded = 0;
1493
- const sections = content.split(/^##\s+/m).filter((s) => s.trim());
1494
- for (const section of sections) {
1495
- const dateMatch = section.match(/\*\*Date:\*\*\s*(.+)/);
1496
- const skillMatch = section.match(/\*\*Skill:\*\*\s*(.+)/);
1497
- const typeMatch = section.match(/\*\*Type:\*\*\s*(.+)/);
1498
- const descMatch = section.match(/\*\*Description:\*\*\s*(.+)/);
1499
- const date = dateMatch ? dateMatch[1].trim() : void 0;
1500
- const skill = skillMatch ? skillMatch[1].trim() : void 0;
1501
- const failureType = typeMatch ? typeMatch[1].trim() : void 0;
1502
- const description = descMatch ? descMatch[1].trim() : void 0;
1503
- if (!description) continue;
1504
- const nodeId = `failure:${hash(description)}`;
1505
- this.store.addNode({
1506
- id: nodeId,
1507
- type: "failure",
1508
- name: description,
1509
- metadata: { date, skill, type: failureType }
1510
- });
1452
+ for (const section of content.split(/^##\s+/m).filter((s) => s.trim())) {
1453
+ const parsed = parseFailureSection(section);
1454
+ if (!parsed) continue;
1455
+ const { description, node } = parsed;
1456
+ this.store.addNode(node);
1511
1457
  nodesAdded++;
1512
- edgesAdded += this.linkToCode(description, nodeId, "caused_by");
1458
+ edgesAdded += this.linkToCode(description, node.id, "caused_by");
1513
1459
  }
1514
- return {
1515
- nodesAdded,
1516
- nodesUpdated: 0,
1517
- edgesAdded,
1518
- edgesUpdated: 0,
1519
- errors,
1520
- durationMs: Date.now() - start
1521
- };
1460
+ return buildResult(nodesAdded, edgesAdded, [], start);
1522
1461
  }
1523
1462
  async ingestAll(projectPath, opts) {
1524
1463
  const start = Date.now();
@@ -1572,6 +1511,74 @@ var KnowledgeIngestor = class {
1572
1511
  return results;
1573
1512
  }
1574
1513
  };
1514
+ async function readFileOrEmpty(filePath) {
1515
+ try {
1516
+ return await fs2.readFile(filePath, "utf-8");
1517
+ } catch {
1518
+ return null;
1519
+ }
1520
+ }
1521
+ function buildResult(nodesAdded, edgesAdded, errors, start) {
1522
+ return {
1523
+ nodesAdded,
1524
+ nodesUpdated: 0,
1525
+ edgesAdded,
1526
+ edgesUpdated: 0,
1527
+ errors,
1528
+ durationMs: Date.now() - start
1529
+ };
1530
+ }
1531
+ function parseADRNode(nodeId, filePath, filename, content) {
1532
+ const titleMatch = content.match(/^#\s+(.+)$/m);
1533
+ const title = titleMatch ? titleMatch[1].trim() : filename;
1534
+ const dateMatch = content.match(/\*\*Date:\*\*\s*(.+)/);
1535
+ const statusMatch = content.match(/\*\*Status:\*\*\s*(.+)/);
1536
+ return {
1537
+ id: nodeId,
1538
+ type: "adr",
1539
+ name: title,
1540
+ path: filePath,
1541
+ metadata: {
1542
+ date: dateMatch ? dateMatch[1].trim() : void 0,
1543
+ status: statusMatch ? statusMatch[1].trim() : void 0
1544
+ }
1545
+ };
1546
+ }
1547
+ function parseLearningNode(nodeId, text, currentDate) {
1548
+ const skillMatch = text.match(/\[skill:([^\]]+)\]/);
1549
+ const outcomeMatch = text.match(/\[outcome:([^\]]+)\]/);
1550
+ return {
1551
+ id: nodeId,
1552
+ type: "learning",
1553
+ name: text,
1554
+ metadata: {
1555
+ skill: skillMatch ? skillMatch[1] : void 0,
1556
+ outcome: outcomeMatch ? outcomeMatch[1] : void 0,
1557
+ date: currentDate
1558
+ }
1559
+ };
1560
+ }
1561
+ function parseFailureSection(section) {
1562
+ const descMatch = section.match(/\*\*Description:\*\*\s*(.+)/);
1563
+ const description = descMatch ? descMatch[1].trim() : void 0;
1564
+ if (!description) return null;
1565
+ const dateMatch = section.match(/\*\*Date:\*\*\s*(.+)/);
1566
+ const skillMatch = section.match(/\*\*Skill:\*\*\s*(.+)/);
1567
+ const typeMatch = section.match(/\*\*Type:\*\*\s*(.+)/);
1568
+ return {
1569
+ description,
1570
+ node: {
1571
+ id: `failure:${hash(description)}`,
1572
+ type: "failure",
1573
+ name: description,
1574
+ metadata: {
1575
+ date: dateMatch ? dateMatch[1].trim() : void 0,
1576
+ skill: skillMatch ? skillMatch[1].trim() : void 0,
1577
+ type: typeMatch ? typeMatch[1].trim() : void 0
1578
+ }
1579
+ }
1580
+ };
1581
+ }
1575
1582
 
1576
1583
  // src/ingest/RequirementIngestor.ts
1577
1584
  var fs3 = __toESM(require("fs/promises"));
@@ -1616,40 +1623,9 @@ var RequirementIngestor = class {
1616
1623
  return emptyResult(Date.now() - start);
1617
1624
  }
1618
1625
  for (const featureDir of featureDirs) {
1619
- const featureName = path4.basename(featureDir);
1620
- const specPath = path4.join(featureDir, "proposal.md").replaceAll("\\", "/");
1621
- let content;
1622
- try {
1623
- content = await fs3.readFile(specPath, "utf-8");
1624
- } catch {
1625
- continue;
1626
- }
1627
- try {
1628
- const specHash = hash(specPath);
1629
- const specNodeId = `file:${specPath}`;
1630
- this.store.addNode({
1631
- id: specNodeId,
1632
- type: "document",
1633
- name: path4.basename(specPath),
1634
- path: specPath,
1635
- metadata: { featureName }
1636
- });
1637
- const requirements = this.extractRequirements(content, specPath, specHash, featureName);
1638
- for (const req of requirements) {
1639
- this.store.addNode(req.node);
1640
- nodesAdded++;
1641
- this.store.addEdge({
1642
- from: req.node.id,
1643
- to: specNodeId,
1644
- type: "specifies"
1645
- });
1646
- edgesAdded++;
1647
- edgesAdded += this.linkByPathPattern(req.node.id, featureName);
1648
- edgesAdded += this.linkByKeywordOverlap(req.node.id, req.node.name);
1649
- }
1650
- } catch (err) {
1651
- errors.push(`${specPath}: ${err instanceof Error ? err.message : String(err)}`);
1652
- }
1626
+ const counts = await this.ingestFeatureDir(featureDir, errors);
1627
+ nodesAdded += counts.nodesAdded;
1628
+ edgesAdded += counts.edgesAdded;
1653
1629
  }
1654
1630
  return {
1655
1631
  nodesAdded,
@@ -1660,6 +1636,48 @@ var RequirementIngestor = class {
1660
1636
  durationMs: Date.now() - start
1661
1637
  };
1662
1638
  }
1639
+ async ingestFeatureDir(featureDir, errors) {
1640
+ const featureName = path4.basename(featureDir);
1641
+ const specPath = path4.join(featureDir, "proposal.md").replaceAll("\\", "/");
1642
+ let content;
1643
+ try {
1644
+ content = await fs3.readFile(specPath, "utf-8");
1645
+ } catch {
1646
+ return { nodesAdded: 0, edgesAdded: 0 };
1647
+ }
1648
+ try {
1649
+ return this.ingestSpec(specPath, content, featureName);
1650
+ } catch (err) {
1651
+ errors.push(`${specPath}: ${err instanceof Error ? err.message : String(err)}`);
1652
+ return { nodesAdded: 0, edgesAdded: 0 };
1653
+ }
1654
+ }
1655
+ ingestSpec(specPath, content, featureName) {
1656
+ const specHash = hash(specPath);
1657
+ const specNodeId = `file:${specPath}`;
1658
+ this.store.addNode({
1659
+ id: specNodeId,
1660
+ type: "document",
1661
+ name: path4.basename(specPath),
1662
+ path: specPath,
1663
+ metadata: { featureName }
1664
+ });
1665
+ const requirements = this.extractRequirements(content, specPath, specHash, featureName);
1666
+ let nodesAdded = 0;
1667
+ let edgesAdded = 0;
1668
+ for (const req of requirements) {
1669
+ const counts = this.ingestRequirement(req.node, specNodeId, featureName);
1670
+ nodesAdded += counts.nodesAdded;
1671
+ edgesAdded += counts.edgesAdded;
1672
+ }
1673
+ return { nodesAdded, edgesAdded };
1674
+ }
1675
+ ingestRequirement(node, specNodeId, featureName) {
1676
+ this.store.addNode(node);
1677
+ this.store.addEdge({ from: node.id, to: specNodeId, type: "specifies" });
1678
+ const edgesAdded = 1 + this.linkByPathPattern(node.id, featureName) + this.linkByKeywordOverlap(node.id, node.name);
1679
+ return { nodesAdded: 1, edgesAdded };
1680
+ }
1663
1681
  /**
1664
1682
  * Parse markdown content and extract numbered items from recognized sections.
1665
1683
  */
@@ -1671,54 +1689,80 @@ var RequirementIngestor = class {
1671
1689
  let globalIndex = 0;
1672
1690
  for (let i = 0; i < lines.length; i++) {
1673
1691
  const line = lines[i];
1674
- const headingMatch = line.match(SECTION_HEADING_RE);
1675
- if (headingMatch) {
1676
- const heading = headingMatch[1].trim();
1677
- const isReqSection = REQUIREMENT_SECTIONS.some(
1678
- (s) => heading.toLowerCase() === s.toLowerCase()
1679
- );
1680
- if (isReqSection) {
1681
- currentSection = heading;
1682
- inRequirementSection = true;
1683
- } else {
1684
- inRequirementSection = false;
1692
+ const sectionResult = this.processHeadingLine(line, inRequirementSection);
1693
+ if (sectionResult !== null) {
1694
+ inRequirementSection = sectionResult.inRequirementSection;
1695
+ if (sectionResult.currentSection !== void 0) {
1696
+ currentSection = sectionResult.currentSection;
1685
1697
  }
1686
1698
  continue;
1687
1699
  }
1688
1700
  if (!inRequirementSection) continue;
1689
1701
  const itemMatch = line.match(NUMBERED_ITEM_RE);
1690
1702
  if (!itemMatch) continue;
1691
- const index = parseInt(itemMatch[1], 10);
1692
- const text = itemMatch[2].trim();
1693
- const rawText = line.trim();
1694
- const lineNumber = i + 1;
1695
1703
  globalIndex++;
1696
- const nodeId = `req:${specHash}:${globalIndex}`;
1697
- const earsPattern = detectEarsPattern(text);
1698
- results.push({
1699
- node: {
1700
- id: nodeId,
1701
- type: "requirement",
1702
- name: text,
1703
- path: specPath,
1704
- location: {
1705
- fileId: `file:${specPath}`,
1706
- startLine: lineNumber,
1707
- endLine: lineNumber
1708
- },
1709
- metadata: {
1710
- specPath,
1711
- index,
1712
- section: currentSection,
1713
- rawText,
1714
- earsPattern,
1715
- featureName
1716
- }
1717
- }
1718
- });
1704
+ results.push(
1705
+ this.buildRequirementNode(
1706
+ line,
1707
+ itemMatch,
1708
+ i + 1,
1709
+ specPath,
1710
+ specHash,
1711
+ globalIndex,
1712
+ featureName,
1713
+ currentSection
1714
+ )
1715
+ );
1719
1716
  }
1720
1717
  return results;
1721
1718
  }
1719
+ /**
1720
+ * Check if a line is a section heading and return updated section state,
1721
+ * or return null if the line is not a heading.
1722
+ */
1723
+ processHeadingLine(line, _inRequirementSection) {
1724
+ const headingMatch = line.match(SECTION_HEADING_RE);
1725
+ if (!headingMatch) return null;
1726
+ const heading = headingMatch[1].trim();
1727
+ const isReqSection = REQUIREMENT_SECTIONS.some(
1728
+ (s) => heading.toLowerCase() === s.toLowerCase()
1729
+ );
1730
+ if (isReqSection) {
1731
+ return { inRequirementSection: true, currentSection: heading };
1732
+ }
1733
+ return { inRequirementSection: false };
1734
+ }
1735
+ /**
1736
+ * Build a requirement GraphNode from a matched numbered-item line.
1737
+ */
1738
+ buildRequirementNode(line, itemMatch, lineNumber, specPath, specHash, globalIndex, featureName, currentSection) {
1739
+ const index = parseInt(itemMatch[1], 10);
1740
+ const text = itemMatch[2].trim();
1741
+ const rawText = line.trim();
1742
+ const nodeId = `req:${specHash}:${globalIndex}`;
1743
+ const earsPattern = detectEarsPattern(text);
1744
+ return {
1745
+ node: {
1746
+ id: nodeId,
1747
+ type: "requirement",
1748
+ name: text,
1749
+ path: specPath,
1750
+ location: {
1751
+ fileId: `file:${specPath}`,
1752
+ startLine: lineNumber,
1753
+ endLine: lineNumber
1754
+ },
1755
+ metadata: {
1756
+ specPath,
1757
+ index,
1758
+ section: currentSection,
1759
+ rawText,
1760
+ earsPattern,
1761
+ featureName
1762
+ }
1763
+ }
1764
+ };
1765
+ }
1722
1766
  /**
1723
1767
  * Convention-based linking: match requirement to code/test files
1724
1768
  * by feature name in their path.
@@ -1922,15 +1966,18 @@ function buildIngestResult(nodesAdded, edgesAdded, errors, start) {
1922
1966
  durationMs: Date.now() - start
1923
1967
  };
1924
1968
  }
1969
+ function appendJqlClause(jql, clause) {
1970
+ return jql ? `${jql} AND ${clause}` : clause;
1971
+ }
1925
1972
  function buildJql(config) {
1926
1973
  const project2 = config.project;
1927
1974
  let jql = project2 ? `project=${project2}` : "";
1928
1975
  const filters = config.filters;
1929
1976
  if (filters?.status?.length) {
1930
- jql += `${jql ? " AND " : ""}status IN (${filters.status.map((s) => `"${s}"`).join(",")})`;
1977
+ jql = appendJqlClause(jql, `status IN (${filters.status.map((s) => `"${s}"`).join(",")})`);
1931
1978
  }
1932
1979
  if (filters?.labels?.length) {
1933
- jql += `${jql ? " AND " : ""}labels IN (${filters.labels.map((l) => `"${l}"`).join(",")})`;
1980
+ jql = appendJqlClause(jql, `labels IN (${filters.labels.map((l) => `"${l}"`).join(",")})`);
1934
1981
  }
1935
1982
  return jql;
1936
1983
  }
@@ -1943,8 +1990,6 @@ var JiraConnector = class {
1943
1990
  }
1944
1991
  async ingest(store, config) {
1945
1992
  const start = Date.now();
1946
- let nodesAdded = 0;
1947
- let edgesAdded = 0;
1948
1993
  const apiKeyEnv = config.apiKeyEnv ?? "JIRA_API_KEY";
1949
1994
  const apiKey = process.env[apiKeyEnv];
1950
1995
  if (!apiKey) {
@@ -1966,38 +2011,39 @@ var JiraConnector = class {
1966
2011
  );
1967
2012
  }
1968
2013
  const jql = buildJql(config);
1969
- const headers = {
1970
- Authorization: `Basic ${apiKey}`,
1971
- "Content-Type": "application/json"
1972
- };
2014
+ const headers = { Authorization: `Basic ${apiKey}`, "Content-Type": "application/json" };
1973
2015
  try {
1974
- let startAt = 0;
1975
- const maxResults = 50;
1976
- let total = Infinity;
1977
- while (startAt < total) {
1978
- const url = `${baseUrl}/rest/api/2/search?jql=${encodeURIComponent(jql)}&startAt=${startAt}&maxResults=${maxResults}`;
1979
- const response = await this.httpClient(url, { headers });
1980
- if (!response.ok) {
1981
- return buildIngestResult(nodesAdded, edgesAdded, ["Jira API request failed"], start);
1982
- }
1983
- const data = await response.json();
1984
- total = data.total;
1985
- for (const issue of data.issues) {
1986
- const counts = this.processIssue(store, issue);
1987
- nodesAdded += counts.nodesAdded;
1988
- edgesAdded += counts.edgesAdded;
1989
- }
1990
- startAt += maxResults;
1991
- }
2016
+ const counts = await this.fetchAllIssues(store, baseUrl, jql, headers);
2017
+ return buildIngestResult(counts.nodesAdded, counts.edgesAdded, [], start);
1992
2018
  } catch (err) {
1993
2019
  return buildIngestResult(
1994
- nodesAdded,
1995
- edgesAdded,
2020
+ 0,
2021
+ 0,
1996
2022
  [`Jira API error: ${err instanceof Error ? err.message : String(err)}`],
1997
2023
  start
1998
2024
  );
1999
2025
  }
2000
- return buildIngestResult(nodesAdded, edgesAdded, [], start);
2026
+ }
2027
+ async fetchAllIssues(store, baseUrl, jql, headers) {
2028
+ let nodesAdded = 0;
2029
+ let edgesAdded = 0;
2030
+ let startAt = 0;
2031
+ const maxResults = 50;
2032
+ let total = Infinity;
2033
+ while (startAt < total) {
2034
+ const url = `${baseUrl}/rest/api/2/search?jql=${encodeURIComponent(jql)}&startAt=${startAt}&maxResults=${maxResults}`;
2035
+ const response = await this.httpClient(url, { headers });
2036
+ if (!response.ok) throw new Error("Jira API request failed");
2037
+ const data = await response.json();
2038
+ total = data.total;
2039
+ for (const issue of data.issues) {
2040
+ const counts = this.processIssue(store, issue);
2041
+ nodesAdded += counts.nodesAdded;
2042
+ edgesAdded += counts.edgesAdded;
2043
+ }
2044
+ startAt += maxResults;
2045
+ }
2046
+ return { nodesAdded, edgesAdded };
2001
2047
  }
2002
2048
  processIssue(store, issue) {
2003
2049
  const nodeId = `issue:jira:${issue.key}`;
@@ -2118,6 +2164,16 @@ var SlackConnector = class {
2118
2164
  };
2119
2165
 
2120
2166
  // src/ingest/connectors/ConfluenceConnector.ts
2167
+ function missingApiKeyResult(envVar, start) {
2168
+ return {
2169
+ nodesAdded: 0,
2170
+ nodesUpdated: 0,
2171
+ edgesAdded: 0,
2172
+ edgesUpdated: 0,
2173
+ errors: [`Missing API key: environment variable "${envVar}" is not set`],
2174
+ durationMs: Date.now() - start
2175
+ };
2176
+ }
2121
2177
  var ConfluenceConnector = class {
2122
2178
  name = "confluence";
2123
2179
  source = "confluence";
@@ -2128,40 +2184,34 @@ var ConfluenceConnector = class {
2128
2184
  async ingest(store, config) {
2129
2185
  const start = Date.now();
2130
2186
  const errors = [];
2131
- let nodesAdded = 0;
2132
- let edgesAdded = 0;
2133
2187
  const apiKeyEnv = config.apiKeyEnv ?? "CONFLUENCE_API_KEY";
2134
2188
  const apiKey = process.env[apiKeyEnv];
2135
2189
  if (!apiKey) {
2136
- return {
2137
- nodesAdded: 0,
2138
- nodesUpdated: 0,
2139
- edgesAdded: 0,
2140
- edgesUpdated: 0,
2141
- errors: [`Missing API key: environment variable "${apiKeyEnv}" is not set`],
2142
- durationMs: Date.now() - start
2143
- };
2190
+ return missingApiKeyResult(apiKeyEnv, start);
2144
2191
  }
2145
2192
  const baseUrlEnv = config.baseUrlEnv ?? "CONFLUENCE_BASE_URL";
2146
2193
  const baseUrl = process.env[baseUrlEnv] ?? "";
2147
2194
  const spaceKey = config.spaceKey ?? "";
2148
- try {
2149
- const result = await this.fetchAllPages(store, baseUrl, apiKey, spaceKey);
2150
- nodesAdded = result.nodesAdded;
2151
- edgesAdded = result.edgesAdded;
2152
- errors.push(...result.errors);
2153
- } catch (err) {
2154
- errors.push(`Confluence fetch error: ${err instanceof Error ? err.message : String(err)}`);
2155
- }
2195
+ const counts = await this.fetchAllPagesHandled(store, baseUrl, apiKey, spaceKey, errors);
2156
2196
  return {
2157
- nodesAdded,
2197
+ nodesAdded: counts.nodesAdded,
2158
2198
  nodesUpdated: 0,
2159
- edgesAdded,
2199
+ edgesAdded: counts.edgesAdded,
2160
2200
  edgesUpdated: 0,
2161
2201
  errors,
2162
2202
  durationMs: Date.now() - start
2163
2203
  };
2164
2204
  }
2205
+ async fetchAllPagesHandled(store, baseUrl, apiKey, spaceKey, errors) {
2206
+ try {
2207
+ const result = await this.fetchAllPages(store, baseUrl, apiKey, spaceKey);
2208
+ errors.push(...result.errors);
2209
+ return { nodesAdded: result.nodesAdded, edgesAdded: result.edgesAdded };
2210
+ } catch (err) {
2211
+ errors.push(`Confluence fetch error: ${err instanceof Error ? err.message : String(err)}`);
2212
+ return { nodesAdded: 0, edgesAdded: 0 };
2213
+ }
2214
+ }
2165
2215
  async fetchAllPages(store, baseUrl, apiKey, spaceKey) {
2166
2216
  const errors = [];
2167
2217
  let nodesAdded = 0;
@@ -2206,6 +2256,61 @@ var ConfluenceConnector = class {
2206
2256
  };
2207
2257
 
2208
2258
  // src/ingest/connectors/CIConnector.ts
2259
+ function emptyResult2(errors, start) {
2260
+ return {
2261
+ nodesAdded: 0,
2262
+ nodesUpdated: 0,
2263
+ edgesAdded: 0,
2264
+ edgesUpdated: 0,
2265
+ errors,
2266
+ durationMs: Date.now() - start
2267
+ };
2268
+ }
2269
+ function ingestRun(store, run) {
2270
+ const buildId = `build:${run.id}`;
2271
+ const safeName = sanitizeExternalText(run.name, 200);
2272
+ let nodesAdded = 0;
2273
+ let edgesAdded = 0;
2274
+ store.addNode({
2275
+ id: buildId,
2276
+ type: "build",
2277
+ name: `${safeName} #${run.id}`,
2278
+ metadata: {
2279
+ source: "github-actions",
2280
+ status: run.status,
2281
+ conclusion: run.conclusion,
2282
+ branch: run.head_branch,
2283
+ sha: run.head_sha,
2284
+ url: run.html_url,
2285
+ createdAt: run.created_at
2286
+ }
2287
+ });
2288
+ nodesAdded++;
2289
+ const commitNode = store.getNode(`commit:${run.head_sha}`);
2290
+ if (commitNode) {
2291
+ store.addEdge({ from: buildId, to: commitNode.id, type: "triggered_by" });
2292
+ edgesAdded++;
2293
+ }
2294
+ if (run.conclusion === "failure") {
2295
+ const testResultId = `test_result:${run.id}`;
2296
+ store.addNode({
2297
+ id: testResultId,
2298
+ type: "test_result",
2299
+ name: `Failed: ${safeName} #${run.id}`,
2300
+ metadata: {
2301
+ source: "github-actions",
2302
+ buildId: String(run.id),
2303
+ conclusion: "failure",
2304
+ branch: run.head_branch,
2305
+ sha: run.head_sha
2306
+ }
2307
+ });
2308
+ nodesAdded++;
2309
+ store.addEdge({ from: testResultId, to: buildId, type: "failed_in" });
2310
+ edgesAdded++;
2311
+ }
2312
+ return { nodesAdded, edgesAdded };
2313
+ }
2209
2314
  var CIConnector = class {
2210
2315
  name = "ci";
2211
2316
  source = "github-actions";
@@ -2216,22 +2321,29 @@ var CIConnector = class {
2216
2321
  async ingest(store, config) {
2217
2322
  const start = Date.now();
2218
2323
  const errors = [];
2219
- let nodesAdded = 0;
2220
- let edgesAdded = 0;
2221
2324
  const apiKeyEnv = config.apiKeyEnv ?? "GITHUB_TOKEN";
2222
2325
  const apiKey = process.env[apiKeyEnv];
2223
2326
  if (!apiKey) {
2224
- return {
2225
- nodesAdded: 0,
2226
- nodesUpdated: 0,
2227
- edgesAdded: 0,
2228
- edgesUpdated: 0,
2229
- errors: [`Missing API key: environment variable "${apiKeyEnv}" is not set`],
2230
- durationMs: Date.now() - start
2231
- };
2327
+ return emptyResult2(
2328
+ [`Missing API key: environment variable "${apiKeyEnv}" is not set`],
2329
+ start
2330
+ );
2232
2331
  }
2233
2332
  const repo = config.repo ?? "";
2234
2333
  const maxRuns = config.maxRuns ?? 10;
2334
+ const counts = await this.fetchAndIngestRuns(store, repo, maxRuns, apiKey, errors);
2335
+ return {
2336
+ nodesAdded: counts.nodesAdded,
2337
+ nodesUpdated: 0,
2338
+ edgesAdded: counts.edgesAdded,
2339
+ edgesUpdated: 0,
2340
+ errors,
2341
+ durationMs: Date.now() - start
2342
+ };
2343
+ }
2344
+ async fetchAndIngestRuns(store, repo, maxRuns, apiKey, errors) {
2345
+ let nodesAdded = 0;
2346
+ let edgesAdded = 0;
2235
2347
  try {
2236
2348
  const url = `https://api.github.com/repos/${repo}/actions/runs?per_page=${maxRuns}`;
2237
2349
  const response = await this.httpClient(url, {
@@ -2239,71 +2351,20 @@ var CIConnector = class {
2239
2351
  });
2240
2352
  if (!response.ok) {
2241
2353
  errors.push(`GitHub Actions API error: status ${response.status}`);
2242
- return {
2243
- nodesAdded: 0,
2244
- nodesUpdated: 0,
2245
- edgesAdded: 0,
2246
- edgesUpdated: 0,
2247
- errors,
2248
- durationMs: Date.now() - start
2249
- };
2354
+ return { nodesAdded, edgesAdded };
2250
2355
  }
2251
2356
  const data = await response.json();
2252
2357
  for (const run of data.workflow_runs) {
2253
- const buildId = `build:${run.id}`;
2254
- const safeName = sanitizeExternalText(run.name, 200);
2255
- store.addNode({
2256
- id: buildId,
2257
- type: "build",
2258
- name: `${safeName} #${run.id}`,
2259
- metadata: {
2260
- source: "github-actions",
2261
- status: run.status,
2262
- conclusion: run.conclusion,
2263
- branch: run.head_branch,
2264
- sha: run.head_sha,
2265
- url: run.html_url,
2266
- createdAt: run.created_at
2267
- }
2268
- });
2269
- nodesAdded++;
2270
- const commitNode = store.getNode(`commit:${run.head_sha}`);
2271
- if (commitNode) {
2272
- store.addEdge({ from: buildId, to: commitNode.id, type: "triggered_by" });
2273
- edgesAdded++;
2274
- }
2275
- if (run.conclusion === "failure") {
2276
- const testResultId = `test_result:${run.id}`;
2277
- store.addNode({
2278
- id: testResultId,
2279
- type: "test_result",
2280
- name: `Failed: ${safeName} #${run.id}`,
2281
- metadata: {
2282
- source: "github-actions",
2283
- buildId: String(run.id),
2284
- conclusion: "failure",
2285
- branch: run.head_branch,
2286
- sha: run.head_sha
2287
- }
2288
- });
2289
- nodesAdded++;
2290
- store.addEdge({ from: testResultId, to: buildId, type: "failed_in" });
2291
- edgesAdded++;
2292
- }
2358
+ const counts = ingestRun(store, run);
2359
+ nodesAdded += counts.nodesAdded;
2360
+ edgesAdded += counts.edgesAdded;
2293
2361
  }
2294
- } catch (err) {
2295
- errors.push(
2296
- `GitHub Actions fetch error: ${err instanceof Error ? err.message : String(err)}`
2297
- );
2298
- }
2299
- return {
2300
- nodesAdded,
2301
- nodesUpdated: 0,
2302
- edgesAdded,
2303
- edgesUpdated: 0,
2304
- errors,
2305
- durationMs: Date.now() - start
2306
- };
2362
+ } catch (err) {
2363
+ errors.push(
2364
+ `GitHub Actions fetch error: ${err instanceof Error ? err.message : String(err)}`
2365
+ );
2366
+ }
2367
+ return { nodesAdded, edgesAdded };
2307
2368
  }
2308
2369
  };
2309
2370
 
@@ -2373,16 +2434,29 @@ var FusionLayer = class {
2373
2434
  return [];
2374
2435
  }
2375
2436
  const allNodes = this.store.findNodes({});
2437
+ const semanticScores = this.buildSemanticScores(queryEmbedding, allNodes.length);
2438
+ const { kwWeight, semWeight } = this.resolveWeights(semanticScores.size > 0);
2439
+ const results = this.scoreNodes(allNodes, keywords, semanticScores, kwWeight, semWeight);
2440
+ results.sort((a, b) => b.score - a.score);
2441
+ return results.slice(0, topK);
2442
+ }
2443
+ buildSemanticScores(queryEmbedding, nodeCount) {
2376
2444
  const semanticScores = /* @__PURE__ */ new Map();
2377
2445
  if (queryEmbedding && this.vectorStore) {
2378
- const vectorResults = this.vectorStore.search(queryEmbedding, allNodes.length);
2446
+ const vectorResults = this.vectorStore.search(queryEmbedding, nodeCount);
2379
2447
  for (const vr of vectorResults) {
2380
2448
  semanticScores.set(vr.id, vr.score);
2381
2449
  }
2382
2450
  }
2383
- const hasSemanticScores = semanticScores.size > 0;
2384
- const kwWeight = hasSemanticScores ? this.keywordWeight : 1;
2385
- const semWeight = hasSemanticScores ? this.semanticWeight : 0;
2451
+ return semanticScores;
2452
+ }
2453
+ resolveWeights(hasSemanticScores) {
2454
+ return {
2455
+ kwWeight: hasSemanticScores ? this.keywordWeight : 1,
2456
+ semWeight: hasSemanticScores ? this.semanticWeight : 0
2457
+ };
2458
+ }
2459
+ scoreNodes(allNodes, keywords, semanticScores, kwWeight, semWeight) {
2386
2460
  const results = [];
2387
2461
  for (const node of allNodes) {
2388
2462
  const kwScore = this.keywordScore(keywords, node);
@@ -2393,15 +2467,11 @@ var FusionLayer = class {
2393
2467
  nodeId: node.id,
2394
2468
  node,
2395
2469
  score: fusedScore,
2396
- signals: {
2397
- keyword: kwScore,
2398
- semantic: semScore
2399
- }
2470
+ signals: { keyword: kwScore, semantic: semScore }
2400
2471
  });
2401
2472
  }
2402
2473
  }
2403
- results.sort((a, b) => b.score - a.score);
2404
- return results.slice(0, topK);
2474
+ return results;
2405
2475
  }
2406
2476
  extractKeywords(query) {
2407
2477
  const tokens = query.toLowerCase().split(/[\s\-_.,:;!?()[\]{}"'`/\\|@#$%^&*+=<>~]+/).filter((t) => t.length >= 2).filter((t) => !STOP_WORDS.has(t));
@@ -2456,37 +2526,50 @@ var GraphEntropyAdapter = class {
2456
2526
  const missingTargets = [];
2457
2527
  let freshEdges = 0;
2458
2528
  for (const edge of documentsEdges) {
2459
- const codeNode = this.store.getNode(edge.to);
2460
- if (!codeNode) {
2529
+ const result = this.classifyDocEdge(edge);
2530
+ if (result.kind === "missing") {
2461
2531
  missingTargets.push(edge.to);
2462
- continue;
2532
+ } else if (result.kind === "fresh") {
2533
+ freshEdges++;
2534
+ } else {
2535
+ staleEdges.push(result.entry);
2463
2536
  }
2464
- const docNode = this.store.getNode(edge.from);
2465
- const codeLastModified = codeNode.lastModified;
2466
- const docLastModified = docNode?.lastModified;
2467
- if (codeLastModified && docLastModified) {
2468
- if (codeLastModified > docLastModified) {
2469
- staleEdges.push({
2537
+ }
2538
+ return { staleEdges, missingTargets, freshEdges };
2539
+ }
2540
+ classifyDocEdge(edge) {
2541
+ const codeNode = this.store.getNode(edge.to);
2542
+ if (!codeNode) {
2543
+ return { kind: "missing" };
2544
+ }
2545
+ const docNode = this.store.getNode(edge.from);
2546
+ const codeLastModified = codeNode.lastModified;
2547
+ const docLastModified = docNode?.lastModified;
2548
+ if (codeLastModified && docLastModified) {
2549
+ if (codeLastModified > docLastModified) {
2550
+ return {
2551
+ kind: "stale",
2552
+ entry: {
2470
2553
  docNodeId: edge.from,
2471
2554
  codeNodeId: edge.to,
2472
2555
  edgeType: edge.type,
2473
2556
  codeLastModified,
2474
2557
  docLastModified
2475
- });
2476
- } else {
2477
- freshEdges++;
2478
- }
2479
- } else {
2480
- staleEdges.push({
2481
- docNodeId: edge.from,
2482
- codeNodeId: edge.to,
2483
- edgeType: edge.type,
2484
- codeLastModified,
2485
- docLastModified
2486
- });
2558
+ }
2559
+ };
2487
2560
  }
2561
+ return { kind: "fresh" };
2488
2562
  }
2489
- return { staleEdges, missingTargets, freshEdges };
2563
+ return {
2564
+ kind: "stale",
2565
+ entry: {
2566
+ docNodeId: edge.from,
2567
+ codeNodeId: edge.to,
2568
+ edgeType: edge.type,
2569
+ codeLastModified,
2570
+ docLastModified
2571
+ }
2572
+ };
2490
2573
  }
2491
2574
  /**
2492
2575
  * BFS from entry points to find reachable vs unreachable code nodes.
@@ -2743,36 +2826,12 @@ var GraphAnomalyAdapter = class {
2743
2826
  store;
2744
2827
  detect(options) {
2745
2828
  const threshold = options?.threshold != null && options.threshold > 0 ? options.threshold : DEFAULT_THRESHOLD;
2746
- const requestedMetrics = options?.metrics ?? [...DEFAULT_METRICS];
2747
- const warnings = [];
2748
- const metricsToAnalyze = [];
2749
- for (const m of requestedMetrics) {
2750
- if (RECOGNIZED_METRICS.has(m)) {
2751
- metricsToAnalyze.push(m);
2752
- } else {
2753
- warnings.push(m);
2754
- }
2755
- }
2756
- const allOutliers = [];
2757
- const analyzedNodeIds = /* @__PURE__ */ new Set();
2758
- const couplingMetrics = ["fanIn", "fanOut", "transitiveDepth"];
2759
- const needsCoupling = metricsToAnalyze.some((m) => couplingMetrics.includes(m));
2760
- const needsComplexity = metricsToAnalyze.includes("hotspotScore");
2761
- const cachedCouplingData = needsCoupling ? new GraphCouplingAdapter(this.store).computeCouplingData() : void 0;
2762
- const cachedHotspotData = needsComplexity ? new GraphComplexityAdapter(this.store).computeComplexityHotspots() : void 0;
2763
- for (const metric of metricsToAnalyze) {
2764
- const entries = this.collectMetricValues(metric, cachedCouplingData, cachedHotspotData);
2765
- for (const e of entries) {
2766
- analyzedNodeIds.add(e.nodeId);
2767
- }
2768
- const outliers = this.computeZScoreOutliers(entries, metric, threshold);
2769
- allOutliers.push(...outliers);
2770
- }
2771
- allOutliers.sort((a, b) => b.zScore - a.zScore);
2829
+ const { metricsToAnalyze, warnings } = this.filterMetrics(
2830
+ options?.metrics ?? [...DEFAULT_METRICS]
2831
+ );
2832
+ const { allOutliers, analyzedNodeIds } = this.computeAllOutliers(metricsToAnalyze, threshold);
2772
2833
  const articulationPoints = this.findArticulationPoints();
2773
- const outlierNodeIds = new Set(allOutliers.map((o) => o.nodeId));
2774
- const apNodeIds = new Set(articulationPoints.map((ap) => ap.nodeId));
2775
- const overlapping = [...outlierNodeIds].filter((id) => apNodeIds.has(id));
2834
+ const overlapping = this.computeOverlap(allOutliers, articulationPoints);
2776
2835
  return {
2777
2836
  statisticalOutliers: allOutliers,
2778
2837
  articulationPoints,
@@ -2788,6 +2847,38 @@ var GraphAnomalyAdapter = class {
2788
2847
  }
2789
2848
  };
2790
2849
  }
2850
+ filterMetrics(requested) {
2851
+ const metricsToAnalyze = [];
2852
+ const warnings = [];
2853
+ for (const m of requested) {
2854
+ if (RECOGNIZED_METRICS.has(m)) {
2855
+ metricsToAnalyze.push(m);
2856
+ } else {
2857
+ warnings.push(m);
2858
+ }
2859
+ }
2860
+ return { metricsToAnalyze, warnings };
2861
+ }
2862
+ computeAllOutliers(metricsToAnalyze, threshold) {
2863
+ const couplingMetrics = ["fanIn", "fanOut", "transitiveDepth"];
2864
+ const needsCoupling = metricsToAnalyze.some((m) => couplingMetrics.includes(m));
2865
+ const cachedCouplingData = needsCoupling ? new GraphCouplingAdapter(this.store).computeCouplingData() : void 0;
2866
+ const cachedHotspotData = metricsToAnalyze.includes("hotspotScore") ? new GraphComplexityAdapter(this.store).computeComplexityHotspots() : void 0;
2867
+ const allOutliers = [];
2868
+ const analyzedNodeIds = /* @__PURE__ */ new Set();
2869
+ for (const metric of metricsToAnalyze) {
2870
+ const entries = this.collectMetricValues(metric, cachedCouplingData, cachedHotspotData);
2871
+ for (const e of entries) analyzedNodeIds.add(e.nodeId);
2872
+ allOutliers.push(...this.computeZScoreOutliers(entries, metric, threshold));
2873
+ }
2874
+ allOutliers.sort((a, b) => b.zScore - a.zScore);
2875
+ return { allOutliers, analyzedNodeIds };
2876
+ }
2877
+ computeOverlap(outliers, articulationPoints) {
2878
+ const outlierNodeIds = new Set(outliers.map((o) => o.nodeId));
2879
+ const apNodeIds = new Set(articulationPoints.map((ap) => ap.nodeId));
2880
+ return [...outlierNodeIds].filter((id) => apNodeIds.has(id));
2881
+ }
2791
2882
  collectMetricValues(metric, cachedCouplingData, cachedHotspotData) {
2792
2883
  const entries = [];
2793
2884
  if (metric === "cyclomaticComplexity") {
@@ -3343,37 +3434,54 @@ var EntityExtractor = class {
3343
3434
  result.push(entity);
3344
3435
  }
3345
3436
  };
3346
- const quotedConsumed = /* @__PURE__ */ new Set();
3437
+ const quotedConsumed = this.extractQuoted(trimmed, add);
3438
+ const casingConsumed = this.extractCasing(trimmed, quotedConsumed, add);
3439
+ const pathConsumed = this.extractPaths(trimmed, add);
3440
+ this.extractNouns(trimmed, buildConsumedSet(quotedConsumed, casingConsumed, pathConsumed), add);
3441
+ return result;
3442
+ }
3443
+ /** Strategy 1: Quoted strings. Returns the set of consumed tokens. */
3444
+ extractQuoted(trimmed, add) {
3445
+ const consumed = /* @__PURE__ */ new Set();
3347
3446
  for (const match of trimmed.matchAll(QUOTED_RE)) {
3348
3447
  const inner = match[1].trim();
3349
3448
  if (inner.length > 0) {
3350
3449
  add(inner);
3351
- quotedConsumed.add(inner);
3450
+ consumed.add(inner);
3352
3451
  }
3353
3452
  }
3354
- const casingConsumed = /* @__PURE__ */ new Set();
3453
+ return consumed;
3454
+ }
3455
+ /** Strategy 2: PascalCase/camelCase tokens. Returns the set of consumed tokens. */
3456
+ extractCasing(trimmed, quotedConsumed, add) {
3457
+ const consumed = /* @__PURE__ */ new Set();
3355
3458
  for (const match of trimmed.matchAll(PASCAL_OR_CAMEL_RE)) {
3356
3459
  const token = match[0];
3357
3460
  if (!quotedConsumed.has(token)) {
3358
3461
  add(token);
3359
- casingConsumed.add(token);
3462
+ consumed.add(token);
3360
3463
  }
3361
3464
  }
3362
- const pathConsumed = /* @__PURE__ */ new Set();
3465
+ return consumed;
3466
+ }
3467
+ /** Strategy 3: File paths. Returns the set of consumed tokens. */
3468
+ extractPaths(trimmed, add) {
3469
+ const consumed = /* @__PURE__ */ new Set();
3363
3470
  for (const match of trimmed.matchAll(FILE_PATH_RE)) {
3364
3471
  const path7 = match[0];
3365
3472
  add(path7);
3366
- pathConsumed.add(path7);
3473
+ consumed.add(path7);
3367
3474
  }
3368
- const allConsumed = buildConsumedSet(quotedConsumed, casingConsumed, pathConsumed);
3369
- const words = trimmed.split(/\s+/);
3370
- for (const raw of words) {
3475
+ return consumed;
3476
+ }
3477
+ /** Strategy 4: Remaining significant nouns after stop-word and intent-keyword removal. */
3478
+ extractNouns(trimmed, allConsumed, add) {
3479
+ for (const raw of trimmed.split(/\s+/)) {
3371
3480
  const cleaned = raw.replace(/^[^a-zA-Z0-9]+|[^a-zA-Z0-9]+$/g, "");
3372
3481
  if (cleaned.length === 0) continue;
3373
3482
  if (isSkippableWord(cleaned, allConsumed)) continue;
3374
3483
  add(cleaned);
3375
3484
  }
3376
- return result;
3377
3485
  }
3378
3486
  };
3379
3487
 
@@ -3790,36 +3898,41 @@ var ENTITY_REQUIRED_INTENTS = /* @__PURE__ */ new Set(["impact", "relationships"
3790
3898
  var classifier = new IntentClassifier();
3791
3899
  var extractor = new EntityExtractor();
3792
3900
  var formatter = new ResponseFormatter();
3901
+ function lowConfidenceResult(intent, confidence) {
3902
+ return {
3903
+ intent,
3904
+ intentConfidence: confidence,
3905
+ entities: [],
3906
+ summary: "I'm not sure what you're asking. Try rephrasing your question.",
3907
+ data: null,
3908
+ suggestions: [
3909
+ 'Try "what breaks if I change <name>?" for impact analysis',
3910
+ 'Try "where is <name>?" to find entities',
3911
+ 'Try "what calls <name>?" for relationships',
3912
+ 'Try "what is <name>?" for explanations',
3913
+ 'Try "what looks wrong?" for anomaly detection'
3914
+ ]
3915
+ };
3916
+ }
3917
+ function noEntityResult(intent, confidence) {
3918
+ return {
3919
+ intent,
3920
+ intentConfidence: confidence,
3921
+ entities: [],
3922
+ summary: "Could not find any matching nodes in the graph for your query. Try using exact class names, function names, or file paths.",
3923
+ data: null
3924
+ };
3925
+ }
3793
3926
  async function askGraph(store, question) {
3794
3927
  const fusion = new FusionLayer(store);
3795
3928
  const resolver = new EntityResolver(store, fusion);
3796
3929
  const classification = classifier.classify(question);
3797
3930
  if (classification.confidence < 0.3) {
3798
- return {
3799
- intent: classification.intent,
3800
- intentConfidence: classification.confidence,
3801
- entities: [],
3802
- summary: "I'm not sure what you're asking. Try rephrasing your question.",
3803
- data: null,
3804
- suggestions: [
3805
- 'Try "what breaks if I change <name>?" for impact analysis',
3806
- 'Try "where is <name>?" to find entities',
3807
- 'Try "what calls <name>?" for relationships',
3808
- 'Try "what is <name>?" for explanations',
3809
- 'Try "what looks wrong?" for anomaly detection'
3810
- ]
3811
- };
3931
+ return lowConfidenceResult(classification.intent, classification.confidence);
3812
3932
  }
3813
- const rawEntities = extractor.extract(question);
3814
- const entities = resolver.resolve(rawEntities);
3933
+ const entities = resolver.resolve(extractor.extract(question));
3815
3934
  if (ENTITY_REQUIRED_INTENTS.has(classification.intent) && entities.length === 0) {
3816
- return {
3817
- intent: classification.intent,
3818
- intentConfidence: classification.confidence,
3819
- entities: [],
3820
- summary: "Could not find any matching nodes in the graph for your query. Try using exact class names, function names, or file paths.",
3821
- data: null
3822
- };
3935
+ return noEntityResult(classification.intent, classification.confidence);
3823
3936
  }
3824
3937
  let data;
3825
3938
  try {
@@ -3833,67 +3946,59 @@ async function askGraph(store, question) {
3833
3946
  data: null
3834
3947
  };
3835
3948
  }
3836
- const summary = formatter.format(classification.intent, entities, data, question);
3837
3949
  return {
3838
3950
  intent: classification.intent,
3839
3951
  intentConfidence: classification.confidence,
3840
3952
  entities,
3841
- summary,
3953
+ summary: formatter.format(classification.intent, entities, data, question),
3842
3954
  data
3843
3955
  };
3844
3956
  }
3957
+ function buildContextBlocks(cql, rootIds, searchResults) {
3958
+ return rootIds.map((rootId) => {
3959
+ const expanded = cql.execute({ rootNodeIds: [rootId], maxDepth: 2 });
3960
+ const match = searchResults.find((r) => r.nodeId === rootId);
3961
+ return {
3962
+ rootNode: rootId,
3963
+ score: match?.score ?? 1,
3964
+ nodes: expanded.nodes,
3965
+ edges: expanded.edges
3966
+ };
3967
+ });
3968
+ }
3969
+ function executeImpact(store, cql, entities, question) {
3970
+ const rootId = entities[0].nodeId;
3971
+ const lower = question.toLowerCase();
3972
+ if (lower.includes("blast radius") || lower.includes("cascade")) {
3973
+ return new CascadeSimulator(store).simulate(rootId);
3974
+ }
3975
+ const result = cql.execute({ rootNodeIds: [rootId], bidirectional: true, maxDepth: 3 });
3976
+ return groupNodesByImpact(result.nodes, rootId);
3977
+ }
3978
+ function executeExplain(cql, entities, question, fusion) {
3979
+ const searchResults = fusion.search(question, 10);
3980
+ const rootIds = entities.length > 0 ? [entities[0].nodeId] : searchResults.slice(0, 3).map((r) => r.nodeId);
3981
+ return { searchResults, context: buildContextBlocks(cql, rootIds, searchResults) };
3982
+ }
3845
3983
  function executeOperation(store, intent, entities, question, fusion) {
3846
3984
  const cql = new ContextQL(store);
3847
3985
  switch (intent) {
3848
- case "impact": {
3849
- const rootId = entities[0].nodeId;
3850
- const lowerQuestion = question.toLowerCase();
3851
- if (lowerQuestion.includes("blast radius") || lowerQuestion.includes("cascade")) {
3852
- const simulator = new CascadeSimulator(store);
3853
- return simulator.simulate(rootId);
3854
- }
3855
- const result = cql.execute({
3856
- rootNodeIds: [rootId],
3857
- bidirectional: true,
3858
- maxDepth: 3
3859
- });
3860
- return groupNodesByImpact(result.nodes, rootId);
3861
- }
3862
- case "find": {
3986
+ case "impact":
3987
+ return executeImpact(store, cql, entities, question);
3988
+ case "find":
3863
3989
  return fusion.search(question, 10);
3864
- }
3865
3990
  case "relationships": {
3866
- const rootId = entities[0].nodeId;
3867
3991
  const result = cql.execute({
3868
- rootNodeIds: [rootId],
3992
+ rootNodeIds: [entities[0].nodeId],
3869
3993
  bidirectional: true,
3870
3994
  maxDepth: 1
3871
3995
  });
3872
3996
  return { nodes: result.nodes, edges: result.edges };
3873
3997
  }
3874
- case "explain": {
3875
- const searchResults = fusion.search(question, 10);
3876
- const contextBlocks = [];
3877
- const rootIds = entities.length > 0 ? [entities[0].nodeId] : searchResults.slice(0, 3).map((r) => r.nodeId);
3878
- for (const rootId of rootIds) {
3879
- const expanded = cql.execute({
3880
- rootNodeIds: [rootId],
3881
- maxDepth: 2
3882
- });
3883
- const matchingResult = searchResults.find((r) => r.nodeId === rootId);
3884
- contextBlocks.push({
3885
- rootNode: rootId,
3886
- score: matchingResult?.score ?? 1,
3887
- nodes: expanded.nodes,
3888
- edges: expanded.edges
3889
- });
3890
- }
3891
- return { searchResults, context: contextBlocks };
3892
- }
3893
- case "anomaly": {
3894
- const adapter = new GraphAnomalyAdapter(store);
3895
- return adapter.detect();
3896
- }
3998
+ case "explain":
3999
+ return executeExplain(cql, entities, question, fusion);
4000
+ case "anomaly":
4001
+ return new GraphAnomalyAdapter(store).detect();
3897
4002
  default:
3898
4003
  return null;
3899
4004
  }
@@ -3914,12 +4019,14 @@ var CODE_NODE_TYPES5 = /* @__PURE__ */ new Set([
3914
4019
  "method",
3915
4020
  "variable"
3916
4021
  ]);
4022
+ function countMetadataChars(node) {
4023
+ return node.metadata ? JSON.stringify(node.metadata).length : 0;
4024
+ }
4025
+ function countBaseChars(node) {
4026
+ return (node.name?.length ?? 0) + (node.path?.length ?? 0) + (node.type?.length ?? 0);
4027
+ }
3917
4028
  function estimateNodeTokens(node) {
3918
- let chars = (node.name?.length ?? 0) + (node.path?.length ?? 0) + (node.type?.length ?? 0);
3919
- if (node.metadata) {
3920
- chars += JSON.stringify(node.metadata).length;
3921
- }
3922
- return Math.ceil(chars / 4);
4029
+ return Math.ceil((countBaseChars(node) + countMetadataChars(node)) / 4);
3923
4030
  }
3924
4031
  var Assembler = class {
3925
4032
  store;
@@ -4000,47 +4107,55 @@ var Assembler = class {
4000
4107
  }
4001
4108
  return { keptNodes, tokenEstimate, truncated };
4002
4109
  }
4003
- /**
4004
- * Compute a token budget allocation across node types.
4005
- */
4006
- computeBudget(totalTokens, phase) {
4007
- const allNodes = this.store.findNodes({});
4110
+ countNodesByType() {
4008
4111
  const typeCounts = {};
4009
- for (const node of allNodes) {
4112
+ for (const node of this.store.findNodes({})) {
4010
4113
  typeCounts[node.type] = (typeCounts[node.type] ?? 0) + 1;
4011
4114
  }
4115
+ return typeCounts;
4116
+ }
4117
+ computeModuleDensity() {
4012
4118
  const density = {};
4013
- const moduleNodes = this.store.findNodes({ type: "module" });
4014
- for (const mod of moduleNodes) {
4015
- const outEdges = this.store.getEdges({ from: mod.id });
4016
- const inEdges = this.store.getEdges({ to: mod.id });
4017
- density[mod.name] = outEdges.length + inEdges.length;
4119
+ for (const mod of this.store.findNodes({ type: "module" })) {
4120
+ const out = this.store.getEdges({ from: mod.id }).length;
4121
+ const inn = this.store.getEdges({ to: mod.id }).length;
4122
+ density[mod.name] = out + inn;
4018
4123
  }
4019
- const boostTypes = phase ? PHASE_NODE_TYPES[phase] : void 0;
4020
- const boostFactor = 2;
4021
- let weightedTotal = 0;
4124
+ return density;
4125
+ }
4126
+ computeTypeWeights(typeCounts, boostTypes) {
4022
4127
  const weights = {};
4128
+ let weightedTotal = 0;
4023
4129
  for (const [type, count] of Object.entries(typeCounts)) {
4024
- const isBoosted = boostTypes?.includes(type);
4025
- const weight = count * (isBoosted ? boostFactor : 1);
4130
+ const weight = count * (boostTypes?.includes(type) ? 2 : 1);
4026
4131
  weights[type] = weight;
4027
4132
  weightedTotal += weight;
4028
4133
  }
4134
+ return { weights, weightedTotal };
4135
+ }
4136
+ allocateProportionally(weights, weightedTotal, totalTokens) {
4029
4137
  const allocations = {};
4030
- if (weightedTotal > 0) {
4031
- let allocated = 0;
4032
- const types = Object.keys(weights);
4033
- for (let i = 0; i < types.length; i++) {
4034
- const type = types[i];
4035
- if (i === types.length - 1) {
4036
- allocations[type] = totalTokens - allocated;
4037
- } else {
4038
- const share = Math.round(weights[type] / weightedTotal * totalTokens);
4039
- allocations[type] = share;
4040
- allocated += share;
4041
- }
4138
+ if (weightedTotal === 0) return allocations;
4139
+ let allocated = 0;
4140
+ const types = Object.keys(weights);
4141
+ for (let i = 0; i < types.length; i++) {
4142
+ const type = types[i];
4143
+ if (i === types.length - 1) {
4144
+ allocations[type] = totalTokens - allocated;
4145
+ } else {
4146
+ const share = Math.round(weights[type] / weightedTotal * totalTokens);
4147
+ allocations[type] = share;
4148
+ allocated += share;
4042
4149
  }
4043
4150
  }
4151
+ return allocations;
4152
+ }
4153
+ computeBudget(totalTokens, phase) {
4154
+ const typeCounts = this.countNodesByType();
4155
+ const density = this.computeModuleDensity();
4156
+ const boostTypes = phase ? PHASE_NODE_TYPES[phase] : void 0;
4157
+ const { weights, weightedTotal } = this.computeTypeWeights(typeCounts, boostTypes);
4158
+ const allocations = this.allocateProportionally(weights, weightedTotal, totalTokens);
4044
4159
  return { total: totalTokens, allocations, density };
4045
4160
  }
4046
4161
  /**
@@ -4071,49 +4186,43 @@ var Assembler = class {
4071
4186
  filePaths: Array.from(filePathSet)
4072
4187
  };
4073
4188
  }
4074
- /**
4075
- * Generate a markdown repository map from graph structure.
4076
- */
4077
- generateMap() {
4078
- const moduleNodes = this.store.findNodes({ type: "module" });
4079
- const modulesWithEdgeCount = moduleNodes.map((mod) => {
4080
- const outEdges = this.store.getEdges({ from: mod.id });
4081
- const inEdges = this.store.getEdges({ to: mod.id });
4082
- return { module: mod, edgeCount: outEdges.length + inEdges.length };
4189
+ buildModuleLines() {
4190
+ const modulesWithEdgeCount = this.store.findNodes({ type: "module" }).map((mod) => {
4191
+ const edgeCount = this.store.getEdges({ from: mod.id }).length + this.store.getEdges({ to: mod.id }).length;
4192
+ return { module: mod, edgeCount };
4083
4193
  });
4084
4194
  modulesWithEdgeCount.sort((a, b) => b.edgeCount - a.edgeCount);
4085
- const lines = ["# Repository Structure", ""];
4086
- if (modulesWithEdgeCount.length > 0) {
4087
- lines.push("## Modules", "");
4088
- for (const { module: mod, edgeCount } of modulesWithEdgeCount) {
4089
- lines.push(`### ${mod.name} (${edgeCount} connections)`);
4090
- lines.push("");
4091
- const containsEdges = this.store.getEdges({ from: mod.id, type: "contains" });
4092
- for (const edge of containsEdges) {
4093
- const fileNode = this.store.getNode(edge.to);
4094
- if (fileNode && fileNode.type === "file") {
4095
- const symbolEdges = this.store.getEdges({ from: fileNode.id, type: "contains" });
4096
- lines.push(`- ${fileNode.path ?? fileNode.name} (${symbolEdges.length} symbols)`);
4097
- }
4195
+ if (modulesWithEdgeCount.length === 0) return [];
4196
+ const lines = ["## Modules", ""];
4197
+ for (const { module: mod, edgeCount } of modulesWithEdgeCount) {
4198
+ lines.push(`### ${mod.name} (${edgeCount} connections)`, "");
4199
+ for (const edge of this.store.getEdges({ from: mod.id, type: "contains" })) {
4200
+ const fileNode = this.store.getNode(edge.to);
4201
+ if (fileNode?.type === "file") {
4202
+ const symbols = this.store.getEdges({ from: fileNode.id, type: "contains" }).length;
4203
+ lines.push(`- ${fileNode.path ?? fileNode.name} (${symbols} symbols)`);
4098
4204
  }
4099
- lines.push("");
4100
4205
  }
4206
+ lines.push("");
4101
4207
  }
4102
- const fileNodes = this.store.findNodes({ type: "file" });
4103
- const nonBarrelFiles = fileNodes.filter((n) => !n.name.startsWith("index."));
4104
- const filesWithOutDegree = nonBarrelFiles.map((f) => {
4105
- const outEdges = this.store.getEdges({ from: f.id });
4106
- return { file: f, outDegree: outEdges.length };
4107
- });
4208
+ return lines;
4209
+ }
4210
+ buildEntryPointLines() {
4211
+ 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 }));
4108
4212
  filesWithOutDegree.sort((a, b) => b.outDegree - a.outDegree);
4109
4213
  const entryPoints = filesWithOutDegree.filter((f) => f.outDegree > 0).slice(0, 5);
4110
- if (entryPoints.length > 0) {
4111
- lines.push("## Entry Points", "");
4112
- for (const { file, outDegree } of entryPoints) {
4113
- lines.push(`- ${file.path ?? file.name} (${outDegree} outbound edges)`);
4114
- }
4115
- lines.push("");
4214
+ if (entryPoints.length === 0) return [];
4215
+ const lines = ["## Entry Points", ""];
4216
+ for (const { file, outDegree } of entryPoints) {
4217
+ lines.push(`- ${file.path ?? file.name} (${outDegree} outbound edges)`);
4116
4218
  }
4219
+ lines.push("");
4220
+ return lines;
4221
+ }
4222
+ generateMap() {
4223
+ const lines = ["# Repository Structure", ""];
4224
+ lines.push(...this.buildModuleLines());
4225
+ lines.push(...this.buildEntryPointLines());
4117
4226
  return lines.join("\n");
4118
4227
  }
4119
4228
  /**
@@ -4247,10 +4356,15 @@ var GraphConstraintAdapter = class {
4247
4356
  }
4248
4357
  store;
4249
4358
  computeDependencyGraph() {
4250
- const fileNodes = this.store.findNodes({ type: "file" });
4251
- const nodes = fileNodes.map((n) => n.path ?? n.id);
4252
- const importsEdges = this.store.getEdges({ type: "imports" });
4253
- const edges = importsEdges.map((e) => {
4359
+ const nodes = this.collectFileNodePaths();
4360
+ const edges = this.collectImportEdges();
4361
+ return { nodes, edges };
4362
+ }
4363
+ collectFileNodePaths() {
4364
+ return this.store.findNodes({ type: "file" }).map((n) => n.path ?? n.id);
4365
+ }
4366
+ collectImportEdges() {
4367
+ return this.store.getEdges({ type: "imports" }).map((e) => {
4254
4368
  const fromNode = this.store.getNode(e.from);
4255
4369
  const toNode = this.store.getNode(e.to);
4256
4370
  const fromPath = fromNode?.path ?? e.from;
@@ -4259,7 +4373,6 @@ var GraphConstraintAdapter = class {
4259
4373
  const line = e.metadata?.line ?? 0;
4260
4374
  return { from: fromPath, to: toPath, importType, line };
4261
4375
  });
4262
- return { nodes, edges };
4263
4376
  }
4264
4377
  computeLayerViolations(layers, rootDir) {
4265
4378
  const { edges } = this.computeDependencyGraph();
@@ -4553,65 +4666,53 @@ var GraphFeedbackAdapter = class {
4553
4666
  const affectedDocs = [];
4554
4667
  let impactScope = 0;
4555
4668
  for (const filePath of changedFiles) {
4556
- const fileNodes = this.store.findNodes({ path: filePath });
4557
- if (fileNodes.length === 0) continue;
4558
- const fileNode = fileNodes[0];
4559
- const inboundImports = this.store.getEdges({ to: fileNode.id, type: "imports" });
4560
- for (const edge of inboundImports) {
4561
- const importerNode = this.store.getNode(edge.from);
4562
- if (importerNode?.path && /test/i.test(importerNode.path)) {
4563
- affectedTests.push({
4564
- testFile: importerNode.path,
4565
- coversFile: filePath
4566
- });
4567
- }
4568
- impactScope++;
4569
- }
4570
- const docsEdges = this.store.getEdges({ to: fileNode.id, type: "documents" });
4571
- for (const edge of docsEdges) {
4572
- const docNode = this.store.getNode(edge.from);
4573
- if (docNode) {
4574
- affectedDocs.push({
4575
- docFile: docNode.path ?? docNode.name,
4576
- documentsFile: filePath
4577
- });
4578
- }
4579
- }
4669
+ const fileNode = this.store.findNodes({ path: filePath })[0];
4670
+ if (!fileNode) continue;
4671
+ const counts = this.collectFileImpact(fileNode.id, filePath, affectedTests, affectedDocs);
4672
+ impactScope += counts.impactScope;
4580
4673
  }
4581
4674
  return { affectedTests, affectedDocs, impactScope };
4582
4675
  }
4583
- computeHarnessCheckData() {
4584
- const nodeCount = this.store.nodeCount;
4585
- const edgeCount = this.store.edgeCount;
4586
- const violatesEdges = this.store.getEdges({ type: "violates" });
4587
- const constraintViolations = violatesEdges.length;
4588
- const fileNodes = this.store.findNodes({ type: "file" });
4589
- let undocumentedFiles = 0;
4590
- for (const node of fileNodes) {
4591
- const docsEdges = this.store.getEdges({ to: node.id, type: "documents" });
4592
- if (docsEdges.length === 0) {
4593
- undocumentedFiles++;
4676
+ collectFileImpact(fileNodeId, filePath, affectedTests, affectedDocs) {
4677
+ const inboundImports = this.store.getEdges({ to: fileNodeId, type: "imports" });
4678
+ for (const edge of inboundImports) {
4679
+ const importerNode = this.store.getNode(edge.from);
4680
+ if (importerNode?.path && /test/i.test(importerNode.path)) {
4681
+ affectedTests.push({ testFile: importerNode.path, coversFile: filePath });
4594
4682
  }
4595
4683
  }
4596
- let unreachableNodes = 0;
4597
- for (const node of fileNodes) {
4598
- const inboundImports = this.store.getEdges({ to: node.id, type: "imports" });
4599
- if (inboundImports.length === 0) {
4600
- const isEntryPoint = node.name === "index.ts" || node.path !== void 0 && node.path.endsWith("/index.ts") || node.metadata?.entryPoint === true;
4601
- if (!isEntryPoint) {
4602
- unreachableNodes++;
4603
- }
4684
+ const docsEdges = this.store.getEdges({ to: fileNodeId, type: "documents" });
4685
+ for (const edge of docsEdges) {
4686
+ const docNode = this.store.getNode(edge.from);
4687
+ if (docNode) {
4688
+ affectedDocs.push({ docFile: docNode.path ?? docNode.name, documentsFile: filePath });
4604
4689
  }
4605
4690
  }
4691
+ return { impactScope: inboundImports.length };
4692
+ }
4693
+ computeHarnessCheckData() {
4694
+ const fileNodes = this.store.findNodes({ type: "file" });
4606
4695
  return {
4607
4696
  graphExists: true,
4608
- nodeCount,
4609
- edgeCount,
4610
- constraintViolations,
4611
- undocumentedFiles,
4612
- unreachableNodes
4697
+ nodeCount: this.store.nodeCount,
4698
+ edgeCount: this.store.edgeCount,
4699
+ constraintViolations: this.store.getEdges({ type: "violates" }).length,
4700
+ undocumentedFiles: this.countUndocumentedFiles(fileNodes),
4701
+ unreachableNodes: this.countUnreachableNodes(fileNodes)
4613
4702
  };
4614
4703
  }
4704
+ countUndocumentedFiles(fileNodes) {
4705
+ return fileNodes.filter(
4706
+ (node) => this.store.getEdges({ to: node.id, type: "documents" }).length === 0
4707
+ ).length;
4708
+ }
4709
+ countUnreachableNodes(fileNodes) {
4710
+ return fileNodes.filter((node) => {
4711
+ if (this.store.getEdges({ to: node.id, type: "imports" }).length > 0) return false;
4712
+ const isEntryPoint = node.name === "index.ts" || node.path !== void 0 && node.path.endsWith("/index.ts") || node.metadata?.entryPoint === true;
4713
+ return !isEntryPoint;
4714
+ }).length;
4715
+ }
4615
4716
  };
4616
4717
 
4617
4718
  // src/independence/TaskIndependenceAnalyzer.ts
@@ -4628,47 +4729,46 @@ var TaskIndependenceAnalyzer = class {
4628
4729
  this.validate(tasks);
4629
4730
  const useGraph = this.store != null && depth > 0;
4630
4731
  const analysisLevel = useGraph ? "graph-expanded" : "file-only";
4732
+ const { originalFiles, expandedFiles } = this.buildFileSets(tasks, useGraph, depth, edgeTypes);
4733
+ const taskIds = tasks.map((t) => t.id);
4734
+ const pairs = this.computeAllPairs(taskIds, originalFiles, expandedFiles);
4735
+ const groups = this.buildGroups(taskIds, pairs);
4736
+ const verdict = this.generateVerdict(taskIds, groups, analysisLevel);
4737
+ return { tasks: taskIds, analysisLevel, depth, pairs, groups, verdict };
4738
+ }
4739
+ // --- Private methods ---
4740
+ buildFileSets(tasks, useGraph, depth, edgeTypes) {
4631
4741
  const originalFiles = /* @__PURE__ */ new Map();
4632
4742
  const expandedFiles = /* @__PURE__ */ new Map();
4633
4743
  for (const task of tasks) {
4634
- const origSet = new Set(task.files);
4635
- originalFiles.set(task.id, origSet);
4636
- if (useGraph) {
4637
- const expanded = this.expandViaGraph(task.files, depth, edgeTypes);
4638
- expandedFiles.set(task.id, expanded);
4639
- } else {
4640
- expandedFiles.set(task.id, /* @__PURE__ */ new Map());
4641
- }
4744
+ originalFiles.set(task.id, new Set(task.files));
4745
+ expandedFiles.set(
4746
+ task.id,
4747
+ useGraph ? this.expandViaGraph(task.files, depth, edgeTypes) : /* @__PURE__ */ new Map()
4748
+ );
4642
4749
  }
4643
- const taskIds = tasks.map((t) => t.id);
4750
+ return { originalFiles, expandedFiles };
4751
+ }
4752
+ computeAllPairs(taskIds, originalFiles, expandedFiles) {
4644
4753
  const pairs = [];
4645
4754
  for (let i = 0; i < taskIds.length; i++) {
4646
4755
  for (let j = i + 1; j < taskIds.length; j++) {
4647
4756
  const idA = taskIds[i];
4648
4757
  const idB = taskIds[j];
4649
- const pair = this.computePairOverlap(
4650
- idA,
4651
- idB,
4652
- originalFiles.get(idA),
4653
- originalFiles.get(idB),
4654
- expandedFiles.get(idA),
4655
- expandedFiles.get(idB)
4758
+ pairs.push(
4759
+ this.computePairOverlap(
4760
+ idA,
4761
+ idB,
4762
+ originalFiles.get(idA),
4763
+ originalFiles.get(idB),
4764
+ expandedFiles.get(idA),
4765
+ expandedFiles.get(idB)
4766
+ )
4656
4767
  );
4657
- pairs.push(pair);
4658
4768
  }
4659
4769
  }
4660
- const groups = this.buildGroups(taskIds, pairs);
4661
- const verdict = this.generateVerdict(taskIds, groups, analysisLevel);
4662
- return {
4663
- tasks: taskIds,
4664
- analysisLevel,
4665
- depth,
4666
- pairs,
4667
- groups,
4668
- verdict
4669
- };
4770
+ return pairs;
4670
4771
  }
4671
- // --- Private methods ---
4672
4772
  validate(tasks) {
4673
4773
  if (tasks.length < 2) {
4674
4774
  throw new Error("At least 2 tasks are required for independence analysis");
@@ -4821,27 +4921,62 @@ var ConflictPredictor = class {
4821
4921
  predict(params) {
4822
4922
  const analyzer = new TaskIndependenceAnalyzer(this.store);
4823
4923
  const result = analyzer.analyze(params);
4924
+ const { churnMap, couplingMap, churnThreshold, couplingThreshold } = this.buildMetricMaps();
4925
+ const conflicts = this.classifyConflicts(
4926
+ result.pairs,
4927
+ churnMap,
4928
+ couplingMap,
4929
+ churnThreshold,
4930
+ couplingThreshold
4931
+ );
4932
+ const taskIds = result.tasks;
4933
+ const groups = this.buildHighSeverityGroups(taskIds, conflicts);
4934
+ const regrouped = !this.groupsEqual(result.groups, groups);
4935
+ const { highCount, mediumCount, lowCount } = this.countBySeverity(conflicts);
4936
+ const verdict = this.generateVerdict(
4937
+ taskIds,
4938
+ groups,
4939
+ result.analysisLevel,
4940
+ highCount,
4941
+ mediumCount,
4942
+ lowCount,
4943
+ regrouped
4944
+ );
4945
+ return {
4946
+ tasks: taskIds,
4947
+ analysisLevel: result.analysisLevel,
4948
+ depth: result.depth,
4949
+ conflicts,
4950
+ groups,
4951
+ summary: { high: highCount, medium: mediumCount, low: lowCount, regrouped },
4952
+ verdict
4953
+ };
4954
+ }
4955
+ // --- Private helpers ---
4956
+ buildMetricMaps() {
4824
4957
  const churnMap = /* @__PURE__ */ new Map();
4825
4958
  const couplingMap = /* @__PURE__ */ new Map();
4826
- let churnThreshold = Infinity;
4827
- let couplingThreshold = Infinity;
4828
- if (this.store != null) {
4829
- const complexityResult = new GraphComplexityAdapter(this.store).computeComplexityHotspots();
4830
- for (const hotspot of complexityResult.hotspots) {
4831
- const existing = churnMap.get(hotspot.file);
4832
- if (existing === void 0 || hotspot.changeFrequency > existing) {
4833
- churnMap.set(hotspot.file, hotspot.changeFrequency);
4834
- }
4835
- }
4836
- const couplingResult = new GraphCouplingAdapter(this.store).computeCouplingData();
4837
- for (const fileData of couplingResult.files) {
4838
- couplingMap.set(fileData.file, fileData.fanIn + fileData.fanOut);
4959
+ if (this.store == null) {
4960
+ return { churnMap, couplingMap, churnThreshold: Infinity, couplingThreshold: Infinity };
4961
+ }
4962
+ const complexityResult = new GraphComplexityAdapter(this.store).computeComplexityHotspots();
4963
+ for (const hotspot of complexityResult.hotspots) {
4964
+ const existing = churnMap.get(hotspot.file);
4965
+ if (existing === void 0 || hotspot.changeFrequency > existing) {
4966
+ churnMap.set(hotspot.file, hotspot.changeFrequency);
4839
4967
  }
4840
- churnThreshold = this.computePercentile(Array.from(churnMap.values()), 80);
4841
- couplingThreshold = this.computePercentile(Array.from(couplingMap.values()), 80);
4842
4968
  }
4969
+ const couplingResult = new GraphCouplingAdapter(this.store).computeCouplingData();
4970
+ for (const fileData of couplingResult.files) {
4971
+ couplingMap.set(fileData.file, fileData.fanIn + fileData.fanOut);
4972
+ }
4973
+ const churnThreshold = this.computePercentile(Array.from(churnMap.values()), 80);
4974
+ const couplingThreshold = this.computePercentile(Array.from(couplingMap.values()), 80);
4975
+ return { churnMap, couplingMap, churnThreshold, couplingThreshold };
4976
+ }
4977
+ classifyConflicts(pairs, churnMap, couplingMap, churnThreshold, couplingThreshold) {
4843
4978
  const conflicts = [];
4844
- for (const pair of result.pairs) {
4979
+ for (const pair of pairs) {
4845
4980
  if (pair.independent) continue;
4846
4981
  const { severity, reason, mitigation } = this.classifyPair(
4847
4982
  pair.taskA,
@@ -4861,9 +4996,9 @@ var ConflictPredictor = class {
4861
4996
  overlaps: pair.overlaps
4862
4997
  });
4863
4998
  }
4864
- const taskIds = result.tasks;
4865
- const groups = this.buildHighSeverityGroups(taskIds, conflicts);
4866
- const regrouped = !this.groupsEqual(result.groups, groups);
4999
+ return conflicts;
5000
+ }
5001
+ countBySeverity(conflicts) {
4867
5002
  let highCount = 0;
4868
5003
  let mediumCount = 0;
4869
5004
  let lowCount = 0;
@@ -4872,68 +5007,57 @@ var ConflictPredictor = class {
4872
5007
  else if (c.severity === "medium") mediumCount++;
4873
5008
  else lowCount++;
4874
5009
  }
4875
- const verdict = this.generateVerdict(
4876
- taskIds,
4877
- groups,
4878
- result.analysisLevel,
4879
- highCount,
4880
- mediumCount,
4881
- lowCount,
4882
- regrouped
4883
- );
5010
+ return { highCount, mediumCount, lowCount };
5011
+ }
5012
+ classifyTransitiveOverlap(taskA, taskB, overlap, churnMap, couplingMap, churnThreshold, couplingThreshold) {
5013
+ const churn = churnMap.get(overlap.file);
5014
+ const coupling = couplingMap.get(overlap.file);
5015
+ const via = overlap.via ?? "unknown";
5016
+ if (churn !== void 0 && churn >= churnThreshold && churnThreshold !== Infinity) {
5017
+ return {
5018
+ severity: "medium",
5019
+ reason: `Transitive overlap on high-churn file ${overlap.file} (via ${via})`,
5020
+ mitigation: `Review: ${overlap.file} changes frequently \u2014 coordinate edits between ${taskA} and ${taskB}`
5021
+ };
5022
+ }
5023
+ if (coupling !== void 0 && coupling >= couplingThreshold && couplingThreshold !== Infinity) {
5024
+ return {
5025
+ severity: "medium",
5026
+ reason: `Transitive overlap on highly-coupled file ${overlap.file} (via ${via})`,
5027
+ mitigation: `Review: ${overlap.file} has high coupling \u2014 coordinate edits between ${taskA} and ${taskB}`
5028
+ };
5029
+ }
4884
5030
  return {
4885
- tasks: taskIds,
4886
- analysisLevel: result.analysisLevel,
4887
- depth: result.depth,
4888
- conflicts,
4889
- groups,
4890
- summary: {
4891
- high: highCount,
4892
- medium: mediumCount,
4893
- low: lowCount,
4894
- regrouped
4895
- },
4896
- verdict
5031
+ severity: "low",
5032
+ reason: `Transitive overlap on ${overlap.file} (via ${via}) \u2014 low risk`,
5033
+ mitigation: `Info: transitive overlap unlikely to cause conflicts`
4897
5034
  };
4898
5035
  }
4899
- // --- Private helpers ---
4900
5036
  classifyPair(taskA, taskB, overlaps, churnMap, couplingMap, churnThreshold, couplingThreshold) {
4901
5037
  let maxSeverity = "low";
4902
5038
  let primaryReason = "";
4903
5039
  let primaryMitigation = "";
4904
5040
  for (const overlap of overlaps) {
4905
- let overlapSeverity;
4906
- let reason;
4907
- let mitigation;
4908
- if (overlap.type === "direct") {
4909
- overlapSeverity = "high";
4910
- reason = `Both tasks write to ${overlap.file}`;
4911
- mitigation = `Serialize: run ${taskA} before ${taskB}`;
4912
- } else {
4913
- const churn = churnMap.get(overlap.file);
4914
- const coupling = couplingMap.get(overlap.file);
4915
- const via = overlap.via ?? "unknown";
4916
- if (churn !== void 0 && churn >= churnThreshold && churnThreshold !== Infinity) {
4917
- overlapSeverity = "medium";
4918
- reason = `Transitive overlap on high-churn file ${overlap.file} (via ${via})`;
4919
- mitigation = `Review: ${overlap.file} changes frequently \u2014 coordinate edits between ${taskA} and ${taskB}`;
4920
- } else if (coupling !== void 0 && coupling >= couplingThreshold && couplingThreshold !== Infinity) {
4921
- overlapSeverity = "medium";
4922
- reason = `Transitive overlap on highly-coupled file ${overlap.file} (via ${via})`;
4923
- mitigation = `Review: ${overlap.file} has high coupling \u2014 coordinate edits between ${taskA} and ${taskB}`;
4924
- } else {
4925
- overlapSeverity = "low";
4926
- reason = `Transitive overlap on ${overlap.file} (via ${via}) \u2014 low risk`;
4927
- mitigation = `Info: transitive overlap unlikely to cause conflicts`;
4928
- }
4929
- }
4930
- if (this.severityRank(overlapSeverity) > this.severityRank(maxSeverity)) {
4931
- maxSeverity = overlapSeverity;
4932
- primaryReason = reason;
4933
- primaryMitigation = mitigation;
5041
+ const classified = overlap.type === "direct" ? {
5042
+ severity: "high",
5043
+ reason: `Both tasks write to ${overlap.file}`,
5044
+ mitigation: `Serialize: run ${taskA} before ${taskB}`
5045
+ } : this.classifyTransitiveOverlap(
5046
+ taskA,
5047
+ taskB,
5048
+ overlap,
5049
+ churnMap,
5050
+ couplingMap,
5051
+ churnThreshold,
5052
+ couplingThreshold
5053
+ );
5054
+ if (this.severityRank(classified.severity) > this.severityRank(maxSeverity)) {
5055
+ maxSeverity = classified.severity;
5056
+ primaryReason = classified.reason;
5057
+ primaryMitigation = classified.mitigation;
4934
5058
  } else if (primaryReason === "") {
4935
- primaryReason = reason;
4936
- primaryMitigation = mitigation;
5059
+ primaryReason = classified.reason;
5060
+ primaryMitigation = classified.mitigation;
4937
5061
  }
4938
5062
  }
4939
5063
  return { severity: maxSeverity, reason: primaryReason, mitigation: primaryMitigation };
@@ -5056,7 +5180,7 @@ var ConflictPredictor = class {
5056
5180
  };
5057
5181
 
5058
5182
  // src/index.ts
5059
- var VERSION = "0.4.0";
5183
+ var VERSION = "0.4.1";
5060
5184
  // Annotate the CommonJS export names for ESM import in node:
5061
5185
  0 && (module.exports = {
5062
5186
  Assembler,