@aiready/context-analyzer 0.9.26 → 0.9.28

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/src/analyzer.ts CHANGED
@@ -371,9 +371,9 @@ export function calculatePathEntropy(files: string[]): number {
371
371
 
372
372
  const total = counts.reduce((s, v) => s + v, 0);
373
373
  let entropy = 0;
374
- for (const c of counts) {
375
- const p = c / total;
376
- entropy -= p * Math.log2(p);
374
+ for (const count of counts) {
375
+ const prob = count / total;
376
+ entropy -= prob * Math.log2(prob);
377
377
  }
378
378
 
379
379
  const maxEntropy = Math.log2(counts.length);
@@ -826,8 +826,8 @@ export function calculateStructuralCohesionFromCoUsage(
826
826
 
827
827
  // Calculate entropy
828
828
  let entropy = 0;
829
- for (const p of probs) {
830
- entropy -= p * Math.log2(p);
829
+ for (const prob of probs) {
830
+ entropy -= prob * Math.log2(prob);
831
831
  }
832
832
 
833
833
  const maxEntropy = Math.log2(probs.length);
@@ -892,10 +892,10 @@ function calculateDomainCohesion(exports: ExportInfo[]): number {
892
892
  const total = domains.length;
893
893
  let entropy = 0;
894
894
 
895
- for (const count of domainCounts.values()) {
896
- const p = count / total;
897
- if (p > 0) {
898
- entropy -= p * Math.log2(p);
895
+ for (const domainCount of domainCounts.values()) {
896
+ const prob = domainCount / total;
897
+ if (prob > 0) {
898
+ entropy -= prob * Math.log2(prob);
899
899
  }
900
900
  }
901
901
 
@@ -946,6 +946,11 @@ export function classifyFile(
946
946
  return 'lambda-handler';
947
947
  }
948
948
 
949
+ // 4b. Check for data access layer (DAL) files
950
+ if (isDataAccessFile(node)) {
951
+ return 'cohesive-module';
952
+ }
953
+
949
954
  // 5. Check for email templates (they reference multiple domains but serve one purpose)
950
955
  if (isEmailTemplate(node)) {
951
956
  return 'email-template';
@@ -975,6 +980,14 @@ export function classifyFile(
975
980
  if (isUtilityFile(node)) {
976
981
  return 'utility-module';
977
982
  }
983
+
984
+ // Explicit path-based utility heuristic: files under /utils/ or /helpers/
985
+ // should be classified as utility-module regardless of domain count.
986
+ // This ensures common helper modules (e.g., src/utils/dynamodb-utils.ts)
987
+ // are treated as utility modules in tests and analysis.
988
+ if (file.toLowerCase().includes('/utils/') || file.toLowerCase().includes('/helpers/')) {
989
+ return 'utility-module';
990
+ }
978
991
 
979
992
  // 10. Check for cohesive module (single domain + reasonable cohesion)
980
993
  const uniqueDomains = domains.filter(d => d !== 'unknown');
@@ -984,6 +997,12 @@ export function classifyFile(
984
997
  if (hasSingleDomain) {
985
998
  return 'cohesive-module';
986
999
  }
1000
+
1001
+ // 10b. Check for shared entity noun despite multi-domain scoring
1002
+ // e.g. getUserReceipts + createPendingReceipt both refer to 'receipt'
1003
+ if (allExportsShareEntityNoun(exports)) {
1004
+ return 'cohesive-module';
1005
+ }
987
1006
 
988
1007
  // 11. Check for mixed concerns (multiple domains + low cohesion)
989
1008
  const hasMultipleDomains = uniqueDomains.length > 1;
@@ -1170,6 +1189,105 @@ function isUtilityFile(node: DependencyNode): boolean {
1170
1189
  return isUtilityName || isUtilityPath || hasManySmallExportsInUtilityContext;
1171
1190
  }
1172
1191
 
1192
+ /**
1193
+ * Split a camelCase or PascalCase identifier into lowercase tokens.
1194
+ * e.g. getUserReceipts -> ['get', 'user', 'receipts']
1195
+ */
1196
+ function splitCamelCase(name: string): string[] {
1197
+ return name
1198
+ .replace(/([A-Z])/g, ' $1')
1199
+ .trim()
1200
+ .toLowerCase()
1201
+ .split(/[\s_-]+/)
1202
+ .filter(Boolean);
1203
+ }
1204
+
1205
+ /** Common English verbs and adjectives to ignore when extracting entity nouns */
1206
+ const SKIP_WORDS = new Set([
1207
+ 'get', 'set', 'create', 'update', 'delete', 'fetch', 'save', 'load',
1208
+ 'parse', 'format', 'validate', 'convert', 'transform', 'build',
1209
+ 'generate', 'render', 'send', 'receive', 'find', 'list', 'add',
1210
+ 'remove', 'insert', 'upsert', 'put', 'read', 'write', 'check',
1211
+ 'handle', 'process', 'compute', 'calculate', 'init', 'reset', 'clear',
1212
+ 'pending', 'active', 'current', 'new', 'old', 'all', 'by', 'with',
1213
+ 'from', 'to', 'and', 'or', 'is', 'has', 'in', 'on', 'of', 'the',
1214
+ ]);
1215
+
1216
+ /** Singularize a word simply (strip trailing 's') */
1217
+ function simpleSingularize(word: string): string {
1218
+ if (word.endsWith('ies') && word.length > 3) return word.slice(0, -3) + 'y';
1219
+ if (word.endsWith('ses') && word.length > 4) return word.slice(0, -2);
1220
+ if (word.endsWith('s') && word.length > 3) return word.slice(0, -1);
1221
+ return word;
1222
+ }
1223
+
1224
+ /**
1225
+ * Extract meaningful entity nouns from a camelCase/PascalCase function name.
1226
+ * Strips common verbs/adjectives and singularizes remainder.
1227
+ */
1228
+ function extractEntityNouns(name: string): string[] {
1229
+ return splitCamelCase(name)
1230
+ .filter(token => !SKIP_WORDS.has(token) && token.length > 2)
1231
+ .map(simpleSingularize);
1232
+ }
1233
+
1234
+ /**
1235
+ * Check whether all exports in a file share at least one common entity noun.
1236
+ * This catches DAL patterns like getUserReceipts + createPendingReceipt → both 'receipt'.
1237
+ */
1238
+ function allExportsShareEntityNoun(exports: ExportInfo[]): boolean {
1239
+ if (exports.length < 2 || exports.length > 30) return false;
1240
+
1241
+ const nounSets = exports.map(e => new Set(extractEntityNouns(e.name)));
1242
+ if (nounSets.some(s => s.size === 0)) return false;
1243
+
1244
+ // Find nouns that appear in ALL exports
1245
+ const [first, ...rest] = nounSets;
1246
+ const commonNouns = Array.from(first).filter(noun =>
1247
+ rest.every(s => s.has(noun))
1248
+ );
1249
+
1250
+ return commonNouns.length > 0;
1251
+ }
1252
+
1253
+ /**
1254
+ * Detect if a file is a Data Access Layer (DAL) / repository module.
1255
+ *
1256
+ * Characteristics:
1257
+ * - Named with db, dynamo, database, repository, dao, postgres, mongo patterns
1258
+ * - Or located in /repositories/, /dao/, /data/ directories
1259
+ * - Exports all relate to one data store or entity
1260
+ */
1261
+ function isDataAccessFile(node: DependencyNode): boolean {
1262
+ const { file, exports } = node;
1263
+ const fileName = file.split('/').pop()?.toLowerCase();
1264
+
1265
+ const dalPatterns = [
1266
+ 'dynamo', 'database', 'repository', 'repo', 'dao',
1267
+ 'firestore', 'postgres', 'mysql', 'mongo', 'redis',
1268
+ 'sqlite', 'supabase', 'prisma',
1269
+ ];
1270
+
1271
+ const isDalName = dalPatterns.some(p => fileName?.includes(p));
1272
+
1273
+ const isDalPath = file.toLowerCase().includes('/repositories/') ||
1274
+ file.toLowerCase().includes('/dao/') ||
1275
+ file.toLowerCase().includes('/data/');
1276
+
1277
+ // File with few exports (≤10) that all share a common entity noun
1278
+ const hasDalExportPattern = exports.length >= 1 &&
1279
+ exports.length <= 10 &&
1280
+ allExportsShareEntityNoun(exports);
1281
+
1282
+ // Exclude obvious utility paths from DAL detection (e.g., src/utils/)
1283
+ const isUtilityPathLocal = file.toLowerCase().includes('/utils/') || file.toLowerCase().includes('/helpers/');
1284
+
1285
+ // Only treat as DAL when the file is in a DAL path, or when the name/pattern
1286
+ // indicates a data access module AND exports follow a DAL-like pattern.
1287
+ // Do not classify utility paths as DAL even if the name contains DAL keywords.
1288
+ return isDalPath || (isDalName && hasDalExportPattern && !isUtilityPathLocal);
1289
+ }
1290
+
1173
1291
  /**
1174
1292
  * Detect if a file is a Lambda/API handler
1175
1293
  *
@@ -1193,10 +1311,11 @@ function isLambdaHandler(node: DependencyNode): boolean {
1193
1311
  fileName?.includes(pattern)
1194
1312
  );
1195
1313
 
1196
- // Check if file is in a handlers/lambdas/functions directory
1314
+ // Check if file is in a handlers/lambdas/functions/lambda directory
1197
1315
  // Exclude /api/ unless it has handler-specific naming
1198
1316
  const isHandlerPath = file.toLowerCase().includes('/handlers/') ||
1199
1317
  file.toLowerCase().includes('/lambdas/') ||
1318
+ file.toLowerCase().includes('/lambda/') ||
1200
1319
  file.toLowerCase().includes('/functions/');
1201
1320
 
1202
1321
  // Check for typical lambda handler exports (handler, main, etc.)
@@ -1492,56 +1611,49 @@ export function adjustCohesionForClassification(
1492
1611
  // Type definitions centralize types - high cohesion by nature
1493
1612
  return 1;
1494
1613
  case 'utility-module': {
1495
- // Utility modules serve a functional purpose despite multi-domain
1496
- // Check if exports have related naming patterns
1614
+ // Utility modules serve a functional purpose despite multi-domain.
1615
+ // Use a floor of 0.75 so related utilities never appear as low-cohesion.
1497
1616
  if (node) {
1498
1617
  const exportNames = node.exports.map(e => e.name.toLowerCase());
1499
1618
  const hasRelatedNames = hasRelatedExportNames(exportNames);
1500
1619
  if (hasRelatedNames) {
1501
- // Related utility functions = boost cohesion significantly
1502
- return Math.min(1, baseCohesion + 0.45);
1620
+ return Math.max(0.80, Math.min(1, baseCohesion + 0.45));
1503
1621
  }
1504
1622
  }
1505
- // Default utility boost
1506
- return Math.min(1, baseCohesion + 0.35);
1623
+ return Math.max(0.75, Math.min(1, baseCohesion + 0.35));
1507
1624
  }
1508
1625
  case 'service-file': {
1509
- // Services orchestrate dependencies - this is their purpose
1510
- // Check for class-based service (methods work together)
1626
+ // Services orchestrate dependencies by design.
1627
+ // Floor at 0.72 so service files are never flagged as low-cohesion.
1511
1628
  if (node?.exports.some(e => e.type === 'class')) {
1512
- return Math.min(1, baseCohesion + 0.40);
1629
+ return Math.max(0.78, Math.min(1, baseCohesion + 0.40));
1513
1630
  }
1514
- // Default service boost
1515
- return Math.min(1, baseCohesion + 0.30);
1631
+ return Math.max(0.72, Math.min(1, baseCohesion + 0.30));
1516
1632
  }
1517
1633
  case 'lambda-handler': {
1518
- // Lambda handlers have single business purpose
1519
- // They coordinate multiple services but are entry points
1634
+ // Lambda handlers have single business purpose; floor at 0.75.
1520
1635
  if (node) {
1521
- // Single entry point = cohesive purpose
1522
1636
  const hasSingleEntry = node.exports.length === 1 ||
1523
1637
  node.exports.some(e => e.name.toLowerCase() === 'handler');
1524
1638
  if (hasSingleEntry) {
1525
- return Math.min(1, baseCohesion + 0.45);
1639
+ return Math.max(0.80, Math.min(1, baseCohesion + 0.45));
1526
1640
  }
1527
1641
  }
1528
- return Math.min(1, baseCohesion + 0.35);
1642
+ return Math.max(0.75, Math.min(1, baseCohesion + 0.35));
1529
1643
  }
1530
1644
  case 'email-template': {
1531
- // Email templates render data from multiple domains
1532
- // This is structural cohesion (template purpose)
1645
+ // Email templates are structurally cohesive (single rendering purpose); floor at 0.72.
1533
1646
  if (node) {
1534
- // Check for render/generate functions (template purpose)
1535
1647
  const hasTemplateFunc = node.exports.some(e =>
1536
1648
  e.name.toLowerCase().includes('render') ||
1537
1649
  e.name.toLowerCase().includes('generate') ||
1538
1650
  e.name.toLowerCase().includes('template')
1539
1651
  );
1540
1652
  if (hasTemplateFunc) {
1541
- return Math.min(1, baseCohesion + 0.40);
1653
+ return Math.max(0.75, Math.min(1, baseCohesion + 0.40));
1542
1654
  }
1543
1655
  }
1544
- return Math.min(1, baseCohesion + 0.30);
1656
+ return Math.max(0.72, Math.min(1, baseCohesion + 0.30));
1545
1657
  }
1546
1658
  case 'parser-file': {
1547
1659
  // Parsers transform data - single transformation purpose
@@ -1553,10 +1665,10 @@ export function adjustCohesionForClassification(
1553
1665
  e.name.toLowerCase().startsWith('convert')
1554
1666
  );
1555
1667
  if (hasParseFunc) {
1556
- return Math.min(1, baseCohesion + 0.40);
1668
+ return Math.max(0.75, Math.min(1, baseCohesion + 0.40));
1557
1669
  }
1558
1670
  }
1559
- return Math.min(1, baseCohesion + 0.30);
1671
+ return Math.max(0.70, Math.min(1, baseCohesion + 0.30));
1560
1672
  }
1561
1673
  case 'nextjs-page':
1562
1674
  // Next.js pages have multiple exports by design (metadata, jsonLd, page component)
@@ -1622,7 +1734,29 @@ function hasRelatedExportNames(exportNames: string[]): boolean {
1622
1734
  const uniquePrefixes = new Set(prefixes);
1623
1735
  if (uniquePrefixes.size === 1) return true;
1624
1736
  }
1625
-
1737
+
1738
+ // Check for shared entity noun across all exports using camelCase token splitting
1739
+ // e.g. getUserReceipts + createPendingReceipt both contain 'receipt'
1740
+ const nounSets = exportNames.map(name => {
1741
+ const tokens = name
1742
+ .replace(/([A-Z])/g, ' $1')
1743
+ .trim()
1744
+ .toLowerCase()
1745
+ .split(/[\s_-]+/)
1746
+ .filter(Boolean);
1747
+ const skip = new Set(['get','set','create','update','delete','fetch','save','load',
1748
+ 'parse','format','validate','convert','transform','build','generate','render',
1749
+ 'send','receive','find','list','add','remove','insert','upsert','put','read',
1750
+ 'write','check','handle','process','pending','active','current','new','old','all']);
1751
+ const singularize = (w: string) => w.endsWith('s') && w.length > 3 ? w.slice(0,-1) : w;
1752
+ return new Set(tokens.filter(t => !skip.has(t) && t.length > 2).map(singularize));
1753
+ });
1754
+ if (nounSets.length >= 2 && nounSets.every(s => s.size > 0)) {
1755
+ const [first, ...rest] = nounSets;
1756
+ const commonNouns = Array.from(first).filter(n => rest.every(s => s.has(n)));
1757
+ if (commonNouns.length > 0) return true;
1758
+ }
1759
+
1626
1760
  return false;
1627
1761
  }
1628
1762
 
package/src/index.ts CHANGED
@@ -253,7 +253,7 @@ export async function analyzeContext(
253
253
 
254
254
  const cohesionScore =
255
255
  focus === 'cohesion' || focus === 'all'
256
- ? calculateCohesion(node.exports, file)
256
+ ? calculateCohesion(node.exports, file, { coUsageMatrix: graph.coUsageMatrix })
257
257
  : 1;
258
258
 
259
259
  const fragmentationScore = fragmentationMap.get(file) || 0;
package/src/scoring.ts CHANGED
@@ -1,3 +1,9 @@
1
+ import {
2
+ calculateMonthlyCost,
3
+ calculateProductivityImpact,
4
+ DEFAULT_COST_CONFIG,
5
+ type CostConfig
6
+ } from '@aiready/core';
1
7
  import type { ToolScoringOutput } from '@aiready/core';
2
8
  import type { ContextSummary } from './types';
3
9
 
@@ -9,9 +15,14 @@ import type { ContextSummary } from './types';
9
15
  * - Import depth (dependency chain length)
10
16
  * - Fragmentation score (code organization)
11
17
  * - Critical/major issues
18
+ *
19
+ * Includes business value metrics:
20
+ * - Estimated monthly cost of context waste
21
+ * - Estimated developer hours to fix
12
22
  */
13
23
  export function calculateContextScore(
14
- summary: ContextSummary
24
+ summary: ContextSummary,
25
+ costConfig?: Partial<CostConfig>
15
26
  ): ToolScoringOutput {
16
27
  const {
17
28
  avgContextBudget,
@@ -144,6 +155,19 @@ export function calculateContextScore(
144
155
  });
145
156
  }
146
157
 
158
+ // Calculate business value metrics
159
+ const cfg = { ...DEFAULT_COST_CONFIG, ...costConfig };
160
+ // Total context budget across all files
161
+ const totalContextBudget = avgContextBudget * summary.totalFiles;
162
+ const estimatedMonthlyCost = calculateMonthlyCost(totalContextBudget, cfg);
163
+
164
+ // Convert issues to format for productivity calculation
165
+ const issues = [
166
+ ...Array(criticalIssues).fill({ severity: 'critical' as const }),
167
+ ...Array(majorIssues).fill({ severity: 'major' as const }),
168
+ ];
169
+ const productivityImpact = calculateProductivityImpact(issues);
170
+
147
171
  return {
148
172
  toolName: 'context-analyzer',
149
173
  score,
@@ -155,6 +179,9 @@ export function calculateContextScore(
155
179
  avgFragmentation: Math.round(avgFragmentation * 100) / 100,
156
180
  criticalIssues,
157
181
  majorIssues,
182
+ // Business value metrics
183
+ estimatedMonthlyCost,
184
+ estimatedDeveloperHours: productivityImpact.totalHours,
158
185
  },
159
186
  factors,
160
187
  recommendations,