@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,77 @@
1
+ // src/matchers/area.matcher.ts
2
+ var AreaMatcher = class {
3
+ constructor(areaConfigs) {
4
+ this.areaConfigs = areaConfigs;
5
+ }
6
+ assign(routes) {
7
+ const areaMap = /* @__PURE__ */ new Map();
8
+ const uncategorised = [];
9
+ for (const area of this.areaConfigs) {
10
+ areaMap.set(area.name, []);
11
+ }
12
+ for (const route of routes) {
13
+ const areaName = this.findBestArea(route.route.path);
14
+ if (areaName) {
15
+ areaMap.get(areaName).push(route);
16
+ } else {
17
+ uncategorised.push(route);
18
+ }
19
+ }
20
+ const areas = [];
21
+ for (const [name, areaRoutes] of areaMap.entries()) {
22
+ if (areaRoutes.length === 0) continue;
23
+ areas.push(this.buildAreaCoverage(name, areaRoutes));
24
+ }
25
+ return { areas, uncategorised };
26
+ }
27
+ /**
28
+ * AI INSTRUCTIONS:
29
+ * 1. For each area config, check if route path starts with any of its patterns
30
+ * 2. Among all matching areas, return the one with the LONGEST matching pattern
31
+ * (most specific wins — /checkout/payment should prefer /checkout over /)
32
+ * 3. Return undefined if no area matches
33
+ */
34
+ findBestArea(routePath) {
35
+ let bestArea;
36
+ let bestLength = 0;
37
+ for (const area of this.areaConfigs) {
38
+ for (const pattern of area.patterns) {
39
+ if (routePath.startsWith(pattern) && pattern.length > bestLength) {
40
+ bestArea = area.name;
41
+ bestLength = pattern.length;
42
+ }
43
+ }
44
+ }
45
+ return bestArea;
46
+ }
47
+ /**
48
+ * AI INSTRUCTIONS: compute AreaCoverage statistics from a list of RouteCoverage.
49
+ * - coveredRoutes: routes where status !== 'none'
50
+ * - automatedCount: routes where status === 'automated' or 'both'
51
+ * - manualOnlyCount: routes where status === 'manual'
52
+ * - noCoverageCount: routes where status === 'none'
53
+ * - coveragePercent: Math.round((coveredRoutes / totalRoutes) * 100)
54
+ */
55
+ buildAreaCoverage(name, routes) {
56
+ const totalRoutes = routes.length;
57
+ const coveredRoutes = routes.filter((r) => r.status !== "none").length;
58
+ const automatedCount = routes.filter((r) => r.status === "automated" || r.status === "both").length;
59
+ const manualOnlyCount = routes.filter((r) => r.status === "manual").length;
60
+ const noCoverageCount = routes.filter((r) => r.status === "none").length;
61
+ const coveragePercent = totalRoutes > 0 ? Math.round(coveredRoutes / totalRoutes * 100) : 0;
62
+ return {
63
+ name,
64
+ routes,
65
+ totalRoutes,
66
+ coveredRoutes,
67
+ automatedCount,
68
+ manualOnlyCount,
69
+ noCoverageCount,
70
+ coveragePercent
71
+ };
72
+ }
73
+ };
74
+ export {
75
+ AreaMatcher
76
+ };
77
+ //# sourceMappingURL=area.matcher.mjs.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":";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":[]}
@@ -0,0 +1,83 @@
1
+ import { AppRoute, TestEntry } from '../core/types.mjs';
2
+
3
+ /**
4
+ * src/matchers/fuzzy.matcher.ts
5
+ *
6
+ * Core matching logic: maps test descriptions to app routes.
7
+ *
8
+ * Strategy (applied in order, stops at first match):
9
+ * 1. METHOD GATE — if both route and title carry HTTP method info, they must agree
10
+ * 2. EXACT — title contains the exact route path with a path-boundary check
11
+ * 3. SLUG — path segments appear in title; param segments require evidence
12
+ * 4. KEYWORD — camelCase/hyphen words from segments appear in title
13
+ *
14
+ * Non-param and param routes are treated differently in SLUG and KEYWORD:
15
+ * - Non-param route (/api/products): require 2+ segment hits AND title must not
16
+ * explicitly extend the path as a sub-resource (/api/products/ or /api/products/:)
17
+ * - Param route (/api/products/:id): require ALL non-param segments to match AND
18
+ * evidence that the title accesses the parameterised segment specifically
19
+ *
20
+ * This prevents:
21
+ * - Cross-service pollution: cart tests matching product routes
22
+ * - Parent/child bleed: /api/products matching tests about /api/products/:id
23
+ * - Short-param false positives: 'id' appearing as substring of 'userId', 'discount' etc.
24
+ */
25
+
26
+ declare class FuzzyMatcher {
27
+ /**
28
+ * Returns a Map keyed by "METHOD path" (or just "path" for method-free routes)
29
+ * so GET /api/products and POST /api/products get independent entries.
30
+ */
31
+ match(routes: AppRoute[], tests: TestEntry[]): Map<string, TestEntry[]>;
32
+ private testMatchesRoute;
33
+ /**
34
+ * Extracts an HTTP method from a test title if present as a standalone word.
35
+ * Returns null when no verb is found so the caller knows to skip the method gate.
36
+ */
37
+ private extractMethodFromTitle;
38
+ /**
39
+ * EXACT: title contains the route path with a path-boundary check.
40
+ * Prevents /api/products from matching a title about /api/products/:id.
41
+ */
42
+ private exactMatch;
43
+ /**
44
+ * SLUG: meaningful path segments must appear in the title.
45
+ *
46
+ * Non-param routes (/api/products):
47
+ * - 2+ segments must appear in the title
48
+ * - Title must NOT explicitly extend this path as a sub-resource
49
+ * (guards against /api/products matching a title about /api/products/:id)
50
+ *
51
+ * Param routes (/api/products/:id, /orders/:orderId/items):
52
+ * - ALL non-param segments must appear in the title
53
+ * - AND the title must contain evidence of the parameterised access
54
+ */
55
+ private slugMatch;
56
+ /**
57
+ * KEYWORD: segments are split into words (camelCase + hyphens) and matched
58
+ * against the title.
59
+ *
60
+ * Same non-param / param split logic as slugMatch:
61
+ * - Non-param routes: 2+ word matches, plus sub-path guard
62
+ * - Param routes: ALL non-param words must match, plus param evidence
63
+ */
64
+ private keywordMatch;
65
+ /**
66
+ * Returns true if the test title explicitly extends the given non-param route
67
+ * as a sub-resource — e.g. "/api/products/" or "/api/products/:" in the title.
68
+ * Used to prevent non-param routes from matching tests about their child routes.
69
+ */
70
+ private titleExtendsPath;
71
+ /**
72
+ * Returns true when the title provides evidence of parameterised access:
73
+ * - The literal param placeholder (':id', ':userId') appears in the title
74
+ * - A concrete numeric ID appears (e.g. /products/42)
75
+ * - A UUID-like value appears
76
+ * - The param name itself appears — only if >= 3 chars to avoid 'id' matching
77
+ * as a substring of 'userId', 'discount', 'modified', etc.
78
+ */
79
+ private hasParamEvidence;
80
+ private splitCamelCase;
81
+ }
82
+
83
+ export { FuzzyMatcher };
@@ -0,0 +1,83 @@
1
+ import { AppRoute, TestEntry } from '../core/types.js';
2
+
3
+ /**
4
+ * src/matchers/fuzzy.matcher.ts
5
+ *
6
+ * Core matching logic: maps test descriptions to app routes.
7
+ *
8
+ * Strategy (applied in order, stops at first match):
9
+ * 1. METHOD GATE — if both route and title carry HTTP method info, they must agree
10
+ * 2. EXACT — title contains the exact route path with a path-boundary check
11
+ * 3. SLUG — path segments appear in title; param segments require evidence
12
+ * 4. KEYWORD — camelCase/hyphen words from segments appear in title
13
+ *
14
+ * Non-param and param routes are treated differently in SLUG and KEYWORD:
15
+ * - Non-param route (/api/products): require 2+ segment hits AND title must not
16
+ * explicitly extend the path as a sub-resource (/api/products/ or /api/products/:)
17
+ * - Param route (/api/products/:id): require ALL non-param segments to match AND
18
+ * evidence that the title accesses the parameterised segment specifically
19
+ *
20
+ * This prevents:
21
+ * - Cross-service pollution: cart tests matching product routes
22
+ * - Parent/child bleed: /api/products matching tests about /api/products/:id
23
+ * - Short-param false positives: 'id' appearing as substring of 'userId', 'discount' etc.
24
+ */
25
+
26
+ declare class FuzzyMatcher {
27
+ /**
28
+ * Returns a Map keyed by "METHOD path" (or just "path" for method-free routes)
29
+ * so GET /api/products and POST /api/products get independent entries.
30
+ */
31
+ match(routes: AppRoute[], tests: TestEntry[]): Map<string, TestEntry[]>;
32
+ private testMatchesRoute;
33
+ /**
34
+ * Extracts an HTTP method from a test title if present as a standalone word.
35
+ * Returns null when no verb is found so the caller knows to skip the method gate.
36
+ */
37
+ private extractMethodFromTitle;
38
+ /**
39
+ * EXACT: title contains the route path with a path-boundary check.
40
+ * Prevents /api/products from matching a title about /api/products/:id.
41
+ */
42
+ private exactMatch;
43
+ /**
44
+ * SLUG: meaningful path segments must appear in the title.
45
+ *
46
+ * Non-param routes (/api/products):
47
+ * - 2+ segments must appear in the title
48
+ * - Title must NOT explicitly extend this path as a sub-resource
49
+ * (guards against /api/products matching a title about /api/products/:id)
50
+ *
51
+ * Param routes (/api/products/:id, /orders/:orderId/items):
52
+ * - ALL non-param segments must appear in the title
53
+ * - AND the title must contain evidence of the parameterised access
54
+ */
55
+ private slugMatch;
56
+ /**
57
+ * KEYWORD: segments are split into words (camelCase + hyphens) and matched
58
+ * against the title.
59
+ *
60
+ * Same non-param / param split logic as slugMatch:
61
+ * - Non-param routes: 2+ word matches, plus sub-path guard
62
+ * - Param routes: ALL non-param words must match, plus param evidence
63
+ */
64
+ private keywordMatch;
65
+ /**
66
+ * Returns true if the test title explicitly extends the given non-param route
67
+ * as a sub-resource — e.g. "/api/products/" or "/api/products/:" in the title.
68
+ * Used to prevent non-param routes from matching tests about their child routes.
69
+ */
70
+ private titleExtendsPath;
71
+ /**
72
+ * Returns true when the title provides evidence of parameterised access:
73
+ * - The literal param placeholder (':id', ':userId') appears in the title
74
+ * - A concrete numeric ID appears (e.g. /products/42)
75
+ * - A UUID-like value appears
76
+ * - The param name itself appears — only if >= 3 chars to avoid 'id' matching
77
+ * as a substring of 'userId', 'discount', 'modified', etc.
78
+ */
79
+ private hasParamEvidence;
80
+ private splitCamelCase;
81
+ }
82
+
83
+ export { FuzzyMatcher };
@@ -0,0 +1,161 @@
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/fuzzy.matcher.ts
21
+ var fuzzy_matcher_exports = {};
22
+ __export(fuzzy_matcher_exports, {
23
+ FuzzyMatcher: () => FuzzyMatcher
24
+ });
25
+ module.exports = __toCommonJS(fuzzy_matcher_exports);
26
+ var HTTP_METHODS = ["DELETE", "OPTIONS", "PATCH", "POST", "HEAD", "GET", "PUT"];
27
+ var FuzzyMatcher = class {
28
+ /**
29
+ * Returns a Map keyed by "METHOD path" (or just "path" for method-free routes)
30
+ * so GET /api/products and POST /api/products get independent entries.
31
+ */
32
+ match(routes, tests) {
33
+ const result = /* @__PURE__ */ new Map();
34
+ for (const route of routes) {
35
+ const matched = tests.filter(
36
+ (test) => this.testMatchesRoute(test.title, route.path, route.method)
37
+ );
38
+ if (matched.length > 0) {
39
+ const key = route.method ? `${route.method} ${route.path}` : route.path;
40
+ result.set(key, matched);
41
+ }
42
+ }
43
+ return result;
44
+ }
45
+ testMatchesRoute(title, routePath, routeMethod) {
46
+ const t = title.toLowerCase();
47
+ if (routeMethod) {
48
+ const titleMethod = this.extractMethodFromTitle(title);
49
+ if (titleMethod && titleMethod !== routeMethod.toUpperCase()) {
50
+ return false;
51
+ }
52
+ }
53
+ return this.exactMatch(t, routePath) || this.slugMatch(t, routePath) || this.keywordMatch(t, routePath);
54
+ }
55
+ /**
56
+ * Extracts an HTTP method from a test title if present as a standalone word.
57
+ * Returns null when no verb is found so the caller knows to skip the method gate.
58
+ */
59
+ extractMethodFromTitle(title) {
60
+ const upper = title.toUpperCase();
61
+ for (const method of HTTP_METHODS) {
62
+ if (new RegExp(`\\b${method}\\b`).test(upper)) return method;
63
+ }
64
+ return null;
65
+ }
66
+ /**
67
+ * EXACT: title contains the route path with a path-boundary check.
68
+ * Prevents /api/products from matching a title about /api/products/:id.
69
+ */
70
+ exactMatch(title, routePath) {
71
+ const route = routePath.toLowerCase();
72
+ const idx = title.indexOf(route);
73
+ if (idx === -1) return false;
74
+ const charAfter = title[idx + route.length];
75
+ return charAfter === void 0 || /[\s?#\n]/.test(charAfter);
76
+ }
77
+ /**
78
+ * SLUG: meaningful path segments must appear in the title.
79
+ *
80
+ * Non-param routes (/api/products):
81
+ * - 2+ segments must appear in the title
82
+ * - Title must NOT explicitly extend this path as a sub-resource
83
+ * (guards against /api/products matching a title about /api/products/:id)
84
+ *
85
+ * Param routes (/api/products/:id, /orders/:orderId/items):
86
+ * - ALL non-param segments must appear in the title
87
+ * - AND the title must contain evidence of the parameterised access
88
+ */
89
+ slugMatch(title, routePath) {
90
+ const route = routePath.toLowerCase();
91
+ const allSegments = route.split("/").filter((s) => s.length > 0);
92
+ const nonParamSegs = allSegments.filter((s) => !s.startsWith(":"));
93
+ const paramSegs = allSegments.filter((s) => s.startsWith(":"));
94
+ if (allSegments.length === 0) return false;
95
+ const nonParamHits = nonParamSegs.filter((seg) => title.includes(seg)).length;
96
+ if (paramSegs.length === 0) {
97
+ const threshold = nonParamSegs.length === 1 ? 1 : 2;
98
+ if (nonParamHits < threshold) return false;
99
+ return !this.titleExtendsPath(title, nonParamSegs);
100
+ }
101
+ if (nonParamHits < nonParamSegs.length) return false;
102
+ return this.hasParamEvidence(title, paramSegs);
103
+ }
104
+ /**
105
+ * KEYWORD: segments are split into words (camelCase + hyphens) and matched
106
+ * against the title.
107
+ *
108
+ * Same non-param / param split logic as slugMatch:
109
+ * - Non-param routes: 2+ word matches, plus sub-path guard
110
+ * - Param routes: ALL non-param words must match, plus param evidence
111
+ */
112
+ keywordMatch(title, routePath) {
113
+ const route = routePath.toLowerCase();
114
+ const allSegments = route.split("/").filter((s) => s.length > 0);
115
+ const nonParamSegs = allSegments.filter((s) => !s.startsWith(":"));
116
+ const paramSegs = allSegments.filter((s) => s.startsWith(":"));
117
+ const nonParamWords = nonParamSegs.flatMap((seg) => seg.split("-")).flatMap((seg) => this.splitCamelCase(seg)).map((w) => w.toLowerCase()).filter((w) => w.length >= 3);
118
+ if (nonParamWords.length === 0 && paramSegs.length === 0) return false;
119
+ const nonParamMatchCount = nonParamWords.filter((w) => title.includes(w)).length;
120
+ if (paramSegs.length === 0) {
121
+ if (nonParamMatchCount < Math.min(2, nonParamWords.length)) return false;
122
+ return !this.titleExtendsPath(title, nonParamSegs);
123
+ }
124
+ if (nonParamMatchCount < nonParamWords.length) return false;
125
+ return this.hasParamEvidence(title, paramSegs);
126
+ }
127
+ /**
128
+ * Returns true if the test title explicitly extends the given non-param route
129
+ * as a sub-resource — e.g. "/api/products/" or "/api/products/:" in the title.
130
+ * Used to prevent non-param routes from matching tests about their child routes.
131
+ */
132
+ titleExtendsPath(title, nonParamSegs) {
133
+ const reconstructed = "/" + nonParamSegs.join("/");
134
+ return title.includes(reconstructed + "/") || title.includes(reconstructed + ":");
135
+ }
136
+ /**
137
+ * Returns true when the title provides evidence of parameterised access:
138
+ * - The literal param placeholder (':id', ':userId') appears in the title
139
+ * - A concrete numeric ID appears (e.g. /products/42)
140
+ * - A UUID-like value appears
141
+ * - The param name itself appears — only if >= 3 chars to avoid 'id' matching
142
+ * as a substring of 'userId', 'discount', 'modified', etc.
143
+ */
144
+ hasParamEvidence(title, paramSegs) {
145
+ return paramSegs.some((param) => {
146
+ const paramName = param.slice(1).toLowerCase();
147
+ return title.includes(":" + paramName) || // literal ':id' in title
148
+ /\b\d+\b/.test(title) || // concrete numeric ID
149
+ /\b[0-9a-f-]{8,}\b/.test(title) || // UUID-like value
150
+ paramName.length >= 3 && title.includes(paramName);
151
+ });
152
+ }
153
+ splitCamelCase(str) {
154
+ return str.replace(/([A-Z])/g, " $1").trim().split(" ").filter(Boolean);
155
+ }
156
+ };
157
+ // Annotate the CommonJS export names for ESM import in node:
158
+ 0 && (module.exports = {
159
+ FuzzyMatcher
160
+ });
161
+ //# sourceMappingURL=fuzzy.matcher.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/matchers/fuzzy.matcher.ts"],"sourcesContent":["/**\n * src/matchers/fuzzy.matcher.ts\n *\n * Core matching logic: maps test descriptions to app routes.\n *\n * Strategy (applied in order, stops at first match):\n * 1. METHOD GATE — if both route and title carry HTTP method info, they must agree\n * 2. EXACT — title contains the exact route path with a path-boundary check\n * 3. SLUG — path segments appear in title; param segments require evidence\n * 4. KEYWORD — camelCase/hyphen words from segments appear in title\n *\n * Non-param and param routes are treated differently in SLUG and KEYWORD:\n * - Non-param route (/api/products): require 2+ segment hits AND title must not\n * explicitly extend the path as a sub-resource (/api/products/ or /api/products/:)\n * - Param route (/api/products/:id): require ALL non-param segments to match AND\n * evidence that the title accesses the parameterised segment specifically\n *\n * This prevents:\n * - Cross-service pollution: cart tests matching product routes\n * - Parent/child bleed: /api/products matching tests about /api/products/:id\n * - Short-param false positives: 'id' appearing as substring of 'userId', 'discount' etc.\n */\n\nimport { AppRoute, TestEntry } from '../core/types'\n\nconst HTTP_METHODS = ['DELETE', 'OPTIONS', 'PATCH', 'POST', 'HEAD', 'GET', 'PUT']\n\nexport class FuzzyMatcher {\n /**\n * Returns a Map keyed by \"METHOD path\" (or just \"path\" for method-free routes)\n * so GET /api/products and POST /api/products get independent entries.\n */\n match(routes: AppRoute[], tests: TestEntry[]): Map<string, TestEntry[]> {\n const result = new Map<string, TestEntry[]>()\n\n for (const route of routes) {\n const matched = tests.filter(test =>\n this.testMatchesRoute(test.title, route.path, route.method)\n )\n if (matched.length > 0) {\n const key = route.method ? `${route.method} ${route.path}` : route.path\n result.set(key, matched)\n }\n }\n\n return result\n }\n\n private testMatchesRoute(title: string, routePath: string, routeMethod?: string): boolean {\n const t = title.toLowerCase()\n\n // ── Method gate ────────────────────────────────────────────────────────────\n // Only fires when BOTH the route has a method AND the title contains an HTTP\n // verb. Tests without a verb (e.g. \"should return product list\") skip the gate\n // and proceed to fuzzy matching unchanged — backwards compatible.\n if (routeMethod) {\n const titleMethod = this.extractMethodFromTitle(title)\n if (titleMethod && titleMethod !== routeMethod.toUpperCase()) {\n return false\n }\n }\n\n return (\n this.exactMatch(t, routePath) ||\n this.slugMatch(t, routePath) ||\n this.keywordMatch(t, routePath)\n )\n }\n\n /**\n * Extracts an HTTP method from a test title if present as a standalone word.\n * Returns null when no verb is found so the caller knows to skip the method gate.\n */\n private extractMethodFromTitle(title: string): string | null {\n const upper = title.toUpperCase()\n for (const method of HTTP_METHODS) {\n if (new RegExp(`\\\\b${method}\\\\b`).test(upper)) return method\n }\n return null\n }\n\n /**\n * EXACT: title contains the route path with a path-boundary check.\n * Prevents /api/products from matching a title about /api/products/:id.\n */\n private exactMatch(title: string, routePath: string): boolean {\n const route = routePath.toLowerCase()\n const idx = title.indexOf(route)\n if (idx === -1) return false\n const charAfter = title[idx + route.length]\n // Reject if the matched path continues as a deeper segment\n return charAfter === undefined || /[\\s?#\\n]/.test(charAfter)\n }\n\n /**\n * SLUG: meaningful path segments must appear in the title.\n *\n * Non-param routes (/api/products):\n * - 2+ segments must appear in the title\n * - Title must NOT explicitly extend this path as a sub-resource\n * (guards against /api/products matching a title about /api/products/:id)\n *\n * Param routes (/api/products/:id, /orders/:orderId/items):\n * - ALL non-param segments must appear in the title\n * - AND the title must contain evidence of the parameterised access\n */\n private slugMatch(title: string, routePath: string): boolean {\n const route = routePath.toLowerCase()\n const allSegments = route.split('/').filter(s => s.length > 0)\n const nonParamSegs = allSegments.filter(s => !s.startsWith(':'))\n const paramSegs = allSegments.filter(s => s.startsWith(':'))\n\n if (allSegments.length === 0) return false\n\n const nonParamHits = nonParamSegs.filter(seg => title.includes(seg)).length\n\n if (paramSegs.length === 0) {\n // Non-param route\n const threshold = nonParamSegs.length === 1 ? 1 : 2\n if (nonParamHits < threshold) return false\n return !this.titleExtendsPath(title, nonParamSegs)\n }\n\n // Param route: all non-param segments must match, then check param evidence\n if (nonParamHits < nonParamSegs.length) return false\n return this.hasParamEvidence(title, paramSegs)\n }\n\n /**\n * KEYWORD: segments are split into words (camelCase + hyphens) and matched\n * against the title.\n *\n * Same non-param / param split logic as slugMatch:\n * - Non-param routes: 2+ word matches, plus sub-path guard\n * - Param routes: ALL non-param words must match, plus param evidence\n */\n private keywordMatch(title: string, routePath: string): boolean {\n const route = routePath.toLowerCase()\n const allSegments = route.split('/').filter(s => s.length > 0)\n const nonParamSegs = allSegments.filter(s => !s.startsWith(':'))\n const paramSegs = allSegments.filter(s => s.startsWith(':'))\n\n const nonParamWords = nonParamSegs\n .flatMap(seg => seg.split('-'))\n .flatMap(seg => this.splitCamelCase(seg))\n .map(w => w.toLowerCase())\n .filter(w => w.length >= 3)\n\n if (nonParamWords.length === 0 && paramSegs.length === 0) return false\n\n const nonParamMatchCount = nonParamWords.filter(w => title.includes(w)).length\n\n if (paramSegs.length === 0) {\n // Non-param route\n if (nonParamMatchCount < Math.min(2, nonParamWords.length)) return false\n return !this.titleExtendsPath(title, nonParamSegs)\n }\n\n // Param route: ALL non-param words must match first — this is the key guard\n // against cross-service false positives (cart tests have 'api' but not 'products')\n if (nonParamMatchCount < nonParamWords.length) return false\n return this.hasParamEvidence(title, paramSegs)\n }\n\n /**\n * Returns true if the test title explicitly extends the given non-param route\n * as a sub-resource — e.g. \"/api/products/\" or \"/api/products/:\" in the title.\n * Used to prevent non-param routes from matching tests about their child routes.\n */\n private titleExtendsPath(title: string, nonParamSegs: string[]): boolean {\n const reconstructed = '/' + nonParamSegs.join('/')\n return title.includes(reconstructed + '/') || title.includes(reconstructed + ':')\n }\n\n /**\n * Returns true when the title provides evidence of parameterised access:\n * - The literal param placeholder (':id', ':userId') appears in the title\n * - A concrete numeric ID appears (e.g. /products/42)\n * - A UUID-like value appears\n * - The param name itself appears — only if >= 3 chars to avoid 'id' matching\n * as a substring of 'userId', 'discount', 'modified', etc.\n */\n private hasParamEvidence(title: string, paramSegs: string[]): boolean {\n return paramSegs.some(param => {\n const paramName = param.slice(1).toLowerCase() // ':userId' → 'userid'\n return (\n title.includes(':' + paramName) || // literal ':id' in title\n /\\b\\d+\\b/.test(title) || // concrete numeric ID\n /\\b[0-9a-f-]{8,}\\b/.test(title) || // UUID-like value\n (paramName.length >= 3 && title.includes(paramName)) // name only if long enough\n )\n })\n }\n\n private splitCamelCase(str: string): string[] {\n return str.replace(/([A-Z])/g, ' $1').trim().split(' ').filter(Boolean)\n }\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,136 @@
1
+ // src/matchers/fuzzy.matcher.ts
2
+ var HTTP_METHODS = ["DELETE", "OPTIONS", "PATCH", "POST", "HEAD", "GET", "PUT"];
3
+ var FuzzyMatcher = class {
4
+ /**
5
+ * Returns a Map keyed by "METHOD path" (or just "path" for method-free routes)
6
+ * so GET /api/products and POST /api/products get independent entries.
7
+ */
8
+ match(routes, tests) {
9
+ const result = /* @__PURE__ */ new Map();
10
+ for (const route of routes) {
11
+ const matched = tests.filter(
12
+ (test) => this.testMatchesRoute(test.title, route.path, route.method)
13
+ );
14
+ if (matched.length > 0) {
15
+ const key = route.method ? `${route.method} ${route.path}` : route.path;
16
+ result.set(key, matched);
17
+ }
18
+ }
19
+ return result;
20
+ }
21
+ testMatchesRoute(title, routePath, routeMethod) {
22
+ const t = title.toLowerCase();
23
+ if (routeMethod) {
24
+ const titleMethod = this.extractMethodFromTitle(title);
25
+ if (titleMethod && titleMethod !== routeMethod.toUpperCase()) {
26
+ return false;
27
+ }
28
+ }
29
+ return this.exactMatch(t, routePath) || this.slugMatch(t, routePath) || this.keywordMatch(t, routePath);
30
+ }
31
+ /**
32
+ * Extracts an HTTP method from a test title if present as a standalone word.
33
+ * Returns null when no verb is found so the caller knows to skip the method gate.
34
+ */
35
+ extractMethodFromTitle(title) {
36
+ const upper = title.toUpperCase();
37
+ for (const method of HTTP_METHODS) {
38
+ if (new RegExp(`\\b${method}\\b`).test(upper)) return method;
39
+ }
40
+ return null;
41
+ }
42
+ /**
43
+ * EXACT: title contains the route path with a path-boundary check.
44
+ * Prevents /api/products from matching a title about /api/products/:id.
45
+ */
46
+ exactMatch(title, routePath) {
47
+ const route = routePath.toLowerCase();
48
+ const idx = title.indexOf(route);
49
+ if (idx === -1) return false;
50
+ const charAfter = title[idx + route.length];
51
+ return charAfter === void 0 || /[\s?#\n]/.test(charAfter);
52
+ }
53
+ /**
54
+ * SLUG: meaningful path segments must appear in the title.
55
+ *
56
+ * Non-param routes (/api/products):
57
+ * - 2+ segments must appear in the title
58
+ * - Title must NOT explicitly extend this path as a sub-resource
59
+ * (guards against /api/products matching a title about /api/products/:id)
60
+ *
61
+ * Param routes (/api/products/:id, /orders/:orderId/items):
62
+ * - ALL non-param segments must appear in the title
63
+ * - AND the title must contain evidence of the parameterised access
64
+ */
65
+ slugMatch(title, routePath) {
66
+ const route = routePath.toLowerCase();
67
+ const allSegments = route.split("/").filter((s) => s.length > 0);
68
+ const nonParamSegs = allSegments.filter((s) => !s.startsWith(":"));
69
+ const paramSegs = allSegments.filter((s) => s.startsWith(":"));
70
+ if (allSegments.length === 0) return false;
71
+ const nonParamHits = nonParamSegs.filter((seg) => title.includes(seg)).length;
72
+ if (paramSegs.length === 0) {
73
+ const threshold = nonParamSegs.length === 1 ? 1 : 2;
74
+ if (nonParamHits < threshold) return false;
75
+ return !this.titleExtendsPath(title, nonParamSegs);
76
+ }
77
+ if (nonParamHits < nonParamSegs.length) return false;
78
+ return this.hasParamEvidence(title, paramSegs);
79
+ }
80
+ /**
81
+ * KEYWORD: segments are split into words (camelCase + hyphens) and matched
82
+ * against the title.
83
+ *
84
+ * Same non-param / param split logic as slugMatch:
85
+ * - Non-param routes: 2+ word matches, plus sub-path guard
86
+ * - Param routes: ALL non-param words must match, plus param evidence
87
+ */
88
+ keywordMatch(title, routePath) {
89
+ const route = routePath.toLowerCase();
90
+ const allSegments = route.split("/").filter((s) => s.length > 0);
91
+ const nonParamSegs = allSegments.filter((s) => !s.startsWith(":"));
92
+ const paramSegs = allSegments.filter((s) => s.startsWith(":"));
93
+ const nonParamWords = nonParamSegs.flatMap((seg) => seg.split("-")).flatMap((seg) => this.splitCamelCase(seg)).map((w) => w.toLowerCase()).filter((w) => w.length >= 3);
94
+ if (nonParamWords.length === 0 && paramSegs.length === 0) return false;
95
+ const nonParamMatchCount = nonParamWords.filter((w) => title.includes(w)).length;
96
+ if (paramSegs.length === 0) {
97
+ if (nonParamMatchCount < Math.min(2, nonParamWords.length)) return false;
98
+ return !this.titleExtendsPath(title, nonParamSegs);
99
+ }
100
+ if (nonParamMatchCount < nonParamWords.length) return false;
101
+ return this.hasParamEvidence(title, paramSegs);
102
+ }
103
+ /**
104
+ * Returns true if the test title explicitly extends the given non-param route
105
+ * as a sub-resource — e.g. "/api/products/" or "/api/products/:" in the title.
106
+ * Used to prevent non-param routes from matching tests about their child routes.
107
+ */
108
+ titleExtendsPath(title, nonParamSegs) {
109
+ const reconstructed = "/" + nonParamSegs.join("/");
110
+ return title.includes(reconstructed + "/") || title.includes(reconstructed + ":");
111
+ }
112
+ /**
113
+ * Returns true when the title provides evidence of parameterised access:
114
+ * - The literal param placeholder (':id', ':userId') appears in the title
115
+ * - A concrete numeric ID appears (e.g. /products/42)
116
+ * - A UUID-like value appears
117
+ * - The param name itself appears — only if >= 3 chars to avoid 'id' matching
118
+ * as a substring of 'userId', 'discount', 'modified', etc.
119
+ */
120
+ hasParamEvidence(title, paramSegs) {
121
+ return paramSegs.some((param) => {
122
+ const paramName = param.slice(1).toLowerCase();
123
+ return title.includes(":" + paramName) || // literal ':id' in title
124
+ /\b\d+\b/.test(title) || // concrete numeric ID
125
+ /\b[0-9a-f-]{8,}\b/.test(title) || // UUID-like value
126
+ paramName.length >= 3 && title.includes(paramName);
127
+ });
128
+ }
129
+ splitCamelCase(str) {
130
+ return str.replace(/([A-Z])/g, " $1").trim().split(" ").filter(Boolean);
131
+ }
132
+ };
133
+ export {
134
+ FuzzyMatcher
135
+ };
136
+ //# sourceMappingURL=fuzzy.matcher.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/matchers/fuzzy.matcher.ts"],"sourcesContent":["/**\n * src/matchers/fuzzy.matcher.ts\n *\n * Core matching logic: maps test descriptions to app routes.\n *\n * Strategy (applied in order, stops at first match):\n * 1. METHOD GATE — if both route and title carry HTTP method info, they must agree\n * 2. EXACT — title contains the exact route path with a path-boundary check\n * 3. SLUG — path segments appear in title; param segments require evidence\n * 4. KEYWORD — camelCase/hyphen words from segments appear in title\n *\n * Non-param and param routes are treated differently in SLUG and KEYWORD:\n * - Non-param route (/api/products): require 2+ segment hits AND title must not\n * explicitly extend the path as a sub-resource (/api/products/ or /api/products/:)\n * - Param route (/api/products/:id): require ALL non-param segments to match AND\n * evidence that the title accesses the parameterised segment specifically\n *\n * This prevents:\n * - Cross-service pollution: cart tests matching product routes\n * - Parent/child bleed: /api/products matching tests about /api/products/:id\n * - Short-param false positives: 'id' appearing as substring of 'userId', 'discount' etc.\n */\n\nimport { AppRoute, TestEntry } from '../core/types'\n\nconst HTTP_METHODS = ['DELETE', 'OPTIONS', 'PATCH', 'POST', 'HEAD', 'GET', 'PUT']\n\nexport class FuzzyMatcher {\n /**\n * Returns a Map keyed by \"METHOD path\" (or just \"path\" for method-free routes)\n * so GET /api/products and POST /api/products get independent entries.\n */\n match(routes: AppRoute[], tests: TestEntry[]): Map<string, TestEntry[]> {\n const result = new Map<string, TestEntry[]>()\n\n for (const route of routes) {\n const matched = tests.filter(test =>\n this.testMatchesRoute(test.title, route.path, route.method)\n )\n if (matched.length > 0) {\n const key = route.method ? `${route.method} ${route.path}` : route.path\n result.set(key, matched)\n }\n }\n\n return result\n }\n\n private testMatchesRoute(title: string, routePath: string, routeMethod?: string): boolean {\n const t = title.toLowerCase()\n\n // ── Method gate ────────────────────────────────────────────────────────────\n // Only fires when BOTH the route has a method AND the title contains an HTTP\n // verb. Tests without a verb (e.g. \"should return product list\") skip the gate\n // and proceed to fuzzy matching unchanged — backwards compatible.\n if (routeMethod) {\n const titleMethod = this.extractMethodFromTitle(title)\n if (titleMethod && titleMethod !== routeMethod.toUpperCase()) {\n return false\n }\n }\n\n return (\n this.exactMatch(t, routePath) ||\n this.slugMatch(t, routePath) ||\n this.keywordMatch(t, routePath)\n )\n }\n\n /**\n * Extracts an HTTP method from a test title if present as a standalone word.\n * Returns null when no verb is found so the caller knows to skip the method gate.\n */\n private extractMethodFromTitle(title: string): string | null {\n const upper = title.toUpperCase()\n for (const method of HTTP_METHODS) {\n if (new RegExp(`\\\\b${method}\\\\b`).test(upper)) return method\n }\n return null\n }\n\n /**\n * EXACT: title contains the route path with a path-boundary check.\n * Prevents /api/products from matching a title about /api/products/:id.\n */\n private exactMatch(title: string, routePath: string): boolean {\n const route = routePath.toLowerCase()\n const idx = title.indexOf(route)\n if (idx === -1) return false\n const charAfter = title[idx + route.length]\n // Reject if the matched path continues as a deeper segment\n return charAfter === undefined || /[\\s?#\\n]/.test(charAfter)\n }\n\n /**\n * SLUG: meaningful path segments must appear in the title.\n *\n * Non-param routes (/api/products):\n * - 2+ segments must appear in the title\n * - Title must NOT explicitly extend this path as a sub-resource\n * (guards against /api/products matching a title about /api/products/:id)\n *\n * Param routes (/api/products/:id, /orders/:orderId/items):\n * - ALL non-param segments must appear in the title\n * - AND the title must contain evidence of the parameterised access\n */\n private slugMatch(title: string, routePath: string): boolean {\n const route = routePath.toLowerCase()\n const allSegments = route.split('/').filter(s => s.length > 0)\n const nonParamSegs = allSegments.filter(s => !s.startsWith(':'))\n const paramSegs = allSegments.filter(s => s.startsWith(':'))\n\n if (allSegments.length === 0) return false\n\n const nonParamHits = nonParamSegs.filter(seg => title.includes(seg)).length\n\n if (paramSegs.length === 0) {\n // Non-param route\n const threshold = nonParamSegs.length === 1 ? 1 : 2\n if (nonParamHits < threshold) return false\n return !this.titleExtendsPath(title, nonParamSegs)\n }\n\n // Param route: all non-param segments must match, then check param evidence\n if (nonParamHits < nonParamSegs.length) return false\n return this.hasParamEvidence(title, paramSegs)\n }\n\n /**\n * KEYWORD: segments are split into words (camelCase + hyphens) and matched\n * against the title.\n *\n * Same non-param / param split logic as slugMatch:\n * - Non-param routes: 2+ word matches, plus sub-path guard\n * - Param routes: ALL non-param words must match, plus param evidence\n */\n private keywordMatch(title: string, routePath: string): boolean {\n const route = routePath.toLowerCase()\n const allSegments = route.split('/').filter(s => s.length > 0)\n const nonParamSegs = allSegments.filter(s => !s.startsWith(':'))\n const paramSegs = allSegments.filter(s => s.startsWith(':'))\n\n const nonParamWords = nonParamSegs\n .flatMap(seg => seg.split('-'))\n .flatMap(seg => this.splitCamelCase(seg))\n .map(w => w.toLowerCase())\n .filter(w => w.length >= 3)\n\n if (nonParamWords.length === 0 && paramSegs.length === 0) return false\n\n const nonParamMatchCount = nonParamWords.filter(w => title.includes(w)).length\n\n if (paramSegs.length === 0) {\n // Non-param route\n if (nonParamMatchCount < Math.min(2, nonParamWords.length)) return false\n return !this.titleExtendsPath(title, nonParamSegs)\n }\n\n // Param route: ALL non-param words must match first — this is the key guard\n // against cross-service false positives (cart tests have 'api' but not 'products')\n if (nonParamMatchCount < nonParamWords.length) return false\n return this.hasParamEvidence(title, paramSegs)\n }\n\n /**\n * Returns true if the test title explicitly extends the given non-param route\n * as a sub-resource — e.g. \"/api/products/\" or \"/api/products/:\" in the title.\n * Used to prevent non-param routes from matching tests about their child routes.\n */\n private titleExtendsPath(title: string, nonParamSegs: string[]): boolean {\n const reconstructed = '/' + nonParamSegs.join('/')\n return title.includes(reconstructed + '/') || title.includes(reconstructed + ':')\n }\n\n /**\n * Returns true when the title provides evidence of parameterised access:\n * - The literal param placeholder (':id', ':userId') appears in the title\n * - A concrete numeric ID appears (e.g. /products/42)\n * - A UUID-like value appears\n * - The param name itself appears — only if >= 3 chars to avoid 'id' matching\n * as a substring of 'userId', 'discount', 'modified', etc.\n */\n private hasParamEvidence(title: string, paramSegs: string[]): boolean {\n return paramSegs.some(param => {\n const paramName = param.slice(1).toLowerCase() // ':userId' → 'userid'\n return (\n title.includes(':' + paramName) || // literal ':id' in title\n /\\b\\d+\\b/.test(title) || // concrete numeric ID\n /\\b[0-9a-f-]{8,}\\b/.test(title) || // UUID-like value\n (paramName.length >= 3 && title.includes(paramName)) // name only if long enough\n )\n })\n }\n\n private splitCamelCase(str: string): string[] {\n return str.replace(/([A-Z])/g, ' $1').trim().split(' ').filter(Boolean)\n }\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,19 @@
1
+ import { CoverageReport } from '../core/types.mjs';
2
+
3
+ /**
4
+ * src/reporters/base.reporter.ts
5
+ *
6
+ * Abstract base class for all reporters.
7
+ * To add a new output format: extend this class, implement write().
8
+ */
9
+
10
+ declare abstract class BaseReporter {
11
+ abstract readonly name: string;
12
+ /**
13
+ * Write the report to the given output path.
14
+ * For console reporter, outputPath may be ignored.
15
+ */
16
+ abstract write(report: CoverageReport, outputPath: string): Promise<void>;
17
+ }
18
+
19
+ export { BaseReporter };
@@ -0,0 +1,19 @@
1
+ import { CoverageReport } from '../core/types.js';
2
+
3
+ /**
4
+ * src/reporters/base.reporter.ts
5
+ *
6
+ * Abstract base class for all reporters.
7
+ * To add a new output format: extend this class, implement write().
8
+ */
9
+
10
+ declare abstract class BaseReporter {
11
+ abstract readonly name: string;
12
+ /**
13
+ * Write the report to the given output path.
14
+ * For console reporter, outputPath may be ignored.
15
+ */
16
+ abstract write(report: CoverageReport, outputPath: string): Promise<void>;
17
+ }
18
+
19
+ export { BaseReporter };