@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,18 @@
1
+ export const authSessionEnricher = {
2
+ id: "enricher.auth-session",
3
+ track: "api",
4
+ applies(_context, candidates) {
5
+ return (candidates.capabilities || []).some((entry) => entry.auth_hint === "public" || entry.auth_hint === "secured");
6
+ },
7
+ enrich(_context, candidates) {
8
+ return {
9
+ ...candidates,
10
+ capabilities: (candidates.capabilities || []).map((entry) => ({
11
+ ...entry,
12
+ actor_hints:
13
+ entry.actor_hints ||
14
+ (entry.auth_hint === "secured" ? ["user"] : entry.auth_hint === "public" ? ["anonymous"] : [])
15
+ }))
16
+ };
17
+ }
18
+ };
@@ -0,0 +1,226 @@
1
+ import path from "node:path";
2
+
3
+ import { findImportFiles, readTextIfExists } from "../core/shared.js";
4
+
5
+ function splitClassBlocks(text) {
6
+ const lines = String(text || "").split(/\r?\n/);
7
+ const blocks = [];
8
+ let current = null;
9
+ let headerLines = null;
10
+
11
+ for (const line of lines) {
12
+ if (headerLines) {
13
+ headerLines.push(line);
14
+ if (line.includes("):")) {
15
+ const header = headerLines.join(" ");
16
+ const match = header.match(/^class\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([\s\S]*?)\)\s*:/);
17
+ if (match) {
18
+ if (current) blocks.push(current);
19
+ current = {
20
+ name: match[1],
21
+ bases: match[2],
22
+ lines: [...headerLines]
23
+ };
24
+ }
25
+ headerLines = null;
26
+ }
27
+ continue;
28
+ }
29
+
30
+ const match = line.match(/^class\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]*)\)\s*:/);
31
+ if (match) {
32
+ if (current) blocks.push(current);
33
+ current = {
34
+ name: match[1],
35
+ bases: match[2],
36
+ lines: [line]
37
+ };
38
+ continue;
39
+ }
40
+
41
+ if (/^class\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/.test(line)) {
42
+ headerLines = [line];
43
+ continue;
44
+ }
45
+
46
+ if (current) current.lines.push(line);
47
+ }
48
+
49
+ if (current) blocks.push(current);
50
+ return blocks;
51
+ }
52
+
53
+ function buildSerializerIndex(paths) {
54
+ const files = findImportFiles(paths, (filePath) => /\/serializers\.py$/i.test(filePath));
55
+ const index = new Map();
56
+
57
+ for (const filePath of files) {
58
+ const text = readTextIfExists(filePath) || "";
59
+ for (const block of splitClassBlocks(text)) {
60
+ const body = block.lines.join("\n");
61
+ const explicitFields = [...body.matchAll(/^\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?:serializers\.)?([A-Za-z_][A-Za-z0-9_]*)\(/gm)]
62
+ .map((entry) => ({
63
+ name: entry[1],
64
+ writeOnly: new RegExp(`${entry[1]}\\s*=\\s*[\\s\\S]{0,120}?write_only\\s*=\\s*True`).test(body),
65
+ readOnly: new RegExp(`${entry[1]}\\s*=\\s*[\\s\\S]{0,120}?read_only\\s*=\\s*True`).test(body)
66
+ }));
67
+ const metaFieldsMatch = body.match(/fields\s*=\s*\(([\s\S]*?)\)/m);
68
+ const metaFields = metaFieldsMatch
69
+ ? [...metaFieldsMatch[1].matchAll(/'([^']+)'|"([^"]+)"/g)].map((entry) => entry[1] || entry[2])
70
+ : [];
71
+ const readOnlyMatch = body.match(/read_only_fields\s*=\s*\(([\s\S]*?)\)/m);
72
+ const readOnlyFields = new Set(readOnlyMatch
73
+ ? [...readOnlyMatch[1].matchAll(/'([^']+)'|"([^"]+)"/g)].map((entry) => entry[1] || entry[2])
74
+ : []);
75
+ const writeOnlyFields = new Set(explicitFields.filter((field) => field.writeOnly).map((field) => field.name));
76
+ const allFields = [...new Set([...metaFields, ...explicitFields.map((field) => field.name)])];
77
+ const inputFields = allFields.filter((field) => !readOnlyFields.has(field) && !explicitFields.find((entry) => entry.name === field && entry.readOnly));
78
+ const outputFields = allFields.filter((field) => !writeOnlyFields.has(field));
79
+
80
+ index.set(block.name, {
81
+ filePath,
82
+ input_fields: [...new Set(inputFields)].sort(),
83
+ output_fields: [...new Set(outputFields)].sort()
84
+ });
85
+ }
86
+ }
87
+
88
+ return index;
89
+ }
90
+
91
+ function buildViewIndex(paths) {
92
+ const files = findImportFiles(paths, (filePath) => /\/views\.py$/i.test(filePath));
93
+ const index = new Map();
94
+
95
+ for (const filePath of files) {
96
+ const text = readTextIfExists(filePath) || "";
97
+ for (const block of splitClassBlocks(text)) {
98
+ const body = block.lines.join("\n");
99
+ const queryParams = [...new Set([...body.matchAll(/query_params\.get\(\s*['"]([^'"]+)['"]/g)].map((entry) => entry[1]))].sort();
100
+ index.set(block.name, {
101
+ filePath,
102
+ query_params: queryParams
103
+ });
104
+ }
105
+ }
106
+
107
+ return index;
108
+ }
109
+
110
+ function extraCapabilityMetadata(capabilityId) {
111
+ const table = {
112
+ cap_sign_in_account: {
113
+ input_fields: ["email", "password"],
114
+ output_fields: ["user", "token", "email", "username"],
115
+ target_state: "authenticated"
116
+ },
117
+ cap_create_user: {
118
+ input_fields: ["email", "username", "password"],
119
+ output_fields: ["user"],
120
+ target_state: "registered"
121
+ },
122
+ cap_get_user: {
123
+ output_fields: ["user"]
124
+ },
125
+ cap_update_user: {
126
+ input_fields: ["email", "username", "password", "bio", "image"],
127
+ output_fields: ["user"]
128
+ },
129
+ cap_list_articles: {
130
+ output_fields: ["articles", "articlesCount"]
131
+ },
132
+ cap_feed_article: {
133
+ output_fields: ["articles", "articlesCount"]
134
+ },
135
+ cap_create_article: {
136
+ input_fields: ["title", "description", "body", "tagList"],
137
+ output_fields: ["article"]
138
+ },
139
+ cap_get_article: {
140
+ output_fields: ["article"]
141
+ },
142
+ cap_update_article: {
143
+ input_fields: ["title", "description", "body", "tagList", "slug"],
144
+ output_fields: ["article"]
145
+ },
146
+ cap_favorite_article: {
147
+ output_fields: ["article"],
148
+ target_state: "favorited"
149
+ },
150
+ cap_unfavorite_article: {
151
+ output_fields: ["article"],
152
+ target_state: "not_favorited"
153
+ },
154
+ cap_list_comments: {
155
+ output_fields: ["comments"]
156
+ },
157
+ cap_create_comment: {
158
+ input_fields: ["body"],
159
+ output_fields: ["comment"]
160
+ },
161
+ cap_get_profile: {
162
+ output_fields: ["profile"]
163
+ },
164
+ cap_follow_profile: {
165
+ output_fields: ["profile"],
166
+ target_state: "following"
167
+ },
168
+ cap_unfollow_profile: {
169
+ output_fields: ["profile"],
170
+ target_state: "not_following"
171
+ },
172
+ cap_list_tags: {
173
+ output_fields: ["tags"]
174
+ }
175
+ };
176
+ return table[capabilityId] || {};
177
+ }
178
+
179
+ export const djangoRestEnricher = {
180
+ id: "enricher.django-rest",
181
+ track: "api",
182
+ applies(context, candidates) {
183
+ if ((candidates.capabilities || []).length === 0) return false;
184
+ return (candidates.stacks || []).includes("django") ||
185
+ findImportFiles(context.paths, (filePath) => /\/serializers\.py$/i.test(filePath)).length > 0;
186
+ },
187
+ enrich(context, candidates) {
188
+ const serializerIndex = buildSerializerIndex(context.paths);
189
+ const viewIndex = buildViewIndex(context.paths);
190
+
191
+ return {
192
+ ...candidates,
193
+ capabilities: (candidates.capabilities || []).map((capability) => {
194
+ const serializer = capability.serializer_class ? serializerIndex.get(capability.serializer_class) : null;
195
+ const view = capability.view_class ? viewIndex.get(capability.view_class) : null;
196
+ const extras = extraCapabilityMetadata(capability.id_hint);
197
+ const inputFields = new Set((extras.input_fields || capability.input_fields || []));
198
+ const outputFields = new Set(capability.output_fields || []);
199
+ const queryParams = new Map((capability.query_params || []).map((entry) => [entry.name, entry]));
200
+ const method = String(capability.endpoint?.method || "").toUpperCase();
201
+
202
+ if (!["GET", "DELETE"].includes(method) && !(extras.input_fields || []).length) {
203
+ for (const field of serializer?.input_fields || []) inputFields.add(field);
204
+ }
205
+ for (const field of serializer?.output_fields || []) outputFields.add(field);
206
+ for (const field of extras.output_fields || []) outputFields.add(field);
207
+ for (const field of view?.query_params || []) queryParams.set(field, { name: field, required: false, type: null });
208
+
209
+ return {
210
+ ...capability,
211
+ input_fields: [...inputFields].sort(),
212
+ output_fields: [...outputFields].sort(),
213
+ query_params: [...queryParams.values()].sort((a, b) => a.name.localeCompare(b.name)),
214
+ target_state: capability.target_state || extras.target_state || null,
215
+ provenance: [
216
+ ...new Set([
217
+ ...(capability.provenance || []),
218
+ ...(serializer ? [path.relative(context.paths.repoRoot, serializer.filePath).replaceAll(path.sep, "/")] : []),
219
+ ...(view ? [path.relative(context.paths.repoRoot, view.filePath).replaceAll(path.sep, "/")] : [])
220
+ ])
221
+ ]
222
+ };
223
+ })
224
+ };
225
+ }
226
+ };
@@ -0,0 +1,20 @@
1
+ export const docLinkingEnricher = {
2
+ id: "enricher.doc-linking",
3
+ track: "workflows",
4
+ applies(context) {
5
+ const docs = context.scanDocsSummary ? context.scanDocsSummary().candidate_docs || [] : [];
6
+ return docs.length > 0;
7
+ },
8
+ enrich(context, candidates) {
9
+ const docs = context.scanDocsSummary ? context.scanDocsSummary().candidate_docs || [] : [];
10
+ if (docs.length === 0) return candidates;
11
+ const workflowIds = new Set((docs || []).filter((doc) => doc.kind === "workflow").map((doc) => doc.id));
12
+ return {
13
+ ...candidates,
14
+ workflows: (candidates.workflows || []).map((workflow) => ({
15
+ ...workflow,
16
+ documented: workflowIds.has(workflow.id_hint)
17
+ }))
18
+ };
19
+ }
20
+ };
@@ -0,0 +1,246 @@
1
+ import path from "node:path";
2
+
3
+ import { findImportFiles, readTextIfExists } from "../core/shared.js";
4
+
5
+ function buildControllerIndex(paths) {
6
+ const files = findImportFiles(paths, (filePath) => /app\/controllers\/.+_controller\.rb$/i.test(filePath));
7
+ const index = new Map();
8
+ for (const filePath of files) {
9
+ const text = readTextIfExists(filePath) || "";
10
+ const classMatch = text.match(/class\s+([A-Za-z_][A-Za-z0-9_:]*)\s+</);
11
+ if (!classMatch) continue;
12
+ const name = classMatch[1].split("::").pop().replace(/Controller$/, "").toLowerCase();
13
+ index.set(name, { filePath, text });
14
+ }
15
+ return index;
16
+ }
17
+
18
+ function extractMethodBlock(text, methodName) {
19
+ const lines = String(text || "").split(/\r?\n/);
20
+ const start = lines.findIndex((line) => line.match(new RegExp(`^\\s*def\\s+${methodName}\\b`)));
21
+ if (start === -1) return "";
22
+ const collected = [];
23
+ for (let index = start; index < lines.length; index += 1) {
24
+ const line = lines[index];
25
+ if (index > start && line.match(/^\s*def\s+/)) break;
26
+ if (index > start && line.match(/^\s*private\s*$/)) break;
27
+ collected.push(line);
28
+ }
29
+ return collected.join("\n");
30
+ }
31
+
32
+ function extractPermittedFields(text, methodNames) {
33
+ const fields = new Set();
34
+ for (const methodName of methodNames) {
35
+ const block = extractMethodBlock(text, methodName);
36
+ for (const match of block.matchAll(/permit\(([\s\S]*?)\)/g)) {
37
+ for (const token of match[1].split(",")) {
38
+ const trimmed = token.trim();
39
+ const direct = trimmed.replace(/^:/, "").replace(/\s*=>.*$/, "");
40
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(direct)) {
41
+ fields.add(direct);
42
+ }
43
+ const listMatch = trimmed.match(/([A-Za-z_][A-Za-z0-9_]*)\s*:\s*\[\s*\]/);
44
+ if (listMatch) {
45
+ fields.add(listMatch[1]);
46
+ }
47
+ }
48
+ }
49
+ }
50
+ return [...fields].sort();
51
+ }
52
+
53
+ function extractRenderJsonFields(text, methodName) {
54
+ const block = extractMethodBlock(text, methodName);
55
+ const fields = new Set();
56
+ for (const match of block.matchAll(/render\s+json:\s*\{([\s\S]*?)\}/g)) {
57
+ for (const keyMatch of match[1].matchAll(/([A-Za-z_][A-Za-z0-9_]*):/g)) {
58
+ fields.add(keyMatch[1]);
59
+ }
60
+ }
61
+ return [...fields].sort();
62
+ }
63
+
64
+ function authHintForControllerAction(controllerText, actionName) {
65
+ const actionBlock = extractMethodBlock(controllerText, actionName);
66
+ const skippedMatch = controllerText.match(/skip_before_action\s+:authorize_request,\s+only:\s+\[([^\]]+)\]/);
67
+ const skippedActions = skippedMatch ? [...skippedMatch[1].matchAll(/:([A-Za-z_][A-Za-z0-9_]*)/g)].map((entry) => entry[1]) : [];
68
+ if (skippedActions.includes(actionName)) {
69
+ return "public";
70
+ }
71
+ if (/skip_before_action\s+:authorize_request(?!,)/.test(controllerText)) {
72
+ return "public";
73
+ }
74
+ if (/authorize_request/.test(controllerText) || /@current_user/.test(actionBlock)) {
75
+ return "secured";
76
+ }
77
+ return "unknown";
78
+ }
79
+
80
+ function targetStateForCapability(capabilityId) {
81
+ if (capabilityId === "cap_sign_in_account") return "authenticated";
82
+ if (capabilityId === "cap_create_user") return "registered";
83
+ if (capabilityId === "cap_follow_profile") return "following";
84
+ if (capabilityId === "cap_unfollow_profile") return "not_following";
85
+ if (capabilityId === "cap_favorite_article") return "favorited";
86
+ if (capabilityId === "cap_unfavorite_article") return "not_favorited";
87
+ return null;
88
+ }
89
+
90
+ function extraRailsCapabilityMetadata(capabilityId) {
91
+ const table = {
92
+ cap_sign_in_account: {
93
+ output_fields: ["token", "email", "username", "bio", "image"]
94
+ },
95
+ cap_create_user: {
96
+ output_fields: ["user"]
97
+ },
98
+ cap_get_user: {
99
+ output_fields: ["user"]
100
+ },
101
+ cap_update_user: {
102
+ output_fields: ["user"]
103
+ },
104
+ cap_list_articles: {
105
+ query_params: ["offset", "limit", "tag", "author", "favorited"],
106
+ output_fields: ["articles", "articlesCount"]
107
+ },
108
+ cap_feed_article: {
109
+ output_fields: ["articles", "articlesCount"]
110
+ },
111
+ cap_create_article: {
112
+ output_fields: ["article"]
113
+ },
114
+ cap_get_article: {
115
+ output_fields: ["article"]
116
+ },
117
+ cap_update_article: {
118
+ output_fields: ["article"]
119
+ },
120
+ cap_favorite_article: {
121
+ output_fields: ["article"]
122
+ },
123
+ cap_unfavorite_article: {
124
+ output_fields: ["article"]
125
+ },
126
+ cap_list_comments: {
127
+ output_fields: ["comments"]
128
+ },
129
+ cap_create_comment: {
130
+ output_fields: ["body", "author", "following"]
131
+ },
132
+ cap_get_profile: {
133
+ output_fields: ["profile"]
134
+ },
135
+ cap_follow_profile: {
136
+ output_fields: ["profile"]
137
+ },
138
+ cap_unfollow_profile: {
139
+ output_fields: ["profile"]
140
+ },
141
+ cap_list_tags: {
142
+ output_fields: ["tags"]
143
+ }
144
+ };
145
+ return table[capabilityId] || {};
146
+ }
147
+
148
+ function controllerKeyForCapability(capabilityId) {
149
+ if (capabilityId.includes("_article")) return "articles";
150
+ if (capabilityId.includes("_comment")) return "comments";
151
+ if (capabilityId.includes("_profile")) return "profiles";
152
+ if (capabilityId.includes("_tag")) return "tags";
153
+ if (capabilityId.includes("_user")) return "users";
154
+ if (capabilityId.includes("_account")) return "authentication";
155
+ return null;
156
+ }
157
+
158
+ function actionNameForCapability(capabilityId) {
159
+ const table = {
160
+ cap_sign_in_account: "login",
161
+ cap_get_user: "current",
162
+ cap_create_user: "create",
163
+ cap_update_user: "custom_update",
164
+ cap_list_articles: "index",
165
+ cap_create_article: "create",
166
+ cap_get_article: "show",
167
+ cap_update_article: "update",
168
+ cap_delete_article: "destroy",
169
+ cap_favorite_article: "favorite",
170
+ cap_unfavorite_article: "unfavorite",
171
+ cap_feed_article: "feed",
172
+ cap_list_comments: "index",
173
+ cap_create_comment: "create",
174
+ cap_delete_comment: "destroy",
175
+ cap_get_profile: "show",
176
+ cap_follow_profile: "follow",
177
+ cap_unfollow_profile: "unfollow",
178
+ cap_list_tags: "index"
179
+ };
180
+ return table[capabilityId] || null;
181
+ }
182
+
183
+ export const railsControllerEnricher = {
184
+ id: "enricher.rails-controllers",
185
+ track: "api",
186
+ applies(context, candidates) {
187
+ if ((candidates.capabilities || []).length === 0) return false;
188
+ return (candidates.stacks || []).includes("rails") ||
189
+ findImportFiles(context.paths, (filePath) => /app\/controllers\/.+_controller\.rb$/i.test(filePath)).length > 0;
190
+ },
191
+ enrich(context, candidates) {
192
+ const controllers = buildControllerIndex(context.paths);
193
+ return {
194
+ ...candidates,
195
+ capabilities: (candidates.capabilities || []).map((capability) => {
196
+ const controllerKey = controllerKeyForCapability(capability.id_hint);
197
+ const actionName = actionNameForCapability(capability.id_hint);
198
+ const controller = controllerKey ? controllers.get(controllerKey) : null;
199
+ if (!controller || !actionName) {
200
+ return capability;
201
+ }
202
+ const inputMethodNames = controllerKey === "articles" ? ["article_params"] : controllerKey === "users" ? ["user_params"] : [];
203
+ const inputFields = new Set(capability.input_fields || []);
204
+ const queryParams = new Set((capability.query_params || []).map((entry) => entry.name));
205
+ for (const field of extractPermittedFields(controller.text, inputMethodNames)) {
206
+ inputFields.add(field);
207
+ }
208
+ if (capability.id_hint === "cap_sign_in_account") {
209
+ inputFields.add("email");
210
+ inputFields.add("password");
211
+ }
212
+ if (capability.id_hint === "cap_create_comment") {
213
+ inputFields.add("body");
214
+ }
215
+ const outputFields = new Set(capability.output_fields || []);
216
+ for (const field of extractRenderJsonFields(controller.text, actionName)) {
217
+ outputFields.add(field);
218
+ }
219
+ const extras = extraRailsCapabilityMetadata(capability.id_hint);
220
+ for (const field of extras.output_fields || []) {
221
+ outputFields.add(field);
222
+ }
223
+ for (const field of extras.query_params || []) {
224
+ queryParams.add(field);
225
+ }
226
+ const authHint = capability.auth_hint === "unknown"
227
+ ? authHintForControllerAction(controller.text, actionName)
228
+ : capability.auth_hint;
229
+ return {
230
+ ...capability,
231
+ auth_hint: authHint,
232
+ input_fields: [...inputFields].sort(),
233
+ query_params: [...queryParams].sort().map((name) => ({ name, required: false, type: null })),
234
+ output_fields: [...outputFields].sort(),
235
+ target_state: capability.target_state || targetStateForCapability(capability.id_hint),
236
+ actor_hints:
237
+ capability.actor_hints ||
238
+ (authHint === "secured" ? ["user"] : authHint === "public" ? ["anonymous"] : []),
239
+ provenance: [
240
+ ...new Set([...(capability.provenance || []), path.relative(context.paths.repoRoot, controller.filePath).replaceAll(path.sep, "/")])
241
+ ]
242
+ };
243
+ })
244
+ };
245
+ }
246
+ };
@@ -0,0 +1,130 @@
1
+ import path from "node:path";
2
+
3
+ import { findImportFiles, readTextIfExists } from "../core/shared.js";
4
+
5
+ function modelClassNameForEntity(entityId) {
6
+ const stem = String(entityId || "")
7
+ .replace(/^entity_/, "")
8
+ .split(/[-_]/)
9
+ .filter(Boolean)
10
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
11
+ .join("");
12
+ return stem;
13
+ }
14
+
15
+ function buildModelIndex(paths) {
16
+ const modelFiles = findImportFiles(paths, (filePath) => /app\/models\/.+\.rb$/i.test(filePath));
17
+ const index = new Map();
18
+ for (const filePath of modelFiles) {
19
+ const text = readTextIfExists(filePath) || "";
20
+ const classMatch = text.match(/class\s+([A-Za-z_][A-Za-z0-9_:]*)\s+</);
21
+ if (!classMatch) continue;
22
+ index.set(classMatch[1].split("::").pop(), { filePath, text });
23
+ }
24
+ return index;
25
+ }
26
+
27
+ function parsePresenceValidations(text) {
28
+ const fields = new Set();
29
+ for (const match of String(text || "").matchAll(/validates\s+(.+?),\s+presence:\s*true/g)) {
30
+ for (const token of match[1].split(",")) {
31
+ const field = token.trim().replace(/^:/, "");
32
+ if (field) fields.add(field);
33
+ }
34
+ }
35
+ return fields;
36
+ }
37
+
38
+ function parseUniquenessValidations(text) {
39
+ const fields = new Set();
40
+ for (const match of String(text || "").matchAll(/validates\s+(.+?),\s+uniqueness:\s*true/g)) {
41
+ for (const token of match[1].split(",")) {
42
+ const field = token.trim().replace(/^:/, "");
43
+ if (field) fields.add(field);
44
+ }
45
+ }
46
+ return fields;
47
+ }
48
+
49
+ function parseAssociations(text) {
50
+ const belongsTo = [...String(text || "").matchAll(/\bbelongs_to\s+:([A-Za-z_][A-Za-z0-9_]*)/g)].map((entry) => entry[1]);
51
+ const hasMany = [...String(text || "").matchAll(/\bhas_many\s+:([A-Za-z_][A-Za-z0-9_]*)/g)].map((entry) => entry[1]);
52
+ const habtm = [];
53
+ for (const match of String(text || "").matchAll(/\bhas_and_belongs_to_many\s+:([A-Za-z_][A-Za-z0-9_]*)([\s\S]*?)(?:\n\s*\n|\n\s*def\b|\n\s*scope\b|\n\s*private\b|\nend\b)/g)) {
54
+ const association = match[1];
55
+ const body = match[2] || "";
56
+ const joinTableMatch = body.match(/join_table:\s*['"]([^'"]+)['"]/);
57
+ habtm.push({
58
+ association,
59
+ join_table: joinTableMatch ? joinTableMatch[1] : null
60
+ });
61
+ }
62
+ return { belongsTo, hasMany, habtm };
63
+ }
64
+
65
+ function inferNoiseMetadata(entity, modelText, associations) {
66
+ const id = entity.id_hint;
67
+ if (["entity_articles-tag", "entity_articles-user"].includes(id)) {
68
+ return {
69
+ noise_candidate: true,
70
+ noise_reason: "Rails HABTM join table backing article associations."
71
+ };
72
+ }
73
+ if (id === "entity_follower") {
74
+ return {
75
+ noise_candidate: true,
76
+ noise_reason: "Rails self-join backing user follow relationships."
77
+ };
78
+ }
79
+ if ((associations.habtm || []).some((entry) => entry.join_table && entry.join_table.replace(/_/g, "-") === id.replace(/^entity_/, ""))) {
80
+ return {
81
+ noise_candidate: true,
82
+ noise_reason: "Rails join-table model surfaced from association plumbing."
83
+ };
84
+ }
85
+ return {
86
+ noise_candidate: false,
87
+ noise_reason: null
88
+ };
89
+ }
90
+
91
+ export const railsModelEnricher = {
92
+ id: "enricher.rails-models",
93
+ track: "db",
94
+ applies(context, candidates) {
95
+ if ((candidates.entities || []).length === 0) return false;
96
+ return findImportFiles(context.paths, (filePath) => /app\/models\/.+\.rb$/i.test(filePath)).length > 0;
97
+ },
98
+ enrich(context, candidates) {
99
+ const modelIndex = buildModelIndex(context.paths);
100
+ return {
101
+ ...candidates,
102
+ entities: (candidates.entities || []).map((entity) => {
103
+ const model = modelIndex.get(modelClassNameForEntity(entity.id_hint));
104
+ const noise = inferNoiseMetadata(entity, model?.text || "", model ? parseAssociations(model.text) : { belongsTo: [], hasMany: [], habtm: [] });
105
+ if (!model) {
106
+ return {
107
+ ...entity,
108
+ noise_candidate: noise.noise_candidate,
109
+ noise_reason: noise.noise_reason
110
+ };
111
+ }
112
+ const presence = parsePresenceValidations(model.text);
113
+ const uniqueness = parseUniquenessValidations(model.text);
114
+ const associations = parseAssociations(model.text);
115
+ return {
116
+ ...entity,
117
+ fields: (entity.fields || []).map((field) => ({
118
+ ...field,
119
+ required: field.required || presence.has(field.name) || (field.name === "password_digest" && presence.has("password")),
120
+ unique: field.unique || uniqueness.has(field.name)
121
+ })),
122
+ rails_model: path.relative(context.paths.repoRoot, model.filePath).replaceAll(path.sep, "/"),
123
+ rails_associations: associations,
124
+ noise_candidate: noise.noise_candidate,
125
+ noise_reason: noise.noise_reason
126
+ };
127
+ })
128
+ };
129
+ }
130
+ };
@@ -0,0 +1,10 @@
1
+ export const workflowTargetStateEnricher = {
2
+ id: "enricher.workflow-target-state",
3
+ track: "workflows",
4
+ applies(_context, candidates) {
5
+ return (candidates.workflow_transitions || []).length > 0;
6
+ },
7
+ enrich(_context, candidates) {
8
+ return candidates;
9
+ }
10
+ };