@qulib/core 0.7.0 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/fixtures/api-fixture-repo/app/api/orders/route.d.ts +7 -0
- package/dist/__tests__/fixtures/api-fixture-repo/app/api/orders/route.d.ts.map +1 -0
- package/dist/__tests__/fixtures/api-fixture-repo/app/api/orders/route.js +7 -0
- package/dist/__tests__/fixtures/api-fixture-repo/app/api/users/route.d.ts +10 -0
- package/dist/__tests__/fixtures/api-fixture-repo/app/api/users/route.d.ts.map +1 -0
- package/dist/__tests__/fixtures/api-fixture-repo/app/api/users/route.js +9 -0
- package/dist/__tests__/fixtures/api-fixture-repo/pages/api/health.d.ts +9 -0
- package/dist/__tests__/fixtures/api-fixture-repo/pages/api/health.d.ts.map +1 -0
- package/dist/__tests__/fixtures/api-fixture-repo/pages/api/health.js +10 -0
- package/dist/adapters/api-adapter.d.ts +26 -0
- package/dist/adapters/api-adapter.d.ts.map +1 -1
- package/dist/adapters/api-adapter.js +156 -2
- package/dist/adapters/playwright-adapter.d.ts.map +1 -1
- package/dist/adapters/playwright-adapter.js +71 -2
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/schemas/automation-maturity.schema.d.ts +8 -8
- package/dist/schemas/automation-maturity.schema.d.ts.map +1 -1
- package/dist/schemas/automation-maturity.schema.js +1 -0
- package/dist/schemas/gap-analysis.schema.d.ts +8 -8
- package/dist/schemas/gap-analysis.schema.js +1 -1
- package/dist/schemas/public-surface.schema.d.ts +5 -5
- package/dist/schemas/repo-analysis.schema.d.ts +7 -7
- package/dist/tools/repo/api-surface.d.ts +59 -0
- package/dist/tools/repo/api-surface.d.ts.map +1 -0
- package/dist/tools/repo/api-surface.js +414 -0
- package/dist/tools/scoring/api-coverage.d.ts +74 -0
- package/dist/tools/scoring/api-coverage.d.ts.map +1 -0
- package/dist/tools/scoring/api-coverage.js +158 -0
- package/dist/tools/scoring/automation-maturity.d.ts +11 -1
- package/dist/tools/scoring/automation-maturity.d.ts.map +1 -1
- package/dist/tools/scoring/automation-maturity.js +43 -9
- package/package.json +4 -2
|
@@ -156,7 +156,7 @@ export declare const RepoAnalysisSchema: z.ZodObject<{
|
|
|
156
156
|
level: z.ZodNumber;
|
|
157
157
|
label: z.ZodString;
|
|
158
158
|
dimensions: z.ZodArray<z.ZodObject<{
|
|
159
|
-
dimension: z.ZodEnum<["test-coverage-breadth", "framework-adoption", "test-id-hygiene", "ci-integration", "auth-test-coverage", "component-test-ratio"]>;
|
|
159
|
+
dimension: z.ZodEnum<["test-coverage-breadth", "framework-adoption", "test-id-hygiene", "ci-integration", "auth-test-coverage", "component-test-ratio", "api-test-coverage"]>;
|
|
160
160
|
score: z.ZodNumber;
|
|
161
161
|
weight: z.ZodNumber;
|
|
162
162
|
evidence: z.ZodArray<z.ZodString, "many">;
|
|
@@ -166,7 +166,7 @@ export declare const RepoAnalysisSchema: z.ZodObject<{
|
|
|
166
166
|
guidance: z.ZodOptional<z.ZodString>;
|
|
167
167
|
}, "strip", z.ZodTypeAny, {
|
|
168
168
|
recommendations: string[];
|
|
169
|
-
dimension: "test-coverage-breadth" | "framework-adoption" | "test-id-hygiene" | "ci-integration" | "auth-test-coverage" | "component-test-ratio";
|
|
169
|
+
dimension: "test-coverage-breadth" | "framework-adoption" | "test-id-hygiene" | "ci-integration" | "auth-test-coverage" | "component-test-ratio" | "api-test-coverage";
|
|
170
170
|
score: number;
|
|
171
171
|
weight: number;
|
|
172
172
|
evidence: string[];
|
|
@@ -175,7 +175,7 @@ export declare const RepoAnalysisSchema: z.ZodObject<{
|
|
|
175
175
|
guidance?: string | undefined;
|
|
176
176
|
}, {
|
|
177
177
|
recommendations: string[];
|
|
178
|
-
dimension: "test-coverage-breadth" | "framework-adoption" | "test-id-hygiene" | "ci-integration" | "auth-test-coverage" | "component-test-ratio";
|
|
178
|
+
dimension: "test-coverage-breadth" | "framework-adoption" | "test-id-hygiene" | "ci-integration" | "auth-test-coverage" | "component-test-ratio" | "api-test-coverage";
|
|
179
179
|
score: number;
|
|
180
180
|
weight: number;
|
|
181
181
|
evidence: string[];
|
|
@@ -193,7 +193,7 @@ export declare const RepoAnalysisSchema: z.ZodObject<{
|
|
|
193
193
|
overallScore: number;
|
|
194
194
|
dimensions: {
|
|
195
195
|
recommendations: string[];
|
|
196
|
-
dimension: "test-coverage-breadth" | "framework-adoption" | "test-id-hygiene" | "ci-integration" | "auth-test-coverage" | "component-test-ratio";
|
|
196
|
+
dimension: "test-coverage-breadth" | "framework-adoption" | "test-id-hygiene" | "ci-integration" | "auth-test-coverage" | "component-test-ratio" | "api-test-coverage";
|
|
197
197
|
score: number;
|
|
198
198
|
weight: number;
|
|
199
199
|
evidence: string[];
|
|
@@ -211,7 +211,7 @@ export declare const RepoAnalysisSchema: z.ZodObject<{
|
|
|
211
211
|
overallScore: number;
|
|
212
212
|
dimensions: {
|
|
213
213
|
recommendations: string[];
|
|
214
|
-
dimension: "test-coverage-breadth" | "framework-adoption" | "test-id-hygiene" | "ci-integration" | "auth-test-coverage" | "component-test-ratio";
|
|
214
|
+
dimension: "test-coverage-breadth" | "framework-adoption" | "test-id-hygiene" | "ci-integration" | "auth-test-coverage" | "component-test-ratio" | "api-test-coverage";
|
|
215
215
|
score: number;
|
|
216
216
|
weight: number;
|
|
217
217
|
evidence: string[];
|
|
@@ -261,7 +261,7 @@ export declare const RepoAnalysisSchema: z.ZodObject<{
|
|
|
261
261
|
overallScore: number;
|
|
262
262
|
dimensions: {
|
|
263
263
|
recommendations: string[];
|
|
264
|
-
dimension: "test-coverage-breadth" | "framework-adoption" | "test-id-hygiene" | "ci-integration" | "auth-test-coverage" | "component-test-ratio";
|
|
264
|
+
dimension: "test-coverage-breadth" | "framework-adoption" | "test-id-hygiene" | "ci-integration" | "auth-test-coverage" | "component-test-ratio" | "api-test-coverage";
|
|
265
265
|
score: number;
|
|
266
266
|
weight: number;
|
|
267
267
|
evidence: string[];
|
|
@@ -311,7 +311,7 @@ export declare const RepoAnalysisSchema: z.ZodObject<{
|
|
|
311
311
|
overallScore: number;
|
|
312
312
|
dimensions: {
|
|
313
313
|
recommendations: string[];
|
|
314
|
-
dimension: "test-coverage-breadth" | "framework-adoption" | "test-id-hygiene" | "ci-integration" | "auth-test-coverage" | "component-test-ratio";
|
|
314
|
+
dimension: "test-coverage-breadth" | "framework-adoption" | "test-id-hygiene" | "ci-integration" | "auth-test-coverage" | "component-test-ratio" | "api-test-coverage";
|
|
315
315
|
score: number;
|
|
316
316
|
weight: number;
|
|
317
317
|
evidence: string[];
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module tools/repo/api-surface
|
|
3
|
+
* @packageBoundary @qulib/core
|
|
4
|
+
*
|
|
5
|
+
* Evidence-only API surface discovery. Three tiers of confidence:
|
|
6
|
+
* Tier1 — OpenAPI / Swagger spec files (YAML or JSON). Only reads `summary` and
|
|
7
|
+
* `parameters` from a real spec; never fabricates endpoints.
|
|
8
|
+
* Tier2 — Framework route files: Next.js App-Router `route.ts` exports,
|
|
9
|
+
* Next.js Pages `pages/api/`, Express router calls from RepoAnalysis.routes,
|
|
10
|
+
* Fastify `fastify.{method}`, Hono `app.{method}`, NestJS decorators.
|
|
11
|
+
* Tier3 — Opt-in heuristics. Currently: tRPC router definition files.
|
|
12
|
+
* Only activated when `options.enableTier3 === true`.
|
|
13
|
+
*
|
|
14
|
+
* Every endpoint carries:
|
|
15
|
+
* sourceFile — repo-relative file path that evidence was read from
|
|
16
|
+
* sourceTier — 'openapi' | 'framework' | 'heuristic'
|
|
17
|
+
* confidence — 'high' | 'medium' | 'low'
|
|
18
|
+
*
|
|
19
|
+
* NEVER invents endpoints or parameters. When a spec file cannot be parsed,
|
|
20
|
+
* or a file pattern does not clearly indicate a route, the file is skipped.
|
|
21
|
+
*/
|
|
22
|
+
import type { RepoAnalysis } from '../../schemas/repo-analysis.schema.js';
|
|
23
|
+
export interface DiscoveredEndpoint {
|
|
24
|
+
/** HTTP method inferred from the source — 'unknown' when ambiguous */
|
|
25
|
+
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'unknown';
|
|
26
|
+
/** Path as extracted from source — may contain framework-specific params like [id] or :id */
|
|
27
|
+
path: string;
|
|
28
|
+
/** Repo-relative file path the evidence was read from */
|
|
29
|
+
sourceFile: string;
|
|
30
|
+
/** Discovery tier */
|
|
31
|
+
sourceTier: 'openapi' | 'framework' | 'heuristic';
|
|
32
|
+
/** Evidence confidence */
|
|
33
|
+
confidence: 'high' | 'medium' | 'low';
|
|
34
|
+
/** Human-readable summary from spec, if available (Tier1 only) */
|
|
35
|
+
summary?: string;
|
|
36
|
+
/** Parameter names extracted from spec (Tier1 only; never fabricated) */
|
|
37
|
+
parameterNames?: string[];
|
|
38
|
+
}
|
|
39
|
+
export interface ApiSurface {
|
|
40
|
+
discoveredAt: string;
|
|
41
|
+
repoPath: string;
|
|
42
|
+
endpoints: DiscoveredEndpoint[];
|
|
43
|
+
/** Number of OpenAPI/Swagger spec files found and successfully parsed */
|
|
44
|
+
openApiSpecsFound: number;
|
|
45
|
+
/** Tier3 heuristics were enabled */
|
|
46
|
+
tier3Enabled: boolean;
|
|
47
|
+
}
|
|
48
|
+
export interface DiscoverApiSurfaceOptions {
|
|
49
|
+
/** Enable Tier3 heuristic discovery (default false — opt-in) */
|
|
50
|
+
enableTier3?: boolean;
|
|
51
|
+
}
|
|
52
|
+
export declare function discoverApiSurface(repoPath: string, options?: DiscoverApiSurfaceOptions): Promise<ApiSurface>;
|
|
53
|
+
/**
|
|
54
|
+
* Variant that also incorporates RepoAnalysis.routes (Express routes already
|
|
55
|
+
* extracted by scanRepo). Use this when you already have a RepoAnalysis to avoid
|
|
56
|
+
* double-reading files.
|
|
57
|
+
*/
|
|
58
|
+
export declare function discoverApiSurfaceWithRepo(repoPath: string, repo: RepoAnalysis, options?: DiscoverApiSurfaceOptions): Promise<ApiSurface>;
|
|
59
|
+
//# sourceMappingURL=api-surface.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"api-surface.d.ts","sourceRoot":"","sources":["../../../src/tools/repo/api-surface.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAKH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,uCAAuC,CAAC;AAiC1E,MAAM,WAAW,kBAAkB;IACjC,sEAAsE;IACtE,MAAM,EAAE,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,GAAG,OAAO,GAAG,SAAS,CAAC;IAChE,6FAA6F;IAC7F,IAAI,EAAE,MAAM,CAAC;IACb,yDAAyD;IACzD,UAAU,EAAE,MAAM,CAAC;IACnB,qBAAqB;IACrB,UAAU,EAAE,SAAS,GAAG,WAAW,GAAG,WAAW,CAAC;IAClD,0BAA0B;IAC1B,UAAU,EAAE,MAAM,GAAG,QAAQ,GAAG,KAAK,CAAC;IACtC,kEAAkE;IAClE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,yEAAyE;IACzE,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;CAC3B;AAED,MAAM,WAAW,UAAU;IACzB,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,kBAAkB,EAAE,CAAC;IAChC,yEAAyE;IACzE,iBAAiB,EAAE,MAAM,CAAC;IAC1B,oCAAoC;IACpC,YAAY,EAAE,OAAO,CAAC;CACvB;AAED,MAAM,WAAW,yBAAyB;IACxC,gEAAgE;IAChE,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAwXD,wBAAsB,kBAAkB,CACtC,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE,yBAA8B,GACtC,OAAO,CAAC,UAAU,CAAC,CAoCrB;AAED;;;;GAIG;AACH,wBAAsB,0BAA0B,CAC9C,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,YAAY,EAClB,OAAO,GAAE,yBAA8B,GACtC,OAAO,CAAC,UAAU,CAAC,CAQrB"}
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module tools/repo/api-surface
|
|
3
|
+
* @packageBoundary @qulib/core
|
|
4
|
+
*
|
|
5
|
+
* Evidence-only API surface discovery. Three tiers of confidence:
|
|
6
|
+
* Tier1 — OpenAPI / Swagger spec files (YAML or JSON). Only reads `summary` and
|
|
7
|
+
* `parameters` from a real spec; never fabricates endpoints.
|
|
8
|
+
* Tier2 — Framework route files: Next.js App-Router `route.ts` exports,
|
|
9
|
+
* Next.js Pages `pages/api/`, Express router calls from RepoAnalysis.routes,
|
|
10
|
+
* Fastify `fastify.{method}`, Hono `app.{method}`, NestJS decorators.
|
|
11
|
+
* Tier3 — Opt-in heuristics. Currently: tRPC router definition files.
|
|
12
|
+
* Only activated when `options.enableTier3 === true`.
|
|
13
|
+
*
|
|
14
|
+
* Every endpoint carries:
|
|
15
|
+
* sourceFile — repo-relative file path that evidence was read from
|
|
16
|
+
* sourceTier — 'openapi' | 'framework' | 'heuristic'
|
|
17
|
+
* confidence — 'high' | 'medium' | 'low'
|
|
18
|
+
*
|
|
19
|
+
* NEVER invents endpoints or parameters. When a spec file cannot be parsed,
|
|
20
|
+
* or a file pattern does not clearly indicate a route, the file is skipped.
|
|
21
|
+
*/
|
|
22
|
+
import { readFile } from 'node:fs/promises';
|
|
23
|
+
import { relative, basename } from 'node:path';
|
|
24
|
+
import glob from 'fast-glob';
|
|
25
|
+
const IGNORE_PATTERNS = ['**/node_modules/**', '**/.next/**', '**/dist/**', '**/build/**'];
|
|
26
|
+
/**
|
|
27
|
+
* Shared fast-glob options for every discovery tier. `suppressErrors: true` makes the
|
|
28
|
+
* recursive walk skip subtrees it cannot read (EACCES/EPERM) or that disappear mid-scan
|
|
29
|
+
* (ENOENT) instead of throwing. Real repos — and shared dirs like /tmp on CI runners
|
|
30
|
+
* (e.g. the root-owned, unreadable /tmp/snap-private-tmp on Ubuntu) — routinely contain
|
|
31
|
+
* directories the scanner cannot enter; discovery must degrade gracefully, never crash.
|
|
32
|
+
* `cwd` is supplied per-call.
|
|
33
|
+
*/
|
|
34
|
+
const GLOB_OPTIONS = {
|
|
35
|
+
onlyFiles: true,
|
|
36
|
+
absolute: true,
|
|
37
|
+
ignore: IGNORE_PATTERNS,
|
|
38
|
+
suppressErrors: true,
|
|
39
|
+
};
|
|
40
|
+
function toPosix(p) {
|
|
41
|
+
return p.split('\\').join('/');
|
|
42
|
+
}
|
|
43
|
+
function normalizeMethod(raw) {
|
|
44
|
+
const upper = raw.toUpperCase();
|
|
45
|
+
if (upper === 'GET')
|
|
46
|
+
return 'GET';
|
|
47
|
+
if (upper === 'POST')
|
|
48
|
+
return 'POST';
|
|
49
|
+
if (upper === 'PUT')
|
|
50
|
+
return 'PUT';
|
|
51
|
+
if (upper === 'DELETE')
|
|
52
|
+
return 'DELETE';
|
|
53
|
+
if (upper === 'PATCH')
|
|
54
|
+
return 'PATCH';
|
|
55
|
+
return 'unknown';
|
|
56
|
+
}
|
|
57
|
+
function isOpenApiDocument(raw) {
|
|
58
|
+
if (typeof raw !== 'object' || raw === null)
|
|
59
|
+
return false;
|
|
60
|
+
const obj = raw;
|
|
61
|
+
return ((typeof obj['openapi'] === 'string' || typeof obj['swagger'] === 'string') &&
|
|
62
|
+
typeof obj['paths'] === 'object');
|
|
63
|
+
}
|
|
64
|
+
async function discoverFromOpenApi(repoPath) {
|
|
65
|
+
const endpoints = [];
|
|
66
|
+
const specFiles = await glob([
|
|
67
|
+
'**/openapi.yaml',
|
|
68
|
+
'**/openapi.yml',
|
|
69
|
+
'**/openapi.json',
|
|
70
|
+
'**/swagger.yaml',
|
|
71
|
+
'**/swagger.yml',
|
|
72
|
+
'**/swagger.json',
|
|
73
|
+
'**/api-docs.yaml',
|
|
74
|
+
'**/api-docs.json',
|
|
75
|
+
], { cwd: repoPath, ...GLOB_OPTIONS });
|
|
76
|
+
let specsFound = 0;
|
|
77
|
+
for (const file of specFiles) {
|
|
78
|
+
const rel = toPosix(relative(repoPath, file));
|
|
79
|
+
let raw;
|
|
80
|
+
try {
|
|
81
|
+
const content = await readFile(file, 'utf8');
|
|
82
|
+
if (file.endsWith('.json')) {
|
|
83
|
+
raw = JSON.parse(content);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
// Dynamic import of js-yaml to avoid bundling issues
|
|
87
|
+
const yaml = (await import('js-yaml'));
|
|
88
|
+
raw = yaml.load(content);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// Skip unparseable files — never fabricate
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (!isOpenApiDocument(raw))
|
|
96
|
+
continue;
|
|
97
|
+
specsFound++;
|
|
98
|
+
const paths = raw.paths ?? {};
|
|
99
|
+
for (const [path, pathItem] of Object.entries(paths)) {
|
|
100
|
+
if (typeof pathItem !== 'object' || pathItem === null)
|
|
101
|
+
continue;
|
|
102
|
+
const methods = ['get', 'post', 'put', 'delete', 'patch'];
|
|
103
|
+
for (const method of methods) {
|
|
104
|
+
const operation = pathItem[method];
|
|
105
|
+
if (typeof operation !== 'object' || operation === null)
|
|
106
|
+
continue;
|
|
107
|
+
const op = operation;
|
|
108
|
+
const parameterNames = (op.parameters ?? [])
|
|
109
|
+
.filter((p) => typeof p?.name === 'string')
|
|
110
|
+
.map((p) => p.name);
|
|
111
|
+
endpoints.push({
|
|
112
|
+
method: normalizeMethod(method),
|
|
113
|
+
path,
|
|
114
|
+
sourceFile: rel,
|
|
115
|
+
sourceTier: 'openapi',
|
|
116
|
+
confidence: 'high',
|
|
117
|
+
...(typeof op.summary === 'string' && op.summary ? { summary: op.summary } : {}),
|
|
118
|
+
...(parameterNames.length > 0 ? { parameterNames } : {}),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return { endpoints, specsFound };
|
|
124
|
+
}
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Tier 2 — Framework route files
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
async function discoverNextAppRouterEndpoints(repoPath) {
|
|
129
|
+
const endpoints = [];
|
|
130
|
+
const routeFiles = await glob(['app/**/route.ts', 'app/**/route.tsx', 'src/app/**/route.ts', 'src/app/**/route.tsx'], {
|
|
131
|
+
cwd: repoPath,
|
|
132
|
+
...GLOB_OPTIONS,
|
|
133
|
+
});
|
|
134
|
+
for (const file of routeFiles) {
|
|
135
|
+
const rel = toPosix(relative(repoPath, file));
|
|
136
|
+
let content;
|
|
137
|
+
try {
|
|
138
|
+
content = await readFile(file, 'utf8');
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
// Extract the route path from the file location
|
|
144
|
+
const routeSegment = rel
|
|
145
|
+
.replace(/^(src\/)?app\//, '')
|
|
146
|
+
.replace(/\/route\.tsx?$/, '');
|
|
147
|
+
// Normalize Next.js dynamic segments: [id] -> [id] (keep as-is, it's the real path)
|
|
148
|
+
const routePath = `/${routeSegment}`.replace(/\/+/g, '/').replace(/\/$/, '') || '/';
|
|
149
|
+
// Find exported HTTP method functions: export async function GET/POST/PUT/DELETE/PATCH
|
|
150
|
+
const methodExportRe = /export\s+(?:async\s+)?function\s+(GET|POST|PUT|DELETE|PATCH)\b/g;
|
|
151
|
+
let match;
|
|
152
|
+
let foundAny = false;
|
|
153
|
+
while ((match = methodExportRe.exec(content)) !== null) {
|
|
154
|
+
foundAny = true;
|
|
155
|
+
endpoints.push({
|
|
156
|
+
method: normalizeMethod(match[1] ?? 'unknown'),
|
|
157
|
+
path: routePath,
|
|
158
|
+
sourceFile: rel,
|
|
159
|
+
sourceTier: 'framework',
|
|
160
|
+
confidence: 'high',
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
// If the file exists but has no recognized export, it's still a route — emit as unknown method
|
|
164
|
+
if (!foundAny) {
|
|
165
|
+
endpoints.push({
|
|
166
|
+
method: 'unknown',
|
|
167
|
+
path: routePath,
|
|
168
|
+
sourceFile: rel,
|
|
169
|
+
sourceTier: 'framework',
|
|
170
|
+
confidence: 'medium',
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return endpoints;
|
|
175
|
+
}
|
|
176
|
+
async function discoverNextPagesApiEndpoints(repoPath) {
|
|
177
|
+
const endpoints = [];
|
|
178
|
+
const apiFiles = await glob(['pages/api/**/*.ts', 'pages/api/**/*.tsx', 'src/pages/api/**/*.ts'], {
|
|
179
|
+
cwd: repoPath,
|
|
180
|
+
...GLOB_OPTIONS,
|
|
181
|
+
});
|
|
182
|
+
for (const file of apiFiles) {
|
|
183
|
+
const rel = toPosix(relative(repoPath, file));
|
|
184
|
+
const name = basename(rel);
|
|
185
|
+
if (name.startsWith('_'))
|
|
186
|
+
continue;
|
|
187
|
+
const routeSegment = rel
|
|
188
|
+
.replace(/^(src\/)?pages\/api\//, '')
|
|
189
|
+
.replace(/\.tsx?$/, '');
|
|
190
|
+
const routePath = routeSegment === 'index'
|
|
191
|
+
? '/api'
|
|
192
|
+
: `/api/${routeSegment.replace(/\/index$/, '')}`.replace(/\/+/g, '/');
|
|
193
|
+
let content;
|
|
194
|
+
try {
|
|
195
|
+
content = await readFile(file, 'utf8');
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
// Pages API routes export a default handler that typically checks req.method internally.
|
|
201
|
+
// We can't know the HTTP methods without executing the code, so we emit 'unknown'.
|
|
202
|
+
// Look for explicit method checks like: req.method === 'POST'
|
|
203
|
+
const methodCheckRe = /req\.method\s*(?:===|==|!==|!=)\s*['"`](GET|POST|PUT|DELETE|PATCH)['"`]/g;
|
|
204
|
+
const methods = new Set();
|
|
205
|
+
let match;
|
|
206
|
+
while ((match = methodCheckRe.exec(content)) !== null) {
|
|
207
|
+
if (match[1])
|
|
208
|
+
methods.add(match[1]);
|
|
209
|
+
}
|
|
210
|
+
if (methods.size > 0) {
|
|
211
|
+
for (const method of methods) {
|
|
212
|
+
endpoints.push({
|
|
213
|
+
method: normalizeMethod(method),
|
|
214
|
+
path: routePath,
|
|
215
|
+
sourceFile: rel,
|
|
216
|
+
sourceTier: 'framework',
|
|
217
|
+
confidence: 'medium',
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
endpoints.push({
|
|
223
|
+
method: 'unknown',
|
|
224
|
+
path: routePath,
|
|
225
|
+
sourceFile: rel,
|
|
226
|
+
sourceTier: 'framework',
|
|
227
|
+
confidence: 'medium',
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return endpoints;
|
|
232
|
+
}
|
|
233
|
+
function discoverExpressEndpoints(repo) {
|
|
234
|
+
// Re-use existing RepoAnalysis.routes which already extracted Express router calls.
|
|
235
|
+
// Filter to only include routes that came from src/ files (Express pattern).
|
|
236
|
+
return repo.routes
|
|
237
|
+
.filter((r) => r.method !== 'GET' || r.file.startsWith('src/'))
|
|
238
|
+
.map((r) => ({
|
|
239
|
+
method: normalizeMethod(r.method),
|
|
240
|
+
path: r.path,
|
|
241
|
+
sourceFile: r.file,
|
|
242
|
+
sourceTier: 'framework',
|
|
243
|
+
confidence: 'medium',
|
|
244
|
+
}));
|
|
245
|
+
}
|
|
246
|
+
async function discoverFastifyEndpoints(repoPath) {
|
|
247
|
+
const endpoints = [];
|
|
248
|
+
const files = await glob(['src/**/*.ts', 'src/**/*.js', 'routes/**/*.ts', 'routes/**/*.js'], {
|
|
249
|
+
cwd: repoPath,
|
|
250
|
+
...GLOB_OPTIONS,
|
|
251
|
+
});
|
|
252
|
+
for (const file of files) {
|
|
253
|
+
const rel = toPosix(relative(repoPath, file));
|
|
254
|
+
let content;
|
|
255
|
+
try {
|
|
256
|
+
content = await readFile(file, 'utf8');
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
// Fastify: fastify.get('/path', ...) or fastify.route({ method: 'GET', url: '/path' })
|
|
262
|
+
const fastifyCallRe = /(?:fastify|app|server)\.(get|post|put|delete|patch)\s*\(\s*['"`]([^'"`]+)['"`]/gi;
|
|
263
|
+
let match;
|
|
264
|
+
while ((match = fastifyCallRe.exec(content)) !== null) {
|
|
265
|
+
const method = match[1];
|
|
266
|
+
const path = match[2];
|
|
267
|
+
if (method && path) {
|
|
268
|
+
endpoints.push({
|
|
269
|
+
method: normalizeMethod(method),
|
|
270
|
+
path: path.startsWith('/') ? path : `/${path}`,
|
|
271
|
+
sourceFile: rel,
|
|
272
|
+
sourceTier: 'framework',
|
|
273
|
+
confidence: 'medium',
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
// Hono: app.get('/path', ...) — same pattern, already covered above if variable is named app/server
|
|
278
|
+
// NestJS decorators: @Get('/path'), @Post('/path'), etc.
|
|
279
|
+
const nestDecoratorRe = /@(Get|Post|Put|Delete|Patch)\s*\(\s*['"`]([^'"`]*)['"`]\s*\)/g;
|
|
280
|
+
while ((match = nestDecoratorRe.exec(content)) !== null) {
|
|
281
|
+
const method = match[1];
|
|
282
|
+
const path = match[2];
|
|
283
|
+
if (method !== undefined && path !== undefined) {
|
|
284
|
+
endpoints.push({
|
|
285
|
+
method: normalizeMethod(method),
|
|
286
|
+
path: path === '' ? '/' : (path.startsWith('/') ? path : `/${path}`),
|
|
287
|
+
sourceFile: rel,
|
|
288
|
+
sourceTier: 'framework',
|
|
289
|
+
confidence: 'medium',
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return endpoints;
|
|
295
|
+
}
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
// Tier 3 — Heuristic (opt-in): tRPC
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
async function discoverTrpcEndpoints(repoPath) {
|
|
300
|
+
const endpoints = [];
|
|
301
|
+
const trpcFiles = await glob(['src/**/*.ts', 'server/**/*.ts', 'lib/**/*.ts', 'app/**/*.ts'], { cwd: repoPath, ...GLOB_OPTIONS });
|
|
302
|
+
for (const file of trpcFiles) {
|
|
303
|
+
const rel = toPosix(relative(repoPath, file));
|
|
304
|
+
let content;
|
|
305
|
+
try {
|
|
306
|
+
content = await readFile(file, 'utf8');
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
// Only look at files that import from @trpc
|
|
312
|
+
if (!content.includes('@trpc/server') && !content.includes('@trpc/next'))
|
|
313
|
+
continue;
|
|
314
|
+
// tRPC procedure definitions: .query({ ... }) or .mutation({ ... })
|
|
315
|
+
// These aren't HTTP routes in the traditional sense, but procedure names are the "paths"
|
|
316
|
+
const queryRe = /(\w+)\s*:\s*(?:publicProcedure|protectedProcedure|procedure)\s*\.(?:input\([^)]*\)\s*\.)?query\s*\(/g;
|
|
317
|
+
const mutationRe = /(\w+)\s*:\s*(?:publicProcedure|protectedProcedure|procedure)\s*\.(?:input\([^)]*\)\s*\.)?mutation\s*\(/g;
|
|
318
|
+
let match;
|
|
319
|
+
while ((match = queryRe.exec(content)) !== null) {
|
|
320
|
+
const name = match[1];
|
|
321
|
+
if (name) {
|
|
322
|
+
endpoints.push({
|
|
323
|
+
method: 'GET',
|
|
324
|
+
path: `/trpc/${name}`,
|
|
325
|
+
sourceFile: rel,
|
|
326
|
+
sourceTier: 'heuristic',
|
|
327
|
+
confidence: 'low',
|
|
328
|
+
summary: `tRPC query: ${name}`,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
while ((match = mutationRe.exec(content)) !== null) {
|
|
333
|
+
const name = match[1];
|
|
334
|
+
if (name) {
|
|
335
|
+
endpoints.push({
|
|
336
|
+
method: 'POST',
|
|
337
|
+
path: `/trpc/${name}`,
|
|
338
|
+
sourceFile: rel,
|
|
339
|
+
sourceTier: 'heuristic',
|
|
340
|
+
confidence: 'low',
|
|
341
|
+
summary: `tRPC mutation: ${name}`,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return endpoints;
|
|
347
|
+
}
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
349
|
+
// De-duplication
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
function deduplicateEndpoints(endpoints) {
|
|
352
|
+
// Higher tier = higher priority; within a tier, first seen wins
|
|
353
|
+
const tierRank = { openapi: 0, framework: 1, heuristic: 2 };
|
|
354
|
+
const seen = new Map();
|
|
355
|
+
for (const ep of endpoints) {
|
|
356
|
+
const key = `${ep.method}:${ep.path}`;
|
|
357
|
+
const existing = seen.get(key);
|
|
358
|
+
if (!existing) {
|
|
359
|
+
seen.set(key, ep);
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
// Keep the one with higher tier rank (lower number = better)
|
|
363
|
+
const existingRank = tierRank[existing.sourceTier];
|
|
364
|
+
const newRank = tierRank[ep.sourceTier];
|
|
365
|
+
if (newRank < existingRank) {
|
|
366
|
+
seen.set(key, ep);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return [...seen.values()];
|
|
371
|
+
}
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
373
|
+
// Public entry point
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
export async function discoverApiSurface(repoPath, options = {}) {
|
|
376
|
+
const tier3Enabled = options.enableTier3 === true;
|
|
377
|
+
// Run tiers in parallel for speed
|
|
378
|
+
const [tier1Result, nextAppEndpoints, nextPagesEndpoints, fastifyEndpoints,] = await Promise.all([
|
|
379
|
+
discoverFromOpenApi(repoPath),
|
|
380
|
+
discoverNextAppRouterEndpoints(repoPath),
|
|
381
|
+
discoverNextPagesApiEndpoints(repoPath),
|
|
382
|
+
discoverFastifyEndpoints(repoPath),
|
|
383
|
+
]);
|
|
384
|
+
// Express uses existing RepoAnalysis — callers may pass a pre-scanned repo via a
|
|
385
|
+
// separate helper. Here we expose the raw discovery functions; the integration
|
|
386
|
+
// layer in computeApiCoverage passes the repo. For standalone usage we return
|
|
387
|
+
// only file-based discoveries.
|
|
388
|
+
const tier1 = tier1Result.endpoints;
|
|
389
|
+
const tier2 = [...nextAppEndpoints, ...nextPagesEndpoints, ...fastifyEndpoints];
|
|
390
|
+
const tier3 = tier3Enabled
|
|
391
|
+
? await discoverTrpcEndpoints(repoPath)
|
|
392
|
+
: [];
|
|
393
|
+
const allEndpoints = deduplicateEndpoints([...tier1, ...tier2, ...tier3]);
|
|
394
|
+
return {
|
|
395
|
+
discoveredAt: new Date().toISOString(),
|
|
396
|
+
repoPath,
|
|
397
|
+
endpoints: allEndpoints,
|
|
398
|
+
openApiSpecsFound: tier1Result.specsFound,
|
|
399
|
+
tier3Enabled,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Variant that also incorporates RepoAnalysis.routes (Express routes already
|
|
404
|
+
* extracted by scanRepo). Use this when you already have a RepoAnalysis to avoid
|
|
405
|
+
* double-reading files.
|
|
406
|
+
*/
|
|
407
|
+
export async function discoverApiSurfaceWithRepo(repoPath, repo, options = {}) {
|
|
408
|
+
const base = await discoverApiSurface(repoPath, options);
|
|
409
|
+
const expressEndpoints = discoverExpressEndpoints(repo);
|
|
410
|
+
return {
|
|
411
|
+
...base,
|
|
412
|
+
endpoints: deduplicateEndpoints([...base.endpoints, ...expressEndpoints]),
|
|
413
|
+
};
|
|
414
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module tools/scoring/api-coverage
|
|
3
|
+
*
|
|
4
|
+
* Computes the `api-test-coverage` dimension for the automation maturity score.
|
|
5
|
+
*
|
|
6
|
+
* Weight: 0.15. The six existing dimensions have been rebalanced to sum to 1.0:
|
|
7
|
+
*
|
|
8
|
+
* Before (sum = 1.0):
|
|
9
|
+
* test-coverage-breadth 0.28
|
|
10
|
+
* framework-adoption 0.22
|
|
11
|
+
* test-id-hygiene 0.18
|
|
12
|
+
* ci-integration 0.14
|
|
13
|
+
* auth-test-coverage 0.10
|
|
14
|
+
* component-test-ratio 0.08
|
|
15
|
+
* ────
|
|
16
|
+
* 1.00
|
|
17
|
+
*
|
|
18
|
+
* After (sum = 1.0, api-test-coverage added at 0.15):
|
|
19
|
+
* test-coverage-breadth 0.24 (-0.04)
|
|
20
|
+
* framework-adoption 0.19 (-0.03)
|
|
21
|
+
* test-id-hygiene 0.15 (-0.03)
|
|
22
|
+
* ci-integration 0.12 (-0.02)
|
|
23
|
+
* auth-test-coverage 0.09 (-0.01)
|
|
24
|
+
* component-test-ratio 0.06 (-0.02)
|
|
25
|
+
* api-test-coverage 0.15 (new)
|
|
26
|
+
* ────
|
|
27
|
+
* 1.00
|
|
28
|
+
*
|
|
29
|
+
* Scoring rules:
|
|
30
|
+
* - If 0 API endpoints discovered → applicability = 'not_applicable' (excluded from denominator)
|
|
31
|
+
* - A test file "covers" an endpoint if:
|
|
32
|
+
* (a) the endpoint path appears verbatim in the file's coveredPaths, OR
|
|
33
|
+
* (b) the file name contains a token from the endpoint path (heuristic fallback)
|
|
34
|
+
* - Each endpoint that is covered raises the score proportionally.
|
|
35
|
+
* - POST/PUT/DELETE endpoints are HIGH severity gaps when untested;
|
|
36
|
+
* GET endpoints are MEDIUM severity gaps when untested.
|
|
37
|
+
*
|
|
38
|
+
* Evidence is per-endpoint and contextual:
|
|
39
|
+
* "GET /api/users — found in app/api/users/route.ts, covered by tests/api/users.test.ts"
|
|
40
|
+
* "POST /api/orders — found in app/api/orders/route.ts, NOT covered"
|
|
41
|
+
*/
|
|
42
|
+
import type { RepoAnalysis } from '../../schemas/repo-analysis.schema.js';
|
|
43
|
+
import type { AutomationMaturityDimension } from '../../schemas/automation-maturity.schema.js';
|
|
44
|
+
import type { ApiSurface, DiscoveredEndpoint } from '../repo/api-surface.js';
|
|
45
|
+
export declare const W_API_COVERAGE = 0.15;
|
|
46
|
+
/**
|
|
47
|
+
* Rebalanced weights for the original 6 dimensions (sum = 0.85).
|
|
48
|
+
* Export these so automation-maturity.ts can import and use them.
|
|
49
|
+
*/
|
|
50
|
+
export declare const REBALANCED_WEIGHTS: {
|
|
51
|
+
readonly TEST_BREADTH: 0.24;
|
|
52
|
+
readonly FRAMEWORK: 0.19;
|
|
53
|
+
readonly TEST_ID: 0.15;
|
|
54
|
+
readonly CI: 0.12;
|
|
55
|
+
readonly AUTH_TESTS: 0.09;
|
|
56
|
+
readonly COMPONENT_RATIO: 0.06;
|
|
57
|
+
};
|
|
58
|
+
export interface ApiEndpointCoverage {
|
|
59
|
+
method: DiscoveredEndpoint['method'];
|
|
60
|
+
path: string;
|
|
61
|
+
sourceFile: string;
|
|
62
|
+
sourceTier: DiscoveredEndpoint['sourceTier'];
|
|
63
|
+
covered: boolean;
|
|
64
|
+
coveringTestFile?: string;
|
|
65
|
+
severity: 'high' | 'medium' | 'low';
|
|
66
|
+
}
|
|
67
|
+
export interface ApiCoverageResult {
|
|
68
|
+
dimension: AutomationMaturityDimension;
|
|
69
|
+
endpointCoverage: ApiEndpointCoverage[];
|
|
70
|
+
untestedHighSeverityCount: number;
|
|
71
|
+
untestedMediumSeverityCount: number;
|
|
72
|
+
}
|
|
73
|
+
export declare function computeApiCoverage(repo: RepoAnalysis, apiSurface: ApiSurface): ApiCoverageResult;
|
|
74
|
+
//# sourceMappingURL=api-coverage.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"api-coverage.d.ts","sourceRoot":"","sources":["../../../src/tools/scoring/api-coverage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,uCAAuC,CAAC;AAC1E,OAAO,KAAK,EACV,2BAA2B,EAE5B,MAAM,6CAA6C,CAAC;AACrD,OAAO,KAAK,EAAE,UAAU,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AAE7E,eAAO,MAAM,cAAc,OAAO,CAAC;AAEnC;;;GAGG;AACH,eAAO,MAAM,kBAAkB;;;;;;;CAOrB,CAAC;AAEX,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,kBAAkB,CAAC,QAAQ,CAAC,CAAC;IACrC,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,kBAAkB,CAAC,YAAY,CAAC,CAAC;IAC7C,OAAO,EAAE,OAAO,CAAC;IACjB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,EAAE,MAAM,GAAG,QAAQ,GAAG,KAAK,CAAC;CACrC;AAED,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,2BAA2B,CAAC;IACvC,gBAAgB,EAAE,mBAAmB,EAAE,CAAC;IACxC,yBAAyB,EAAE,MAAM,CAAC;IAClC,2BAA2B,EAAE,MAAM,CAAC;CACrC;AA+BD,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,YAAY,EAClB,UAAU,EAAE,UAAU,GACrB,iBAAiB,CA8FnB"}
|