@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.
- package/dist/core/config.d.mts +26 -0
- package/dist/core/config.d.ts +26 -0
- package/dist/core/config.js +139 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/config.mjs +104 -0
- package/dist/core/config.mjs.map +1 -0
- package/dist/core/detector.d.mts +43 -0
- package/dist/core/detector.d.ts +43 -0
- package/dist/core/detector.js +431 -0
- package/dist/core/detector.js.map +1 -0
- package/dist/core/detector.mjs +395 -0
- package/dist/core/detector.mjs.map +1 -0
- package/dist/core/engine.d.mts +29 -0
- package/dist/core/engine.d.ts +29 -0
- package/dist/core/engine.js +151 -0
- package/dist/core/engine.js.map +1 -0
- package/dist/core/engine.mjs +126 -0
- package/dist/core/engine.mjs.map +1 -0
- package/dist/core/types.d.mts +109 -0
- package/dist/core/types.d.ts +109 -0
- package/dist/core/types.js +19 -0
- package/dist/core/types.js.map +1 -0
- package/dist/core/types.mjs +1 -0
- package/dist/core/types.mjs.map +1 -0
- package/dist/matchers/area.matcher.d.mts +38 -0
- package/dist/matchers/area.matcher.d.ts +38 -0
- package/dist/matchers/area.matcher.js +102 -0
- package/dist/matchers/area.matcher.js.map +1 -0
- package/dist/matchers/area.matcher.mjs +77 -0
- package/dist/matchers/area.matcher.mjs.map +1 -0
- package/dist/matchers/fuzzy.matcher.d.mts +83 -0
- package/dist/matchers/fuzzy.matcher.d.ts +83 -0
- package/dist/matchers/fuzzy.matcher.js +161 -0
- package/dist/matchers/fuzzy.matcher.js.map +1 -0
- package/dist/matchers/fuzzy.matcher.mjs +136 -0
- package/dist/matchers/fuzzy.matcher.mjs.map +1 -0
- package/dist/reporters/base.reporter.d.mts +19 -0
- package/dist/reporters/base.reporter.d.ts +19 -0
- package/dist/reporters/base.reporter.js +32 -0
- package/dist/reporters/base.reporter.js.map +1 -0
- package/dist/reporters/base.reporter.mjs +7 -0
- package/dist/reporters/base.reporter.mjs.map +1 -0
- package/dist/reporters/console.reporter.d.mts +16 -0
- package/dist/reporters/console.reporter.d.ts +16 -0
- package/dist/reporters/console.reporter.js +130 -0
- package/dist/reporters/console.reporter.js.map +1 -0
- package/dist/reporters/console.reporter.mjs +95 -0
- package/dist/reporters/console.reporter.mjs.map +1 -0
- package/dist/sources/base.source.d.mts +22 -0
- package/dist/sources/base.source.d.ts +22 -0
- package/dist/sources/base.source.js +130 -0
- package/dist/sources/base.source.js.map +1 -0
- package/dist/sources/base.source.mjs +93 -0
- package/dist/sources/base.source.mjs.map +1 -0
- package/dist/sources/playwright.source.d.mts +34 -0
- package/dist/sources/playwright.source.d.ts +34 -0
- package/dist/sources/playwright.source.js +209 -0
- package/dist/sources/playwright.source.js.map +1 -0
- package/dist/sources/playwright.source.mjs +172 -0
- package/dist/sources/playwright.source.mjs.map +1 -0
- package/dist/sources/routes.source.d.mts +58 -0
- package/dist/sources/routes.source.d.ts +58 -0
- package/dist/sources/routes.source.js +288 -0
- package/dist/sources/routes.source.js.map +1 -0
- package/dist/sources/routes.source.mjs +251 -0
- package/dist/sources/routes.source.mjs.map +1 -0
- package/dist/sources/yaml.source.d.mts +16 -0
- package/dist/sources/yaml.source.d.ts +16 -0
- package/dist/sources/yaml.source.js +160 -0
- package/dist/sources/yaml.source.js.map +1 -0
- package/dist/sources/yaml.source.mjs +123 -0
- package/dist/sources/yaml.source.mjs.map +1 -0
- package/dist/utils/http.d.mts +7 -0
- package/dist/utils/http.d.ts +7 -0
- package/dist/utils/http.js +37 -0
- package/dist/utils/http.js.map +1 -0
- package/dist/utils/http.mjs +12 -0
- package/dist/utils/http.mjs.map +1 -0
- package/dist/utils/logger.d.mts +35 -0
- package/dist/utils/logger.d.ts +35 -0
- package/dist/utils/logger.js +114 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/logger.mjs +79 -0
- package/dist/utils/logger.mjs.map +1 -0
- package/package.json +115 -0
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
// src/core/detector.ts
|
|
2
|
+
import * as fs3 from "fs";
|
|
3
|
+
import * as path3 from "path";
|
|
4
|
+
|
|
5
|
+
// src/utils/logger.ts
|
|
6
|
+
import * as fs from "fs";
|
|
7
|
+
import * as path from "path";
|
|
8
|
+
var Logger = class {
|
|
9
|
+
logPath = null;
|
|
10
|
+
fd = null;
|
|
11
|
+
/**
|
|
12
|
+
* Open (or create) qualitylens.log in the given directory.
|
|
13
|
+
* Appends to an existing log so multiple runs accumulate history.
|
|
14
|
+
* Call once at the start of each command.
|
|
15
|
+
*/
|
|
16
|
+
configure(outputDir) {
|
|
17
|
+
try {
|
|
18
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
19
|
+
this.logPath = path.join(outputDir, "qualitylens.log");
|
|
20
|
+
this.fd = fs.openSync(this.logPath, "a");
|
|
21
|
+
this.write("INFO", `--- qualitylens session started (pid ${process.pid}) ---`);
|
|
22
|
+
} catch {
|
|
23
|
+
this.logPath = null;
|
|
24
|
+
this.fd = null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Path to the current log file, or null if not configured. */
|
|
28
|
+
get filePath() {
|
|
29
|
+
return this.logPath;
|
|
30
|
+
}
|
|
31
|
+
info(msg, detail) {
|
|
32
|
+
this.write("INFO", msg, detail);
|
|
33
|
+
}
|
|
34
|
+
warn(msg, detail) {
|
|
35
|
+
this.write("WARN", msg, detail);
|
|
36
|
+
}
|
|
37
|
+
/** Logs full detail (stack trace, raw error) to file only. */
|
|
38
|
+
error(msg, detail) {
|
|
39
|
+
this.write("ERROR", msg, detail);
|
|
40
|
+
}
|
|
41
|
+
debug(msg, detail) {
|
|
42
|
+
this.write("DEBUG", msg, detail);
|
|
43
|
+
}
|
|
44
|
+
close() {
|
|
45
|
+
if (this.fd !== null) {
|
|
46
|
+
try {
|
|
47
|
+
fs.closeSync(this.fd);
|
|
48
|
+
} catch {
|
|
49
|
+
}
|
|
50
|
+
this.fd = null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
write(level, msg, detail) {
|
|
54
|
+
if (this.fd === null) return;
|
|
55
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString();
|
|
56
|
+
let line = `[${ts}] ${level.padEnd(5)} ${msg}`;
|
|
57
|
+
if (detail !== void 0) {
|
|
58
|
+
if (detail instanceof Error) {
|
|
59
|
+
line += `
|
|
60
|
+
${detail.message}`;
|
|
61
|
+
if (detail.stack) {
|
|
62
|
+
line += "\n" + detail.stack.split("\n").map((l) => " " + l).join("\n");
|
|
63
|
+
}
|
|
64
|
+
} else if (typeof detail === "string" && detail.trim()) {
|
|
65
|
+
line += "\n" + detail.split("\n").map((l) => " " + l).join("\n");
|
|
66
|
+
} else if (typeof detail === "object") {
|
|
67
|
+
try {
|
|
68
|
+
line += "\n " + JSON.stringify(detail, null, 2).replace(/\n/g, "\n ");
|
|
69
|
+
} catch {
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
fs.writeSync(this.fd, line + "\n");
|
|
75
|
+
} catch {
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
var logger = new Logger();
|
|
80
|
+
|
|
81
|
+
// src/sources/routes.source.ts
|
|
82
|
+
import * as fs2 from "fs";
|
|
83
|
+
import * as path2 from "path";
|
|
84
|
+
import glob from "fast-glob";
|
|
85
|
+
var RoutesSource = class {
|
|
86
|
+
constructor(config) {
|
|
87
|
+
this.config = config;
|
|
88
|
+
}
|
|
89
|
+
async collect() {
|
|
90
|
+
const type = this.config.type;
|
|
91
|
+
logger.info(`[routes] collecting routes`, { type, path: this.config.path });
|
|
92
|
+
try {
|
|
93
|
+
switch (type) {
|
|
94
|
+
case "nextjs":
|
|
95
|
+
return await this.collectNextJs();
|
|
96
|
+
case "openapi":
|
|
97
|
+
return await this.collectOpenApi();
|
|
98
|
+
case "express":
|
|
99
|
+
return await this.collectExpress();
|
|
100
|
+
case "manual":
|
|
101
|
+
return await this.collectManual();
|
|
102
|
+
default:
|
|
103
|
+
throw new Error(
|
|
104
|
+
`Unknown routes.type "${this.config.type}" in qualitylens.yaml.
|
|
105
|
+
Valid values: nextjs, openapi, express, manual`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
} catch (err) {
|
|
109
|
+
logger.error(`[routes] collection failed (type=${type}, path=${this.config.path})`, err);
|
|
110
|
+
throw err;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Next.js pages/ or app/ directory scanner.
|
|
115
|
+
*
|
|
116
|
+
* AI INSTRUCTIONS:
|
|
117
|
+
* 1. Glob all .tsx, .ts, .jsx, .js files under config.path
|
|
118
|
+
* 2. Convert file path to route:
|
|
119
|
+
* pages/checkout/payment.tsx → /checkout/payment
|
|
120
|
+
* pages/index.tsx → /
|
|
121
|
+
* pages/auth/[id].tsx → /auth/:id
|
|
122
|
+
* app/checkout/page.tsx → /checkout (App Router pattern)
|
|
123
|
+
* 3. Skip: _app, _document, _error, api/ routes, layout.tsx, loading.tsx
|
|
124
|
+
* 4. Return AppRoute[]
|
|
125
|
+
*/
|
|
126
|
+
async collectNextJs() {
|
|
127
|
+
const rootPath = path2.resolve(this.config.path);
|
|
128
|
+
if (!fs2.existsSync(rootPath)) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`Next.js routes directory not found: ${rootPath}
|
|
131
|
+
Check routes.path in qualitylens.yaml \u2014 it should point to your pages/ or app/ directory.`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
const appRouterPages = await glob("**/page.{tsx,ts,jsx,js}", {
|
|
135
|
+
cwd: rootPath,
|
|
136
|
+
absolute: false,
|
|
137
|
+
ignore: ["**/node_modules/**"]
|
|
138
|
+
});
|
|
139
|
+
if (appRouterPages.length > 0) {
|
|
140
|
+
return appRouterPages.map((filePath) => {
|
|
141
|
+
const dir = filePath.replace(/\/page\.[jt]sx?$/, "").replace(/^page\.[jt]sx?$/, "");
|
|
142
|
+
const segments = dir.split("/").filter((seg) => !(seg.startsWith("(") && seg.endsWith(")")));
|
|
143
|
+
const routeSegments = segments.map(
|
|
144
|
+
(seg) => seg.startsWith("[") && seg.endsWith("]") ? `:${seg.slice(1, -1)}` : seg
|
|
145
|
+
);
|
|
146
|
+
const normalised = ("/" + routeSegments.join("/")).replace(/\/+/g, "/").replace(/\/$/, "") || "/";
|
|
147
|
+
return { path: normalised };
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
const pageFiles = await glob("**/*.{tsx,ts,jsx,js}", {
|
|
151
|
+
cwd: rootPath,
|
|
152
|
+
absolute: false,
|
|
153
|
+
ignore: ["**/node_modules/**", "**/_app.*", "**/_document.*", "**/_error.*", "**/api/**"]
|
|
154
|
+
});
|
|
155
|
+
return pageFiles.map((filePath) => {
|
|
156
|
+
const withoutExt = filePath.replace(/\.[jt]sx?$/, "");
|
|
157
|
+
const withoutIndex = withoutExt.replace(/(^|\/)index$/, "");
|
|
158
|
+
const segments = withoutIndex.split("/").map((seg) => seg.startsWith("[") && seg.endsWith("]") ? `:${seg.slice(1, -1)}` : seg);
|
|
159
|
+
return { path: ("/" + segments.join("/")).replace(/\/+/g, "/").replace(/\/$/, "") || "/" };
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* OpenAPI / Swagger spec parser.
|
|
164
|
+
*
|
|
165
|
+
* AI INSTRUCTIONS:
|
|
166
|
+
* 1. Read the JSON or YAML file at config.path
|
|
167
|
+
* 2. Extract spec.paths object — keys are route paths
|
|
168
|
+
* 3. For each path, extract HTTP methods (get, post, put, delete, patch)
|
|
169
|
+
* 4. Return one AppRoute per path+method combination
|
|
170
|
+
* 5. Support both JSON (.json) and YAML (.yaml, .yml) specs
|
|
171
|
+
*/
|
|
172
|
+
async collectOpenApi() {
|
|
173
|
+
const absPath = path2.resolve(this.config.path);
|
|
174
|
+
if (!fs2.existsSync(absPath)) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
`OpenAPI spec not found: ${absPath}
|
|
177
|
+
If your spec is generated at build time, check the routes.generate command in qualitylens.yaml.
|
|
178
|
+
Run the generate command manually first to confirm it produces the file.`
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
let content;
|
|
182
|
+
try {
|
|
183
|
+
content = fs2.readFileSync(absPath, "utf-8");
|
|
184
|
+
} catch (err) {
|
|
185
|
+
throw new Error(`Could not read OpenAPI spec at ${absPath}: ${err.message}`);
|
|
186
|
+
}
|
|
187
|
+
const ext = path2.extname(this.config.path).toLowerCase();
|
|
188
|
+
let spec;
|
|
189
|
+
try {
|
|
190
|
+
spec = ext === ".json" ? JSON.parse(content) : (await import("js-yaml")).load(content);
|
|
191
|
+
} catch (err) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
`Failed to parse OpenAPI spec at ${absPath} as ${ext === ".json" ? "JSON" : "YAML"}.
|
|
194
|
+
Parse error: ${err.message}
|
|
195
|
+
Check the file is valid by running: node -e "JSON.parse(require('fs').readFileSync('${absPath}','utf-8'))"`
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
const HTTP_METHODS = ["get", "post", "put", "patch", "delete", "head", "options"];
|
|
199
|
+
const routes = [];
|
|
200
|
+
for (const [routePath, pathItem] of Object.entries(spec.paths ?? {})) {
|
|
201
|
+
const normalisedPath = routePath.replace(/\{(\w+)\}/g, ":$1");
|
|
202
|
+
for (const method of HTTP_METHODS) {
|
|
203
|
+
if (pathItem[method]) {
|
|
204
|
+
routes.push({ path: normalisedPath, method: method.toUpperCase() });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return routes;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Express routes manifest (JSON file).
|
|
212
|
+
* Format: [{ path: '/checkout', method: 'GET' }, ...]
|
|
213
|
+
* Users generate this with express-list-routes or similar.
|
|
214
|
+
*/
|
|
215
|
+
async collectExpress() {
|
|
216
|
+
return this.readJsonRouteManifest("express");
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Manual route list in qualitylens.yaml.
|
|
220
|
+
* config.path points to a JSON file with [{ path: '/route' }]
|
|
221
|
+
*/
|
|
222
|
+
async collectManual() {
|
|
223
|
+
return this.readJsonRouteManifest("manual");
|
|
224
|
+
}
|
|
225
|
+
/** Shared reader for express and manual JSON route manifests. */
|
|
226
|
+
readJsonRouteManifest(type) {
|
|
227
|
+
const absPath = path2.resolve(this.config.path);
|
|
228
|
+
if (!fs2.existsSync(absPath)) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`Routes manifest not found: ${absPath}
|
|
231
|
+
routes.type is "${type}" \u2014 qualitylens expects a JSON file at routes.path.
|
|
232
|
+
Format: [{ "path": "/api/users", "method": "GET" }, ...]`
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
let content;
|
|
236
|
+
try {
|
|
237
|
+
content = fs2.readFileSync(absPath, "utf-8");
|
|
238
|
+
} catch (err) {
|
|
239
|
+
throw new Error(`Could not read routes manifest at ${absPath}: ${err.message}`);
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
return JSON.parse(content);
|
|
243
|
+
} catch (err) {
|
|
244
|
+
throw new Error(
|
|
245
|
+
`Failed to parse routes manifest at ${absPath} as JSON.
|
|
246
|
+
Parse error: ${err.message}
|
|
247
|
+
Expected format: [{ "path": "/api/users", "method": "GET" }, ...]`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
// src/core/detector.ts
|
|
254
|
+
var KNOWN_AREA_NAMES = {
|
|
255
|
+
auth: "Authentication",
|
|
256
|
+
login: "Authentication",
|
|
257
|
+
register: "Authentication",
|
|
258
|
+
logout: "Authentication",
|
|
259
|
+
signup: "Authentication",
|
|
260
|
+
checkout: "Checkout",
|
|
261
|
+
cart: "Shopping & Cart",
|
|
262
|
+
basket: "Shopping & Cart",
|
|
263
|
+
orders: "Orders",
|
|
264
|
+
order: "Orders",
|
|
265
|
+
account: "Account",
|
|
266
|
+
profile: "Account",
|
|
267
|
+
admin: "Admin",
|
|
268
|
+
settings: "Settings",
|
|
269
|
+
products: "Products",
|
|
270
|
+
product: "Products",
|
|
271
|
+
users: "Users",
|
|
272
|
+
user: "Users",
|
|
273
|
+
health: "Health",
|
|
274
|
+
ai: "AI",
|
|
275
|
+
billing: "Billing",
|
|
276
|
+
dashboard: "Dashboard"
|
|
277
|
+
};
|
|
278
|
+
async function detect(targetDir) {
|
|
279
|
+
const projectName = path3.basename(targetDir);
|
|
280
|
+
const pkgPath = path3.join(targetDir, "package.json");
|
|
281
|
+
const pkg = fs3.existsSync(pkgPath) ? JSON.parse(fs3.readFileSync(pkgPath, "utf-8")) : {};
|
|
282
|
+
const allDeps = {
|
|
283
|
+
...pkg.dependencies,
|
|
284
|
+
...pkg.devDependencies
|
|
285
|
+
};
|
|
286
|
+
const hasNext = "next" in allDeps;
|
|
287
|
+
const hasExpress = "express" in allDeps;
|
|
288
|
+
const hasSwaggerJsdoc = "swagger-jsdoc" in allDeps;
|
|
289
|
+
const hasPlaywright = "@playwright/test" in allDeps;
|
|
290
|
+
const TEST_DIR_CANDIDATES = ["e2e-tests", "e2e", "tests", "__tests__", "cypress/e2e", "test"];
|
|
291
|
+
let testsPath = null;
|
|
292
|
+
for (const candidate of TEST_DIR_CANDIDATES) {
|
|
293
|
+
if (fs3.existsSync(path3.join(targetDir, candidate))) {
|
|
294
|
+
testsPath = `./${candidate}`;
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
let framework = "unknown";
|
|
299
|
+
let routeType = "manual";
|
|
300
|
+
let routesPath = null;
|
|
301
|
+
let generate = null;
|
|
302
|
+
if (hasNext) {
|
|
303
|
+
framework = "nextjs";
|
|
304
|
+
routeType = "nextjs";
|
|
305
|
+
for (const candidate of ["src/app", "app", "src/pages", "pages"]) {
|
|
306
|
+
if (fs3.existsSync(path3.join(targetDir, candidate))) {
|
|
307
|
+
routesPath = `./${candidate}`;
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
} else if (hasExpress && hasSwaggerJsdoc) {
|
|
312
|
+
framework = "express-openapi";
|
|
313
|
+
routeType = "openapi";
|
|
314
|
+
routesPath = "./openapi.json";
|
|
315
|
+
generate = "npx ts-node src/generate-openapi.ts";
|
|
316
|
+
} else if (hasExpress) {
|
|
317
|
+
framework = "express";
|
|
318
|
+
routeType = "express";
|
|
319
|
+
}
|
|
320
|
+
let discoveredRoutes = [];
|
|
321
|
+
if (routesPath) {
|
|
322
|
+
const absoluteRoutesPath = path3.join(targetDir, routesPath.replace("./", ""));
|
|
323
|
+
const shouldDiscover = routeType === "nextjs" || routeType === "openapi" && fs3.existsSync(absoluteRoutesPath);
|
|
324
|
+
if (shouldDiscover) {
|
|
325
|
+
try {
|
|
326
|
+
const source = new RoutesSource({
|
|
327
|
+
type: routeType === "openapi" ? "openapi" : "nextjs",
|
|
328
|
+
path: absoluteRoutesPath
|
|
329
|
+
});
|
|
330
|
+
discoveredRoutes = await source.collect();
|
|
331
|
+
} catch {
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
const basePath = detectBasePath(discoveredRoutes);
|
|
336
|
+
const suggestedAreas = suggestAreas(discoveredRoutes, basePath);
|
|
337
|
+
return {
|
|
338
|
+
projectName,
|
|
339
|
+
framework,
|
|
340
|
+
routeType,
|
|
341
|
+
routesPath,
|
|
342
|
+
testsPath,
|
|
343
|
+
generate,
|
|
344
|
+
basePath,
|
|
345
|
+
hasSwaggerJsdoc,
|
|
346
|
+
hasPlaywright,
|
|
347
|
+
discoveredRoutes,
|
|
348
|
+
suggestedAreas
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
function detectBasePath(routes) {
|
|
352
|
+
if (routes.length === 0) return null;
|
|
353
|
+
const versionedPrefix = routes[0].path.match(/^(\/api\/v\d+)\//)?.[1];
|
|
354
|
+
if (versionedPrefix) {
|
|
355
|
+
const count = routes.filter((r) => r.path.startsWith(versionedPrefix + "/")).length;
|
|
356
|
+
if (count > routes.length * 0.5) return versionedPrefix;
|
|
357
|
+
}
|
|
358
|
+
const apiCount = routes.filter((r) => r.path.startsWith("/api/")).length;
|
|
359
|
+
if (apiCount > routes.length * 0.5) return "/api";
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
function suggestAreas(routes, basePath) {
|
|
363
|
+
if (routes.length === 0) return [];
|
|
364
|
+
const nameGroups = /* @__PURE__ */ new Map();
|
|
365
|
+
for (const route of routes) {
|
|
366
|
+
const hasPrefix = basePath ? route.path.startsWith(basePath + "/") : false;
|
|
367
|
+
const normalised = hasPrefix ? route.path.slice(basePath.length) : route.path;
|
|
368
|
+
const segments = normalised.split("/").filter((s) => s.length > 0 && !s.startsWith(":"));
|
|
369
|
+
const firstSegment = segments[0] ?? "";
|
|
370
|
+
if (!firstSegment) continue;
|
|
371
|
+
const areaName = toAreaName(firstSegment);
|
|
372
|
+
const pattern = hasPrefix ? `${basePath}/${firstSegment}` : `/${firstSegment}`;
|
|
373
|
+
if (!nameGroups.has(areaName)) {
|
|
374
|
+
nameGroups.set(areaName, { routes: [], patterns: /* @__PURE__ */ new Set() });
|
|
375
|
+
}
|
|
376
|
+
const group = nameGroups.get(areaName);
|
|
377
|
+
group.routes.push(route);
|
|
378
|
+
group.patterns.add(pattern);
|
|
379
|
+
}
|
|
380
|
+
return Array.from(nameGroups.entries()).map(([name, { routes: routes2, patterns }]) => ({
|
|
381
|
+
suggestedName: name,
|
|
382
|
+
routes: routes2,
|
|
383
|
+
patterns: Array.from(patterns)
|
|
384
|
+
}));
|
|
385
|
+
}
|
|
386
|
+
function toAreaName(segment) {
|
|
387
|
+
const lower = segment.toLowerCase();
|
|
388
|
+
if (KNOWN_AREA_NAMES[lower]) return KNOWN_AREA_NAMES[lower];
|
|
389
|
+
return segment.charAt(0).toUpperCase() + segment.slice(1);
|
|
390
|
+
}
|
|
391
|
+
export {
|
|
392
|
+
detect,
|
|
393
|
+
suggestAreas
|
|
394
|
+
};
|
|
395
|
+
//# sourceMappingURL=detector.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/core/detector.ts","../../src/utils/logger.ts","../../src/sources/routes.source.ts"],"sourcesContent":["/**\n * src/core/detector.ts\n *\n * Auto-detection logic for `qualitylens init`.\n * Inspects a target directory and returns everything it can infer:\n * framework, route type, routes path, discovered routes, and suggested areas.\n *\n * Nothing here writes files or prompts the user — it only observes.\n */\n\nimport * as fs from 'fs'\nimport * as path from 'path'\nimport { RoutesSource } from '../sources/routes.source'\nimport { AppRoute } from './types'\n\nexport type Framework = 'nextjs' | 'express-openapi' | 'express' | 'unknown'\nexport type RouteType = 'nextjs' | 'openapi' | 'express' | 'manual'\n\nexport interface SuggestedArea {\n suggestedName: string\n routes: AppRoute[]\n patterns: string[]\n}\n\nexport interface DetectionResult {\n projectName: string\n framework: Framework\n routeType: RouteType\n routesPath: string | null // null = could not detect\n testsPath: string | null // null = use config dir default\n generate: string | null // suggested generate command for openapi\n basePath: string | null // detected route prefix to strip when grouping areas (e.g. /api, /api/v1)\n hasSwaggerJsdoc: boolean\n hasPlaywright: boolean\n discoveredRoutes: AppRoute[]\n suggestedAreas: SuggestedArea[]\n}\n\n// Known path segment → human-readable area name.\n// Segments not listed here get title-cased automatically (e.g. 'billing' → 'Billing').\nconst KNOWN_AREA_NAMES: Record<string, string> = {\n auth: 'Authentication',\n login: 'Authentication',\n register: 'Authentication',\n logout: 'Authentication',\n signup: 'Authentication',\n checkout: 'Checkout',\n cart: 'Shopping & Cart',\n basket: 'Shopping & Cart',\n orders: 'Orders',\n order: 'Orders',\n account: 'Account',\n profile: 'Account',\n admin: 'Admin',\n settings: 'Settings',\n products: 'Products',\n product: 'Products',\n users: 'Users',\n user: 'Users',\n health: 'Health',\n ai: 'AI',\n billing: 'Billing',\n dashboard: 'Dashboard',\n}\n\nexport async function detect(targetDir: string): Promise<DetectionResult> {\n const projectName = path.basename(targetDir)\n\n // ── Read package.json ────────────────────────────────────────────────────\n const pkgPath = path.join(targetDir, 'package.json')\n const pkg = fs.existsSync(pkgPath)\n ? JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))\n : {}\n\n const allDeps: Record<string, string> = {\n ...pkg.dependencies,\n ...pkg.devDependencies,\n }\n\n const hasNext = 'next' in allDeps\n const hasExpress = 'express' in allDeps\n const hasSwaggerJsdoc = 'swagger-jsdoc' in allDeps\n const hasPlaywright = '@playwright/test' in allDeps\n\n // ── Detect test directory ────────────────────────────────────────────────\n // Check common test directory names in order of specificity.\n // null means \"use the config file directory\" (the default behaviour).\n const TEST_DIR_CANDIDATES = ['e2e-tests', 'e2e', 'tests', '__tests__', 'cypress/e2e', 'test']\n let testsPath: string | null = null\n for (const candidate of TEST_DIR_CANDIDATES) {\n if (fs.existsSync(path.join(targetDir, candidate))) {\n testsPath = `./${candidate}`\n break\n }\n }\n\n // ── Determine framework and route type ──────────────────────────────────\n let framework: Framework = 'unknown'\n let routeType: RouteType = 'manual'\n let routesPath: string | null = null\n let generate: string | null = null\n\n if (hasNext) {\n framework = 'nextjs'\n routeType = 'nextjs'\n\n // Check for App Router first, then Pages Router, in likely locations\n for (const candidate of ['src/app', 'app', 'src/pages', 'pages']) {\n if (fs.existsSync(path.join(targetDir, candidate))) {\n routesPath = `./${candidate}`\n break\n }\n }\n } else if (hasExpress && hasSwaggerJsdoc) {\n framework = 'express-openapi'\n routeType = 'openapi'\n routesPath = './openapi.json'\n // Suggest generate command — user must create the script, but we show them what to put\n generate = 'npx ts-node src/generate-openapi.ts'\n } else if (hasExpress) {\n framework = 'express'\n routeType = 'express'\n }\n\n // ── Discover routes ──────────────────────────────────────────────────────\n // Next.js: always discoverable from the file system.\n // OpenAPI: discoverable only if the spec file already exists on disk.\n // Express/manual: need a generate step — skip discovery.\n let discoveredRoutes: AppRoute[] = []\n\n if (routesPath) {\n const absoluteRoutesPath = path.join(targetDir, routesPath.replace('./', ''))\n const shouldDiscover =\n routeType === 'nextjs' ||\n (routeType === 'openapi' && fs.existsSync(absoluteRoutesPath))\n\n if (shouldDiscover) {\n try {\n const source = new RoutesSource({\n type: routeType === 'openapi' ? 'openapi' : 'nextjs',\n path: absoluteRoutesPath,\n })\n discoveredRoutes = await source.collect()\n } catch {\n // Not a blocker — init still works, areas will be empty\n }\n }\n }\n\n // ── Detect basePath ──────────────────────────────────────────────────────\n const basePath = detectBasePath(discoveredRoutes)\n\n // ── Suggest areas from discovered routes ────────────────────────────────\n const suggestedAreas = suggestAreas(discoveredRoutes, basePath)\n\n return {\n projectName,\n framework,\n routeType,\n routesPath,\n testsPath,\n generate,\n basePath,\n hasSwaggerJsdoc,\n hasPlaywright,\n discoveredRoutes,\n suggestedAreas,\n }\n}\n\n/**\n * Infers the common base path prefix from discovered routes.\n * Checks for /api/vN (versioned) first, then plain /api.\n * Returns null when fewer than half the routes share the prefix — avoids false guesses.\n */\nfunction detectBasePath(routes: AppRoute[]): string | null {\n if (routes.length === 0) return null\n\n // Check for versioned prefix /api/vN\n const versionedPrefix = routes[0].path.match(/^(\\/api\\/v\\d+)\\//)?.[1]\n if (versionedPrefix) {\n const count = routes.filter(r => r.path.startsWith(versionedPrefix + '/')).length\n if (count > routes.length * 0.5) return versionedPrefix\n }\n\n // Check for plain /api prefix\n const apiCount = routes.filter(r => r.path.startsWith('/api/')).length\n if (apiCount > routes.length * 0.5) return '/api'\n\n return null\n}\n\n/**\n * Groups routes by their first meaningful path segment and maps each group\n * to a human-readable area name.\n * basePath (e.g. /api, /api/v1) is stripped per-route before grouping so routes\n * are organised by service name rather than the shared prefix.\n * Exported so the init command can re-run it after the user confirms/changes basePath.\n */\nexport function suggestAreas(routes: AppRoute[], basePath: string | null): SuggestedArea[] {\n if (routes.length === 0) return []\n\n // Group routes by suggested area name (multiple segments can map to the same name)\n const nameGroups = new Map<string, { routes: AppRoute[]; patterns: Set<string> }>()\n\n for (const route of routes) {\n // Strip basePath per-route when it matches — handles mixed specs where only\n // some routes carry the prefix (e.g. /health alongside /api/products)\n const hasPrefix = basePath ? route.path.startsWith(basePath + '/') : false\n const normalised = hasPrefix ? route.path.slice(basePath!.length) : route.path\n const segments = normalised.split('/').filter(s => s.length > 0 && !s.startsWith(':'))\n const firstSegment = segments[0] ?? ''\n\n // Root route (/) goes into an 'uncategorised' bucket — not forced into an area\n if (!firstSegment) continue\n\n const areaName = toAreaName(firstSegment)\n const pattern = hasPrefix ? `${basePath}/${firstSegment}` : `/${firstSegment}`\n\n if (!nameGroups.has(areaName)) {\n nameGroups.set(areaName, { routes: [], patterns: new Set() })\n }\n const group = nameGroups.get(areaName)!\n group.routes.push(route)\n group.patterns.add(pattern)\n }\n\n return Array.from(nameGroups.entries()).map(([name, { routes, patterns }]) => ({\n suggestedName: name,\n routes,\n patterns: Array.from(patterns),\n }))\n}\n\n/** Converts a raw path segment to a human-readable area name. */\nfunction toAreaName(segment: string): string {\n const lower = segment.toLowerCase()\n if (KNOWN_AREA_NAMES[lower]) return KNOWN_AREA_NAMES[lower]\n // Title-case unknown segments: 'invoices' → 'Invoices'\n return segment.charAt(0).toUpperCase() + segment.slice(1)\n}\n","/**\n * src/utils/logger.ts\n *\n * Module-level singleton logger.\n * - Always writes to a log file (qualitylens.log) with timestamps and full detail.\n * - Console output is handled separately by each command — the logger only writes the file.\n *\n * Usage:\n * import { logger } from './utils/logger'\n * logger.configure('./reports') // call once at startup\n * logger.info('scan started', { config: opts.config })\n * logger.error('route discovery failed', err)\n */\n\nimport * as fs from 'fs'\nimport * as path from 'path'\n\ntype LogLevel = 'INFO' | 'WARN' | 'ERROR' | 'DEBUG'\n\nclass Logger {\n private logPath: string | null = null\n private fd: number | null = null\n\n /**\n * Open (or create) qualitylens.log in the given directory.\n * Appends to an existing log so multiple runs accumulate history.\n * Call once at the start of each command.\n */\n configure(outputDir: string): void {\n try {\n fs.mkdirSync(outputDir, { recursive: true })\n this.logPath = path.join(outputDir, 'qualitylens.log')\n this.fd = fs.openSync(this.logPath, 'a')\n this.write('INFO', `--- qualitylens session started (pid ${process.pid}) ---`)\n } catch {\n // If we can't open a log file, continue silently — logging is non-blocking\n this.logPath = null\n this.fd = null\n }\n }\n\n /** Path to the current log file, or null if not configured. */\n get filePath(): string | null {\n return this.logPath\n }\n\n info(msg: string, detail?: unknown): void {\n this.write('INFO', msg, detail)\n }\n\n warn(msg: string, detail?: unknown): void {\n this.write('WARN', msg, detail)\n }\n\n /** Logs full detail (stack trace, raw error) to file only. */\n error(msg: string, detail?: unknown): void {\n this.write('ERROR', msg, detail)\n }\n\n debug(msg: string, detail?: unknown): void {\n this.write('DEBUG', msg, detail)\n }\n\n close(): void {\n if (this.fd !== null) {\n try { fs.closeSync(this.fd) } catch { /* ignore */ }\n this.fd = null\n }\n }\n\n private write(level: LogLevel, msg: string, detail?: unknown): void {\n if (this.fd === null) return\n\n const ts = new Date().toISOString()\n let line = `[${ts}] ${level.padEnd(5)} ${msg}`\n\n if (detail !== undefined) {\n if (detail instanceof Error) {\n line += `\\n ${detail.message}`\n if (detail.stack) {\n line += '\\n' + detail.stack.split('\\n').map(l => ' ' + l).join('\\n')\n }\n } else if (typeof detail === 'string' && detail.trim()) {\n line += '\\n' + detail.split('\\n').map(l => ' ' + l).join('\\n')\n } else if (typeof detail === 'object') {\n try {\n line += '\\n ' + JSON.stringify(detail, null, 2).replace(/\\n/g, '\\n ')\n } catch { /* circular ref or similar — skip */ }\n }\n }\n\n try {\n fs.writeSync(this.fd, line + '\\n')\n } catch { /* disk full or fd closed — ignore */ }\n }\n}\n\nexport const logger = new Logger()\n","/**\n * src/sources/routes.source.ts\n * \n * Discovers application routes from the project structure.\n * Supports: Next.js pages/, Next.js app/, OpenAPI spec, Express manifest.\n * \n * AI INSTRUCTIONS:\n * Implement each strategy as a private method.\n * collect() dispatches to the right strategy based on config.type.\n */\n\nimport { AppRoute, TestGapConfig } from '../core/types'\nimport { logger } from '../utils/logger'\nimport * as fs from 'fs'\nimport * as path from 'path'\nimport glob from 'fast-glob'\n\nexport class RoutesSource {\n constructor(private config: TestGapConfig['routes']) {}\n\n async collect(): Promise<AppRoute[]> {\n const type = this.config.type\n logger.info(`[routes] collecting routes`, { type, path: this.config.path })\n\n try {\n switch (type) {\n case 'nextjs': return await this.collectNextJs()\n case 'openapi': return await this.collectOpenApi()\n case 'express': return await this.collectExpress()\n case 'manual': return await this.collectManual()\n default:\n throw new Error(\n `Unknown routes.type \"${(this.config as any).type}\" in qualitylens.yaml.\\n` +\n ` Valid values: nextjs, openapi, express, manual`\n )\n }\n } catch (err) {\n logger.error(`[routes] collection failed (type=${type}, path=${this.config.path})`, err)\n throw err\n }\n }\n\n /**\n * Next.js pages/ or app/ directory scanner.\n * \n * AI INSTRUCTIONS:\n * 1. Glob all .tsx, .ts, .jsx, .js files under config.path\n * 2. Convert file path to route:\n * pages/checkout/payment.tsx → /checkout/payment\n * pages/index.tsx → /\n * pages/auth/[id].tsx → /auth/:id\n * app/checkout/page.tsx → /checkout (App Router pattern)\n * 3. Skip: _app, _document, _error, api/ routes, layout.tsx, loading.tsx\n * 4. Return AppRoute[]\n */\n private async collectNextJs(): Promise<AppRoute[]> {\n const rootPath = path.resolve(this.config.path)\n\n if (!fs.existsSync(rootPath)) {\n throw new Error(\n `Next.js routes directory not found: ${rootPath}\\n` +\n ` Check routes.path in qualitylens.yaml — it should point to your pages/ or app/ directory.`\n )\n }\n\n // Detect App Router vs Pages Router by looking for files named \"page.tsx/ts\".\n // App Router → files are named page.tsx inside route folders: app/cart/page.tsx\n // Pages Router → files ARE the routes: pages/cart.tsx\n const appRouterPages = await glob('**/page.{tsx,ts,jsx,js}', {\n cwd: rootPath,\n absolute: false,\n ignore: ['**/node_modules/**'],\n })\n\n if (appRouterPages.length > 0) {\n // ── App Router ──────────────────────────────────────────────────────────\n return appRouterPages.map(filePath => {\n // fast-glob always uses forward slashes — safe to split on '/' on all OSes.\n // filePath examples:\n // page.tsx → /\n // cart/page.tsx → /cart\n // orders/[id]/page.tsx → /orders/:id\n // (auth)/login/page.tsx → /login (route groups in parens are stripped)\n\n // Remove the trailing \"/page.tsx\" (or page.ts etc.)\n const dir = filePath\n .replace(/\\/page\\.[jt]sx?$/, '')\n .replace(/^page\\.[jt]sx?$/, '')\n\n // Strip route groups: (auth) → removed entirely\n const segments = dir\n .split('/')\n .filter(seg => !(seg.startsWith('(') && seg.endsWith(')')))\n\n // Convert [param] dynamic segments to :param\n const routeSegments = segments.map(seg =>\n seg.startsWith('[') && seg.endsWith(']') ? `:${seg.slice(1, -1)}` : seg\n )\n\n const normalised = ('/' + routeSegments.join('/')).replace(/\\/+/g, '/').replace(/\\/$/, '') || '/'\n return { path: normalised }\n })\n }\n\n // ── Pages Router ──────────────────────────────────────────────────────────\n const pageFiles = await glob('**/*.{tsx,ts,jsx,js}', {\n cwd: rootPath,\n absolute: false,\n ignore: ['**/node_modules/**', '**/_app.*', '**/_document.*', '**/_error.*', '**/api/**'],\n })\n\n return pageFiles.map(filePath => {\n // pages/checkout/payment.tsx → /checkout/payment\n // pages/index.tsx → /\n const withoutExt = filePath.replace(/\\.[jt]sx?$/, '')\n const withoutIndex = withoutExt.replace(/(^|\\/)index$/, '')\n const segments = withoutIndex\n .split('/')\n .map(seg => seg.startsWith('[') && seg.endsWith(']') ? `:${seg.slice(1, -1)}` : seg)\n\n return { path: ('/' + segments.join('/')).replace(/\\/+/g, '/').replace(/\\/$/, '') || '/' }\n })\n }\n\n /**\n * OpenAPI / Swagger spec parser.\n * \n * AI INSTRUCTIONS:\n * 1. Read the JSON or YAML file at config.path\n * 2. Extract spec.paths object — keys are route paths\n * 3. For each path, extract HTTP methods (get, post, put, delete, patch)\n * 4. Return one AppRoute per path+method combination\n * 5. Support both JSON (.json) and YAML (.yaml, .yml) specs\n */\n private async collectOpenApi(): Promise<AppRoute[]> {\n const absPath = path.resolve(this.config.path)\n\n if (!fs.existsSync(absPath)) {\n throw new Error(\n `OpenAPI spec not found: ${absPath}\\n` +\n ` If your spec is generated at build time, check the routes.generate command in qualitylens.yaml.\\n` +\n ` Run the generate command manually first to confirm it produces the file.`\n )\n }\n\n let content: string\n try {\n content = fs.readFileSync(absPath, 'utf-8')\n } catch (err) {\n throw new Error(`Could not read OpenAPI spec at ${absPath}: ${(err as Error).message}`)\n }\n\n // Support both JSON and YAML OpenAPI specs — detect by file extension\n const ext = path.extname(this.config.path).toLowerCase()\n let spec: any\n try {\n spec = (ext === '.json')\n ? JSON.parse(content)\n : (await import('js-yaml')).load(content)\n } catch (err) {\n throw new Error(\n `Failed to parse OpenAPI spec at ${absPath} as ${ext === '.json' ? 'JSON' : 'YAML'}.\\n` +\n ` Parse error: ${(err as Error).message}\\n` +\n ` Check the file is valid by running: node -e \"JSON.parse(require('fs').readFileSync('${absPath}','utf-8'))\"`\n )\n }\n\n // The OpenAPI `paths` object is a map of route → { get: {}, post: {}, ... }\n // e.g. { '/api/products': { get: {...}, post: {...} }, '/api/products/{id}': { get: {...} } }\n const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options']\n const routes: AppRoute[] = []\n\n for (const [routePath, pathItem] of Object.entries(spec.paths ?? {})) {\n // Convert OpenAPI {param} style to :param style (consistent with the rest of qualitylens)\n const normalisedPath = routePath.replace(/\\{(\\w+)\\}/g, ':$1')\n\n for (const method of HTTP_METHODS) {\n if ((pathItem as any)[method]) {\n routes.push({ path: normalisedPath, method: method.toUpperCase() })\n }\n }\n }\n\n return routes\n }\n\n /**\n * Express routes manifest (JSON file).\n * Format: [{ path: '/checkout', method: 'GET' }, ...]\n * Users generate this with express-list-routes or similar.\n */\n private async collectExpress(): Promise<AppRoute[]> {\n return this.readJsonRouteManifest('express')\n }\n\n /**\n * Manual route list in qualitylens.yaml.\n * config.path points to a JSON file with [{ path: '/route' }]\n */\n private async collectManual(): Promise<AppRoute[]> {\n return this.readJsonRouteManifest('manual')\n }\n\n /** Shared reader for express and manual JSON route manifests. */\n private readJsonRouteManifest(type: string): AppRoute[] {\n const absPath = path.resolve(this.config.path)\n\n if (!fs.existsSync(absPath)) {\n throw new Error(\n `Routes manifest not found: ${absPath}\\n` +\n ` routes.type is \"${type}\" — qualitylens expects a JSON file at routes.path.\\n` +\n ` Format: [{ \"path\": \"/api/users\", \"method\": \"GET\" }, ...]`\n )\n }\n\n let content: string\n try {\n content = fs.readFileSync(absPath, 'utf-8')\n } catch (err) {\n throw new Error(`Could not read routes manifest at ${absPath}: ${(err as Error).message}`)\n }\n\n try {\n return JSON.parse(content) as AppRoute[]\n } catch (err) {\n throw new Error(\n `Failed to parse routes manifest at ${absPath} as JSON.\\n` +\n ` Parse error: ${(err as Error).message}\\n` +\n ` Expected format: [{ \"path\": \"/api/users\", \"method\": \"GET\" }, ...]`\n )\n }\n }\n}\n"],"mappings":"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","names":["fs","path","fs","path","routes"]}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { TestGapConfig, CoverageReport } from './types.mjs';
|
|
2
|
+
import { BaseSource } from '../sources/base.source.mjs';
|
|
3
|
+
import { RoutesSource } from '../sources/routes.source.mjs';
|
|
4
|
+
import { FuzzyMatcher } from '../matchers/fuzzy.matcher.mjs';
|
|
5
|
+
import { AreaMatcher } from '../matchers/area.matcher.mjs';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* src/core/engine.ts
|
|
9
|
+
*
|
|
10
|
+
* The main orchestrator. Zero I/O — all dependencies injected.
|
|
11
|
+
* This design makes the engine fully unit-testable with mock sources.
|
|
12
|
+
*
|
|
13
|
+
* AI INSTRUCTIONS: implement run() using the step-by-step comments.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
declare class CoverageEngine {
|
|
17
|
+
private sources;
|
|
18
|
+
private routesSource;
|
|
19
|
+
private matcher;
|
|
20
|
+
private areaMatcher;
|
|
21
|
+
private config;
|
|
22
|
+
constructor(sources: BaseSource[], routesSource: RoutesSource, matcher: FuzzyMatcher, areaMatcher: AreaMatcher, config: TestGapConfig);
|
|
23
|
+
run(): Promise<CoverageReport>;
|
|
24
|
+
private deriveStatus;
|
|
25
|
+
private deriveStaleness;
|
|
26
|
+
private latestDate;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export { CoverageEngine };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { TestGapConfig, CoverageReport } from './types.js';
|
|
2
|
+
import { BaseSource } from '../sources/base.source.js';
|
|
3
|
+
import { RoutesSource } from '../sources/routes.source.js';
|
|
4
|
+
import { FuzzyMatcher } from '../matchers/fuzzy.matcher.js';
|
|
5
|
+
import { AreaMatcher } from '../matchers/area.matcher.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* src/core/engine.ts
|
|
9
|
+
*
|
|
10
|
+
* The main orchestrator. Zero I/O — all dependencies injected.
|
|
11
|
+
* This design makes the engine fully unit-testable with mock sources.
|
|
12
|
+
*
|
|
13
|
+
* AI INSTRUCTIONS: implement run() using the step-by-step comments.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
declare class CoverageEngine {
|
|
17
|
+
private sources;
|
|
18
|
+
private routesSource;
|
|
19
|
+
private matcher;
|
|
20
|
+
private areaMatcher;
|
|
21
|
+
private config;
|
|
22
|
+
constructor(sources: BaseSource[], routesSource: RoutesSource, matcher: FuzzyMatcher, areaMatcher: AreaMatcher, config: TestGapConfig);
|
|
23
|
+
run(): Promise<CoverageReport>;
|
|
24
|
+
private deriveStatus;
|
|
25
|
+
private deriveStaleness;
|
|
26
|
+
private latestDate;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export { CoverageEngine };
|
|
@@ -0,0 +1,151 @@
|
|
|
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/core/engine.ts
|
|
21
|
+
var engine_exports = {};
|
|
22
|
+
__export(engine_exports, {
|
|
23
|
+
CoverageEngine: () => CoverageEngine
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(engine_exports);
|
|
26
|
+
var CoverageEngine = class {
|
|
27
|
+
constructor(sources, routesSource, matcher, areaMatcher, config) {
|
|
28
|
+
this.sources = sources;
|
|
29
|
+
this.routesSource = routesSource;
|
|
30
|
+
this.matcher = matcher;
|
|
31
|
+
this.areaMatcher = areaMatcher;
|
|
32
|
+
this.config = config;
|
|
33
|
+
}
|
|
34
|
+
async run() {
|
|
35
|
+
const sourceResults = await Promise.all(this.sources.map((s) => s.collect()));
|
|
36
|
+
const allTests = sourceResults.flat();
|
|
37
|
+
const routes = await this.routesSource.collect();
|
|
38
|
+
const matchMap = this.matcher.match(routes, allTests);
|
|
39
|
+
const matchedTests = /* @__PURE__ */ new Set();
|
|
40
|
+
for (const tests of matchMap.values()) {
|
|
41
|
+
for (const t of tests) matchedTests.add(t);
|
|
42
|
+
}
|
|
43
|
+
const hints = this.config.testHints ?? [];
|
|
44
|
+
const hintMap = /* @__PURE__ */ new Map();
|
|
45
|
+
for (const hint of hints) hintMap.set(hint.test, hint);
|
|
46
|
+
const routeGaps = [];
|
|
47
|
+
const acknowledgedTests = [];
|
|
48
|
+
const hintResolved = /* @__PURE__ */ new Set();
|
|
49
|
+
for (const test of allTests) {
|
|
50
|
+
if (matchedTests.has(test)) continue;
|
|
51
|
+
const hint = hintMap.get(test.title);
|
|
52
|
+
if (!hint) continue;
|
|
53
|
+
if (hint.status === "resolved" && hint.route) {
|
|
54
|
+
const key = hint.method ? `${hint.method} ${hint.route}` : hint.route;
|
|
55
|
+
const existing = matchMap.get(key) ?? [];
|
|
56
|
+
matchMap.set(key, [...existing, test]);
|
|
57
|
+
matchedTests.add(test);
|
|
58
|
+
hintResolved.add(test);
|
|
59
|
+
} else if (hint.status === "gap") {
|
|
60
|
+
const existing = routeGaps.find(
|
|
61
|
+
(g) => g.route === hint.route && g.method === hint.method
|
|
62
|
+
);
|
|
63
|
+
if (existing) {
|
|
64
|
+
existing.coveredByTests.push(test);
|
|
65
|
+
} else {
|
|
66
|
+
routeGaps.push({
|
|
67
|
+
route: hint.route ?? test.title,
|
|
68
|
+
method: hint.method,
|
|
69
|
+
reason: hint.reason,
|
|
70
|
+
coveredByTests: [test]
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
matchedTests.add(test);
|
|
74
|
+
} else if (hint.status === "acknowledged" || hint.status === "ignore") {
|
|
75
|
+
acknowledgedTests.push(hint);
|
|
76
|
+
matchedTests.add(test);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const routeCoverages = routes.map((route) => {
|
|
80
|
+
const key = route.method ? `${route.method} ${route.path}` : route.path;
|
|
81
|
+
const matched = matchMap.get(key) ?? [];
|
|
82
|
+
const automatedTests = matched.filter((t) => t.source === "playwright");
|
|
83
|
+
const manualTests = matched.filter((t) => t.source === "ado" || t.source === "yaml");
|
|
84
|
+
const status = this.deriveStatus(automatedTests.length, manualTests.length);
|
|
85
|
+
const lastTestedAt = this.latestDate([...automatedTests, ...manualTests]);
|
|
86
|
+
const staleness = this.deriveStaleness(lastTestedAt, manualTests.length);
|
|
87
|
+
return {
|
|
88
|
+
route,
|
|
89
|
+
status,
|
|
90
|
+
automatedTests,
|
|
91
|
+
manualTests,
|
|
92
|
+
staleness,
|
|
93
|
+
lastTestedAt
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
const { areas, uncategorised } = this.areaMatcher.assign(routeCoverages);
|
|
97
|
+
const total = routeCoverages.length;
|
|
98
|
+
const automated = routeCoverages.filter((r) => r.status === "automated" || r.status === "both").length;
|
|
99
|
+
const manualOnly = routeCoverages.filter((r) => r.status === "manual").length;
|
|
100
|
+
const none = routeCoverages.filter((r) => r.status === "none").length;
|
|
101
|
+
const toLRM = (counts) => {
|
|
102
|
+
if (total === 0) return counts.map(() => 0);
|
|
103
|
+
const exact = counts.map((n) => n / total * 100);
|
|
104
|
+
const floored = exact.map((n) => Math.floor(n));
|
|
105
|
+
const remainder = 100 - floored.reduce((a, b) => a + b, 0);
|
|
106
|
+
const order = exact.map((n, i) => ({ i, frac: n - Math.floor(n) })).sort((a, b) => b.frac - a.frac);
|
|
107
|
+
for (let k = 0; k < remainder; k++) floored[order[k].i]++;
|
|
108
|
+
return floored;
|
|
109
|
+
};
|
|
110
|
+
const [automatedCoverage, manualOnlyCoverage, noCoverage] = toLRM([automated, manualOnly, none]);
|
|
111
|
+
const totalCoverage = automatedCoverage + manualOnlyCoverage;
|
|
112
|
+
return {
|
|
113
|
+
generatedAt: /* @__PURE__ */ new Date(),
|
|
114
|
+
projectName: this.config.projectName,
|
|
115
|
+
summary: {
|
|
116
|
+
totalRoutes: total,
|
|
117
|
+
totalCoverage,
|
|
118
|
+
automatedCoverage,
|
|
119
|
+
manualOnlyCoverage,
|
|
120
|
+
noCoverage
|
|
121
|
+
},
|
|
122
|
+
areas,
|
|
123
|
+
uncategorised,
|
|
124
|
+
unmatchedTests: allTests.filter((t) => !matchedTests.has(t)),
|
|
125
|
+
routeGaps,
|
|
126
|
+
acknowledgedTests
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
deriveStatus(autoCount, manualCount) {
|
|
130
|
+
if (autoCount > 0 && manualCount > 0) return "both";
|
|
131
|
+
if (autoCount > 0) return "automated";
|
|
132
|
+
if (manualCount > 0) return "manual";
|
|
133
|
+
return "none";
|
|
134
|
+
}
|
|
135
|
+
deriveStaleness(lastTestedAt, manualCount) {
|
|
136
|
+
if (manualCount === 0) return "unknown";
|
|
137
|
+
if (!lastTestedAt) return "unknown";
|
|
138
|
+
const daysSince = (Date.now() - lastTestedAt.getTime()) / (1e3 * 60 * 60 * 24);
|
|
139
|
+
return daysSince > this.config.staleThresholdDays ? "stale" : "fresh";
|
|
140
|
+
}
|
|
141
|
+
latestDate(tests) {
|
|
142
|
+
const dates = tests.map((t) => t.lastRun).filter((d) => d instanceof Date);
|
|
143
|
+
if (dates.length === 0) return void 0;
|
|
144
|
+
return new Date(Math.max(...dates.map((d) => d.getTime())));
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
148
|
+
0 && (module.exports = {
|
|
149
|
+
CoverageEngine
|
|
150
|
+
});
|
|
151
|
+
//# sourceMappingURL=engine.js.map
|