@democratize-quality/qualitylens-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/dist/core/config.d.mts +26 -0
  2. package/dist/core/config.d.ts +26 -0
  3. package/dist/core/config.js +139 -0
  4. package/dist/core/config.js.map +1 -0
  5. package/dist/core/config.mjs +104 -0
  6. package/dist/core/config.mjs.map +1 -0
  7. package/dist/core/detector.d.mts +43 -0
  8. package/dist/core/detector.d.ts +43 -0
  9. package/dist/core/detector.js +431 -0
  10. package/dist/core/detector.js.map +1 -0
  11. package/dist/core/detector.mjs +395 -0
  12. package/dist/core/detector.mjs.map +1 -0
  13. package/dist/core/engine.d.mts +29 -0
  14. package/dist/core/engine.d.ts +29 -0
  15. package/dist/core/engine.js +151 -0
  16. package/dist/core/engine.js.map +1 -0
  17. package/dist/core/engine.mjs +126 -0
  18. package/dist/core/engine.mjs.map +1 -0
  19. package/dist/core/types.d.mts +109 -0
  20. package/dist/core/types.d.ts +109 -0
  21. package/dist/core/types.js +19 -0
  22. package/dist/core/types.js.map +1 -0
  23. package/dist/core/types.mjs +1 -0
  24. package/dist/core/types.mjs.map +1 -0
  25. package/dist/matchers/area.matcher.d.mts +38 -0
  26. package/dist/matchers/area.matcher.d.ts +38 -0
  27. package/dist/matchers/area.matcher.js +102 -0
  28. package/dist/matchers/area.matcher.js.map +1 -0
  29. package/dist/matchers/area.matcher.mjs +77 -0
  30. package/dist/matchers/area.matcher.mjs.map +1 -0
  31. package/dist/matchers/fuzzy.matcher.d.mts +83 -0
  32. package/dist/matchers/fuzzy.matcher.d.ts +83 -0
  33. package/dist/matchers/fuzzy.matcher.js +161 -0
  34. package/dist/matchers/fuzzy.matcher.js.map +1 -0
  35. package/dist/matchers/fuzzy.matcher.mjs +136 -0
  36. package/dist/matchers/fuzzy.matcher.mjs.map +1 -0
  37. package/dist/reporters/base.reporter.d.mts +19 -0
  38. package/dist/reporters/base.reporter.d.ts +19 -0
  39. package/dist/reporters/base.reporter.js +32 -0
  40. package/dist/reporters/base.reporter.js.map +1 -0
  41. package/dist/reporters/base.reporter.mjs +7 -0
  42. package/dist/reporters/base.reporter.mjs.map +1 -0
  43. package/dist/reporters/console.reporter.d.mts +16 -0
  44. package/dist/reporters/console.reporter.d.ts +16 -0
  45. package/dist/reporters/console.reporter.js +130 -0
  46. package/dist/reporters/console.reporter.js.map +1 -0
  47. package/dist/reporters/console.reporter.mjs +95 -0
  48. package/dist/reporters/console.reporter.mjs.map +1 -0
  49. package/dist/sources/base.source.d.mts +22 -0
  50. package/dist/sources/base.source.d.ts +22 -0
  51. package/dist/sources/base.source.js +130 -0
  52. package/dist/sources/base.source.js.map +1 -0
  53. package/dist/sources/base.source.mjs +93 -0
  54. package/dist/sources/base.source.mjs.map +1 -0
  55. package/dist/sources/playwright.source.d.mts +34 -0
  56. package/dist/sources/playwright.source.d.ts +34 -0
  57. package/dist/sources/playwright.source.js +209 -0
  58. package/dist/sources/playwright.source.js.map +1 -0
  59. package/dist/sources/playwright.source.mjs +172 -0
  60. package/dist/sources/playwright.source.mjs.map +1 -0
  61. package/dist/sources/routes.source.d.mts +58 -0
  62. package/dist/sources/routes.source.d.ts +58 -0
  63. package/dist/sources/routes.source.js +288 -0
  64. package/dist/sources/routes.source.js.map +1 -0
  65. package/dist/sources/routes.source.mjs +251 -0
  66. package/dist/sources/routes.source.mjs.map +1 -0
  67. package/dist/sources/yaml.source.d.mts +16 -0
  68. package/dist/sources/yaml.source.d.ts +16 -0
  69. package/dist/sources/yaml.source.js +160 -0
  70. package/dist/sources/yaml.source.js.map +1 -0
  71. package/dist/sources/yaml.source.mjs +123 -0
  72. package/dist/sources/yaml.source.mjs.map +1 -0
  73. package/dist/utils/http.d.mts +7 -0
  74. package/dist/utils/http.d.ts +7 -0
  75. package/dist/utils/http.js +37 -0
  76. package/dist/utils/http.js.map +1 -0
  77. package/dist/utils/http.mjs +12 -0
  78. package/dist/utils/http.mjs.map +1 -0
  79. package/dist/utils/logger.d.mts +35 -0
  80. package/dist/utils/logger.d.ts +35 -0
  81. package/dist/utils/logger.js +114 -0
  82. package/dist/utils/logger.js.map +1 -0
  83. package/dist/utils/logger.mjs +79 -0
  84. package/dist/utils/logger.mjs.map +1 -0
  85. package/package.json +115 -0
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/core/engine.ts"],"sourcesContent":["/**\n * src/core/engine.ts\n * \n * The main orchestrator. Zero I/O — all dependencies injected.\n * This design makes the engine fully unit-testable with mock sources.\n * \n * AI INSTRUCTIONS: implement run() using the step-by-step comments.\n */\n\nimport {\n CoverageReport,\n CoverageStatus,\n RouteGap,\n RouteCoverage,\n Staleness,\n TestEntry,\n TestGapConfig,\n TestHint,\n} from './types'\nimport { BaseSource } from '../sources/base.source'\nimport { RoutesSource } from '../sources/routes.source'\nimport { FuzzyMatcher } from '../matchers/fuzzy.matcher'\nimport { AreaMatcher } from '../matchers/area.matcher'\n\nexport class CoverageEngine {\n constructor(\n private sources: BaseSource[],\n private routesSource: RoutesSource,\n private matcher: FuzzyMatcher,\n private areaMatcher: AreaMatcher,\n private config: TestGapConfig\n ) {}\n\n async run(): Promise<CoverageReport> {\n // Step 1: Collect from all sources in parallel\n const sourceResults = await Promise.all(this.sources.map(s => s.collect()))\n const allTests: TestEntry[] = sourceResults.flat()\n\n // Step 2: Discover routes\n const routes = await this.routesSource.collect()\n\n // Step 3: Match tests to routes\n const matchMap = this.matcher.match(routes, allTests)\n\n // Collect all matched tests so we can find orphans after\n const matchedTests = new Set<TestEntry>()\n for (const tests of matchMap.values()) {\n for (const t of tests) matchedTests.add(t)\n }\n\n // Step 3b: Apply testHints to fuzzy-unmatched tests\n const hints = this.config.testHints ?? []\n const hintMap = new Map<string, TestHint>()\n for (const hint of hints) hintMap.set(hint.test, hint)\n\n const routeGaps: RouteGap[] = []\n const acknowledgedTests: TestHint[] = []\n const hintResolved = new Set<TestEntry>()\n\n for (const test of allTests) {\n if (matchedTests.has(test)) continue // already matched by fuzzy\n const hint = hintMap.get(test.title)\n if (!hint) continue\n\n if (hint.status === 'resolved' && hint.route) {\n // Inject into matchMap so RouteCoverage picks it up\n const key = hint.method ? `${hint.method} ${hint.route}` : hint.route\n const existing = matchMap.get(key) ?? []\n matchMap.set(key, [...existing, test])\n matchedTests.add(test)\n hintResolved.add(test)\n } else if (hint.status === 'gap') {\n const existing = routeGaps.find(g =>\n g.route === hint.route && g.method === hint.method\n )\n if (existing) {\n existing.coveredByTests.push(test)\n } else {\n routeGaps.push({\n route: hint.route ?? test.title,\n method: hint.method,\n reason: hint.reason,\n coveredByTests: [test],\n })\n }\n matchedTests.add(test)\n } else if (hint.status === 'acknowledged' || hint.status === 'ignore') {\n acknowledgedTests.push(hint)\n matchedTests.add(test)\n }\n }\n\n // Step 4: Build RouteCoverage for each route\n const routeCoverages: RouteCoverage[] = routes.map(route => {\n const key = route.method ? `${route.method} ${route.path}` : route.path\n const matched = matchMap.get(key) ?? []\n const automatedTests = matched.filter(t => t.source === 'playwright')\n const manualTests = matched.filter(t => t.source === 'ado' || t.source === 'yaml')\n\n const status = this.deriveStatus(automatedTests.length, manualTests.length)\n const lastTestedAt = this.latestDate([...automatedTests, ...manualTests])\n const staleness = this.deriveStaleness(lastTestedAt, manualTests.length)\n\n return {\n route,\n status,\n automatedTests,\n manualTests,\n staleness,\n lastTestedAt,\n }\n })\n\n // Step 5: Assign to functional areas\n const { areas, uncategorised } = this.areaMatcher.assign(routeCoverages)\n\n // Step 6: Build summary\n const total = routeCoverages.length\n const automated = routeCoverages.filter(r => r.status === 'automated' || r.status === 'both').length\n const manualOnly = routeCoverages.filter(r => r.status === 'manual').length\n const none = routeCoverages.filter(r => r.status === 'none').length\n\n // LRM: apply rounding only to the three mutually exclusive groups so they\n // always sum to 100. totalCoverage is derived, never independently rounded.\n const toLRM = (counts: number[]): number[] => {\n if (total === 0) return counts.map(() => 0)\n const exact = counts.map(n => (n / total) * 100)\n const floored = exact.map(n => Math.floor(n))\n const remainder = 100 - floored.reduce((a, b) => a + b, 0)\n const order = exact.map((n, i) => ({ i, frac: n - Math.floor(n) })).sort((a, b) => b.frac - a.frac)\n for (let k = 0; k < remainder; k++) floored[order[k].i]++\n return floored\n }\n const [automatedCoverage, manualOnlyCoverage, noCoverage] = toLRM([automated, manualOnly, none])\n const totalCoverage = automatedCoverage + manualOnlyCoverage\n\n return {\n generatedAt: new Date(),\n projectName: this.config.projectName,\n summary: {\n totalRoutes: total,\n totalCoverage,\n automatedCoverage,\n manualOnlyCoverage,\n noCoverage,\n },\n areas,\n uncategorised,\n unmatchedTests: allTests.filter(t => !matchedTests.has(t)),\n routeGaps,\n acknowledgedTests,\n }\n }\n\n private deriveStatus(autoCount: number, manualCount: number): CoverageStatus {\n if (autoCount > 0 && manualCount > 0) return 'both'\n if (autoCount > 0) return 'automated'\n if (manualCount > 0) return 'manual'\n return 'none'\n }\n\n private deriveStaleness(lastTestedAt: Date | undefined, manualCount: number): Staleness {\n // Only flag staleness for manual tests — automated tests run on every push\n if (manualCount === 0) return 'unknown'\n if (!lastTestedAt) return 'unknown'\n\n const daysSince = (Date.now() - lastTestedAt.getTime()) / (1000 * 60 * 60 * 24)\n return daysSince > this.config.staleThresholdDays ? 'stale' : 'fresh'\n }\n\n private latestDate(tests: TestEntry[]): Date | undefined {\n const dates = tests.map(t => t.lastRun).filter((d): d is Date => d instanceof Date)\n if (dates.length === 0) return undefined\n return new Date(Math.max(...dates.map(d => d.getTime())))\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAwBO,IAAM,iBAAN,MAAqB;AAAA,EAC1B,YACU,SACA,cACA,SACA,aACA,QACR;AALQ;AACA;AACA;AACA;AACA;AAAA,EACP;AAAA,EAEH,MAAM,MAA+B;AAEnC,UAAM,gBAAgB,MAAM,QAAQ,IAAI,KAAK,QAAQ,IAAI,OAAK,EAAE,QAAQ,CAAC,CAAC;AAC1E,UAAM,WAAwB,cAAc,KAAK;AAGjD,UAAM,SAAS,MAAM,KAAK,aAAa,QAAQ;AAG/C,UAAM,WAAW,KAAK,QAAQ,MAAM,QAAQ,QAAQ;AAGpD,UAAM,eAAe,oBAAI,IAAe;AACxC,eAAW,SAAS,SAAS,OAAO,GAAG;AACrC,iBAAW,KAAK,MAAO,cAAa,IAAI,CAAC;AAAA,IAC3C;AAGA,UAAM,QAAQ,KAAK,OAAO,aAAa,CAAC;AACxC,UAAM,UAAU,oBAAI,IAAsB;AAC1C,eAAW,QAAQ,MAAO,SAAQ,IAAI,KAAK,MAAM,IAAI;AAErD,UAAM,YAAwB,CAAC;AAC/B,UAAM,oBAAgC,CAAC;AACvC,UAAM,eAAe,oBAAI,IAAe;AAExC,eAAW,QAAQ,UAAU;AAC3B,UAAI,aAAa,IAAI,IAAI,EAAG;AAC5B,YAAM,OAAO,QAAQ,IAAI,KAAK,KAAK;AACnC,UAAI,CAAC,KAAM;AAEX,UAAI,KAAK,WAAW,cAAc,KAAK,OAAO;AAE5C,cAAM,MAAM,KAAK,SAAS,GAAG,KAAK,MAAM,IAAI,KAAK,KAAK,KAAK,KAAK;AAChE,cAAM,WAAW,SAAS,IAAI,GAAG,KAAK,CAAC;AACvC,iBAAS,IAAI,KAAK,CAAC,GAAG,UAAU,IAAI,CAAC;AACrC,qBAAa,IAAI,IAAI;AACrB,qBAAa,IAAI,IAAI;AAAA,MACvB,WAAW,KAAK,WAAW,OAAO;AAChC,cAAM,WAAW,UAAU;AAAA,UAAK,OAC9B,EAAE,UAAU,KAAK,SAAS,EAAE,WAAW,KAAK;AAAA,QAC9C;AACA,YAAI,UAAU;AACZ,mBAAS,eAAe,KAAK,IAAI;AAAA,QACnC,OAAO;AACL,oBAAU,KAAK;AAAA,YACb,OAAO,KAAK,SAAS,KAAK;AAAA,YAC1B,QAAQ,KAAK;AAAA,YACb,QAAQ,KAAK;AAAA,YACb,gBAAgB,CAAC,IAAI;AAAA,UACvB,CAAC;AAAA,QACH;AACA,qBAAa,IAAI,IAAI;AAAA,MACvB,WAAW,KAAK,WAAW,kBAAkB,KAAK,WAAW,UAAU;AACrE,0BAAkB,KAAK,IAAI;AAC3B,qBAAa,IAAI,IAAI;AAAA,MACvB;AAAA,IACF;AAGA,UAAM,iBAAkC,OAAO,IAAI,WAAS;AAC1D,YAAM,MAAM,MAAM,SAAS,GAAG,MAAM,MAAM,IAAI,MAAM,IAAI,KAAK,MAAM;AACnE,YAAM,UAAU,SAAS,IAAI,GAAG,KAAK,CAAC;AACtC,YAAM,iBAAiB,QAAQ,OAAO,OAAK,EAAE,WAAW,YAAY;AACpE,YAAM,cAAc,QAAQ,OAAO,OAAK,EAAE,WAAW,SAAS,EAAE,WAAW,MAAM;AAEjF,YAAM,SAAS,KAAK,aAAa,eAAe,QAAQ,YAAY,MAAM;AAC1E,YAAM,eAAe,KAAK,WAAW,CAAC,GAAG,gBAAgB,GAAG,WAAW,CAAC;AACxE,YAAM,YAAY,KAAK,gBAAgB,cAAc,YAAY,MAAM;AAEvE,aAAO;AAAA,QACL;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,MACF;AAAA,IACF,CAAC;AAGD,UAAM,EAAE,OAAO,cAAc,IAAI,KAAK,YAAY,OAAO,cAAc;AAGvE,UAAM,QAAQ,eAAe;AAC7B,UAAM,YAAY,eAAe,OAAO,OAAK,EAAE,WAAW,eAAe,EAAE,WAAW,MAAM,EAAE;AAC9F,UAAM,aAAa,eAAe,OAAO,OAAK,EAAE,WAAW,QAAQ,EAAE;AACrE,UAAM,OAAO,eAAe,OAAO,OAAK,EAAE,WAAW,MAAM,EAAE;AAI7D,UAAM,QAAQ,CAAC,WAA+B;AAC5C,UAAI,UAAU,EAAG,QAAO,OAAO,IAAI,MAAM,CAAC;AAC1C,YAAM,QAAQ,OAAO,IAAI,OAAM,IAAI,QAAS,GAAG;AAC/C,YAAM,UAAU,MAAM,IAAI,OAAK,KAAK,MAAM,CAAC,CAAC;AAC5C,YAAM,YAAY,MAAM,QAAQ,OAAO,CAAC,GAAG,MAAM,IAAI,GAAG,CAAC;AACzD,YAAM,QAAQ,MAAM,IAAI,CAAC,GAAG,OAAO,EAAE,GAAG,MAAM,IAAI,KAAK,MAAM,CAAC,EAAE,EAAE,EAAE,KAAK,CAAC,GAAG,MAAM,EAAE,OAAO,EAAE,IAAI;AAClG,eAAS,IAAI,GAAG,IAAI,WAAW,IAAK,SAAQ,MAAM,CAAC,EAAE,CAAC;AACtD,aAAO;AAAA,IACT;AACA,UAAM,CAAC,mBAAmB,oBAAoB,UAAU,IAAI,MAAM,CAAC,WAAW,YAAY,IAAI,CAAC;AAC/F,UAAM,gBAAgB,oBAAoB;AAE1C,WAAO;AAAA,MACL,aAAa,oBAAI,KAAK;AAAA,MACtB,aAAa,KAAK,OAAO;AAAA,MACzB,SAAS;AAAA,QACP,aAAa;AAAA,QACb;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,MACF;AAAA,MACA;AAAA,MACA;AAAA,MACA,gBAAgB,SAAS,OAAO,OAAK,CAAC,aAAa,IAAI,CAAC,CAAC;AAAA,MACzD;AAAA,MACA;AAAA,IACF;AAAA,EACF;AAAA,EAEQ,aAAa,WAAmB,aAAqC;AAC3E,QAAI,YAAY,KAAK,cAAc,EAAG,QAAO;AAC7C,QAAI,YAAY,EAAG,QAAO;AAC1B,QAAI,cAAc,EAAG,QAAO;AAC5B,WAAO;AAAA,EACT;AAAA,EAEQ,gBAAgB,cAAgC,aAAgC;AAEtF,QAAI,gBAAgB,EAAG,QAAO;AAC9B,QAAI,CAAC,aAAc,QAAO;AAE1B,UAAM,aAAa,KAAK,IAAI,IAAI,aAAa,QAAQ,MAAM,MAAO,KAAK,KAAK;AAC5E,WAAO,YAAY,KAAK,OAAO,qBAAqB,UAAU;AAAA,EAChE;AAAA,EAEQ,WAAW,OAAsC;AACvD,UAAM,QAAQ,MAAM,IAAI,OAAK,EAAE,OAAO,EAAE,OAAO,CAAC,MAAiB,aAAa,IAAI;AAClF,QAAI,MAAM,WAAW,EAAG,QAAO;AAC/B,WAAO,IAAI,KAAK,KAAK,IAAI,GAAG,MAAM,IAAI,OAAK,EAAE,QAAQ,CAAC,CAAC,CAAC;AAAA,EAC1D;AACF;","names":[]}
@@ -0,0 +1,126 @@
1
+ // src/core/engine.ts
2
+ var CoverageEngine = class {
3
+ constructor(sources, routesSource, matcher, areaMatcher, config) {
4
+ this.sources = sources;
5
+ this.routesSource = routesSource;
6
+ this.matcher = matcher;
7
+ this.areaMatcher = areaMatcher;
8
+ this.config = config;
9
+ }
10
+ async run() {
11
+ const sourceResults = await Promise.all(this.sources.map((s) => s.collect()));
12
+ const allTests = sourceResults.flat();
13
+ const routes = await this.routesSource.collect();
14
+ const matchMap = this.matcher.match(routes, allTests);
15
+ const matchedTests = /* @__PURE__ */ new Set();
16
+ for (const tests of matchMap.values()) {
17
+ for (const t of tests) matchedTests.add(t);
18
+ }
19
+ const hints = this.config.testHints ?? [];
20
+ const hintMap = /* @__PURE__ */ new Map();
21
+ for (const hint of hints) hintMap.set(hint.test, hint);
22
+ const routeGaps = [];
23
+ const acknowledgedTests = [];
24
+ const hintResolved = /* @__PURE__ */ new Set();
25
+ for (const test of allTests) {
26
+ if (matchedTests.has(test)) continue;
27
+ const hint = hintMap.get(test.title);
28
+ if (!hint) continue;
29
+ if (hint.status === "resolved" && hint.route) {
30
+ const key = hint.method ? `${hint.method} ${hint.route}` : hint.route;
31
+ const existing = matchMap.get(key) ?? [];
32
+ matchMap.set(key, [...existing, test]);
33
+ matchedTests.add(test);
34
+ hintResolved.add(test);
35
+ } else if (hint.status === "gap") {
36
+ const existing = routeGaps.find(
37
+ (g) => g.route === hint.route && g.method === hint.method
38
+ );
39
+ if (existing) {
40
+ existing.coveredByTests.push(test);
41
+ } else {
42
+ routeGaps.push({
43
+ route: hint.route ?? test.title,
44
+ method: hint.method,
45
+ reason: hint.reason,
46
+ coveredByTests: [test]
47
+ });
48
+ }
49
+ matchedTests.add(test);
50
+ } else if (hint.status === "acknowledged" || hint.status === "ignore") {
51
+ acknowledgedTests.push(hint);
52
+ matchedTests.add(test);
53
+ }
54
+ }
55
+ const routeCoverages = routes.map((route) => {
56
+ const key = route.method ? `${route.method} ${route.path}` : route.path;
57
+ const matched = matchMap.get(key) ?? [];
58
+ const automatedTests = matched.filter((t) => t.source === "playwright");
59
+ const manualTests = matched.filter((t) => t.source === "ado" || t.source === "yaml");
60
+ const status = this.deriveStatus(automatedTests.length, manualTests.length);
61
+ const lastTestedAt = this.latestDate([...automatedTests, ...manualTests]);
62
+ const staleness = this.deriveStaleness(lastTestedAt, manualTests.length);
63
+ return {
64
+ route,
65
+ status,
66
+ automatedTests,
67
+ manualTests,
68
+ staleness,
69
+ lastTestedAt
70
+ };
71
+ });
72
+ const { areas, uncategorised } = this.areaMatcher.assign(routeCoverages);
73
+ const total = routeCoverages.length;
74
+ const automated = routeCoverages.filter((r) => r.status === "automated" || r.status === "both").length;
75
+ const manualOnly = routeCoverages.filter((r) => r.status === "manual").length;
76
+ const none = routeCoverages.filter((r) => r.status === "none").length;
77
+ const toLRM = (counts) => {
78
+ if (total === 0) return counts.map(() => 0);
79
+ const exact = counts.map((n) => n / total * 100);
80
+ const floored = exact.map((n) => Math.floor(n));
81
+ const remainder = 100 - floored.reduce((a, b) => a + b, 0);
82
+ const order = exact.map((n, i) => ({ i, frac: n - Math.floor(n) })).sort((a, b) => b.frac - a.frac);
83
+ for (let k = 0; k < remainder; k++) floored[order[k].i]++;
84
+ return floored;
85
+ };
86
+ const [automatedCoverage, manualOnlyCoverage, noCoverage] = toLRM([automated, manualOnly, none]);
87
+ const totalCoverage = automatedCoverage + manualOnlyCoverage;
88
+ return {
89
+ generatedAt: /* @__PURE__ */ new Date(),
90
+ projectName: this.config.projectName,
91
+ summary: {
92
+ totalRoutes: total,
93
+ totalCoverage,
94
+ automatedCoverage,
95
+ manualOnlyCoverage,
96
+ noCoverage
97
+ },
98
+ areas,
99
+ uncategorised,
100
+ unmatchedTests: allTests.filter((t) => !matchedTests.has(t)),
101
+ routeGaps,
102
+ acknowledgedTests
103
+ };
104
+ }
105
+ deriveStatus(autoCount, manualCount) {
106
+ if (autoCount > 0 && manualCount > 0) return "both";
107
+ if (autoCount > 0) return "automated";
108
+ if (manualCount > 0) return "manual";
109
+ return "none";
110
+ }
111
+ deriveStaleness(lastTestedAt, manualCount) {
112
+ if (manualCount === 0) return "unknown";
113
+ if (!lastTestedAt) return "unknown";
114
+ const daysSince = (Date.now() - lastTestedAt.getTime()) / (1e3 * 60 * 60 * 24);
115
+ return daysSince > this.config.staleThresholdDays ? "stale" : "fresh";
116
+ }
117
+ latestDate(tests) {
118
+ const dates = tests.map((t) => t.lastRun).filter((d) => d instanceof Date);
119
+ if (dates.length === 0) return void 0;
120
+ return new Date(Math.max(...dates.map((d) => d.getTime())));
121
+ }
122
+ };
123
+ export {
124
+ CoverageEngine
125
+ };
126
+ //# sourceMappingURL=engine.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/core/engine.ts"],"sourcesContent":["/**\n * src/core/engine.ts\n * \n * The main orchestrator. Zero I/O — all dependencies injected.\n * This design makes the engine fully unit-testable with mock sources.\n * \n * AI INSTRUCTIONS: implement run() using the step-by-step comments.\n */\n\nimport {\n CoverageReport,\n CoverageStatus,\n RouteGap,\n RouteCoverage,\n Staleness,\n TestEntry,\n TestGapConfig,\n TestHint,\n} from './types'\nimport { BaseSource } from '../sources/base.source'\nimport { RoutesSource } from '../sources/routes.source'\nimport { FuzzyMatcher } from '../matchers/fuzzy.matcher'\nimport { AreaMatcher } from '../matchers/area.matcher'\n\nexport class CoverageEngine {\n constructor(\n private sources: BaseSource[],\n private routesSource: RoutesSource,\n private matcher: FuzzyMatcher,\n private areaMatcher: AreaMatcher,\n private config: TestGapConfig\n ) {}\n\n async run(): Promise<CoverageReport> {\n // Step 1: Collect from all sources in parallel\n const sourceResults = await Promise.all(this.sources.map(s => s.collect()))\n const allTests: TestEntry[] = sourceResults.flat()\n\n // Step 2: Discover routes\n const routes = await this.routesSource.collect()\n\n // Step 3: Match tests to routes\n const matchMap = this.matcher.match(routes, allTests)\n\n // Collect all matched tests so we can find orphans after\n const matchedTests = new Set<TestEntry>()\n for (const tests of matchMap.values()) {\n for (const t of tests) matchedTests.add(t)\n }\n\n // Step 3b: Apply testHints to fuzzy-unmatched tests\n const hints = this.config.testHints ?? []\n const hintMap = new Map<string, TestHint>()\n for (const hint of hints) hintMap.set(hint.test, hint)\n\n const routeGaps: RouteGap[] = []\n const acknowledgedTests: TestHint[] = []\n const hintResolved = new Set<TestEntry>()\n\n for (const test of allTests) {\n if (matchedTests.has(test)) continue // already matched by fuzzy\n const hint = hintMap.get(test.title)\n if (!hint) continue\n\n if (hint.status === 'resolved' && hint.route) {\n // Inject into matchMap so RouteCoverage picks it up\n const key = hint.method ? `${hint.method} ${hint.route}` : hint.route\n const existing = matchMap.get(key) ?? []\n matchMap.set(key, [...existing, test])\n matchedTests.add(test)\n hintResolved.add(test)\n } else if (hint.status === 'gap') {\n const existing = routeGaps.find(g =>\n g.route === hint.route && g.method === hint.method\n )\n if (existing) {\n existing.coveredByTests.push(test)\n } else {\n routeGaps.push({\n route: hint.route ?? test.title,\n method: hint.method,\n reason: hint.reason,\n coveredByTests: [test],\n })\n }\n matchedTests.add(test)\n } else if (hint.status === 'acknowledged' || hint.status === 'ignore') {\n acknowledgedTests.push(hint)\n matchedTests.add(test)\n }\n }\n\n // Step 4: Build RouteCoverage for each route\n const routeCoverages: RouteCoverage[] = routes.map(route => {\n const key = route.method ? `${route.method} ${route.path}` : route.path\n const matched = matchMap.get(key) ?? []\n const automatedTests = matched.filter(t => t.source === 'playwright')\n const manualTests = matched.filter(t => t.source === 'ado' || t.source === 'yaml')\n\n const status = this.deriveStatus(automatedTests.length, manualTests.length)\n const lastTestedAt = this.latestDate([...automatedTests, ...manualTests])\n const staleness = this.deriveStaleness(lastTestedAt, manualTests.length)\n\n return {\n route,\n status,\n automatedTests,\n manualTests,\n staleness,\n lastTestedAt,\n }\n })\n\n // Step 5: Assign to functional areas\n const { areas, uncategorised } = this.areaMatcher.assign(routeCoverages)\n\n // Step 6: Build summary\n const total = routeCoverages.length\n const automated = routeCoverages.filter(r => r.status === 'automated' || r.status === 'both').length\n const manualOnly = routeCoverages.filter(r => r.status === 'manual').length\n const none = routeCoverages.filter(r => r.status === 'none').length\n\n // LRM: apply rounding only to the three mutually exclusive groups so they\n // always sum to 100. totalCoverage is derived, never independently rounded.\n const toLRM = (counts: number[]): number[] => {\n if (total === 0) return counts.map(() => 0)\n const exact = counts.map(n => (n / total) * 100)\n const floored = exact.map(n => Math.floor(n))\n const remainder = 100 - floored.reduce((a, b) => a + b, 0)\n const order = exact.map((n, i) => ({ i, frac: n - Math.floor(n) })).sort((a, b) => b.frac - a.frac)\n for (let k = 0; k < remainder; k++) floored[order[k].i]++\n return floored\n }\n const [automatedCoverage, manualOnlyCoverage, noCoverage] = toLRM([automated, manualOnly, none])\n const totalCoverage = automatedCoverage + manualOnlyCoverage\n\n return {\n generatedAt: new Date(),\n projectName: this.config.projectName,\n summary: {\n totalRoutes: total,\n totalCoverage,\n automatedCoverage,\n manualOnlyCoverage,\n noCoverage,\n },\n areas,\n uncategorised,\n unmatchedTests: allTests.filter(t => !matchedTests.has(t)),\n routeGaps,\n acknowledgedTests,\n }\n }\n\n private deriveStatus(autoCount: number, manualCount: number): CoverageStatus {\n if (autoCount > 0 && manualCount > 0) return 'both'\n if (autoCount > 0) return 'automated'\n if (manualCount > 0) return 'manual'\n return 'none'\n }\n\n private deriveStaleness(lastTestedAt: Date | undefined, manualCount: number): Staleness {\n // Only flag staleness for manual tests — automated tests run on every push\n if (manualCount === 0) return 'unknown'\n if (!lastTestedAt) return 'unknown'\n\n const daysSince = (Date.now() - lastTestedAt.getTime()) / (1000 * 60 * 60 * 24)\n return daysSince > this.config.staleThresholdDays ? 'stale' : 'fresh'\n }\n\n private latestDate(tests: TestEntry[]): Date | undefined {\n const dates = tests.map(t => t.lastRun).filter((d): d is Date => d instanceof Date)\n if (dates.length === 0) return undefined\n return new Date(Math.max(...dates.map(d => d.getTime())))\n }\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,109 @@
1
+ /**
2
+ * src/core/types.ts
3
+ *
4
+ * ALL shared interfaces live here.
5
+ * Every other file in this project imports types from this file.
6
+ * Do not scatter interface definitions — keep this as the single source of truth.
7
+ */
8
+ type CoverageStatus = 'automated' | 'manual' | 'both' | 'none';
9
+ type TestSource = 'playwright' | 'ado' | 'yaml';
10
+ type Staleness = 'fresh' | 'stale' | 'unknown';
11
+ type TestHintStatus = 'resolved' | 'gap' | 'acknowledged' | 'ignore';
12
+ interface TestHint {
13
+ test: string;
14
+ status: TestHintStatus;
15
+ route?: string;
16
+ method?: string;
17
+ reason?: string;
18
+ confidence?: 'high' | 'medium' | 'low';
19
+ resolvedBy?: string;
20
+ }
21
+ interface AppRoute {
22
+ path: string;
23
+ method?: string;
24
+ area?: string;
25
+ }
26
+ interface TestEntry {
27
+ title: string;
28
+ source: TestSource;
29
+ filePath?: string;
30
+ lastRun?: Date;
31
+ tags?: string[];
32
+ }
33
+ interface RouteGap {
34
+ route: string;
35
+ method?: string;
36
+ reason?: string;
37
+ coveredByTests: TestEntry[];
38
+ }
39
+ interface RouteCoverage {
40
+ route: AppRoute;
41
+ status: CoverageStatus;
42
+ automatedTests: TestEntry[];
43
+ manualTests: TestEntry[];
44
+ staleness: Staleness;
45
+ lastTestedAt?: Date;
46
+ notes?: string;
47
+ }
48
+ interface AreaCoverage {
49
+ name: string;
50
+ routes: RouteCoverage[];
51
+ totalRoutes: number;
52
+ coveredRoutes: number;
53
+ automatedCount: number;
54
+ manualOnlyCount: number;
55
+ noCoverageCount: number;
56
+ coveragePercent: number;
57
+ }
58
+ interface CoverageReport {
59
+ generatedAt: Date;
60
+ projectName: string;
61
+ summary: {
62
+ totalRoutes: number;
63
+ totalCoverage: number;
64
+ automatedCoverage: number;
65
+ manualOnlyCoverage: number;
66
+ noCoverage: number;
67
+ };
68
+ areas: AreaCoverage[];
69
+ uncategorised: RouteCoverage[];
70
+ unmatchedTests: TestEntry[];
71
+ routeGaps: RouteGap[];
72
+ acknowledgedTests: TestHint[];
73
+ }
74
+ interface TestGapConfig {
75
+ projectName: string;
76
+ staleThresholdDays: number;
77
+ routes: {
78
+ type: 'nextjs' | 'express' | 'openapi' | 'manual';
79
+ path: string;
80
+ generate?: string;
81
+ basePath?: string;
82
+ };
83
+ tests?: {
84
+ path: string;
85
+ };
86
+ areas: {
87
+ name: string;
88
+ patterns: string[];
89
+ }[];
90
+ ado?: {
91
+ orgUrl: string;
92
+ project: string;
93
+ planId: number;
94
+ patEnvVar: string;
95
+ };
96
+ manualCoverage?: {
97
+ route: string;
98
+ lastTested: string;
99
+ tester?: string;
100
+ notes?: string;
101
+ }[];
102
+ thresholds?: {
103
+ area?: string;
104
+ minCoverage: number;
105
+ }[];
106
+ testHints?: TestHint[];
107
+ }
108
+
109
+ export type { AppRoute, AreaCoverage, CoverageReport, CoverageStatus, RouteCoverage, RouteGap, Staleness, TestEntry, TestGapConfig, TestHint, TestHintStatus, TestSource };
@@ -0,0 +1,109 @@
1
+ /**
2
+ * src/core/types.ts
3
+ *
4
+ * ALL shared interfaces live here.
5
+ * Every other file in this project imports types from this file.
6
+ * Do not scatter interface definitions — keep this as the single source of truth.
7
+ */
8
+ type CoverageStatus = 'automated' | 'manual' | 'both' | 'none';
9
+ type TestSource = 'playwright' | 'ado' | 'yaml';
10
+ type Staleness = 'fresh' | 'stale' | 'unknown';
11
+ type TestHintStatus = 'resolved' | 'gap' | 'acknowledged' | 'ignore';
12
+ interface TestHint {
13
+ test: string;
14
+ status: TestHintStatus;
15
+ route?: string;
16
+ method?: string;
17
+ reason?: string;
18
+ confidence?: 'high' | 'medium' | 'low';
19
+ resolvedBy?: string;
20
+ }
21
+ interface AppRoute {
22
+ path: string;
23
+ method?: string;
24
+ area?: string;
25
+ }
26
+ interface TestEntry {
27
+ title: string;
28
+ source: TestSource;
29
+ filePath?: string;
30
+ lastRun?: Date;
31
+ tags?: string[];
32
+ }
33
+ interface RouteGap {
34
+ route: string;
35
+ method?: string;
36
+ reason?: string;
37
+ coveredByTests: TestEntry[];
38
+ }
39
+ interface RouteCoverage {
40
+ route: AppRoute;
41
+ status: CoverageStatus;
42
+ automatedTests: TestEntry[];
43
+ manualTests: TestEntry[];
44
+ staleness: Staleness;
45
+ lastTestedAt?: Date;
46
+ notes?: string;
47
+ }
48
+ interface AreaCoverage {
49
+ name: string;
50
+ routes: RouteCoverage[];
51
+ totalRoutes: number;
52
+ coveredRoutes: number;
53
+ automatedCount: number;
54
+ manualOnlyCount: number;
55
+ noCoverageCount: number;
56
+ coveragePercent: number;
57
+ }
58
+ interface CoverageReport {
59
+ generatedAt: Date;
60
+ projectName: string;
61
+ summary: {
62
+ totalRoutes: number;
63
+ totalCoverage: number;
64
+ automatedCoverage: number;
65
+ manualOnlyCoverage: number;
66
+ noCoverage: number;
67
+ };
68
+ areas: AreaCoverage[];
69
+ uncategorised: RouteCoverage[];
70
+ unmatchedTests: TestEntry[];
71
+ routeGaps: RouteGap[];
72
+ acknowledgedTests: TestHint[];
73
+ }
74
+ interface TestGapConfig {
75
+ projectName: string;
76
+ staleThresholdDays: number;
77
+ routes: {
78
+ type: 'nextjs' | 'express' | 'openapi' | 'manual';
79
+ path: string;
80
+ generate?: string;
81
+ basePath?: string;
82
+ };
83
+ tests?: {
84
+ path: string;
85
+ };
86
+ areas: {
87
+ name: string;
88
+ patterns: string[];
89
+ }[];
90
+ ado?: {
91
+ orgUrl: string;
92
+ project: string;
93
+ planId: number;
94
+ patEnvVar: string;
95
+ };
96
+ manualCoverage?: {
97
+ route: string;
98
+ lastTested: string;
99
+ tester?: string;
100
+ notes?: string;
101
+ }[];
102
+ thresholds?: {
103
+ area?: string;
104
+ minCoverage: number;
105
+ }[];
106
+ testHints?: TestHint[];
107
+ }
108
+
109
+ export type { AppRoute, AreaCoverage, CoverageReport, CoverageStatus, RouteCoverage, RouteGap, Staleness, TestEntry, TestGapConfig, TestHint, TestHintStatus, TestSource };
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __copyProps = (to, from, except, desc) => {
7
+ if (from && typeof from === "object" || typeof from === "function") {
8
+ for (let key of __getOwnPropNames(from))
9
+ if (!__hasOwnProp.call(to, key) && key !== except)
10
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
11
+ }
12
+ return to;
13
+ };
14
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
15
+
16
+ // src/core/types.ts
17
+ var types_exports = {};
18
+ module.exports = __toCommonJS(types_exports);
19
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/core/types.ts"],"sourcesContent":["/**\n * src/core/types.ts\n * \n * ALL shared interfaces live here.\n * Every other file in this project imports types from this file.\n * Do not scatter interface definitions — keep this as the single source of truth.\n */\n\nexport type CoverageStatus = 'automated' | 'manual' | 'both' | 'none'\nexport type TestSource = 'playwright' | 'ado' | 'yaml'\nexport type Staleness = 'fresh' | 'stale' | 'unknown'\nexport type TestHintStatus = 'resolved' | 'gap' | 'acknowledged' | 'ignore'\n\nexport interface TestHint {\n test: string // exact test title to match\n status: TestHintStatus // resolved | gap | acknowledged | ignore\n route?: string // route path — for resolved + gap\n method?: string // HTTP method — for gap (e.g. DELETE, PATCH)\n reason?: string\n confidence?: 'high' | 'medium' | 'low'\n resolvedBy?: string // 'agent' | 'human' | 'ai'\n}\n\nexport interface AppRoute {\n path: string // e.g. /checkout/payment\n method?: string // GET | POST | etc — from OpenAPI\n area?: string // populated by AreaMatcher\n}\n\nexport interface TestEntry {\n title: string\n source: TestSource\n filePath?: string // playwright: which spec file\n lastRun?: Date // manual: from yaml or ADO\n tags?: string[]\n}\n\nexport interface RouteGap {\n route: string\n method?: string\n reason?: string\n coveredByTests: TestEntry[]\n}\n\nexport interface RouteCoverage {\n route: AppRoute\n status: CoverageStatus\n automatedTests: TestEntry[]\n manualTests: TestEntry[]\n staleness: Staleness\n lastTestedAt?: Date\n notes?: string\n}\n\nexport interface AreaCoverage {\n name: string\n routes: RouteCoverage[]\n totalRoutes: number\n coveredRoutes: number\n automatedCount: number\n manualOnlyCount: number\n noCoverageCount: number\n coveragePercent: number // 0-100, rounded integer\n}\n\nexport interface CoverageReport {\n generatedAt: Date\n projectName: string\n summary: {\n totalRoutes: number\n totalCoverage: number\n automatedCoverage: number\n manualOnlyCoverage: number\n noCoverage: number\n }\n areas: AreaCoverage[]\n uncategorised: RouteCoverage[]\n unmatchedTests: TestEntry[] // still unresolved — no hint applied\n routeGaps: RouteGap[] // tests targeting routes not in schema (gap hints)\n acknowledgedTests: TestHint[] // acknowledged or ignored hints\n}\n\nexport interface TestGapConfig {\n projectName: string\n staleThresholdDays: number\n routes: {\n type: 'nextjs' | 'express' | 'openapi' | 'manual'\n path: string\n generate?: string // shell command to run before route discovery (e.g. to produce openapi.json)\n basePath?: string // prefix stripped when grouping routes into areas (e.g. /api, /api/v1)\n }\n tests?: {\n path: string // directory to scan for *.spec.ts / *.test.ts files\n // relative to qualitylens.yaml location\n // default: same directory as qualitylens.yaml\n }\n areas: {\n name: string\n patterns: string[]\n }[]\n ado?: {\n orgUrl: string\n project: string\n planId: number\n patEnvVar: string // env var name — never hardcode the PAT value\n }\n manualCoverage?: {\n route: string\n lastTested: string // ISO date string\n tester?: string\n notes?: string\n }[]\n thresholds?: {\n area?: string // area name or '*' for global\n minCoverage: number // 0-100\n }[]\n testHints?: TestHint[]\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAAA;AAAA;","names":[]}
@@ -0,0 +1 @@
1
+ //# sourceMappingURL=types.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
@@ -0,0 +1,38 @@
1
+ import { TestGapConfig, RouteCoverage, AreaCoverage } from '../core/types.mjs';
2
+
3
+ /**
4
+ * src/matchers/area.matcher.ts
5
+ *
6
+ * Groups routes into functional areas based on path prefix patterns.
7
+ * Most-specific pattern wins (longest prefix match).
8
+ *
9
+ * AI INSTRUCTIONS: implement assign() using the skeleton below.
10
+ */
11
+
12
+ declare class AreaMatcher {
13
+ private areaConfigs;
14
+ constructor(areaConfigs: TestGapConfig['areas']);
15
+ assign(routes: RouteCoverage[]): {
16
+ areas: AreaCoverage[];
17
+ uncategorised: RouteCoverage[];
18
+ };
19
+ /**
20
+ * AI INSTRUCTIONS:
21
+ * 1. For each area config, check if route path starts with any of its patterns
22
+ * 2. Among all matching areas, return the one with the LONGEST matching pattern
23
+ * (most specific wins — /checkout/payment should prefer /checkout over /)
24
+ * 3. Return undefined if no area matches
25
+ */
26
+ private findBestArea;
27
+ /**
28
+ * AI INSTRUCTIONS: compute AreaCoverage statistics from a list of RouteCoverage.
29
+ * - coveredRoutes: routes where status !== 'none'
30
+ * - automatedCount: routes where status === 'automated' or 'both'
31
+ * - manualOnlyCount: routes where status === 'manual'
32
+ * - noCoverageCount: routes where status === 'none'
33
+ * - coveragePercent: Math.round((coveredRoutes / totalRoutes) * 100)
34
+ */
35
+ private buildAreaCoverage;
36
+ }
37
+
38
+ export { AreaMatcher };
@@ -0,0 +1,38 @@
1
+ import { TestGapConfig, RouteCoverage, AreaCoverage } from '../core/types.js';
2
+
3
+ /**
4
+ * src/matchers/area.matcher.ts
5
+ *
6
+ * Groups routes into functional areas based on path prefix patterns.
7
+ * Most-specific pattern wins (longest prefix match).
8
+ *
9
+ * AI INSTRUCTIONS: implement assign() using the skeleton below.
10
+ */
11
+
12
+ declare class AreaMatcher {
13
+ private areaConfigs;
14
+ constructor(areaConfigs: TestGapConfig['areas']);
15
+ assign(routes: RouteCoverage[]): {
16
+ areas: AreaCoverage[];
17
+ uncategorised: RouteCoverage[];
18
+ };
19
+ /**
20
+ * AI INSTRUCTIONS:
21
+ * 1. For each area config, check if route path starts with any of its patterns
22
+ * 2. Among all matching areas, return the one with the LONGEST matching pattern
23
+ * (most specific wins — /checkout/payment should prefer /checkout over /)
24
+ * 3. Return undefined if no area matches
25
+ */
26
+ private findBestArea;
27
+ /**
28
+ * AI INSTRUCTIONS: compute AreaCoverage statistics from a list of RouteCoverage.
29
+ * - coveredRoutes: routes where status !== 'none'
30
+ * - automatedCount: routes where status === 'automated' or 'both'
31
+ * - manualOnlyCount: routes where status === 'manual'
32
+ * - noCoverageCount: routes where status === 'none'
33
+ * - coveragePercent: Math.round((coveredRoutes / totalRoutes) * 100)
34
+ */
35
+ private buildAreaCoverage;
36
+ }
37
+
38
+ export { AreaMatcher };
@@ -0,0 +1,102 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/matchers/area.matcher.ts
21
+ var area_matcher_exports = {};
22
+ __export(area_matcher_exports, {
23
+ AreaMatcher: () => AreaMatcher
24
+ });
25
+ module.exports = __toCommonJS(area_matcher_exports);
26
+ var AreaMatcher = class {
27
+ constructor(areaConfigs) {
28
+ this.areaConfigs = areaConfigs;
29
+ }
30
+ assign(routes) {
31
+ const areaMap = /* @__PURE__ */ new Map();
32
+ const uncategorised = [];
33
+ for (const area of this.areaConfigs) {
34
+ areaMap.set(area.name, []);
35
+ }
36
+ for (const route of routes) {
37
+ const areaName = this.findBestArea(route.route.path);
38
+ if (areaName) {
39
+ areaMap.get(areaName).push(route);
40
+ } else {
41
+ uncategorised.push(route);
42
+ }
43
+ }
44
+ const areas = [];
45
+ for (const [name, areaRoutes] of areaMap.entries()) {
46
+ if (areaRoutes.length === 0) continue;
47
+ areas.push(this.buildAreaCoverage(name, areaRoutes));
48
+ }
49
+ return { areas, uncategorised };
50
+ }
51
+ /**
52
+ * AI INSTRUCTIONS:
53
+ * 1. For each area config, check if route path starts with any of its patterns
54
+ * 2. Among all matching areas, return the one with the LONGEST matching pattern
55
+ * (most specific wins — /checkout/payment should prefer /checkout over /)
56
+ * 3. Return undefined if no area matches
57
+ */
58
+ findBestArea(routePath) {
59
+ let bestArea;
60
+ let bestLength = 0;
61
+ for (const area of this.areaConfigs) {
62
+ for (const pattern of area.patterns) {
63
+ if (routePath.startsWith(pattern) && pattern.length > bestLength) {
64
+ bestArea = area.name;
65
+ bestLength = pattern.length;
66
+ }
67
+ }
68
+ }
69
+ return bestArea;
70
+ }
71
+ /**
72
+ * AI INSTRUCTIONS: compute AreaCoverage statistics from a list of RouteCoverage.
73
+ * - coveredRoutes: routes where status !== 'none'
74
+ * - automatedCount: routes where status === 'automated' or 'both'
75
+ * - manualOnlyCount: routes where status === 'manual'
76
+ * - noCoverageCount: routes where status === 'none'
77
+ * - coveragePercent: Math.round((coveredRoutes / totalRoutes) * 100)
78
+ */
79
+ buildAreaCoverage(name, routes) {
80
+ const totalRoutes = routes.length;
81
+ const coveredRoutes = routes.filter((r) => r.status !== "none").length;
82
+ const automatedCount = routes.filter((r) => r.status === "automated" || r.status === "both").length;
83
+ const manualOnlyCount = routes.filter((r) => r.status === "manual").length;
84
+ const noCoverageCount = routes.filter((r) => r.status === "none").length;
85
+ const coveragePercent = totalRoutes > 0 ? Math.round(coveredRoutes / totalRoutes * 100) : 0;
86
+ return {
87
+ name,
88
+ routes,
89
+ totalRoutes,
90
+ coveredRoutes,
91
+ automatedCount,
92
+ manualOnlyCount,
93
+ noCoverageCount,
94
+ coveragePercent
95
+ };
96
+ }
97
+ };
98
+ // Annotate the CommonJS export names for ESM import in node:
99
+ 0 && (module.exports = {
100
+ AreaMatcher
101
+ });
102
+ //# sourceMappingURL=area.matcher.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/matchers/area.matcher.ts"],"sourcesContent":["/**\n * src/matchers/area.matcher.ts\n * \n * Groups routes into functional areas based on path prefix patterns.\n * Most-specific pattern wins (longest prefix match).\n * \n * AI INSTRUCTIONS: implement assign() using the skeleton below.\n */\n\nimport { AreaCoverage, RouteCoverage, TestGapConfig } from '../core/types'\n\nexport class AreaMatcher {\n constructor(private areaConfigs: TestGapConfig['areas']) {}\n\n assign(routes: RouteCoverage[]): {\n areas: AreaCoverage[]\n uncategorised: RouteCoverage[]\n } {\n const areaMap = new Map<string, RouteCoverage[]>()\n const uncategorised: RouteCoverage[] = []\n\n // Initialise area buckets\n for (const area of this.areaConfigs) {\n areaMap.set(area.name, [])\n }\n\n // Assign each route to the best-matching area\n for (const route of routes) {\n const areaName = this.findBestArea(route.route.path)\n if (areaName) {\n areaMap.get(areaName)!.push(route)\n } else {\n uncategorised.push(route)\n }\n }\n\n // Build AreaCoverage objects\n const areas: AreaCoverage[] = []\n for (const [name, areaRoutes] of areaMap.entries()) {\n if (areaRoutes.length === 0) continue\n areas.push(this.buildAreaCoverage(name, areaRoutes))\n }\n\n return { areas, uncategorised }\n }\n\n /**\n * AI INSTRUCTIONS:\n * 1. For each area config, check if route path starts with any of its patterns\n * 2. Among all matching areas, return the one with the LONGEST matching pattern\n * (most specific wins — /checkout/payment should prefer /checkout over /)\n * 3. Return undefined if no area matches\n */\n private findBestArea(routePath: string): string | undefined {\n // TODO (AI): implement\n let bestArea: string | undefined\n let bestLength = 0\n\n for (const area of this.areaConfigs) {\n for (const pattern of area.patterns) {\n if (routePath.startsWith(pattern) && pattern.length > bestLength) {\n bestArea = area.name\n bestLength = pattern.length\n }\n }\n }\n\n return bestArea\n }\n\n /**\n * AI INSTRUCTIONS: compute AreaCoverage statistics from a list of RouteCoverage.\n * - coveredRoutes: routes where status !== 'none'\n * - automatedCount: routes where status === 'automated' or 'both'\n * - manualOnlyCount: routes where status === 'manual'\n * - noCoverageCount: routes where status === 'none'\n * - coveragePercent: Math.round((coveredRoutes / totalRoutes) * 100)\n */\n private buildAreaCoverage(name: string, routes: RouteCoverage[]): AreaCoverage {\n // TODO (AI): implement\n const totalRoutes = routes.length\n const coveredRoutes = routes.filter(r => r.status !== 'none').length\n const automatedCount = routes.filter(r => r.status === 'automated' || r.status === 'both').length\n const manualOnlyCount = routes.filter(r => r.status === 'manual').length\n const noCoverageCount = routes.filter(r => r.status === 'none').length\n const coveragePercent = totalRoutes > 0 ? Math.round((coveredRoutes / totalRoutes) * 100) : 0\n\n return {\n name,\n routes,\n totalRoutes,\n coveredRoutes,\n automatedCount,\n manualOnlyCount,\n noCoverageCount,\n coveragePercent,\n }\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAWO,IAAM,cAAN,MAAkB;AAAA,EACvB,YAAoB,aAAqC;AAArC;AAAA,EAAsC;AAAA,EAE1D,OAAO,QAGL;AACA,UAAM,UAAU,oBAAI,IAA6B;AACjD,UAAM,gBAAiC,CAAC;AAGxC,eAAW,QAAQ,KAAK,aAAa;AACnC,cAAQ,IAAI,KAAK,MAAM,CAAC,CAAC;AAAA,IAC3B;AAGA,eAAW,SAAS,QAAQ;AAC1B,YAAM,WAAW,KAAK,aAAa,MAAM,MAAM,IAAI;AACnD,UAAI,UAAU;AACZ,gBAAQ,IAAI,QAAQ,EAAG,KAAK,KAAK;AAAA,MACnC,OAAO;AACL,sBAAc,KAAK,KAAK;AAAA,MAC1B;AAAA,IACF;AAGA,UAAM,QAAwB,CAAC;AAC/B,eAAW,CAAC,MAAM,UAAU,KAAK,QAAQ,QAAQ,GAAG;AAClD,UAAI,WAAW,WAAW,EAAG;AAC7B,YAAM,KAAK,KAAK,kBAAkB,MAAM,UAAU,CAAC;AAAA,IACrD;AAEA,WAAO,EAAE,OAAO,cAAc;AAAA,EAChC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASQ,aAAa,WAAuC;AAE1D,QAAI;AACJ,QAAI,aAAa;AAEjB,eAAW,QAAQ,KAAK,aAAa;AACnC,iBAAW,WAAW,KAAK,UAAU;AACnC,YAAI,UAAU,WAAW,OAAO,KAAK,QAAQ,SAAS,YAAY;AAChE,qBAAW,KAAK;AAChB,uBAAa,QAAQ;AAAA,QACvB;AAAA,MACF;AAAA,IACF;AAEA,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUQ,kBAAkB,MAAc,QAAuC;AAE7E,UAAM,cAAc,OAAO;AAC3B,UAAM,gBAAgB,OAAO,OAAO,OAAK,EAAE,WAAW,MAAM,EAAE;AAC9D,UAAM,iBAAiB,OAAO,OAAO,OAAK,EAAE,WAAW,eAAe,EAAE,WAAW,MAAM,EAAE;AAC3F,UAAM,kBAAkB,OAAO,OAAO,OAAK,EAAE,WAAW,QAAQ,EAAE;AAClE,UAAM,kBAAkB,OAAO,OAAO,OAAK,EAAE,WAAW,MAAM,EAAE;AAChE,UAAM,kBAAkB,cAAc,IAAI,KAAK,MAAO,gBAAgB,cAAe,GAAG,IAAI;AAE5F,WAAO;AAAA,MACL;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAAA,EACF;AACF;","names":[]}