@topogram/cli 0.3.34

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 (257) hide show
  1. package/ARCHITECTURE.md +67 -0
  2. package/CHANGELOG.md +240 -0
  3. package/README.md +223 -0
  4. package/package.json +51 -0
  5. package/src/adoption/index.js +3 -0
  6. package/src/adoption/plan.js +702 -0
  7. package/src/adoption/reporting.js +464 -0
  8. package/src/adoption/review-groups.js +313 -0
  9. package/src/agent-ops/query-builders.js +5012 -0
  10. package/src/archive/archive.js +141 -0
  11. package/src/archive/compact.js +26 -0
  12. package/src/archive/jsonl.js +70 -0
  13. package/src/archive/resolver-bridge.js +82 -0
  14. package/src/archive/schema.js +87 -0
  15. package/src/archive/unarchive.js +108 -0
  16. package/src/catalog.js +752 -0
  17. package/src/cli/catalog-alias.js +166 -0
  18. package/src/cli.js +9738 -0
  19. package/src/component-behavior.js +173 -0
  20. package/src/example-implementation.js +91 -0
  21. package/src/format.js +19 -0
  22. package/src/generator/adapters.d.ts +4 -0
  23. package/src/generator/adapters.js +325 -0
  24. package/src/generator/api.d.ts +1 -0
  25. package/src/generator/api.js +1196 -0
  26. package/src/generator/check.js +355 -0
  27. package/src/generator/component-conformance.js +767 -0
  28. package/src/generator/components.js +39 -0
  29. package/src/generator/context/bundle.js +291 -0
  30. package/src/generator/context/diff.js +256 -0
  31. package/src/generator/context/digest.js +182 -0
  32. package/src/generator/context/domain-coverage.js +94 -0
  33. package/src/generator/context/domain-page.js +137 -0
  34. package/src/generator/context/index.js +42 -0
  35. package/src/generator/context/report.js +121 -0
  36. package/src/generator/context/shared.js +1397 -0
  37. package/src/generator/context/slice.js +703 -0
  38. package/src/generator/context/task-mode.js +466 -0
  39. package/src/generator/docs.js +327 -0
  40. package/src/generator/index.js +161 -0
  41. package/src/generator/native/parity-bundle.js +311 -0
  42. package/src/generator/output.js +300 -0
  43. package/src/generator/registry.js +482 -0
  44. package/src/generator/runtime/app-bundle.js +456 -0
  45. package/src/generator/runtime/bundle-shared.js +166 -0
  46. package/src/generator/runtime/compile-check.js +163 -0
  47. package/src/generator/runtime/deployment.js +287 -0
  48. package/src/generator/runtime/environment.js +635 -0
  49. package/src/generator/runtime/index.js +32 -0
  50. package/src/generator/runtime/runtime-check.js +554 -0
  51. package/src/generator/runtime/shared.js +515 -0
  52. package/src/generator/runtime/smoke.js +219 -0
  53. package/src/generator/schema.js +204 -0
  54. package/src/generator/sdlc/board.js +66 -0
  55. package/src/generator/sdlc/doc-page.js +53 -0
  56. package/src/generator/sdlc/index.js +23 -0
  57. package/src/generator/sdlc/release-notes.js +62 -0
  58. package/src/generator/sdlc/traceability-matrix.js +65 -0
  59. package/src/generator/shared.js +29 -0
  60. package/src/generator/surfaces/contracts.js +146 -0
  61. package/src/generator/surfaces/databases/contract.js +40 -0
  62. package/src/generator/surfaces/databases/index.js +84 -0
  63. package/src/generator/surfaces/databases/lifecycle-shared.d.ts +1 -0
  64. package/src/generator/surfaces/databases/lifecycle-shared.js +612 -0
  65. package/src/generator/surfaces/databases/migration-plan.js +281 -0
  66. package/src/generator/surfaces/databases/postgres/capabilities.js +14 -0
  67. package/src/generator/surfaces/databases/postgres/drizzle.js +99 -0
  68. package/src/generator/surfaces/databases/postgres/index.js +9 -0
  69. package/src/generator/surfaces/databases/postgres/lifecycle.js +16 -0
  70. package/src/generator/surfaces/databases/postgres/prisma.js +159 -0
  71. package/src/generator/surfaces/databases/postgres/sql-migration.js +102 -0
  72. package/src/generator/surfaces/databases/postgres/sql-schema.js +34 -0
  73. package/src/generator/surfaces/databases/shared.d.ts +1 -0
  74. package/src/generator/surfaces/databases/shared.js +350 -0
  75. package/src/generator/surfaces/databases/snapshot.js +96 -0
  76. package/src/generator/surfaces/databases/sqlite/capabilities.js +14 -0
  77. package/src/generator/surfaces/databases/sqlite/index.js +8 -0
  78. package/src/generator/surfaces/databases/sqlite/lifecycle.js +16 -0
  79. package/src/generator/surfaces/databases/sqlite/prisma.js +143 -0
  80. package/src/generator/surfaces/databases/sqlite/sql-migration.js +65 -0
  81. package/src/generator/surfaces/databases/sqlite/sql-schema.js +27 -0
  82. package/src/generator/surfaces/index.js +25 -0
  83. package/src/generator/surfaces/native/swiftui-app.js +38 -0
  84. package/src/generator/surfaces/native/swiftui-templates/Package.swift.txt +20 -0
  85. package/src/generator/surfaces/native/swiftui-templates/README.generated.md +26 -0
  86. package/src/generator/surfaces/native/swiftui-templates/runtime/DynamicScreens.swift +682 -0
  87. package/src/generator/surfaces/native/swiftui-templates/runtime/TodoAPIClient.swift +156 -0
  88. package/src/generator/surfaces/native/swiftui-templates/runtime/TodoSwiftUIApp.swift +44 -0
  89. package/src/generator/surfaces/native/swiftui-templates/runtime/Visibility.swift +183 -0
  90. package/src/generator/surfaces/services/express.d.ts +1 -0
  91. package/src/generator/surfaces/services/express.js +766 -0
  92. package/src/generator/surfaces/services/hono.d.ts +1 -0
  93. package/src/generator/surfaces/services/hono.js +204 -0
  94. package/src/generator/surfaces/services/index.js +42 -0
  95. package/src/generator/surfaces/services/persistence-wiring.js +240 -0
  96. package/src/generator/surfaces/services/runtime-helpers.js +631 -0
  97. package/src/generator/surfaces/services/server-contract.js +80 -0
  98. package/src/generator/surfaces/services/stateless.d.ts +1 -0
  99. package/src/generator/surfaces/services/stateless.js +97 -0
  100. package/src/generator/surfaces/shared.js +64 -0
  101. package/src/generator/surfaces/web/api-client.js +1 -0
  102. package/src/generator/surfaces/web/forms.js +1 -0
  103. package/src/generator/surfaces/web/index.d.ts +2 -0
  104. package/src/generator/surfaces/web/index.js +53 -0
  105. package/src/generator/surfaces/web/react-components.js +248 -0
  106. package/src/generator/surfaces/web/react.js +538 -0
  107. package/src/generator/surfaces/web/routes.js +1 -0
  108. package/src/generator/surfaces/web/screens.js +1 -0
  109. package/src/generator/surfaces/web/shared.js +369 -0
  110. package/src/generator/surfaces/web/sveltekit-actions.js +28 -0
  111. package/src/generator/surfaces/web/sveltekit-components.js +234 -0
  112. package/src/generator/surfaces/web/sveltekit.js +426 -0
  113. package/src/generator/surfaces/web/ui-web-contract.js +65 -0
  114. package/src/generator/surfaces/web/vanilla.js +239 -0
  115. package/src/generator/verification.js +84 -0
  116. package/src/generator.js +1 -0
  117. package/src/import/core/context.js +52 -0
  118. package/src/import/core/contracts.js +23 -0
  119. package/src/import/core/registry.js +81 -0
  120. package/src/import/core/runner.js +646 -0
  121. package/src/import/core/shared.js +910 -0
  122. package/src/import/enrichers/auth-session.js +18 -0
  123. package/src/import/enrichers/django-rest.js +226 -0
  124. package/src/import/enrichers/doc-linking.js +20 -0
  125. package/src/import/enrichers/rails-controllers.js +246 -0
  126. package/src/import/enrichers/rails-models.js +130 -0
  127. package/src/import/enrichers/workflow-target-state.js +10 -0
  128. package/src/import/extractors/api/aspnet-core.js +304 -0
  129. package/src/import/extractors/api/django-routes.js +318 -0
  130. package/src/import/extractors/api/express.js +154 -0
  131. package/src/import/extractors/api/fastify.js +371 -0
  132. package/src/import/extractors/api/flutter-dio.js +135 -0
  133. package/src/import/extractors/api/generic-route-fallback.js +90 -0
  134. package/src/import/extractors/api/graphql-code-first.js +565 -0
  135. package/src/import/extractors/api/graphql-sdl.js +309 -0
  136. package/src/import/extractors/api/jaxrs.js +303 -0
  137. package/src/import/extractors/api/micronaut.js +213 -0
  138. package/src/import/extractors/api/next-route.js +50 -0
  139. package/src/import/extractors/api/next-server-action.js +51 -0
  140. package/src/import/extractors/api/nextauth.js +52 -0
  141. package/src/import/extractors/api/openapi-code.js +242 -0
  142. package/src/import/extractors/api/openapi.js +232 -0
  143. package/src/import/extractors/api/rails-routes.js +230 -0
  144. package/src/import/extractors/api/react-native-repository.js +128 -0
  145. package/src/import/extractors/api/retrofit.js +103 -0
  146. package/src/import/extractors/api/spring-web.js +372 -0
  147. package/src/import/extractors/api/swift-webapi.js +116 -0
  148. package/src/import/extractors/api/trpc.js +212 -0
  149. package/src/import/extractors/db/django-models.js +232 -0
  150. package/src/import/extractors/db/dotnet-models.js +93 -0
  151. package/src/import/extractors/db/drizzle.js +242 -0
  152. package/src/import/extractors/db/ef-core.js +221 -0
  153. package/src/import/extractors/db/flutter-entities.js +120 -0
  154. package/src/import/extractors/db/jpa.js +120 -0
  155. package/src/import/extractors/db/liquibase.js +180 -0
  156. package/src/import/extractors/db/mybatis-xml.js +145 -0
  157. package/src/import/extractors/db/prisma.js +185 -0
  158. package/src/import/extractors/db/rails-schema.js +175 -0
  159. package/src/import/extractors/db/react-native-entities.js +95 -0
  160. package/src/import/extractors/db/room.js +193 -0
  161. package/src/import/extractors/db/snapshot.js +112 -0
  162. package/src/import/extractors/db/sql.js +180 -0
  163. package/src/import/extractors/db/swiftdata.js +137 -0
  164. package/src/import/extractors/ui/android-compose.js +230 -0
  165. package/src/import/extractors/ui/backend-only.js +70 -0
  166. package/src/import/extractors/ui/blazor.js +227 -0
  167. package/src/import/extractors/ui/flutter-screens.js +152 -0
  168. package/src/import/extractors/ui/maui-xaml.js +135 -0
  169. package/src/import/extractors/ui/next-app-router.js +83 -0
  170. package/src/import/extractors/ui/next-pages-router.js +141 -0
  171. package/src/import/extractors/ui/razor-pages.js +181 -0
  172. package/src/import/extractors/ui/react-native-screens.js +166 -0
  173. package/src/import/extractors/ui/react-router.js +139 -0
  174. package/src/import/extractors/ui/sveltekit.js +123 -0
  175. package/src/import/extractors/ui/swiftui.js +193 -0
  176. package/src/import/extractors/ui/uikit.js +175 -0
  177. package/src/import/extractors/verification/generic.js +290 -0
  178. package/src/import/extractors/workflows/generic.js +137 -0
  179. package/src/import/index.js +7 -0
  180. package/src/import/provenance.js +158 -0
  181. package/src/new-project.js +2107 -0
  182. package/src/parser.js +439 -0
  183. package/src/policy/review-boundaries.js +165 -0
  184. package/src/project-config.js +535 -0
  185. package/src/proofs/backend-parity.js +19 -0
  186. package/src/proofs/contract-audit.js +220 -0
  187. package/src/proofs/ios-parity.js +7 -0
  188. package/src/proofs/issues-parity.js +10 -0
  189. package/src/proofs/web-parity.js +50 -0
  190. package/src/realization/api/build-api-realization.js +5 -0
  191. package/src/realization/api/index.js +1 -0
  192. package/src/realization/backend/build-backend-runtime-realization.js +82 -0
  193. package/src/realization/backend/index.d.ts +1 -0
  194. package/src/realization/backend/index.js +4 -0
  195. package/src/realization/db/build-db-realization.js +17 -0
  196. package/src/realization/db/index.js +3 -0
  197. package/src/realization/db/migration-plan.js +5 -0
  198. package/src/realization/db/snapshot.js +5 -0
  199. package/src/realization/ui/build-ui-shared-realization.js +305 -0
  200. package/src/realization/ui/build-web-realization.js +189 -0
  201. package/src/realization/ui/index.js +2 -0
  202. package/src/reconcile/docs.js +280 -0
  203. package/src/reconcile/index.js +3 -0
  204. package/src/reconcile/journeys.js +441 -0
  205. package/src/resolver/docs.js +1 -0
  206. package/src/resolver/enrich/acceptance-criterion.js +14 -0
  207. package/src/resolver/enrich/bug.js +12 -0
  208. package/src/resolver/enrich/component.js +2 -0
  209. package/src/resolver/enrich/index.js +1 -0
  210. package/src/resolver/enrich/pitch.js +18 -0
  211. package/src/resolver/enrich/requirement.js +20 -0
  212. package/src/resolver/enrich/task.js +16 -0
  213. package/src/resolver/expressions.js +1 -0
  214. package/src/resolver/index.js +2422 -0
  215. package/src/resolver/normalize.js +1 -0
  216. package/src/resolver.js +1 -0
  217. package/src/sdlc/adopt.js +65 -0
  218. package/src/sdlc/check.js +86 -0
  219. package/src/sdlc/dod/acceptance-criterion.js +22 -0
  220. package/src/sdlc/dod/bug.js +26 -0
  221. package/src/sdlc/dod/document.js +23 -0
  222. package/src/sdlc/dod/index.js +25 -0
  223. package/src/sdlc/dod/pitch.js +23 -0
  224. package/src/sdlc/dod/requirement.js +34 -0
  225. package/src/sdlc/dod/task.js +39 -0
  226. package/src/sdlc/explain.js +116 -0
  227. package/src/sdlc/history.js +80 -0
  228. package/src/sdlc/paths.js +11 -0
  229. package/src/sdlc/release.js +106 -0
  230. package/src/sdlc/scaffold.js +89 -0
  231. package/src/sdlc/status-filter.js +54 -0
  232. package/src/sdlc/transition.js +112 -0
  233. package/src/sdlc/transitions/acceptance-criterion.js +28 -0
  234. package/src/sdlc/transitions/bug.js +31 -0
  235. package/src/sdlc/transitions/document.js +29 -0
  236. package/src/sdlc/transitions/index.js +56 -0
  237. package/src/sdlc/transitions/pitch.js +34 -0
  238. package/src/sdlc/transitions/requirement.js +31 -0
  239. package/src/sdlc/transitions/task.js +34 -0
  240. package/src/template-trust.js +597 -0
  241. package/src/validator/expressions.js +1 -0
  242. package/src/validator/index.js +3424 -0
  243. package/src/validator/kinds.js +346 -0
  244. package/src/validator/per-kind/acceptance-criterion.js +91 -0
  245. package/src/validator/per-kind/bug.js +77 -0
  246. package/src/validator/per-kind/component.js +274 -0
  247. package/src/validator/per-kind/domain.js +205 -0
  248. package/src/validator/per-kind/pitch.js +101 -0
  249. package/src/validator/per-kind/requirement.js +75 -0
  250. package/src/validator/per-kind/task.js +96 -0
  251. package/src/validator/registry.js +1 -0
  252. package/src/validator/utils.js +12 -0
  253. package/src/validator.js +1 -0
  254. package/src/workflows.js +7597 -0
  255. package/src/workspace-docs.js +265 -0
  256. package/template-helpers/react.js +5 -0
  257. package/template-helpers/sveltekit.js +5 -0
@@ -0,0 +1,90 @@
1
+ import { findImportFiles, inferRouteAuthHint, inferRouteCapabilityId, inferRouteQueryParams, makeCandidateRecord, normalizeOpenApiPath, relativeTo } from "../../core/shared.js";
2
+
3
+ function extractHandlerContext(text, handlerName) {
4
+ if (!handlerName) return "";
5
+ const escaped = handlerName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6
+ for (const pattern of [
7
+ new RegExp(`function\\s+${escaped}\\s*\\([^)]*\\)\\s*\\{([\\s\\S]{0,1200}?)\\n\\}`, "m"),
8
+ new RegExp(`const\\s+${escaped}\\s*=\\s*(?:async\\s*)?\\([^)]*\\)\\s*=>\\s*\\{([\\s\\S]{0,1200}?)\\n\\}`, "m")
9
+ ]) {
10
+ const match = text.match(pattern);
11
+ if (match) return match[1];
12
+ }
13
+ return "";
14
+ }
15
+
16
+ function inferServerRoutes(context) {
17
+ const routes = [];
18
+ const routeFiles = findImportFiles(
19
+ context.paths,
20
+ (filePath) =>
21
+ /\.(ts|tsx|js|jsx|mjs|cjs)$/.test(filePath) &&
22
+ /(server|api|routes|src)/i.test(filePath)
23
+ );
24
+ for (const filePath of routeFiles) {
25
+ const text = context.helpers.readTextIfExists(filePath) || "";
26
+ for (const match of text.matchAll(/\.(get|post|put|patch|delete)\(\s*["'`]([^"'`]+)["'`]\s*,([\s\S]*?)\)\s*;?/gi)) {
27
+ const handlerTokens = [...match[3].matchAll(/\b([A-Za-z_][A-Za-z0-9_]*)\b/g)].map((entry) => entry[1]);
28
+ const handlerHint = handlerTokens.length > 0 ? handlerTokens[handlerTokens.length - 1] : null;
29
+ const handlerContext = handlerHint ? extractHandlerContext(text, handlerHint) : "";
30
+ routes.push({
31
+ file: filePath,
32
+ method: match[1].toUpperCase(),
33
+ path: match[2],
34
+ handler_hint: handlerHint,
35
+ path_params: [...normalizeOpenApiPath(match[2]).matchAll(/\{([^}]+)\}/g)].map((entry) => entry[1]),
36
+ query_params: inferRouteQueryParams(handlerContext),
37
+ auth_hint: inferRouteAuthHint(match[3], handlerContext)
38
+ });
39
+ }
40
+ }
41
+ return routes;
42
+ }
43
+
44
+ export const genericRouteFallbackExtractor = {
45
+ id: "api.generic-route-fallback",
46
+ track: "api",
47
+ detect(context) {
48
+ const routes = inferServerRoutes(context);
49
+ return {
50
+ score: routes.length > 0 ? 35 : 0,
51
+ reasons: routes.length > 0 ? ["Found generic server route handlers"] : []
52
+ };
53
+ },
54
+ extract(context) {
55
+ const routes = inferServerRoutes(context);
56
+ const findings = [];
57
+ const candidates = { capabilities: [], routes: [], stacks: [] };
58
+ if (routes.length > 0) {
59
+ findings.push({
60
+ kind: "route_inventory",
61
+ files: [...new Set(routes.map((route) => relativeTo(context.paths.repoRoot, route.file)))],
62
+ route_count: routes.length
63
+ });
64
+ candidates.capabilities.push(...routes.map((route) => makeCandidateRecord({
65
+ kind: "capability",
66
+ idHint: inferRouteCapabilityId(route),
67
+ label: `${route.method} ${route.path}`,
68
+ confidence: "medium",
69
+ sourceKind: "route_code",
70
+ provenance: `${relativeTo(context.paths.repoRoot, route.file)}#${route.method} ${route.path}`,
71
+ endpoint: { method: route.method, path: normalizeOpenApiPath(route.path) },
72
+ path_params: (route.path_params || []).map((name) => ({ name, required: true, type: null })),
73
+ query_params: (route.query_params || []).map((name) => ({ name, required: false, type: null })),
74
+ header_params: [],
75
+ input_fields: [],
76
+ output_fields: [],
77
+ auth_hint: route.auth_hint || "unknown",
78
+ track: "api"
79
+ })));
80
+ candidates.routes.push(...routes.map((route) => ({
81
+ path: normalizeOpenApiPath(route.path),
82
+ method: route.method,
83
+ confidence: "medium",
84
+ source_kind: "route_code",
85
+ provenance: `${relativeTo(context.paths.repoRoot, route.file)}#${route.method} ${route.path}`
86
+ })));
87
+ }
88
+ return { findings, candidates };
89
+ }
90
+ };
@@ -0,0 +1,565 @@
1
+ import {
2
+ canonicalCandidateTerm,
3
+ dedupeCandidateRecords,
4
+ findImportFiles,
5
+ idHintify,
6
+ makeCandidateRecord,
7
+ pluralizeCandidateTerm,
8
+ readTextIfExists,
9
+ relativeTo,
10
+ titleCase
11
+ } from "../../core/shared.js";
12
+ import path from "node:path";
13
+
14
+ function stripComments(text) {
15
+ return String(text || "")
16
+ .replace(/\/\*[\s\S]*?\*\//g, "")
17
+ .replace(/\/\/.*$/gm, "");
18
+ }
19
+
20
+ function findClassBlocks(text) {
21
+ const blocks = [];
22
+ const source = String(text || "");
23
+ let index = 0;
24
+ while (index < source.length) {
25
+ const classIndex = source.indexOf("class ", index);
26
+ if (classIndex === -1) break;
27
+ const headerStart = source.lastIndexOf("@", classIndex);
28
+ const scanStart = headerStart >= 0 && source.slice(headerStart, classIndex).includes("@") ? headerStart : classIndex;
29
+ const headerMatch = source.slice(classIndex).match(/^class\s+([A-Za-z_][A-Za-z0-9_]*)/);
30
+ if (!headerMatch) {
31
+ index = classIndex + 5;
32
+ continue;
33
+ }
34
+ const name = headerMatch[1];
35
+ const braceIndex = source.indexOf("{", classIndex);
36
+ if (braceIndex === -1) break;
37
+ let depth = 1;
38
+ let end = braceIndex + 1;
39
+ while (end < source.length && depth > 0) {
40
+ const char = source[end];
41
+ if (char === "{") depth += 1;
42
+ if (char === "}") depth -= 1;
43
+ end += 1;
44
+ }
45
+ blocks.push({
46
+ text: source.slice(scanStart, end),
47
+ header: source.slice(scanStart, braceIndex),
48
+ body: source.slice(braceIndex + 1, end - 1),
49
+ name
50
+ });
51
+ index = end;
52
+ }
53
+ return blocks;
54
+ }
55
+
56
+ function extractBalancedSegment(source, openIndex, openChar = "(", closeChar = ")") {
57
+ if (openIndex < 0 || source[openIndex] !== openChar) return null;
58
+ let depth = 1;
59
+ let index = openIndex + 1;
60
+ let inString = false;
61
+ let quote = null;
62
+ while (index < source.length && depth > 0) {
63
+ const char = source[index];
64
+ const prev = source[index - 1];
65
+ if (inString) {
66
+ if (char === quote && prev !== "\\") {
67
+ inString = false;
68
+ quote = null;
69
+ }
70
+ index += 1;
71
+ continue;
72
+ }
73
+ if (char === "'" || char === '"' || char === "`") {
74
+ inString = true;
75
+ quote = char;
76
+ index += 1;
77
+ continue;
78
+ }
79
+ if (char === openChar) depth += 1;
80
+ if (char === closeChar) depth -= 1;
81
+ index += 1;
82
+ }
83
+ return depth === 0 ? source.slice(openIndex + 1, index - 1) : null;
84
+ }
85
+
86
+ function findCallBodies(text, callPrefix) {
87
+ const bodies = [];
88
+ let start = 0;
89
+ while (start < text.length) {
90
+ const idx = text.indexOf(callPrefix, start);
91
+ if (idx === -1) break;
92
+ const openIndex = text.indexOf("(", idx + callPrefix.length - 1);
93
+ if (openIndex === -1) break;
94
+ const body = extractBalancedSegment(text, openIndex, "(", ")");
95
+ if (body != null) {
96
+ bodies.push(body);
97
+ start = openIndex + body.length + 2;
98
+ } else {
99
+ start = idx + callPrefix.length;
100
+ }
101
+ }
102
+ return bodies;
103
+ }
104
+
105
+ function extractReturnedObjectBody(segment) {
106
+ const fieldsIdx = segment.indexOf("fields:");
107
+ if (fieldsIdx === -1) return null;
108
+ const objectStart = segment.indexOf("{", fieldsIdx);
109
+ if (objectStart === -1) return null;
110
+ return extractBalancedSegment(segment, objectStart, "{", "}");
111
+ }
112
+
113
+ function parseDecoratorClassFields(classBlock) {
114
+ const fields = [];
115
+ const body = stripComments(classBlock.body);
116
+ const pattern = /@Field\s*\(([\s\S]*?)\)\s*([\r\n\s]*)?([A-Za-z_][A-Za-z0-9_]*)\??\s*:/g;
117
+ for (const match of body.matchAll(pattern)) {
118
+ const decoratorArgs = match[1] || "";
119
+ const fieldName = match[3];
120
+ let typeName = null;
121
+ const arrowType = decoratorArgs.match(/=>\s*\[?\s*([A-Za-z_][A-Za-z0-9_]*)/);
122
+ if (arrowType) typeName = arrowType[1];
123
+ fields.push({
124
+ name: fieldName,
125
+ typeName
126
+ });
127
+ }
128
+ return fields;
129
+ }
130
+
131
+ function parseNestTypes(files) {
132
+ const inputTypes = new Map();
133
+ const objectTypes = new Map();
134
+ for (const filePath of files) {
135
+ const text = readTextIfExists(filePath) || "";
136
+ for (const classBlock of findClassBlocks(text)) {
137
+ if (/@InputType\s*\(/.test(classBlock.header)) {
138
+ inputTypes.set(classBlock.name, parseDecoratorClassFields(classBlock));
139
+ }
140
+ if (/@ObjectType\s*\(/.test(classBlock.header)) {
141
+ objectTypes.set(classBlock.name, parseDecoratorClassFields(classBlock));
142
+ }
143
+ }
144
+ }
145
+ return { inputTypes, objectTypes };
146
+ }
147
+
148
+ function parsePothosTypeName(fragment) {
149
+ const typeMatch =
150
+ String(fragment || "").match(/type\s*:\s*\[\s*([A-Za-z_][A-Za-z0-9_]*)\s*\]/) ||
151
+ String(fragment || "").match(/type\s*:\s*['"]([A-Za-z_][A-Za-z0-9_]*)['"]/) ||
152
+ String(fragment || "").match(/type\s*:\s*([A-Za-z_][A-Za-z0-9_]*)/);
153
+ return typeMatch ? typeMatch[1] : null;
154
+ }
155
+
156
+ function parsePothosTypes(files) {
157
+ const inputTypes = new Map();
158
+ const objectTypes = new Map();
159
+ for (const filePath of files) {
160
+ const text = readTextIfExists(filePath) || "";
161
+ for (const body of findCallBodies(text, "builder.prismaObject")) {
162
+ const nameMatch = body.match(/^\s*['"]([A-Za-z_][A-Za-z0-9_]*)['"]/);
163
+ if (!nameMatch) continue;
164
+ const objectBody = extractReturnedObjectBody(body);
165
+ if (!objectBody) continue;
166
+ const fields = [];
167
+ for (const match of objectBody.matchAll(/([A-Za-z_][A-Za-z0-9_]*)\s*:\s*t\.(?:expose[A-Za-z_]+|relation)\s*\(/g)) {
168
+ fields.push({ name: match[1], typeName: null });
169
+ }
170
+ objectTypes.set(nameMatch[1], fields);
171
+ }
172
+ for (const body of findCallBodies(text, "builder.inputType")) {
173
+ const nameMatch = body.match(/^\s*['"]([A-Za-z_][A-Za-z0-9_]*)['"]/);
174
+ if (!nameMatch) continue;
175
+ const objectBody = extractReturnedObjectBody(body);
176
+ if (!objectBody) continue;
177
+ const fields = [];
178
+ for (const match of objectBody.matchAll(/([A-Za-z_][A-Za-z0-9_]*)\s*:\s*t\.(string|int|id|field)\s*\(([\s\S]*?)\)/g)) {
179
+ const fieldName = match[1];
180
+ const fieldKind = match[2];
181
+ const decoratorArgs = match[3] || "";
182
+ fields.push({
183
+ name: fieldName,
184
+ typeName: fieldKind === "field" ? parsePothosTypeName(decoratorArgs) : null
185
+ });
186
+ }
187
+ inputTypes.set(nameMatch[1], fields);
188
+ }
189
+ }
190
+ return { inputTypes, objectTypes };
191
+ }
192
+
193
+ function parseNexusTypes(files) {
194
+ const inputTypes = new Map();
195
+ const objectTypes = new Map();
196
+ const operationBlocks = [];
197
+ for (const filePath of files) {
198
+ const text = readTextIfExists(filePath) || "";
199
+ for (const body of findCallBodies(text, "objectType")) {
200
+ const nameMatch = body.match(/name\s*:\s*['"]([A-Za-z_][A-Za-z0-9_]*)['"]/);
201
+ if (!nameMatch) continue;
202
+ const typeName = nameMatch[1];
203
+ const definitionMatch = body.match(/definition\s*\(\s*t\s*\)\s*\{/);
204
+ if (!definitionMatch) continue;
205
+ const definitionStart = body.indexOf("{", definitionMatch.index);
206
+ const definitionBody = extractBalancedSegment(body, definitionStart, "{", "}");
207
+ if (!definitionBody) continue;
208
+ if (typeName === "Query" || typeName === "Mutation") {
209
+ operationBlocks.push({ typeName, body: definitionBody, filePath });
210
+ continue;
211
+ }
212
+ const fields = [];
213
+ for (const line of definitionBody.matchAll(/t(?:\.[A-Za-z_]+)*\.(?:field|string|int|boolean)\s*\(\s*['"]([A-Za-z_][A-Za-z0-9_]*)['"](?:\s*,\s*\{([\s\S]*?)\})?/g)) {
214
+ const fieldName = line[1];
215
+ const options = line[2] || "";
216
+ const fieldType = parsePothosTypeName(options);
217
+ fields.push({ name: fieldName, typeName: fieldType });
218
+ }
219
+ objectTypes.set(typeName, fields);
220
+ }
221
+ for (const body of findCallBodies(text, "inputObjectType")) {
222
+ const nameMatch = body.match(/name\s*:\s*['"]([A-Za-z_][A-Za-z0-9_]*)['"]/);
223
+ if (!nameMatch) continue;
224
+ const typeName = nameMatch[1];
225
+ const definitionMatch = body.match(/definition\s*\(\s*t\s*\)\s*\{/);
226
+ if (!definitionMatch) continue;
227
+ const definitionStart = body.indexOf("{", definitionMatch.index);
228
+ const definitionBody = extractBalancedSegment(body, definitionStart, "{", "}");
229
+ if (!definitionBody) continue;
230
+ const fields = [];
231
+ for (const line of definitionBody.matchAll(/t(?:\.[A-Za-z_]+)*\.(field|string|int|boolean)\s*\(\s*['"]([A-Za-z_][A-Za-z0-9_]*)['"](?:\s*,\s*\{([\s\S]*?)\})?/g)) {
232
+ const fieldKind = line[1];
233
+ const fieldName = line[2];
234
+ const options = line[3] || "";
235
+ fields.push({
236
+ name: fieldName,
237
+ typeName: fieldKind === "field" ? parsePothosTypeName(options) : null
238
+ });
239
+ }
240
+ inputTypes.set(typeName, fields);
241
+ }
242
+ }
243
+ return { inputTypes, objectTypes, operationBlocks };
244
+ }
245
+
246
+ function flattenInputFields(typeName, inputTypes, seen = new Set()) {
247
+ const normalized = String(typeName || "").replace(/[^\w]/g, "");
248
+ if (!normalized || seen.has(normalized)) return [];
249
+ const fields = inputTypes.get(normalized);
250
+ if (!fields) return [];
251
+ seen.add(normalized);
252
+ const names = [];
253
+ for (const field of fields) {
254
+ const nested = flattenInputFields(field.typeName, inputTypes, seen);
255
+ if (nested.length > 0) {
256
+ names.push(...nested);
257
+ } else {
258
+ names.push(field.name);
259
+ }
260
+ }
261
+ seen.delete(normalized);
262
+ return [...new Set(names)].sort();
263
+ }
264
+
265
+ function outputFieldsForType(typeName, objectTypes) {
266
+ const normalized = String(typeName || "").replace(/[^\w]/g, "");
267
+ return [...new Set((objectTypes.get(normalized) || []).map((field) => field.name))].sort();
268
+ }
269
+
270
+ function inferEntityId(operationName, returnType) {
271
+ const normalizedReturn = String(returnType || "").replace(/[^\w]/g, "");
272
+ if (normalizedReturn && !["String", "Int", "Float", "Boolean", "ID", "Date"].includes(normalizedReturn)) {
273
+ return `entity_${idHintify(canonicalCandidateTerm(normalizedReturn))}`;
274
+ }
275
+ const suffixMatch = String(operationName || "").match(/([A-Z][A-Za-z0-9_]*)$/);
276
+ return `entity_${idHintify(canonicalCandidateTerm(suffixMatch ? suffixMatch[1] : operationName || "item"))}`;
277
+ }
278
+
279
+ function inferCapabilityId(operationName, returnType, rootType) {
280
+ const entityStem = inferEntityId(operationName, returnType).replace(/^entity_/, "");
281
+ const normalizedName = idHintify(operationName);
282
+ if (rootType === "Query" && canonicalCandidateTerm(operationName) === canonicalCandidateTerm(entityStem)) {
283
+ return `cap_get_${entityStem}`;
284
+ }
285
+ if (/(signup|register)/i.test(operationName)) return `cap_register_${entityStem}`;
286
+ if (/(signin|sign_in|login|authenticate)/i.test(normalizedName)) return `cap_sign_in_${entityStem}`;
287
+ if (/(delete|remove)/i.test(operationName)) return `cap_delete_${entityStem}`;
288
+ if (/(togglepublish|publish)/i.test(normalizedName)) return `cap_publish_${entityStem}`;
289
+ if (/increment.*view.*count/i.test(normalizedName)) return `cap_update_${entityStem}_view_count`;
290
+ if (/(create|add|new)/i.test(operationName)) return `cap_create_${entityStem}`;
291
+ if (rootType === "Query" && (/^(all|list|feed|drafts)/i.test(operationName) || /^\[/.test(String(returnType || "")))) {
292
+ return `cap_list_${pluralizeCandidateTerm(entityStem)}`;
293
+ }
294
+ if (rootType === "Query" && /(byid|get|find|detail)/i.test(operationName)) {
295
+ return `cap_get_${entityStem}`;
296
+ }
297
+ if (rootType === "Mutation") return `cap_update_${entityStem}`;
298
+ return `${rootType === "Mutation" ? "cap_update_" : "cap_get_"}${entityStem}_${normalizedName}`;
299
+ }
300
+
301
+ function inferTargetState(operationName, capabilityId) {
302
+ if (/(signup|register)/i.test(operationName)) return "registered";
303
+ if (/(signin|login|authenticate)/i.test(operationName)) return "authenticated";
304
+ if (/(togglepublish|publish)/i.test(operationName)) return "published";
305
+ if (/delete/i.test(operationName) || capabilityId.startsWith("cap_delete_")) return "deleted";
306
+ return null;
307
+ }
308
+
309
+ function inferGraphqlEndpoint(files) {
310
+ for (const filePath of files) {
311
+ const text = readTextIfExists(filePath) || "";
312
+ const explicitPath = text.match(/\bpath\s*:\s*["'`]([^"'`]+)["'`]/);
313
+ if (explicitPath) return explicitPath[1];
314
+ if (/GraphQLModule\.forRoot/.test(text) || /createYoga|ApolloServer/.test(text)) {
315
+ return "/graphql";
316
+ }
317
+ }
318
+ return "/graphql";
319
+ }
320
+
321
+ function parseResolverOperations(filePath, text, inputTypes, objectTypes, endpointPath) {
322
+ const operations = [];
323
+ const source = String(text || "");
324
+ const pattern = /@(Query|Mutation)\s*\(([\s\S]*?)\)\s*(?:async\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*\(([\s\S]*?)\)\s*[:{]/g;
325
+ for (const match of source.matchAll(pattern)) {
326
+ const rootType = match[1];
327
+ const decoratorArgs = match[2] || "";
328
+ const methodName = match[3];
329
+ const params = match[4] || "";
330
+ const returnTypeMatch = decoratorArgs.match(/=>\s*(\[[A-Za-z_][A-Za-z0-9_]*\]|[A-Za-z_][A-Za-z0-9_]*)/);
331
+ const returnType = returnTypeMatch ? returnTypeMatch[1] : null;
332
+ const inputFields = [];
333
+ for (const paramMatch of params.matchAll(/@Args\(\s*['"`]([^'"`]+)['"`][^)]*\)\s*([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([^,\n)]+)/g)) {
334
+ const argName = paramMatch[1];
335
+ const paramType = paramMatch[3].trim();
336
+ const nested = flattenInputFields(paramType, inputTypes);
337
+ if (nested.length > 0) {
338
+ inputFields.push(...nested);
339
+ } else {
340
+ inputFields.push(argName);
341
+ }
342
+ }
343
+ const capabilityId = inferCapabilityId(methodName, returnType, rootType);
344
+ operations.push({
345
+ rootType,
346
+ methodName,
347
+ capabilityId,
348
+ inputFields: [...new Set(inputFields)].sort(),
349
+ outputFields: outputFieldsForType(returnType, objectTypes),
350
+ entityId: inferEntityId(methodName, returnType),
351
+ targetState: inferTargetState(methodName, capabilityId),
352
+ provenance: `${relativeTo(path.dirname(path.dirname(filePath)), filePath).replace(/^src\//, `${relativeTo(path.dirname(path.dirname(filePath)), path.dirname(path.dirname(filePath)))}`)}`,
353
+ filePath,
354
+ endpointPath
355
+ });
356
+ }
357
+ return operations;
358
+ }
359
+
360
+ function parsePothosOperations(filePath, text, inputTypes, objectTypes, endpointPath) {
361
+ const operations = [];
362
+ const source = String(text || "");
363
+ for (const rootType of ["queryField", "mutationField"]) {
364
+ for (const body of findCallBodies(source, `builder.${rootType}`)) {
365
+ const nameMatch = body.match(/^\s*['"]([A-Za-z_][A-Za-z0-9_]*)['"]/);
366
+ if (!nameMatch) continue;
367
+ const methodName = nameMatch[1];
368
+ const prismaFieldMatch = body.match(/t\.prismaField\s*\(\s*\{/);
369
+ if (!prismaFieldMatch) continue;
370
+ const prismaFieldStart = body.indexOf("{", prismaFieldMatch.index);
371
+ const prismaFieldBody = extractBalancedSegment(body, prismaFieldStart, "{", "}");
372
+ if (!prismaFieldBody) continue;
373
+ const returnTypeMatch =
374
+ prismaFieldBody.match(/type\s*:\s*\[\s*['"]?([A-Za-z_][A-Za-z0-9_]*)['"]?\s*\]/) ||
375
+ prismaFieldBody.match(/type\s*:\s*['"]([A-Za-z_][A-Za-z0-9_]*)['"]/) ||
376
+ prismaFieldBody.match(/type\s*:\s*([A-Za-z_][A-Za-z0-9_]*)/);
377
+ const returnType = returnTypeMatch ? returnTypeMatch[0].includes("[") ? `[${returnTypeMatch[1]}]` : returnTypeMatch[1] : null;
378
+ const inputFields = [];
379
+ const argsMatch = prismaFieldBody.match(/args\s*:\s*\{/);
380
+ if (argsMatch) {
381
+ const argsStart = prismaFieldBody.indexOf("{", argsMatch.index);
382
+ const argsBody = extractBalancedSegment(prismaFieldBody, argsStart, "{", "}");
383
+ for (const argMatch of String(argsBody || "").matchAll(/([A-Za-z_][A-Za-z0-9_]*)\s*:\s*t\.arg(?:\.[A-Za-z_]+)?\s*\(([\s\S]*?)\)/g)) {
384
+ const argName = argMatch[1];
385
+ const argBody = argMatch[2] || "";
386
+ const nestedType = parsePothosTypeName(argBody);
387
+ const nestedFields = flattenInputFields(nestedType, inputTypes);
388
+ if (nestedFields.length > 0) {
389
+ inputFields.push(...nestedFields);
390
+ } else {
391
+ inputFields.push(argName);
392
+ }
393
+ }
394
+ }
395
+ const capabilityId = inferCapabilityId(methodName, returnType, rootType === "mutationField" ? "Mutation" : "Query");
396
+ operations.push({
397
+ rootType: rootType === "mutationField" ? "Mutation" : "Query",
398
+ methodName,
399
+ capabilityId,
400
+ inputFields: [...new Set(inputFields)].sort(),
401
+ outputFields: outputFieldsForType(returnType, objectTypes),
402
+ entityId: inferEntityId(methodName, returnType),
403
+ targetState: inferTargetState(methodName, capabilityId),
404
+ filePath,
405
+ endpointPath
406
+ });
407
+ }
408
+ }
409
+ return operations;
410
+ }
411
+
412
+ function parseNexusArgFields(argExpression, inputTypes) {
413
+ const fields = [];
414
+ for (const match of String(argExpression || "").matchAll(/([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([\s\S]*?)(?:,|$)/g)) {
415
+ const argName = match[1];
416
+ const argBody = match[2] || "";
417
+ const nestedType = parsePothosTypeName(argBody);
418
+ const nestedFields = flattenInputFields(nestedType, inputTypes);
419
+ if (nestedFields.length > 0) {
420
+ fields.push(...nestedFields);
421
+ } else {
422
+ fields.push(argName);
423
+ }
424
+ }
425
+ return [...new Set(fields)].sort();
426
+ }
427
+
428
+ function parseNexusOperations(operationBlocks, inputTypes, objectTypes, endpointPath) {
429
+ const operations = [];
430
+ for (const block of operationBlocks) {
431
+ const rootType = block.typeName;
432
+ const source = block.body;
433
+ for (const match of source.matchAll(/t(?:\.[A-Za-z_]+)*\.field\s*\(\s*['"]([A-Za-z_][A-Za-z0-9_]*)['"]\s*,\s*\{/g)) {
434
+ const methodName = match[1];
435
+ const bodyStart = source.indexOf("{", match.index + match[0].lastIndexOf("{"));
436
+ const fieldBody = extractBalancedSegment(source, bodyStart, "{", "}");
437
+ if (!fieldBody) continue;
438
+ const typeMatch = parsePothosTypeName(fieldBody);
439
+ const argsMatch = fieldBody.match(/args\s*:\s*\{/);
440
+ const inputFields = [];
441
+ if (argsMatch) {
442
+ const argsStart = fieldBody.indexOf("{", argsMatch.index);
443
+ const argsBody = extractBalancedSegment(fieldBody, argsStart, "{", "}");
444
+ inputFields.push(...parseNexusArgFields(argsBody, inputTypes));
445
+ }
446
+ const capabilityId = inferCapabilityId(methodName, typeMatch, rootType);
447
+ operations.push({
448
+ rootType,
449
+ methodName,
450
+ capabilityId,
451
+ inputFields: [...new Set(inputFields)].sort(),
452
+ outputFields: outputFieldsForType(typeMatch, objectTypes),
453
+ entityId: inferEntityId(methodName, typeMatch),
454
+ targetState: inferTargetState(methodName, capabilityId),
455
+ filePath: block.filePath,
456
+ endpointPath
457
+ });
458
+ }
459
+ }
460
+ return operations;
461
+ }
462
+
463
+ export const graphQlCodeFirstExtractor = {
464
+ id: "api.graphql-code-first",
465
+ track: "api",
466
+ detect(context) {
467
+ const files = findImportFiles(context.paths, (filePath) => /(\/src\/.+|\/pages\/api\/.+)\.(ts|tsx|js|jsx)$/i.test(filePath));
468
+ const hasNestResolvers = files.some((filePath) => {
469
+ const text = readTextIfExists(filePath) || "";
470
+ return /@nestjs\/graphql/.test(text) && /@(Query|Mutation)\s*\(/.test(text);
471
+ });
472
+ const hasPothosSource = files.some((filePath) => {
473
+ const text = readTextIfExists(filePath) || "";
474
+ return /@pothos\/core/.test(text) && /builder\.(queryField|mutationField)\s*\(/.test(text);
475
+ });
476
+ const hasNexusSource = files.some((filePath) => {
477
+ const text = readTextIfExists(filePath) || "";
478
+ return /from ['"]nexus['"]/.test(text) && /objectType\s*\(\s*\{/.test(text) && /name\s*:\s*['"](Query|Mutation)['"]/.test(text);
479
+ });
480
+ return {
481
+ score: hasNestResolvers ? 86 : hasPothosSource ? 84 : hasNexusSource ? 82 : 0,
482
+ reasons: hasNestResolvers
483
+ ? ["Found source-only Nest GraphQL resolvers"]
484
+ : hasPothosSource
485
+ ? ["Found source-only Pothos GraphQL schema definitions"]
486
+ : hasNexusSource
487
+ ? ["Found source-only Nexus GraphQL schema definitions"]
488
+ : []
489
+ };
490
+ },
491
+ extract(context) {
492
+ const files = findImportFiles(context.paths, (filePath) => /(\/src\/.+|\/pages\/api\/.+)\.(ts|tsx|js|jsx)$/i.test(filePath)).filter((filePath) => !/\.test\./i.test(filePath));
493
+ const nestTypes = parseNestTypes(files);
494
+ const pothosTypes = parsePothosTypes(files);
495
+ const nexusTypes = parseNexusTypes(files);
496
+ const inputTypes = new Map([...nestTypes.inputTypes.entries(), ...pothosTypes.inputTypes.entries(), ...nexusTypes.inputTypes.entries()]);
497
+ const objectTypes = new Map([...nestTypes.objectTypes.entries(), ...pothosTypes.objectTypes.entries(), ...nexusTypes.objectTypes.entries()]);
498
+ const endpointPath = inferGraphqlEndpoint(files);
499
+ const operations = [];
500
+ for (const filePath of files) {
501
+ const text = readTextIfExists(filePath) || "";
502
+ if (/@nestjs\/graphql/.test(text) && /@(Query|Mutation)\s*\(/.test(text)) {
503
+ operations.push(...parseResolverOperations(filePath, text, inputTypes, objectTypes, endpointPath));
504
+ }
505
+ if (/@pothos\/core/.test(text) && /builder\.(queryField|mutationField)\s*\(/.test(text)) {
506
+ operations.push(...parsePothosOperations(filePath, text, inputTypes, objectTypes, endpointPath));
507
+ }
508
+ }
509
+ operations.push(...parseNexusOperations(nexusTypes.operationBlocks, inputTypes, objectTypes, endpointPath));
510
+ const findings = [];
511
+ const candidates = { capabilities: [], routes: [], stacks: [] };
512
+ for (const operation of operations) {
513
+ const provenance = `${relativeTo(context.paths.repoRoot, operation.filePath)}#${operation.rootType}.${operation.methodName}`;
514
+ candidates.capabilities.push(
515
+ makeCandidateRecord({
516
+ kind: "capability",
517
+ idHint: operation.capabilityId,
518
+ label: titleCase(operation.capabilityId.replace(/^cap_/, "")),
519
+ confidence: "high",
520
+ sourceKind: "route_code",
521
+ provenance,
522
+ endpoint: {
523
+ method: operation.rootType === "Mutation" ? "POST" : "GET",
524
+ path: endpointPath
525
+ },
526
+ path_params: [],
527
+ query_params: [],
528
+ header_params: [],
529
+ input_fields: operation.inputFields,
530
+ output_fields: operation.outputFields,
531
+ auth_hint: "public",
532
+ entity_id: operation.entityId,
533
+ graphql_operation: {
534
+ root_type: operation.rootType,
535
+ field: operation.methodName
536
+ },
537
+ target_state: operation.targetState,
538
+ track: "api"
539
+ })
540
+ );
541
+ candidates.routes.push({
542
+ path: endpointPath,
543
+ method: operation.rootType === "Mutation" ? "POST" : "GET",
544
+ confidence: "high",
545
+ source_kind: "route_code",
546
+ provenance
547
+ });
548
+ }
549
+ if (operations.length > 0) {
550
+ findings.push({
551
+ kind: "graphql_code_first_operations",
552
+ files: [...new Set(operations.map((entry) => relativeTo(context.paths.repoRoot, entry.filePath)))],
553
+ operation_count: operations.length
554
+ });
555
+ candidates.stacks.push("graphql_code_first");
556
+ }
557
+ candidates.capabilities = dedupeCandidateRecords(candidates.capabilities, (record) => record.id_hint);
558
+ candidates.routes = dedupeCandidateRecords(
559
+ candidates.routes.map((route) => ({ ...route, id_hint: `${route.method}_${route.path}` })),
560
+ (record) => `${record.method}:${record.path}:${record.source_kind}`
561
+ ).map(({ id_hint, ...route }) => route);
562
+ candidates.stacks = [...new Set(candidates.stacks)].sort();
563
+ return { findings, candidates };
564
+ }
565
+ };