@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.
Files changed (34) hide show
  1. package/dist/__tests__/fixtures/api-fixture-repo/app/api/orders/route.d.ts +7 -0
  2. package/dist/__tests__/fixtures/api-fixture-repo/app/api/orders/route.d.ts.map +1 -0
  3. package/dist/__tests__/fixtures/api-fixture-repo/app/api/orders/route.js +7 -0
  4. package/dist/__tests__/fixtures/api-fixture-repo/app/api/users/route.d.ts +10 -0
  5. package/dist/__tests__/fixtures/api-fixture-repo/app/api/users/route.d.ts.map +1 -0
  6. package/dist/__tests__/fixtures/api-fixture-repo/app/api/users/route.js +9 -0
  7. package/dist/__tests__/fixtures/api-fixture-repo/pages/api/health.d.ts +9 -0
  8. package/dist/__tests__/fixtures/api-fixture-repo/pages/api/health.d.ts.map +1 -0
  9. package/dist/__tests__/fixtures/api-fixture-repo/pages/api/health.js +10 -0
  10. package/dist/adapters/api-adapter.d.ts +26 -0
  11. package/dist/adapters/api-adapter.d.ts.map +1 -1
  12. package/dist/adapters/api-adapter.js +156 -2
  13. package/dist/adapters/playwright-adapter.d.ts.map +1 -1
  14. package/dist/adapters/playwright-adapter.js +71 -2
  15. package/dist/index.d.ts +4 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +2 -0
  18. package/dist/schemas/automation-maturity.schema.d.ts +8 -8
  19. package/dist/schemas/automation-maturity.schema.d.ts.map +1 -1
  20. package/dist/schemas/automation-maturity.schema.js +1 -0
  21. package/dist/schemas/gap-analysis.schema.d.ts +8 -8
  22. package/dist/schemas/gap-analysis.schema.js +1 -1
  23. package/dist/schemas/public-surface.schema.d.ts +5 -5
  24. package/dist/schemas/repo-analysis.schema.d.ts +7 -7
  25. package/dist/tools/repo/api-surface.d.ts +59 -0
  26. package/dist/tools/repo/api-surface.d.ts.map +1 -0
  27. package/dist/tools/repo/api-surface.js +414 -0
  28. package/dist/tools/scoring/api-coverage.d.ts +74 -0
  29. package/dist/tools/scoring/api-coverage.d.ts.map +1 -0
  30. package/dist/tools/scoring/api-coverage.js +158 -0
  31. package/dist/tools/scoring/automation-maturity.d.ts +11 -1
  32. package/dist/tools/scoring/automation-maturity.d.ts.map +1 -1
  33. package/dist/tools/scoring/automation-maturity.js +43 -9
  34. 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"}