@topogram/cli 0.3.63 → 0.3.64

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 (121) hide show
  1. package/package.json +1 -1
  2. package/src/adoption/plan.d.ts +6 -0
  3. package/src/adoption/reporting.d.ts +10 -0
  4. package/src/adoption/review-groups.d.ts +6 -0
  5. package/src/agent-brief.d.ts +3 -0
  6. package/src/agent-brief.js +495 -0
  7. package/src/agent-ops/query-builders.d.ts +26 -0
  8. package/src/archive/archive.d.ts +2 -0
  9. package/src/archive/compact.d.ts +1 -0
  10. package/src/archive/unarchive.d.ts +1 -0
  11. package/src/catalog.d.ts +10 -0
  12. package/src/catalog.js +62 -66
  13. package/src/cli/catalog-alias.d.ts +1 -0
  14. package/src/cli/command-parser.js +38 -0
  15. package/src/cli/command-parsers/core.js +102 -0
  16. package/src/cli/command-parsers/generator.js +39 -0
  17. package/src/cli/command-parsers/import.js +44 -0
  18. package/src/cli/command-parsers/legacy-workflow.js +21 -0
  19. package/src/cli/command-parsers/project.js +47 -0
  20. package/src/cli/command-parsers/sdlc.js +47 -0
  21. package/src/cli/command-parsers/shared.js +51 -0
  22. package/src/cli/command-parsers/template.js +48 -0
  23. package/src/cli/commands/agent.js +47 -0
  24. package/src/cli/commands/catalog.js +617 -0
  25. package/src/cli/commands/check.js +268 -0
  26. package/src/cli/commands/doctor.js +268 -0
  27. package/src/cli/commands/emit.js +149 -0
  28. package/src/cli/commands/generate.js +96 -0
  29. package/src/cli/commands/generator-policy.js +785 -0
  30. package/src/cli/commands/generator.js +443 -0
  31. package/src/cli/commands/import-runner.js +157 -0
  32. package/src/cli/commands/import.js +1734 -0
  33. package/src/cli/commands/inspect.js +55 -0
  34. package/src/cli/commands/new.js +94 -0
  35. package/src/cli/commands/package.js +815 -0
  36. package/src/cli/commands/query.js +1302 -0
  37. package/src/cli/commands/release-rollout.js +257 -0
  38. package/src/cli/commands/release-shared.js +528 -0
  39. package/src/cli/commands/release-status.js +429 -0
  40. package/src/cli/commands/release.js +107 -0
  41. package/src/cli/commands/sdlc.js +168 -0
  42. package/src/cli/commands/setup.js +76 -0
  43. package/src/cli/commands/source.js +291 -0
  44. package/src/cli/commands/template-runner.js +198 -0
  45. package/src/cli/commands/template.js +2145 -0
  46. package/src/cli/commands/trust.js +219 -0
  47. package/src/cli/commands/version.js +40 -0
  48. package/src/cli/commands/widget.js +168 -0
  49. package/src/cli/commands/workflow.js +63 -0
  50. package/src/cli/dispatcher.js +392 -0
  51. package/src/cli/help-dispatch.js +188 -0
  52. package/src/cli/help.js +296 -0
  53. package/src/cli/migration-guidance.js +59 -0
  54. package/src/cli/options.js +96 -0
  55. package/src/cli/output-safety.js +107 -0
  56. package/src/cli/path-normalization.js +29 -0
  57. package/src/cli.js +47 -11711
  58. package/src/example-implementation.d.ts +2 -0
  59. package/src/format.d.ts +1 -0
  60. package/src/generator/check.d.ts +1 -0
  61. package/src/generator/context/bundle.d.ts +1 -0
  62. package/src/generator/context/shared.d.ts +2 -0
  63. package/src/generator/native/parity-bundle.js +2 -1
  64. package/src/generator/surfaces/web/html-escape.js +22 -0
  65. package/src/generator/surfaces/web/react.js +10 -8
  66. package/src/generator/surfaces/web/sveltekit.js +7 -5
  67. package/src/generator/surfaces/web/vanilla.js +8 -4
  68. package/src/generator.d.ts +2 -0
  69. package/src/github-client.js +520 -0
  70. package/src/import/core/shared.js +20 -62
  71. package/src/import/extractors/api/flutter-dio.js +4 -8
  72. package/src/import/extractors/api/react-native-repository.js +4 -8
  73. package/src/import/index.d.ts +4 -0
  74. package/src/import/provenance.d.ts +4 -0
  75. package/src/new-project.js +100 -11
  76. package/src/npm-safety.js +79 -0
  77. package/src/parser.d.ts +1 -0
  78. package/src/path-helpers.d.ts +1 -0
  79. package/src/path-helpers.js +20 -0
  80. package/src/project-config.js +1 -0
  81. package/src/reconcile/docs.d.ts +8 -0
  82. package/src/reconcile/journeys.d.ts +1 -0
  83. package/src/resolver.d.ts +1 -0
  84. package/src/runtime-support.js +29 -0
  85. package/src/sdlc/adopt.d.ts +1 -0
  86. package/src/sdlc/check.d.ts +1 -0
  87. package/src/sdlc/explain.d.ts +1 -0
  88. package/src/sdlc/release.d.ts +1 -0
  89. package/src/sdlc/scaffold.d.ts +1 -0
  90. package/src/sdlc/transition.d.ts +1 -0
  91. package/src/text-helpers.d.ts +6 -0
  92. package/src/text-helpers.js +245 -0
  93. package/src/topogram-config.js +306 -0
  94. package/src/validator.d.ts +2 -0
  95. package/src/workflows/adoption/index.js +26 -0
  96. package/src/workflows/docs-generate.js +262 -0
  97. package/src/workflows/docs-scan.js +703 -0
  98. package/src/workflows/docs.js +15 -0
  99. package/src/workflows/import-app/api.js +799 -0
  100. package/src/workflows/import-app/db.js +538 -0
  101. package/src/workflows/import-app/index.js +30 -0
  102. package/src/workflows/import-app/shared.js +218 -0
  103. package/src/workflows/import-app/ui.js +443 -0
  104. package/src/workflows/import-app/workflow.js +159 -0
  105. package/src/workflows/reconcile/adoption-plan.js +742 -0
  106. package/src/workflows/reconcile/auth.js +692 -0
  107. package/src/workflows/reconcile/bundle-core.js +600 -0
  108. package/src/workflows/reconcile/bundle-shared.js +75 -0
  109. package/src/workflows/reconcile/candidate-model.js +477 -0
  110. package/src/workflows/reconcile/canonical-surface.js +264 -0
  111. package/src/workflows/reconcile/gap-report.js +333 -0
  112. package/src/workflows/reconcile/ids.js +6 -0
  113. package/src/workflows/reconcile/impacts.js +625 -0
  114. package/src/workflows/reconcile/index.js +7 -0
  115. package/src/workflows/reconcile/renderers.js +461 -0
  116. package/src/workflows/reconcile/summary.js +90 -0
  117. package/src/workflows/reconcile/workflow.js +309 -0
  118. package/src/workflows/shared.js +189 -0
  119. package/src/workflows/types.d.ts +93 -0
  120. package/src/workflows.d.ts +1 -0
  121. package/src/workflows.js +10 -7652
@@ -0,0 +1,799 @@
1
+ // @ts-check
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ import { relativeTo } from "../../path-helpers.js";
6
+ import { canonicalCandidateTerm, idHintify, slugify, titleCase } from "../../text-helpers.js";
7
+ import { listFilesRecursive, readTextIfExists } from "../shared.js";
8
+ import { inferReactRoutes, inferSvelteRoutes, routeSegments } from "./ui.js";
9
+ import { dedupeCandidateRecords, findImportFiles, inferCapabilityEntityId, makeCandidateRecord, normalizeOpenApiPath, selectPreferredImportFiles } from "./shared.js";
10
+
11
+ /** @param {WorkspacePaths} paths @returns {any} */
12
+ export function discoverApiSources(paths) {
13
+ const allOpenApiFiles = findImportFiles(
14
+ paths,
15
+ (/** @type {any} */ filePath) =>
16
+ /(openapi|swagger)\.(json|ya?ml)$/i.test(path.basename(filePath))
17
+ );
18
+ const openApiFiles = selectPreferredImportFiles(paths, allOpenApiFiles, "openapi");
19
+ const routeFiles = findImportFiles(
20
+ paths,
21
+ (/** @type {any} */ filePath) =>
22
+ /\.(ts|tsx|js|jsx|mjs|cjs)$/.test(filePath) &&
23
+ /(server|api|routes|src)/i.test(filePath)
24
+ );
25
+ return { openApiFiles, routeFiles };
26
+ }
27
+
28
+ /** @param {WorkflowRecord} document @param {any} provenance @param {string} sourceKind @returns {any} */
29
+ function parseOpenApiDocument(document, provenance, sourceKind = "openapi") {
30
+ /** @type {any[]} */
31
+ const capabilities = [];
32
+ /** @type {any[]} */
33
+ const routes = [];
34
+ const pathsObject = document.paths || {};
35
+ for (const [endpointPath, operations] of Object.entries(pathsObject)) {
36
+ for (const [method, operation] of Object.entries(operations || {})) {
37
+ const normalizedMethod = method.toUpperCase();
38
+ const operationId = operation.operationId || `candidate_${normalizedMethod.toLowerCase()}_${slugify(endpointPath)}`;
39
+ const requestSchema =
40
+ operation.requestBody?.content?.["application/json"]?.schema?.$ref ||
41
+ operation.requestBody?.content?.["application/json"]?.schema?.type ||
42
+ null;
43
+ const successResponse = Object.entries(operation.responses || {}).find((/** @type {any} */ [status]) => /^2/.test(status));
44
+ const responseSchema =
45
+ successResponse?.[1]?.content?.["application/json"]?.schema?.$ref ||
46
+ successResponse?.[1]?.content?.["application/json"]?.schema?.type ||
47
+ null;
48
+ const parameterHints = extractOpenApiParameterHints(document, endpointPath, operation);
49
+ const requestFieldHints = extractOpenApiSchemaFieldHints(document, operation.requestBody?.content?.["application/json"]?.schema);
50
+ const responseFieldHints = extractOpenApiSchemaFieldHints(document, successResponse?.[1]?.content?.["application/json"]?.schema);
51
+ const securitySchemes = extractOpenApiSecuritySchemes(document, operation);
52
+ capabilities.push(
53
+ makeCandidateRecord({
54
+ kind: "capability",
55
+ idHint: operationId,
56
+ label: operation.summary || titleCase(operationId.replace(/^cap_/, "")),
57
+ confidence: "high",
58
+ sourceKind,
59
+ provenance: `${provenance}#${normalizedMethod} ${endpointPath}`,
60
+ endpoint: {
61
+ method: normalizedMethod,
62
+ path: endpointPath
63
+ },
64
+ input_hint: requestSchema,
65
+ output_hint: responseSchema,
66
+ input_fields: requestFieldHints.body_fields,
67
+ output_fields: responseFieldHints.body_fields,
68
+ path_params: parameterHints.path,
69
+ query_params: parameterHints.query,
70
+ header_params: parameterHints.header,
71
+ security_schemes: securitySchemes,
72
+ auth_hint: securitySchemes.length > 0 ? "secured" : "unknown"
73
+ })
74
+ );
75
+ routes.push({
76
+ path: endpointPath,
77
+ method: normalizedMethod,
78
+ source_kind: sourceKind,
79
+ provenance: `${provenance}#${normalizedMethod} ${endpointPath}`
80
+ });
81
+ }
82
+ }
83
+ return { capabilities, routes };
84
+ }
85
+
86
+ /** @param {string} ref @returns {any} */
87
+ function openApiRefName(ref) {
88
+ if (!ref || typeof ref !== "string") {
89
+ return null;
90
+ }
91
+ return ref.split("/").pop() || null;
92
+ }
93
+
94
+ /** @param {WorkflowRecord} document @param {WorkflowRecord} schema @param {Set<any>} seen @returns {any} */
95
+ function resolveOpenApiSchema(document, schema, seen = new Set()) {
96
+ if (!schema || typeof schema !== "object") {
97
+ return null;
98
+ }
99
+ if (schema.$ref) {
100
+ if (seen.has(schema.$ref)) {
101
+ return null;
102
+ }
103
+ seen.add(schema.$ref);
104
+ if (!schema.$ref.startsWith("#/")) {
105
+ return null;
106
+ }
107
+ const segments = schema.$ref.slice(2).split("/");
108
+ let current = document;
109
+ for (const segment of segments) {
110
+ current = current?.[segment];
111
+ if (current == null) {
112
+ return null;
113
+ }
114
+ }
115
+ return resolveOpenApiSchema(document, current, seen) || current;
116
+ }
117
+ return schema;
118
+ }
119
+
120
+ /** @param {WorkflowRecord} document @param {WorkflowRecord} schema @param {Set<any>} fields @param {Set<any>} seen @returns {any} */
121
+ function collectOpenApiObjectFields(document, schema, fields = new Set(), seen = new Set()) {
122
+ const resolved = resolveOpenApiSchema(document, schema, seen);
123
+ if (!resolved || typeof resolved !== "object") {
124
+ return fields;
125
+ }
126
+ if (resolved.type === "array" && resolved.items) {
127
+ collectOpenApiObjectFields(document, resolved.items, fields, seen);
128
+ return fields;
129
+ }
130
+ for (const propertyName of Object.keys(resolved.properties || {})) {
131
+ fields.add(propertyName);
132
+ }
133
+ for (const entry of resolved.allOf || []) {
134
+ collectOpenApiObjectFields(document, entry, fields, seen);
135
+ }
136
+ for (const entry of resolved.oneOf || []) {
137
+ collectOpenApiObjectFields(document, entry, fields, seen);
138
+ }
139
+ for (const entry of resolved.anyOf || []) {
140
+ collectOpenApiObjectFields(document, entry, fields, seen);
141
+ }
142
+ return fields;
143
+ }
144
+
145
+ /** @param {WorkflowRecord} document @param {WorkflowRecord} schema @returns {any} */
146
+ function extractOpenApiSchemaFieldHints(document, schema) {
147
+ const fieldNames = [...collectOpenApiObjectFields(document, schema)].sort();
148
+ return {
149
+ schema_ref: openApiRefName(schema?.$ref || null),
150
+ body_fields: fieldNames
151
+ };
152
+ }
153
+
154
+ /** @param {string} endpointPath @param {WorkflowRecord} operation @returns {any} */
155
+ function collectOpenApiParameters(endpointPath, operation) {
156
+ const pathParams = [...String(endpointPath || "").matchAll(/\{([^}]+)\}/g)].map((/** @type {any} */ match) => ({
157
+ name: match[1],
158
+ in: "path",
159
+ required: true
160
+ }));
161
+ return [...pathParams, ...((operation.parameters || []).filter(Boolean))];
162
+ }
163
+
164
+ /** @param {WorkflowRecord} document @param {string} endpointPath @param {WorkflowRecord} operation @returns {any} */
165
+ function extractOpenApiParameterHints(document, endpointPath, operation) {
166
+ /** @type {WorkflowRecord} */
167
+ const grouped = {
168
+ path: [],
169
+ query: [],
170
+ header: []
171
+ };
172
+ for (const parameter of collectOpenApiParameters(endpointPath, operation)) {
173
+ const schema = resolveOpenApiSchema(document, parameter.schema || null);
174
+ const target = parameter.in === "query" ? "query" : parameter.in === "header" ? "header" : "path";
175
+ grouped[target].push({
176
+ name: parameter.name,
177
+ required: Boolean(parameter.required),
178
+ type: schema?.type || null
179
+ });
180
+ }
181
+ for (const key of Object.keys(grouped)) {
182
+ grouped[key] = grouped[key].sort((/** @type {any} */ a, /** @type {any} */ b) => a.name.localeCompare(b.name));
183
+ }
184
+ return grouped;
185
+ }
186
+
187
+ /** @param {WorkflowRecord} document @param {WorkflowRecord} operation @returns {any} */
188
+ function extractOpenApiSecuritySchemes(document, operation) {
189
+ const securityEntries = [...(operation.security || []), ...(document.security || [])];
190
+ const schemes = new Set();
191
+ for (const entry of securityEntries) {
192
+ for (const key of Object.keys(entry || {})) {
193
+ schemes.add(key);
194
+ }
195
+ }
196
+ return [...schemes].sort();
197
+ }
198
+
199
+ /** @param {string} text @returns {any} */
200
+ function parseOpenApiYaml(text) {
201
+ /** @type {WorkflowRecord} */
202
+ const doc = { paths: {} };
203
+ let currentPath = null;
204
+ let currentMethod = null;
205
+ let inRequestBody = false;
206
+ let inResponses = false;
207
+ let currentResponseStatus = null;
208
+
209
+ for (const rawLine of text.split(/\r?\n/)) {
210
+ const line = rawLine.replace(/#.*$/, "");
211
+ if (!line.trim()) {
212
+ continue;
213
+ }
214
+ const pathMatch = line.match(/^\s{2}(\/[^:]+):\s*$/);
215
+ if (pathMatch) {
216
+ currentPath = pathMatch[1];
217
+ currentMethod = null;
218
+ doc.paths[currentPath] = doc.paths[currentPath] || {};
219
+ inRequestBody = false;
220
+ inResponses = false;
221
+ currentResponseStatus = null;
222
+ continue;
223
+ }
224
+ const methodMatch = line.match(/^\s{4}(get|post|put|patch|delete):\s*$/i);
225
+ if (methodMatch && currentPath) {
226
+ currentMethod = methodMatch[1].toLowerCase();
227
+ doc.paths[currentPath][currentMethod] = { responses: {} };
228
+ inRequestBody = false;
229
+ inResponses = false;
230
+ currentResponseStatus = null;
231
+ continue;
232
+ }
233
+ if (!currentPath || !currentMethod) {
234
+ continue;
235
+ }
236
+ const operation = doc.paths[currentPath][currentMethod];
237
+ const operationIdMatch = line.match(/^\s{6}operationId:\s*(.+)$/);
238
+ if (operationIdMatch) {
239
+ operation.operationId = operationIdMatch[1].trim().replace(/^["']|["']$/g, "");
240
+ continue;
241
+ }
242
+ const summaryMatch = line.match(/^\s{6}summary:\s*(.+)$/);
243
+ if (summaryMatch) {
244
+ operation.summary = summaryMatch[1].trim().replace(/^["']|["']$/g, "");
245
+ continue;
246
+ }
247
+ if (/^\s{6}requestBody:\s*$/.test(line)) {
248
+ inRequestBody = true;
249
+ inResponses = false;
250
+ currentResponseStatus = null;
251
+ continue;
252
+ }
253
+ if (/^\s{6}responses:\s*$/.test(line)) {
254
+ inResponses = true;
255
+ inRequestBody = false;
256
+ currentResponseStatus = null;
257
+ continue;
258
+ }
259
+ const responseStatusMatch = line.match(/^\s{8}['"]?([0-9Xx]{3})['"]?:\s*$/);
260
+ if (inResponses && responseStatusMatch) {
261
+ currentResponseStatus = responseStatusMatch[1];
262
+ operation.responses[currentResponseStatus] = operation.responses[currentResponseStatus] || {};
263
+ continue;
264
+ }
265
+ const refMatch = line.match(/^\s+\$ref:\s*(.+)$/);
266
+ if (refMatch) {
267
+ const ref = refMatch[1].trim().replace(/^["']|["']$/g, "");
268
+ if (inRequestBody) {
269
+ operation.requestBody = operation.requestBody || { content: { "application/json": { schema: {} } } };
270
+ operation.requestBody.content["application/json"].schema.$ref = ref;
271
+ } else if (inResponses && currentResponseStatus) {
272
+ operation.responses[currentResponseStatus].content = operation.responses[currentResponseStatus].content || { "application/json": { schema: {} } };
273
+ operation.responses[currentResponseStatus].content["application/json"].schema.$ref = ref;
274
+ }
275
+ }
276
+ }
277
+
278
+ return doc;
279
+ }
280
+
281
+ /** @param {WorkspacePaths} paths @returns {any} */
282
+ function inferServerRoutes(paths) {
283
+ /** @type {any[]} */
284
+ const routes = [];
285
+ const routeFiles = findImportFiles(
286
+ paths,
287
+ (/** @type {any} */ filePath) =>
288
+ /\.(ts|tsx|js|jsx|mjs|cjs)$/.test(filePath) &&
289
+ /(server|api|routes|src)/i.test(filePath)
290
+ );
291
+ for (const filePath of routeFiles) {
292
+ const text = readTextIfExists(filePath) || "";
293
+ for (const match of text.matchAll(/\.(get|post|put|patch|delete)\(\s*["'`]([^"'`]+)["'`]\s*,([\s\S]*?)\)\s*;?/gi)) {
294
+ const handlerTokens = [...match[3].matchAll(/\b([A-Za-z_][A-Za-z0-9_]*)\b/g)].map((/** @type {any} */ entry) => entry[1]);
295
+ const handlerHint = handlerTokens.length > 0 ? handlerTokens[handlerTokens.length - 1] : null;
296
+ const pathParams = [...normalizeOpenApiPath(match[2]).matchAll(/\{([^}]+)\}/g)].map((/** @type {any} */ entry) => entry[1]);
297
+ const handlerContext = handlerHint ? extractHandlerContext(text, handlerHint) : "";
298
+ const queryParams = inferRouteQueryParams(handlerContext);
299
+ const authHint = inferRouteAuthHint(match[3], handlerContext);
300
+ routes.push({
301
+ file: filePath,
302
+ method: match[1].toUpperCase(),
303
+ path: match[2],
304
+ handler_hint: handlerHint,
305
+ path_params: pathParams,
306
+ query_params: queryParams,
307
+ auth_hint: authHint
308
+ });
309
+ }
310
+ }
311
+ return routes;
312
+ }
313
+
314
+ /** @param {WorkspacePaths} paths @returns {any} */
315
+ function inferNextApiRoutes(paths) {
316
+ const apiRoot = path.join(paths.workspaceRoot, "app", "api");
317
+ if (!fs.existsSync(apiRoot)) {
318
+ return [];
319
+ }
320
+ const routeFiles = listFilesRecursive(
321
+ apiRoot,
322
+ (/** @type {any} */ child) => /\/route\.(tsx|ts|jsx|js)$/.test(child) || /^route\.(tsx|ts|jsx|js)$/.test(path.basename(child))
323
+ );
324
+ /** @type {any[]} */
325
+ const routes = [];
326
+ for (const filePath of routeFiles) {
327
+ const text = readTextIfExists(filePath) || "";
328
+ const relative = relativeTo(apiRoot, filePath);
329
+ const routePath = `/${relative}`
330
+ .replace(/\/route\.(tsx|ts|jsx|js)$/, "")
331
+ .replace(/\[(\.\.\.)?([^\]]+)\]/g, (/** @type {any} */ _match, /** @type {any} */ catchAll, /** @type {any} */ name) => catchAll ? `:${name}*` : `:${name}`);
332
+ for (const match of text.matchAll(/export\s+async\s+function\s+(GET|POST|PUT|PATCH|DELETE)\s*\(([^)]*)\)/g)) {
333
+ const method = match[1].toUpperCase();
334
+ const handlerContext = extractNamedExportBlock(text, match[1]) || "";
335
+ const queryParams = inferNextRequestSearchParams(handlerContext);
336
+ const outputFields = inferNextJsonFields(handlerContext);
337
+ const authHint = inferRouteAuthHint(match[0], handlerContext);
338
+ routes.push({
339
+ file: filePath,
340
+ method,
341
+ path: routePath === "" ? "/" : routePath,
342
+ handler_hint: match[1].toLowerCase(),
343
+ path_params: [...normalizeOpenApiPath(routePath).matchAll(/\{([^}]+)\}/g)].map((/** @type {any} */ entry) => entry[1]),
344
+ query_params: queryParams,
345
+ output_fields: outputFields,
346
+ auth_hint: authHint,
347
+ source_kind: "route_code"
348
+ });
349
+ }
350
+ }
351
+ return routes;
352
+ }
353
+
354
+ /** @param {any} appRoot @param {string} filePath @returns {any} */
355
+ function nextAppRoutePathFromFile(appRoot, filePath) {
356
+ const relative = relativeTo(appRoot, filePath);
357
+ return `/${relative}`
358
+ .replace(/\/actions\.(tsx|ts|jsx|js)$/, "")
359
+ .replace(/\/page\.(tsx|ts|jsx|js|mdx)$/, "")
360
+ .replace(/\[(\.\.\.)?([^\]]+)\]/g, (/** @type {any} */ _match, /** @type {any} */ catchAll, /** @type {any} */ name) => catchAll ? `:${name}*` : `:${name}`)
361
+ .replace(/\/index$/, "")
362
+ .replace(/^\/$/, "/") || "/";
363
+ }
364
+
365
+ /** @param {string} text @returns {any} */
366
+ function inferFormDataFields(text) {
367
+ const fields = new Set();
368
+ for (const match of String(text || "").matchAll(/formData\.get\(\s*["'`]([^"'`]+)["'`]\s*\)/g)) {
369
+ fields.add(match[1]);
370
+ }
371
+ return [...fields].sort();
372
+ }
373
+
374
+ /** @param {string} text @returns {any} */
375
+ function inferInputNames(text) {
376
+ const fields = new Set();
377
+ for (const match of String(text || "").matchAll(/\bname=["'`]([^"'`]+)["'`]/g)) {
378
+ fields.add(match[1]);
379
+ }
380
+ return [...fields].sort();
381
+ }
382
+
383
+ /** @param {WorkspacePaths} paths @returns {any} */
384
+ function inferNextAuthCapabilities(paths) {
385
+ const authConfigPath = path.join(paths.workspaceRoot, "auth.ts");
386
+ const authConfigText = readTextIfExists(authConfigPath) || "";
387
+ const hasCredentialsProvider = /CredentialsProvider\s*\(/.test(authConfigText);
388
+ const createsUserOnAuthorize = /prisma\.user\.create\s*\(/.test(authConfigText);
389
+ const loginPagePath = path.join(paths.workspaceRoot, "app", "login", "page.tsx");
390
+ const registerPagePath = path.join(paths.workspaceRoot, "app", "register", "page.tsx");
391
+ const pages = [
392
+ {
393
+ file: loginPagePath,
394
+ path: "/login",
395
+ id_hint: "cap_sign_in_user",
396
+ label: "Sign In User",
397
+ target_state: "authenticated"
398
+ },
399
+ {
400
+ file: registerPagePath,
401
+ path: "/register",
402
+ id_hint: "cap_register_user",
403
+ label: "Register User",
404
+ target_state: createsUserOnAuthorize ? "registered" : "created"
405
+ }
406
+ ];
407
+ /** @type {any[]} */
408
+ const capabilities = [];
409
+ for (const page of pages) {
410
+ const text = readTextIfExists(page.file) || "";
411
+ if (!text || !/signIn\(\s*["'`]credentials["'`]/.test(text)) {
412
+ continue;
413
+ }
414
+ const inputFields = inferInputNames(text);
415
+ capabilities.push({
416
+ file: page.file,
417
+ function_name: page.id_hint.replace(/^cap_/, ""),
418
+ method: "POST",
419
+ path: page.path,
420
+ id_hint: page.id_hint,
421
+ label: page.label,
422
+ input_fields: inputFields,
423
+ output_fields: [],
424
+ path_params: [],
425
+ auth_hint: "public",
426
+ entity_id: "entity_user",
427
+ target_state: page.target_state,
428
+ provenance: [
429
+ relativeTo(paths.repoRoot, page.file),
430
+ ...(hasCredentialsProvider ? [relativeTo(paths.repoRoot, authConfigPath)] : [])
431
+ ],
432
+ source_kind: "route_code"
433
+ });
434
+ }
435
+ return capabilities.sort((/** @type {any} */ a, /** @type {any} */ b) => a.id_hint.localeCompare(b.id_hint));
436
+ }
437
+
438
+ /** @param {WorkspacePaths} paths @returns {any} */
439
+ function inferNextServerActionCapabilities(paths) {
440
+ const appRoot = path.join(paths.workspaceRoot, "app");
441
+ if (!fs.existsSync(appRoot)) {
442
+ return [];
443
+ }
444
+ const actionFiles = listFilesRecursive(
445
+ appRoot,
446
+ (/** @type {any} */ child) =>
447
+ /\/actions\.(tsx|ts|jsx|js)$/.test(child) ||
448
+ /\/page\.(tsx|ts|jsx|js|mdx)$/.test(child) ||
449
+ /^page\.(tsx|ts|jsx|js|mdx)$/.test(path.basename(child))
450
+ );
451
+ /** @type {any[]} */
452
+ const capabilities = [];
453
+ for (const filePath of actionFiles) {
454
+ const text = readTextIfExists(filePath) || "";
455
+ const routePath = nextAppRoutePathFromFile(appRoot, filePath);
456
+ for (const match of text.matchAll(/(?:export\s+)?async\s+function\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]*)\)\s*\{([\s\S]{0,2400}?)\n\}/g)) {
457
+ const functionName = match[1];
458
+ const body = match[3] || "";
459
+ const trimmedBody = body.trimStart();
460
+ const isServerAction =
461
+ /\/actions\.(tsx|ts|jsx|js)$/.test(filePath) ||
462
+ trimmedBody.startsWith('"use server"') ||
463
+ trimmedBody.startsWith("'use server'");
464
+ if (!isServerAction) {
465
+ continue;
466
+ }
467
+ /** @type {WorkflowRecord} */
468
+ const routeLike = {
469
+ file: filePath,
470
+ method: "POST",
471
+ path: routePath,
472
+ handler_hint: functionName,
473
+ auth_hint: inferRouteAuthHint(functionName, body)
474
+ };
475
+ capabilities.push({
476
+ file: filePath,
477
+ function_name: functionName,
478
+ method: "POST",
479
+ path: routePath,
480
+ id_hint: inferRouteCapabilityId(routeLike),
481
+ input_fields: inferFormDataFields(body),
482
+ output_fields: [],
483
+ path_params: [...normalizeOpenApiPath(routePath).matchAll(/\{([^}]+)\}/g)].map((/** @type {any} */ entry) => entry[1]),
484
+ auth_hint: routeLike.auth_hint,
485
+ entity_id: inferCapabilityEntityId({ endpoint: { path: routePath }, id_hint: inferRouteCapabilityId(routeLike) }),
486
+ source_kind: "route_code"
487
+ });
488
+ }
489
+ }
490
+ return capabilities.sort((/** @type {any} */ a, /** @type {any} */ b) => a.id_hint.localeCompare(b.id_hint) || a.path.localeCompare(b.path));
491
+ }
492
+
493
+ /** @param {string} text @param {string} exportName @returns {any} */
494
+ function extractNamedExportBlock(text, exportName) {
495
+ const escapedName = exportName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
496
+ const match = text.match(new RegExp(`export\\s+async\\s+function\\s+${escapedName}\\s*\\([^)]*\\)\\s*\\{([\\s\\S]{0,2000}?)\\n\\}`, "m"));
497
+ return match ? match[1] : "";
498
+ }
499
+
500
+ /** @param {string} text @returns {any} */
501
+ function inferNextRequestSearchParams(text) {
502
+ const params = new Set();
503
+ for (const match of String(text || "").matchAll(/searchParams\.get\(\s*["'`]([^"'`]+)["'`]\s*\)/g)) {
504
+ params.add(match[1]);
505
+ }
506
+ return [...params].sort();
507
+ }
508
+
509
+ /** @param {string} text @returns {any} */
510
+ function inferNextJsonFields(text) {
511
+ const fields = new Set();
512
+ for (const match of String(text || "").matchAll(/NextResponse\.json\(\s*\{([\s\S]{0,400}?)\}\s*\)/g)) {
513
+ for (const fieldMatch of match[1].matchAll(/\b([A-Za-z_][A-Za-z0-9_]*)\b\s*[:,]/g)) {
514
+ fields.add(fieldMatch[1]);
515
+ }
516
+ }
517
+ return [...fields].sort();
518
+ }
519
+
520
+ /** @param {string} text @param {any} handlerName @returns {any} */
521
+ function extractHandlerContext(text, handlerName) {
522
+ if (!handlerName) {
523
+ return "";
524
+ }
525
+ const escapedName = handlerName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
526
+ const patterns = [
527
+ new RegExp(`function\\s+${escapedName}\\s*\\([^)]*\\)\\s*\\{([\\s\\S]{0,1200}?)\\n\\}`, "m"),
528
+ new RegExp(`const\\s+${escapedName}\\s*=\\s*(?:async\\s*)?\\([^)]*\\)\\s*=>\\s*\\{([\\s\\S]{0,1200}?)\\n\\}`, "m")
529
+ ];
530
+ for (const pattern of patterns) {
531
+ const match = text.match(pattern);
532
+ if (match) {
533
+ return match[1];
534
+ }
535
+ }
536
+ return "";
537
+ }
538
+
539
+ /** @param {string} text @returns {any} */
540
+ function inferRouteQueryParams(text) {
541
+ const params = new Set();
542
+ for (const match of String(text || "").matchAll(/\bquery\s*\(\s*["'`]([^"'`]+)["'`]\s*\)/g)) {
543
+ params.add(match[1]);
544
+ }
545
+ for (const match of String(text || "").matchAll(/\bquery\.([A-Za-z_][A-Za-z0-9_]*)\b/g)) {
546
+ params.add(match[1]);
547
+ }
548
+ return [...params].sort();
549
+ }
550
+
551
+ /** @param {any[]} routeArguments @param {any} handlerContext @returns {any} */
552
+ function inferRouteAuthHint(routeArguments, handlerContext) {
553
+ const combined = `${routeArguments || ""}\n${handlerContext || ""}`.toLowerCase();
554
+ return /\b(auth|session|permission|guard|protected|require_auth|requireauth|ensureauth)\b/.test(combined)
555
+ ? "secured"
556
+ : "unknown";
557
+ }
558
+
559
+ /** @param {WorkflowRecord} route @returns {any} */
560
+ function inferRouteCapabilityId(route) {
561
+ if (route.handler_hint) {
562
+ const genericHttpHandler = /^(get|post|put|patch|delete)$/i.test(route.handler_hint);
563
+ if (!genericHttpHandler) {
564
+ const normalizedHandler = route.handler_hint
565
+ .replace(/^(handle|on)/i, "")
566
+ .replace(/(handler|route|controller|action)$/i, "");
567
+ const handlerTokens = normalizedHandler
568
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
569
+ .split(/[^A-Za-z0-9]+/)
570
+ .filter(Boolean)
571
+ .map((/** @type {any} */ token) => token.toLowerCase());
572
+ if (handlerTokens.length > 0) {
573
+ return `cap_${handlerTokens.join("_")}`;
574
+ }
575
+ }
576
+ }
577
+ const method = String(route.method || "").toUpperCase();
578
+ const segments = routeSegments(normalizeOpenApiPath(route.path));
579
+ const resource = canonicalCandidateTerm(segments[0] || "item");
580
+ if (method === "GET" && segments.length <= 1) {
581
+ return `cap_list_${resource}s`;
582
+ }
583
+ if (method === "GET" && segments.length > 1) {
584
+ return `cap_get_${resource}`;
585
+ }
586
+ if (method === "POST") {
587
+ return `cap_create_${resource}`;
588
+ }
589
+ if (method === "PATCH" || method === "PUT") {
590
+ return `cap_update_${resource}`;
591
+ }
592
+ if (method === "DELETE") {
593
+ return `cap_delete_${resource}`;
594
+ }
595
+ return `candidate_${route.method.toLowerCase()}_${slugify(route.path)}`;
596
+ }
597
+
598
+ /** @param {WorkspacePaths} paths @returns {any} */
599
+ export function collectApiImport(paths) {
600
+ /** @type {any[]} */
601
+ const findings = [];
602
+ /** @type {WorkflowRecord} */
603
+ const candidates = {
604
+ capabilities: [],
605
+ routes: [],
606
+ stacks: []
607
+ };
608
+ const { openApiFiles } = discoverApiSources(paths);
609
+ let usedOpenApi = false;
610
+ for (const filePath of openApiFiles) {
611
+ const provenance = relativeTo(paths.repoRoot, filePath);
612
+ const text = readTextIfExists(filePath) || "";
613
+ const document = filePath.endsWith(".json") ? JSON.parse(text) : parseOpenApiYaml(text);
614
+ const parsed = parseOpenApiDocument(document, provenance, "openapi");
615
+ usedOpenApi = true;
616
+ findings.push({
617
+ kind: "openapi",
618
+ file: provenance,
619
+ capability_count: parsed.capabilities.length
620
+ });
621
+ candidates.capabilities.push(...parsed.capabilities);
622
+ candidates.routes.push(...parsed.routes.map((/** @type {any} */ route) => ({
623
+ path: route.path,
624
+ method: route.method,
625
+ confidence: "high",
626
+ source_kind: route.source_kind,
627
+ provenance: route.provenance
628
+ })));
629
+ }
630
+ if (!usedOpenApi) {
631
+ const inferredRoutes = [
632
+ ...inferNextApiRoutes(paths),
633
+ ...inferServerRoutes(paths)
634
+ ];
635
+ const inferredServerActions = inferNextServerActionCapabilities(paths);
636
+ const inferredAuthCapabilities = inferNextAuthCapabilities(paths);
637
+ if (inferredRoutes.length > 0) {
638
+ findings.push({
639
+ kind: "route_inventory",
640
+ files: [...new Set(inferredRoutes.map((/** @type {any} */ route) => relativeTo(paths.repoRoot, route.file)))],
641
+ route_count: inferredRoutes.length
642
+ });
643
+ candidates.capabilities.push(
644
+ ...inferredRoutes.map((/** @type {any} */ route) =>
645
+ makeCandidateRecord({
646
+ kind: "capability",
647
+ idHint: inferRouteCapabilityId(route),
648
+ label: `${route.method} ${route.path}`,
649
+ confidence: "medium",
650
+ sourceKind: "route_code",
651
+ provenance: `${relativeTo(paths.repoRoot, route.file)}#${route.method} ${route.path}`,
652
+ endpoint: {
653
+ method: route.method,
654
+ path: normalizeOpenApiPath(route.path)
655
+ },
656
+ path_params: (route.path_params || []).map((/** @type {any} */ name) => ({ name, required: true, type: null })),
657
+ query_params: (route.query_params || []).map((/** @type {any} */ name) => ({ name, required: false, type: null })),
658
+ header_params: [],
659
+ input_fields: [],
660
+ output_fields: route.output_fields || [],
661
+ auth_hint: route.auth_hint || "unknown"
662
+ })
663
+ )
664
+ );
665
+ candidates.routes.push(
666
+ ...inferredRoutes.map((/** @type {any} */ route) => ({
667
+ path: normalizeOpenApiPath(route.path),
668
+ method: route.method,
669
+ confidence: "medium",
670
+ source_kind: "route_code",
671
+ provenance: `${relativeTo(paths.repoRoot, route.file)}#${route.method} ${route.path}`
672
+ }))
673
+ );
674
+ }
675
+ if (inferredServerActions.length > 0) {
676
+ findings.push({
677
+ kind: "next_server_actions",
678
+ files: [...new Set(inferredServerActions.map((/** @type {any} */ action) => relativeTo(paths.repoRoot, action.file)))],
679
+ action_count: inferredServerActions.length
680
+ });
681
+ candidates.capabilities.push(
682
+ ...inferredServerActions.map((/** @type {any} */ action) =>
683
+ makeCandidateRecord({
684
+ kind: "capability",
685
+ idHint: action.id_hint,
686
+ label: titleCase(action.id_hint.replace(/^cap_/, "")),
687
+ confidence: "medium",
688
+ sourceKind: action.source_kind,
689
+ provenance: `${relativeTo(paths.repoRoot, action.file)}#${action.function_name}`,
690
+ endpoint: {
691
+ method: action.method,
692
+ path: normalizeOpenApiPath(action.path)
693
+ },
694
+ path_params: (action.path_params || []).map((/** @type {any} */ name) => ({ name, required: true, type: null })),
695
+ query_params: [],
696
+ header_params: [],
697
+ input_fields: action.input_fields || [],
698
+ output_fields: action.output_fields || [],
699
+ auth_hint: action.auth_hint || "unknown"
700
+ })
701
+ )
702
+ );
703
+ candidates.routes.push(
704
+ ...inferredServerActions.map((/** @type {any} */ action) => ({
705
+ path: normalizeOpenApiPath(action.path),
706
+ method: action.method,
707
+ confidence: "medium",
708
+ source_kind: action.source_kind,
709
+ provenance: `${relativeTo(paths.repoRoot, action.file)}#${action.function_name}`
710
+ }))
711
+ );
712
+ }
713
+ if (inferredAuthCapabilities.length > 0) {
714
+ findings.push({
715
+ kind: "next_auth_flows",
716
+ files: [...new Set(inferredAuthCapabilities.flatMap((/** @type {any} */ capability) => capability.provenance || []))],
717
+ capability_count: inferredAuthCapabilities.length
718
+ });
719
+ candidates.capabilities.push(
720
+ ...inferredAuthCapabilities.map((/** @type {any} */ capability) =>
721
+ makeCandidateRecord({
722
+ kind: "capability",
723
+ idHint: capability.id_hint,
724
+ label: capability.label,
725
+ confidence: "medium",
726
+ sourceKind: capability.source_kind,
727
+ provenance: capability.provenance,
728
+ endpoint: {
729
+ method: capability.method,
730
+ path: normalizeOpenApiPath(capability.path)
731
+ },
732
+ path_params: [],
733
+ query_params: [],
734
+ header_params: [],
735
+ input_fields: capability.input_fields || [],
736
+ output_fields: capability.output_fields || [],
737
+ auth_hint: capability.auth_hint || "unknown",
738
+ entity_id: capability.entity_id,
739
+ target_state: capability.target_state || null
740
+ })
741
+ )
742
+ );
743
+ candidates.routes.push(
744
+ ...inferredAuthCapabilities.map((/** @type {any} */ capability) => ({
745
+ path: normalizeOpenApiPath(capability.path),
746
+ method: capability.method,
747
+ confidence: "medium",
748
+ source_kind: capability.source_kind,
749
+ provenance: capability.provenance
750
+ }))
751
+ );
752
+ }
753
+ }
754
+ const reactRoutes = inferReactRoutes(path.join(paths.workspaceRoot, "apps", "web"));
755
+ if (reactRoutes.length > 0) {
756
+ findings.push({
757
+ kind: "react_routes",
758
+ file: relativeTo(paths.repoRoot, path.join(paths.workspaceRoot, "apps", "web", "src", "App.tsx")),
759
+ routes: reactRoutes
760
+ });
761
+ candidates.routes.push(...reactRoutes.map((/** @type {any} */ route) => ({
762
+ path: route,
763
+ method: "GET",
764
+ confidence: "medium",
765
+ source_kind: "generated_artifact",
766
+ provenance: relativeTo(paths.repoRoot, path.join(paths.workspaceRoot, "apps", "web", "src", "App.tsx"))
767
+ })));
768
+ candidates.stacks.push("react_web");
769
+ }
770
+ const svelteRoutes = inferSvelteRoutes(path.join(paths.workspaceRoot, "apps", "web-sveltekit"));
771
+ if (svelteRoutes.length > 0) {
772
+ findings.push({
773
+ kind: "sveltekit_routes",
774
+ file: relativeTo(paths.repoRoot, path.join(paths.workspaceRoot, "apps", "web-sveltekit", "src", "routes")),
775
+ routes: svelteRoutes
776
+ });
777
+ candidates.routes.push(...svelteRoutes.map((/** @type {any} */ route) => ({
778
+ path: route,
779
+ method: "GET",
780
+ confidence: "medium",
781
+ source_kind: "generated_artifact",
782
+ provenance: relativeTo(paths.repoRoot, path.join(paths.workspaceRoot, "apps", "web-sveltekit", "src", "routes"))
783
+ })));
784
+ candidates.stacks.push("sveltekit_web");
785
+ }
786
+ candidates.capabilities = dedupeCandidateRecords(candidates.capabilities, (/** @type {any} */ record) => record.id_hint);
787
+ candidates.routes = dedupeCandidateRecords(
788
+ candidates.routes.map((/** @type {any} */ route) => ({
789
+ ...route,
790
+ id_hint: route.id_hint || `${route.method}_${route.path}`
791
+ })),
792
+ (/** @type {any} */ record) => `${record.method}:${record.path}:${record.source_kind}`
793
+ ).map((/** @type {any} */ record) => {
794
+ const { id_hint, ...route } = record;
795
+ return route;
796
+ });
797
+
798
+ return { findings, candidates };
799
+ }