@topogram/cli 0.3.76 → 0.3.78

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 (67) hide show
  1. package/package.json +1 -1
  2. package/src/adoption/plan/index.js +12 -1
  3. package/src/cli/commands/import/workspace.js +1 -0
  4. package/src/cli.js +3 -3
  5. package/src/import/core/runner/candidates.js +2 -0
  6. package/src/import/core/runner/reports.js +12 -5
  7. package/src/import/core/runner/tracks.js +1 -1
  8. package/src/import/core/shared/files.js +76 -1
  9. package/src/import/core/shared/next-app.js +2 -2
  10. package/src/import/core/shared/ui-routes.js +106 -0
  11. package/src/import/core/shared.js +8 -1
  12. package/src/import/enrichers/django-rest.js +4 -4
  13. package/src/import/enrichers/rails-controllers.js +3 -3
  14. package/src/import/enrichers/rails-models.js +3 -3
  15. package/src/import/extractors/api/aspnet-core.js +5 -5
  16. package/src/import/extractors/api/django-routes.js +5 -5
  17. package/src/import/extractors/api/express.js +4 -4
  18. package/src/import/extractors/api/fastify.js +7 -7
  19. package/src/import/extractors/api/flutter-dio.js +4 -4
  20. package/src/import/extractors/api/generic-route-fallback.js +3 -2
  21. package/src/import/extractors/api/graphql-code-first.js +3 -3
  22. package/src/import/extractors/api/graphql-sdl.js +5 -5
  23. package/src/import/extractors/api/jaxrs.js +3 -3
  24. package/src/import/extractors/api/micronaut.js +3 -3
  25. package/src/import/extractors/api/openapi-code.js +4 -4
  26. package/src/import/extractors/api/openapi.js +3 -3
  27. package/src/import/extractors/api/rails-routes.js +3 -3
  28. package/src/import/extractors/api/react-native-repository.js +3 -3
  29. package/src/import/extractors/api/retrofit.js +3 -3
  30. package/src/import/extractors/api/spring-web.js +3 -3
  31. package/src/import/extractors/api/swift-webapi.js +3 -3
  32. package/src/import/extractors/api/trpc.js +4 -4
  33. package/src/import/extractors/cli/generic.js +5 -4
  34. package/src/import/extractors/db/django-models.js +4 -4
  35. package/src/import/extractors/db/dotnet-models.js +4 -4
  36. package/src/import/extractors/db/drizzle.js +21 -9
  37. package/src/import/extractors/db/ef-core.js +5 -5
  38. package/src/import/extractors/db/flutter-entities.js +3 -3
  39. package/src/import/extractors/db/jpa.js +3 -3
  40. package/src/import/extractors/db/liquibase.js +3 -3
  41. package/src/import/extractors/db/maintained-seams.js +27 -4
  42. package/src/import/extractors/db/mybatis-xml.js +4 -4
  43. package/src/import/extractors/db/prisma.js +10 -3
  44. package/src/import/extractors/db/rails-schema.js +3 -3
  45. package/src/import/extractors/db/react-native-entities.js +3 -3
  46. package/src/import/extractors/db/room.js +5 -5
  47. package/src/import/extractors/db/snapshot.js +3 -3
  48. package/src/import/extractors/db/sql.js +3 -3
  49. package/src/import/extractors/db/swiftdata.js +3 -3
  50. package/src/import/extractors/ui/android-compose.js +4 -4
  51. package/src/import/extractors/ui/backend-only.js +3 -3
  52. package/src/import/extractors/ui/blazor.js +3 -3
  53. package/src/import/extractors/ui/flutter-screens.js +3 -3
  54. package/src/import/extractors/ui/maui-xaml.js +4 -4
  55. package/src/import/extractors/ui/next-app-router.js +26 -5
  56. package/src/import/extractors/ui/next-pages-router.js +34 -10
  57. package/src/import/extractors/ui/razor-pages.js +3 -3
  58. package/src/import/extractors/ui/react-native-screens.js +4 -4
  59. package/src/import/extractors/ui/react-router.js +34 -6
  60. package/src/import/extractors/ui/sveltekit.js +34 -6
  61. package/src/import/extractors/ui/swiftui.js +4 -3
  62. package/src/import/extractors/ui/uikit.js +3 -3
  63. package/src/workflows/reconcile/bundle-core/index.js +20 -1
  64. package/src/workflows/reconcile/candidate-model.js +13 -1
  65. package/src/workflows/reconcile/impacts/adoption-plan.js +13 -0
  66. package/src/workflows/reconcile/renderers.js +33 -0
  67. package/src/workflows/reconcile/workflow.js +2 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topogram/cli",
3
- "version": "0.3.76",
3
+ "version": "0.3.78",
4
4
  "description": "Topogram CLI for checking Topogram workspaces and generating app bundles.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -741,7 +741,18 @@ export function buildAgentAdoptionPlan(adoptionPlan, maintainedBoundaryArtifact
741
741
  mapping_suggestions: [{
742
742
  type: "manual_project_config_review",
743
743
  id: seam.id_hint || seam.seam_id,
744
- reason: "Review the inferred database migration strategy before editing topogram.project.json."
744
+ reason: "Review the inferred database migration strategy before editing topogram.project.json.",
745
+ project_config_target: seam.project_config_target || {
746
+ file: "topogram.project.json",
747
+ path: "topology.runtimes[].migration"
748
+ },
749
+ proposed_runtime_migration: seam.proposed_runtime_migration || null,
750
+ missing_decisions: seam.missing_decisions || [],
751
+ evidence: seam.evidence || seam.provenance || [],
752
+ manual_next_steps: seam.manual_next_steps || [
753
+ "Review evidence and missing decisions.",
754
+ "Manually copy the accepted migration strategy into topogram.project.json."
755
+ ]
745
756
  }],
746
757
  available_actions: ADOPTION_STATE_VOCABULARY
747
758
  }));
@@ -115,6 +115,7 @@ export function importCandidateCounts(summary) {
115
115
  apiRoutes: candidates.api?.routes?.length || 0,
116
116
  uiScreens: candidates.ui?.screens?.length || 0,
117
117
  uiRoutes: candidates.ui?.routes?.length || 0,
118
+ uiFlows: candidates.ui?.flows?.length || 0,
118
119
  uiWidgets: candidates.ui?.widgets?.length || candidates.ui?.components?.length || 0,
119
120
  uiShapes: candidates.ui?.shapes?.length || 0,
120
121
  cliCommands: candidates.cli?.commands?.length || 0,
package/src/cli.js CHANGED
@@ -63,18 +63,18 @@ const inputPath = commandArgs && Object.prototype.hasOwnProperty.call(commandArg
63
63
  const cliOptions = parseCliOptions(args, commandArgs);
64
64
 
65
65
  try {
66
- process.exit(await runCliDispatch({
66
+ process.exitCode = await runCliDispatch({
67
67
  args,
68
68
  commandArgs,
69
69
  inputPath,
70
70
  cliOptions,
71
71
  executablePath: path.resolve(process.argv[1] || fileURLToPath(import.meta.url))
72
- }));
72
+ });
73
73
  } catch (error) {
74
74
  if (error.validation) {
75
75
  console.error(formatValidationErrors(error.validation));
76
76
  } else {
77
77
  console.error(error.message);
78
78
  }
79
- process.exit(1);
79
+ process.exitCode = 1;
80
80
  }
@@ -284,6 +284,7 @@ export function normalizeCandidatesForTrack(track, candidates) {
284
284
  screens: dedupeCandidateRecords(candidates.screens || [], idHint),
285
285
  routes: dedupeCandidateRecords(candidates.routes || [], idHint),
286
286
  actions: dedupeCandidateRecords(candidates.actions || [], idHint),
287
+ flows: dedupeCandidateRecords(candidates.flows || [], idHint),
287
288
  widgets,
288
289
  shapes: dedupeCandidateRecords([...(candidates.shapes || []), ...eventShapes], idHint),
289
290
  stacks: [...new Set(candidates.stacks || [])].sort()
@@ -330,6 +331,7 @@ export function enrichUiWidgetDataSources(uiCandidates, allCandidates) {
330
331
  const { components, ...canonicalCandidates } = uiCandidates;
331
332
  return {
332
333
  ...canonicalCandidates,
334
+ flows: dedupeCandidateRecords(canonicalCandidates.flows || [], (/** @type {any} */ flow) => flow.id_hint),
333
335
  widgets: widgets.map((widget) => {
334
336
  const dataSource = inferredDataSourceForWidget(widget, allCandidates);
335
337
  const dataProp = widget.data_prop || "rows";
@@ -10,9 +10,12 @@ import { uiWidgetCandidates } from "./candidates.js";
10
10
  */
11
11
  export function reportMarkdown(track, candidates) {
12
12
  if (track === "db") {
13
- const seamLines = (candidates.maintained_seams || []).map((/** @type {any} */ seam) =>
14
- `- \`${seam.id_hint}\` tool \`${seam.tool}\` confidence ${seam.confidence || "unknown"} schema \`${seam.schemaPath || "none"}\` migrations \`${seam.migrationsPath || "review-required"}\` missing decisions ${(seam.missing_decisions || []).length}`
15
- );
13
+ const seamLines = (candidates.maintained_seams || []).flatMap((/** @type {any} */ seam) => [
14
+ `- \`${seam.id_hint}\` tool \`${seam.tool}\` confidence ${seam.confidence || "unknown"} schema \`${seam.schemaPath || "none"}\` migrations \`${seam.migrationsPath || "review-required"}\` missing decisions ${(seam.missing_decisions || []).length}`,
15
+ ` - project config target: \`${seam.project_config_target?.path || "topology.runtimes[].migration"}\``,
16
+ ` - evidence: ${(seam.evidence || []).slice(0, 3).map((/** @type {string} */ item) => `\`${item}\``).join(", ") || "_none_"}`,
17
+ ` - manual next: ${(seam.manual_next_steps || []).slice(0, 2).join(" ")}`
18
+ ]);
16
19
  return ensureTrailingNewline(
17
20
  `# DB Import Report\n\n- Entities: ${candidates.entities.length}\n- Enums: ${candidates.enums.length}\n- Relations: ${candidates.relations.length}\n- Indexes: ${candidates.indexes.length}\n- Maintained DB migration seams: ${(candidates.maintained_seams || []).length}\n\n## Maintained DB Migration Seam Candidates\n\n${seamLines.length ? seamLines.join("\n") : "- none"}\n`
18
21
  );
@@ -25,11 +28,15 @@ export function reportMarkdown(track, candidates) {
25
28
  if (track === "ui") {
26
29
  const widgets = uiWidgetCandidates(candidates);
27
30
  const shapes = candidates.shapes || [];
31
+ const flows = candidates.flows || [];
28
32
  const widgetLines = widgets.map((widget) =>
29
33
  `- \`${widget.id_hint}\` confidence ${widget.confidence || "unknown"} pattern \`${widget.pattern || widget.inferred_pattern || "unknown"}\` region \`${widget.region || widget.inferred_region || "unknown"}\` events ${(widget.inferred_events || []).length} evidence ${(widget.evidence || widget.provenance || []).length} missing decisions ${(widget.missing_decisions || []).length}`
30
34
  );
35
+ const flowLines = flows.map((/** @type {any} */ flow) =>
36
+ `- \`${flow.id_hint}\` type \`${flow.flow_type}\` confidence ${flow.confidence || "unknown"} routes ${(flow.route_paths || []).map((/** @type {string} */ route) => `\`${route}\``).join(", ") || "_none_"} missing decisions ${(flow.missing_decisions || []).length}`
37
+ );
31
38
  return ensureTrailingNewline(
32
- `# UI Import Report\n\n- Screens: ${candidates.screens.length}\n- Routes: ${candidates.routes.length}\n- Actions: ${candidates.actions.length}\n- Widgets: ${widgets.length}\n- Event payload shapes: ${shapes.length}\n- Stacks: ${candidates.stacks.length ? candidates.stacks.join(", ") : "none"}\n\n## Widget Candidates\n\n${widgetLines.length ? widgetLines.join("\n") : "- none"}\n\n## Next Validation\n\n- Review candidates under \`topo/candidates/app/ui/drafts/widgets/**\`.\n- Run \`topogram import plan <path>\` before adoption.\n- After adoption, run \`topogram check <path>\`, \`topogram widget check <path>\`, and \`topogram widget behavior <path>\`.\n`
39
+ `# UI Import Report\n\n- Screens: ${candidates.screens.length}\n- Routes: ${candidates.routes.length}\n- Actions: ${candidates.actions.length}\n- Flow candidates: ${flows.length}\n- Widgets: ${widgets.length}\n- Event payload shapes: ${shapes.length}\n- Stacks: ${candidates.stacks.length ? candidates.stacks.join(", ") : "none"}\n\n## Flow Candidates\n\n${flowLines.length ? flowLines.join("\n") : "- none"}\n\n## Widget Candidates\n\n${widgetLines.length ? widgetLines.join("\n") : "- none"}\n\n## Next Validation\n\n- Review flow candidates in \`topo/candidates/app/ui/candidates.json\` before adding shared UI contract behavior.\n- Review candidates under \`topo/candidates/app/ui/drafts/widgets/**\`.\n- Run \`topogram import plan <path>\` before adoption.\n- After adoption, run \`topogram check <path>\`, \`topogram widget check <path>\`, and \`topogram widget behavior <path>\`.\n`
33
40
  );
34
41
  }
35
42
  if (track === "cli") {
@@ -57,6 +64,6 @@ export function reportMarkdown(track, candidates) {
57
64
  */
58
65
  export function appReportMarkdown(candidates, tracks) {
59
66
  return ensureTrailingNewline(
60
- `# App Import Report\n\nTracks: ${tracks.join(", ")}\n\n## DB\n\n- Entities: ${candidates.db?.entities?.length || 0}\n- Enums: ${candidates.db?.enums?.length || 0}\n- Relations: ${candidates.db?.relations?.length || 0}\n- Maintained DB migration seams: ${candidates.db?.maintained_seams?.length || 0}\n\n## API\n\n- Capabilities: ${candidates.api?.capabilities?.length || 0}\n- Routes: ${candidates.api?.routes?.length || 0}\n- Stacks: ${candidates.api?.stacks?.length ? candidates.api.stacks.join(", ") : "none"}\n\n## UI\n\n- Screens: ${candidates.ui?.screens?.length || 0}\n- Routes: ${candidates.ui?.routes?.length || 0}\n- Actions: ${candidates.ui?.actions?.length || 0}\n- Widgets: ${uiWidgetCandidates(candidates.ui).length}\n- Event payload shapes: ${candidates.ui?.shapes?.length || 0}\n- Stacks: ${candidates.ui?.stacks?.length ? candidates.ui.stacks.join(", ") : "none"}\n\n## CLI\n\n- Commands: ${candidates.cli?.commands?.length || 0}\n- Capabilities: ${candidates.cli?.capabilities?.length || 0}\n- CLI surfaces: ${candidates.cli?.surfaces?.length || 0}\n\n## Workflows\n\n- Workflows: ${candidates.workflows?.workflows?.length || 0}\n- States: ${candidates.workflows?.workflow_states?.length || 0}\n- Transitions: ${candidates.workflows?.workflow_transitions?.length || 0}\n\n## Verification\n\n- Verifications: ${candidates.verification?.verifications?.length || 0}\n- Scenarios: ${candidates.verification?.scenarios?.length || 0}\n- Frameworks: ${candidates.verification?.frameworks?.length ? candidates.verification.frameworks.join(", ") : "none"}\n- Scripts: ${candidates.verification?.scripts?.length || 0}\n`
67
+ `# App Import Report\n\nTracks: ${tracks.join(", ")}\n\n## DB\n\n- Entities: ${candidates.db?.entities?.length || 0}\n- Enums: ${candidates.db?.enums?.length || 0}\n- Relations: ${candidates.db?.relations?.length || 0}\n- Maintained DB migration seams: ${candidates.db?.maintained_seams?.length || 0}\n\n## API\n\n- Capabilities: ${candidates.api?.capabilities?.length || 0}\n- Routes: ${candidates.api?.routes?.length || 0}\n- Stacks: ${candidates.api?.stacks?.length ? candidates.api.stacks.join(", ") : "none"}\n\n## UI\n\n- Screens: ${candidates.ui?.screens?.length || 0}\n- Routes: ${candidates.ui?.routes?.length || 0}\n- Actions: ${candidates.ui?.actions?.length || 0}\n- Flow candidates: ${candidates.ui?.flows?.length || 0}\n- Widgets: ${uiWidgetCandidates(candidates.ui).length}\n- Event payload shapes: ${candidates.ui?.shapes?.length || 0}\n- Stacks: ${candidates.ui?.stacks?.length ? candidates.ui.stacks.join(", ") : "none"}\n\n## CLI\n\n- Commands: ${candidates.cli?.commands?.length || 0}\n- Capabilities: ${candidates.cli?.capabilities?.length || 0}\n- CLI surfaces: ${candidates.cli?.surfaces?.length || 0}\n\n## Workflows\n\n- Workflows: ${candidates.workflows?.workflows?.length || 0}\n- States: ${candidates.workflows?.workflow_states?.length || 0}\n- Transitions: ${candidates.workflows?.workflow_transitions?.length || 0}\n\n## Verification\n\n- Verifications: ${candidates.verification?.verifications?.length || 0}\n- Scenarios: ${candidates.verification?.scenarios?.length || 0}\n- Frameworks: ${candidates.verification?.frameworks?.length ? candidates.verification.frameworks.join(", ") : "none"}\n- Scripts: ${candidates.verification?.scripts?.length || 0}\n`
61
68
  );
62
69
  }
@@ -102,7 +102,7 @@ function initialCandidatesForTrack(track) {
102
102
  return { capabilities: [], routes: [], stacks: [] };
103
103
  }
104
104
  if (track === "ui") {
105
- return { screens: [], routes: [], actions: [], stacks: [] };
105
+ return { screens: [], routes: [], actions: [], flows: [], stacks: [] };
106
106
  }
107
107
  if (track === "cli") {
108
108
  return { commands: [], capabilities: [], surfaces: [] };
@@ -6,12 +6,17 @@ import { relativeTo } from "../../../path-helpers.js";
6
6
  export const DEFAULT_IGNORED_DIRS = new Set([
7
7
  ".git",
8
8
  ".next",
9
+ ".tmp",
9
10
  ".turbo",
10
11
  ".yarn",
12
+ ".cache",
13
+ ".parcel-cache",
14
+ ".svelte-kit",
11
15
  "build",
12
16
  "coverage",
13
17
  "dist",
14
18
  "node_modules",
19
+ "out",
15
20
  "tmp"
16
21
  ]);
17
22
 
@@ -82,6 +87,54 @@ export function normalizeImportRelativePath(paths, filePath) {
82
87
  return relativeTo(paths.repoRoot, filePath);
83
88
  }
84
89
 
90
+ /**
91
+ * @param {import("./types.d.ts").ImportPaths} paths
92
+ * @param {string} filePath
93
+ * @returns {string}
94
+ */
95
+ export function importSourcePathRelativeToWorkspace(paths, filePath) {
96
+ return relativeTo(paths.workspaceRoot, filePath).replaceAll(path.sep, "/");
97
+ }
98
+
99
+ /**
100
+ * @param {import("./types.d.ts").ImportPaths} paths
101
+ * @param {string} filePath
102
+ * @returns {"runtime_source"|"parser_config"|"docs"|"tests"|"fixtures"|"generated_output"}
103
+ */
104
+ export function classifyImportSourcePath(paths, filePath) {
105
+ const relativePath = importSourcePathRelativeToWorkspace(paths, filePath);
106
+ const basename = path.basename(relativePath).toLowerCase();
107
+ if (/(^|\/)(\.tmp|tmp|temp|dist|build|coverage|out|generated|docs-generated|snapshots?)(\/|$)/i.test(relativePath)) {
108
+ return "generated_output";
109
+ }
110
+ if (/^(template|templates|[^/]+-templates|[^/]+_templates)(\/|$)/i.test(relativePath)) {
111
+ return "fixtures";
112
+ }
113
+ if (/(^|\/)(test|tests|__tests__|spec|specs|mocks?)(\/|$)|\.(test|spec)\.(js|jsx|ts|tsx|mjs|cjs)$/i.test(relativePath)) {
114
+ return "tests";
115
+ }
116
+ if (/(^|\/)(fixture|fixtures|examples?)(\/|$)/i.test(relativePath)) {
117
+ return "fixtures";
118
+ }
119
+ if (/(^|\/)(doc|docs|documentation|guides?)(\/|$)|^(readme|changelog|contributing|license)(\.[a-z0-9]+)?$/i.test(relativePath) || /^(readme|changelog|contributing|license)(\.[a-z0-9]+)?$/i.test(basename)) {
120
+ return "docs";
121
+ }
122
+ if (/(^|\/)(package\.json|openapi\.(json|ya?ml)|swagger\.(json|ya?ml)|drizzle\.config\.(ts|js|mjs|cjs)|tsconfig\.json)$/i.test(relativePath)) {
123
+ return "parser_config";
124
+ }
125
+ return "runtime_source";
126
+ }
127
+
128
+ /**
129
+ * @param {import("./types.d.ts").ImportPaths} paths
130
+ * @param {string} filePath
131
+ * @returns {boolean}
132
+ */
133
+ export function isPrimaryImportSource(paths, filePath) {
134
+ const sourceType = classifyImportSourcePath(paths, filePath);
135
+ return sourceType === "runtime_source" || sourceType === "parser_config";
136
+ }
137
+
85
138
  /**
86
139
  * @param {import("./types.d.ts").ImportPaths} paths
87
140
  * @param {string} filePath
@@ -130,6 +183,9 @@ export function canonicalSourceRank(paths, filePath, kind) {
130
183
  rank += penalty.weight;
131
184
  }
132
185
  }
186
+ if (!isPrimaryImportSource(paths, filePath)) {
187
+ rank += 1000;
188
+ }
133
189
  return rank;
134
190
  }
135
191
 
@@ -157,9 +213,10 @@ export function selectPreferredImportFiles(paths, files, kind) {
157
213
  /**
158
214
  * @param {import("./types.d.ts").ImportPaths} paths
159
215
  * @param {any} predicate
216
+ * @param {{ primaryOnly?: boolean }} [options]
160
217
  * @returns {any}
161
218
  */
162
- export function findImportFiles(paths, predicate) {
219
+ export function findImportFiles(paths, predicate, options = {}) {
163
220
  const files = new Set();
164
221
  for (const rootDir of importSearchRoots(paths)) {
165
222
  for (const filePath of listFilesRecursive(rootDir, predicate)) {
@@ -170,8 +227,26 @@ export function findImportFiles(paths, predicate) {
170
227
  ) {
171
228
  continue;
172
229
  }
230
+ if (options.primaryOnly && !isPrimaryImportSource(paths, filePath)) {
231
+ continue;
232
+ }
173
233
  files.add(filePath);
174
234
  }
175
235
  }
176
236
  return [...files].sort();
177
237
  }
238
+
239
+ /**
240
+ * Find files that are eligible to create primary import candidates.
241
+ *
242
+ * Docs, tests, fixture/template roots, generated output, and cache output may
243
+ * still support evidence elsewhere, but candidate-producing extractors should
244
+ * start from this helper so the primary-source rule is one API boundary.
245
+ *
246
+ * @param {import("./types.d.ts").ImportPaths} paths
247
+ * @param {any} predicate
248
+ * @returns {any}
249
+ */
250
+ export function findPrimaryImportFiles(paths, predicate) {
251
+ return findImportFiles(paths, predicate, { primaryOnly: true });
252
+ }
@@ -10,7 +10,7 @@ import {
10
10
  normalizeEndpointPathForMatch,
11
11
  normalizeOpenApiPath
12
12
  } from "./api-routes.js";
13
- import { entityIdForRoute, screenIdForRoute, screenKindForRoute, uiCapabilityHintsForRoute } from "./ui-routes.js";
13
+ import { entityIdForRoute, inferNonResourceUiFlow, screenIdForRoute, screenKindForRoute, uiCapabilityHintsForRoute } from "./ui-routes.js";
14
14
 
15
15
  /**
16
16
  * @param {string} rootDir
@@ -48,7 +48,7 @@ export function inferNextAppRoutes(rootDir) {
48
48
  */
49
49
  export function nextScreenKindForRoute(routePath) {
50
50
  const normalized = String(routePath || "");
51
- if (/\/(login|register|setup)$/.test(normalized)) return "flow";
51
+ if (inferNonResourceUiFlow(normalized) || /\/(login|register|setup)$/.test(normalized)) return "flow";
52
52
  return screenKindForRoute(routePath);
53
53
  }
54
54
 
@@ -5,6 +5,51 @@ import { relativeTo } from "../../../path-helpers.js";
5
5
  import { canonicalCandidateTerm } from "./candidates.js";
6
6
  import { listFilesRecursive, readTextIfExists } from "./files.js";
7
7
 
8
+ const NON_RESOURCE_FLOW_DEFINITIONS = [
9
+ {
10
+ flow_type: "auth",
11
+ concept_id: "flow_auth",
12
+ pattern: /\/(login|log-in|signin|sign-in|sign_in|logout|log-out|register|signup|sign-up|forgot-password|reset-password|password-reset)(\/|$)/i,
13
+ confidence: "medium",
14
+ missing_decisions: ["confirm auth provider and session lifecycle", "confirm success and failure destinations"]
15
+ },
16
+ {
17
+ flow_type: "onboarding_wizard",
18
+ concept_id: "flow_onboarding",
19
+ pattern: /\/(onboarding|setup|welcome|get-started|getting-started|wizard|stepper)(\/|$)/i,
20
+ confidence: "medium",
21
+ missing_decisions: ["confirm step order and completion criteria", "confirm resumability and exit behavior"]
22
+ },
23
+ {
24
+ flow_type: "settings_preferences",
25
+ concept_id: "flow_settings",
26
+ pattern: /\/(settings|preferences|profile|account|billing|security)(\/|$)/i,
27
+ confidence: "medium",
28
+ missing_decisions: ["confirm settings ownership boundary", "confirm save, validation, and permission behavior"]
29
+ },
30
+ {
31
+ flow_type: "dashboard_reporting",
32
+ concept_id: "flow_dashboard",
33
+ pattern: /\/(dashboard|reports|reporting|analytics|insights|metrics)(\/|$)/i,
34
+ confidence: "medium",
35
+ missing_decisions: ["confirm reporting data sources", "confirm refresh cadence and empty states"]
36
+ },
37
+ {
38
+ flow_type: "search_filter",
39
+ concept_id: "flow_search",
40
+ pattern: /\/(search|results|explore|browse|discover)(\/|$)/i,
41
+ confidence: "medium",
42
+ missing_decisions: ["confirm searchable resources", "confirm filter, sorting, and pagination behavior"]
43
+ },
44
+ {
45
+ flow_type: "bulk_review",
46
+ concept_id: "flow_bulk_review",
47
+ pattern: /\/(review|reviews|approvals|approval|moderation|queue|bulk)(\/|$)/i,
48
+ confidence: "medium",
49
+ missing_decisions: ["confirm bulk action eligibility", "confirm review outcomes and audit requirements"]
50
+ }
51
+ ];
52
+
8
53
  /**
9
54
  * @param {string} routePath
10
55
  * @returns {any}
@@ -23,12 +68,72 @@ export function routeSegments(routePath) {
23
68
  export function screenKindForRoute(routePath) {
24
69
  const normalized = String(routePath || "");
25
70
  const segments = routeSegments(normalized);
71
+ if (inferNonResourceUiFlow(normalized)) return "flow";
26
72
  if (/\/new$/.test(normalized)) return "form";
27
73
  if (/\/:?[A-Za-z0-9_]+\/edit$/.test(normalized)) return "form";
28
74
  if (segments.length >= 2 && !/\/new$/.test(normalized) && !/\/edit$/.test(normalized)) return "detail";
29
75
  return "list";
30
76
  }
31
77
 
78
+ /**
79
+ * @param {string} routePath
80
+ * @returns {any}
81
+ */
82
+ export function inferNonResourceUiFlow(routePath) {
83
+ const normalized = String(routePath || "");
84
+ for (const definition of NON_RESOURCE_FLOW_DEFINITIONS) {
85
+ if (definition.pattern.test(normalized)) {
86
+ return {
87
+ flow_type: definition.flow_type,
88
+ concept_id: definition.concept_id,
89
+ confidence: definition.confidence,
90
+ missing_decisions: definition.missing_decisions
91
+ };
92
+ }
93
+ }
94
+ return null;
95
+ }
96
+
97
+ /**
98
+ * @param {string} routePath
99
+ * @param {string} screenId
100
+ * @returns {any}
101
+ */
102
+ export function uiFlowIdForRoute(routePath, screenId) {
103
+ const flow = inferNonResourceUiFlow(routePath);
104
+ if (!flow) return null;
105
+ return `flow_${canonicalCandidateTerm(screenId || flow.flow_type).replace(/-/g, "_")}`;
106
+ }
107
+
108
+ /**
109
+ * @param {string} routePath
110
+ * @param {string} screenId
111
+ * @param {string} screenKind
112
+ * @returns {any}
113
+ */
114
+ export function proposedUiContractAdditionsForFlow(routePath, screenId, screenKind) {
115
+ return {
116
+ projection_type: "ui_contract",
117
+ screens: [
118
+ {
119
+ id: screenId,
120
+ kind: screenKind || "flow",
121
+ title: screenId
122
+ }
123
+ ],
124
+ screen_routes: [
125
+ {
126
+ screen: screenId,
127
+ path: routePath
128
+ }
129
+ ],
130
+ notes: [
131
+ "Review-only flow recovery proposal.",
132
+ "Promote into the shared UI contract only after confirming behavior, state, and ownership decisions."
133
+ ]
134
+ };
135
+ }
136
+
32
137
  /**
33
138
  * @param {string} routePath
34
139
  * @returns {any}
@@ -37,6 +142,7 @@ export function screenIdForRoute(routePath) {
37
142
  const segments = routeSegments(routePath);
38
143
  const resource = canonicalCandidateTerm(segments[0] || "home");
39
144
  const kind = screenKindForRoute(routePath);
145
+ if (kind === "flow") return canonicalCandidateTerm(segments.join("_") || "home").replace(/-/g, "_");
40
146
  if (kind === "form" && /\/new$/.test(routePath)) return `${resource}_create`;
41
147
  if (kind === "form" && /\/edit$/.test(routePath)) return `${resource}_edit`;
42
148
  if (kind === "detail") return `${resource}_detail`;
@@ -19,9 +19,13 @@ export {
19
19
  listFilesRecursive,
20
20
  importSearchRoots,
21
21
  normalizeImportRelativePath,
22
+ importSourcePathRelativeToWorkspace,
23
+ classifyImportSourcePath,
24
+ isPrimaryImportSource,
22
25
  canonicalSourceRank,
23
26
  selectPreferredImportFiles,
24
- findImportFiles
27
+ findImportFiles,
28
+ findPrimaryImportFiles
25
29
  } from "./shared/files.js";
26
30
  export {
27
31
  normalizeOpenApiPath,
@@ -38,6 +42,9 @@ export {
38
42
  screenIdForRoute,
39
43
  uiCapabilityHintsForRoute,
40
44
  entityIdForRoute,
45
+ inferNonResourceUiFlow,
46
+ uiFlowIdForRoute,
47
+ proposedUiContractAdditionsForFlow,
41
48
  inferReactRoutes,
42
49
  inferSvelteRoutes,
43
50
  inferNavigationStructure,
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
 
3
- import { findImportFiles, readTextIfExists } from "../core/shared.js";
3
+ import { findPrimaryImportFiles, readTextIfExists } from "../core/shared.js";
4
4
 
5
5
  function splitClassBlocks(text) {
6
6
  const lines = String(text || "").split(/\r?\n/);
@@ -51,7 +51,7 @@ function splitClassBlocks(text) {
51
51
  }
52
52
 
53
53
  function buildSerializerIndex(paths) {
54
- const files = findImportFiles(paths, (filePath) => /\/serializers\.py$/i.test(filePath));
54
+ const files = findPrimaryImportFiles(paths, (filePath) => /\/serializers\.py$/i.test(filePath));
55
55
  const index = new Map();
56
56
 
57
57
  for (const filePath of files) {
@@ -89,7 +89,7 @@ function buildSerializerIndex(paths) {
89
89
  }
90
90
 
91
91
  function buildViewIndex(paths) {
92
- const files = findImportFiles(paths, (filePath) => /\/views\.py$/i.test(filePath));
92
+ const files = findPrimaryImportFiles(paths, (filePath) => /\/views\.py$/i.test(filePath));
93
93
  const index = new Map();
94
94
 
95
95
  for (const filePath of files) {
@@ -182,7 +182,7 @@ export const djangoRestEnricher = {
182
182
  applies(context, candidates) {
183
183
  if ((candidates.capabilities || []).length === 0) return false;
184
184
  return (candidates.stacks || []).includes("django") ||
185
- findImportFiles(context.paths, (filePath) => /\/serializers\.py$/i.test(filePath)).length > 0;
185
+ findPrimaryImportFiles(context.paths, (filePath) => /\/serializers\.py$/i.test(filePath)).length > 0;
186
186
  },
187
187
  enrich(context, candidates) {
188
188
  const serializerIndex = buildSerializerIndex(context.paths);
@@ -1,9 +1,9 @@
1
1
  import path from "node:path";
2
2
 
3
- import { findImportFiles, readTextIfExists } from "../core/shared.js";
3
+ import { findPrimaryImportFiles, readTextIfExists } from "../core/shared.js";
4
4
 
5
5
  function buildControllerIndex(paths) {
6
- const files = findImportFiles(paths, (filePath) => /app\/controllers\/.+_controller\.rb$/i.test(filePath));
6
+ const files = findPrimaryImportFiles(paths, (filePath) => /app\/controllers\/.+_controller\.rb$/i.test(filePath));
7
7
  const index = new Map();
8
8
  for (const filePath of files) {
9
9
  const text = readTextIfExists(filePath) || "";
@@ -186,7 +186,7 @@ export const railsControllerEnricher = {
186
186
  applies(context, candidates) {
187
187
  if ((candidates.capabilities || []).length === 0) return false;
188
188
  return (candidates.stacks || []).includes("rails") ||
189
- findImportFiles(context.paths, (filePath) => /app\/controllers\/.+_controller\.rb$/i.test(filePath)).length > 0;
189
+ findPrimaryImportFiles(context.paths, (filePath) => /app\/controllers\/.+_controller\.rb$/i.test(filePath)).length > 0;
190
190
  },
191
191
  enrich(context, candidates) {
192
192
  const controllers = buildControllerIndex(context.paths);
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
 
3
- import { findImportFiles, readTextIfExists } from "../core/shared.js";
3
+ import { findPrimaryImportFiles, readTextIfExists } from "../core/shared.js";
4
4
 
5
5
  function modelClassNameForEntity(entityId) {
6
6
  const stem = String(entityId || "")
@@ -13,7 +13,7 @@ function modelClassNameForEntity(entityId) {
13
13
  }
14
14
 
15
15
  function buildModelIndex(paths) {
16
- const modelFiles = findImportFiles(paths, (filePath) => /app\/models\/.+\.rb$/i.test(filePath));
16
+ const modelFiles = findPrimaryImportFiles(paths, (filePath) => /app\/models\/.+\.rb$/i.test(filePath));
17
17
  const index = new Map();
18
18
  for (const filePath of modelFiles) {
19
19
  const text = readTextIfExists(filePath) || "";
@@ -93,7 +93,7 @@ export const railsModelEnricher = {
93
93
  track: "db",
94
94
  applies(context, candidates) {
95
95
  if ((candidates.entities || []).length === 0) return false;
96
- return findImportFiles(context.paths, (filePath) => /app\/models\/.+\.rb$/i.test(filePath)).length > 0;
96
+ return findPrimaryImportFiles(context.paths, (filePath) => /app\/models\/.+\.rb$/i.test(filePath)).length > 0;
97
97
  },
98
98
  enrich(context, candidates) {
99
99
  const modelIndex = buildModelIndex(context.paths);
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  dedupeCandidateRecords,
3
- findImportFiles,
3
+ findPrimaryImportFiles,
4
4
  inferApiEntityIdFromPath,
5
5
  inferRouteCapabilityId,
6
6
  makeCandidateRecord,
@@ -22,7 +22,7 @@ function parsePublicProperties(text) {
22
22
  }
23
23
 
24
24
  function buildDotnetFileIndex(paths) {
25
- const files = findImportFiles(paths, (filePath) => /\.cs$/i.test(filePath));
25
+ const files = findPrimaryImportFiles(paths, (filePath) => /\.cs$/i.test(filePath));
26
26
  const index = new Map();
27
27
  for (const filePath of files) {
28
28
  index.set(relativeTo(paths.repoRoot, filePath), readTextIfExists(filePath) || "");
@@ -239,8 +239,8 @@ export const aspNetCoreExtractor = {
239
239
  id: "api.aspnet-core",
240
240
  track: "api",
241
241
  detect(context) {
242
- const controllerFiles = findImportFiles(context.paths, (filePath) => /Controller\.cs$/i.test(filePath));
243
- const programFiles = findImportFiles(context.paths, (filePath) => /Program\.cs$/i.test(filePath));
242
+ const controllerFiles = findPrimaryImportFiles(context.paths, (filePath) => /Controller\.cs$/i.test(filePath));
243
+ const programFiles = findPrimaryImportFiles(context.paths, (filePath) => /Program\.cs$/i.test(filePath));
244
244
  const score = controllerFiles.length > 0 && programFiles.some((filePath) => /WebApplication\.CreateBuilder|AddSwaggerGen|AddMvc/.test(readTextIfExists(filePath) || "")) ? 88 : 0;
245
245
  return {
246
246
  score,
@@ -248,7 +248,7 @@ export const aspNetCoreExtractor = {
248
248
  };
249
249
  },
250
250
  extract(context) {
251
- const controllerFiles = findImportFiles(context.paths, (filePath) => /Controller\.cs$/i.test(filePath));
251
+ const controllerFiles = findPrimaryImportFiles(context.paths, (filePath) => /Controller\.cs$/i.test(filePath));
252
252
  const featureFiles = buildDotnetFileIndex(context.paths).files.map((filePath) => ({ filePath, text: readTextIfExists(filePath) || "" }));
253
253
  const findings = [];
254
254
  const candidates = { capabilities: [], routes: [], stacks: [] };
@@ -2,7 +2,7 @@ import path from "node:path";
2
2
 
3
3
  import {
4
4
  dedupeCandidateRecords,
5
- findImportFiles,
5
+ findPrimaryImportFiles,
6
6
  inferApiEntityIdFromPath,
7
7
  inferRouteCapabilityId,
8
8
  makeCandidateRecord,
@@ -90,7 +90,7 @@ function permissionAuthHint(permissionText, method) {
90
90
  }
91
91
 
92
92
  function buildViewIndex(paths) {
93
- const viewFiles = findImportFiles(paths, (filePath) => /\/views\.py$/i.test(filePath));
93
+ const viewFiles = findPrimaryImportFiles(paths, (filePath) => /\/views\.py$/i.test(filePath));
94
94
  const index = new Map();
95
95
 
96
96
  for (const filePath of viewFiles) {
@@ -250,8 +250,8 @@ export const djangoRoutesExtractor = {
250
250
  id: "api.django-routes",
251
251
  track: "api",
252
252
  detect(context) {
253
- const manageFiles = findImportFiles(context.paths, (filePath) => /\/manage\.py$/i.test(filePath));
254
- const urlFiles = findImportFiles(context.paths, (filePath) => /\/urls\.py$/i.test(filePath));
253
+ const manageFiles = findPrimaryImportFiles(context.paths, (filePath) => /\/manage\.py$/i.test(filePath));
254
+ const urlFiles = findPrimaryImportFiles(context.paths, (filePath) => /\/urls\.py$/i.test(filePath));
255
255
  const score = manageFiles.length > 0 && urlFiles.length > 0 ? 90 : 0;
256
256
  return {
257
257
  score,
@@ -259,7 +259,7 @@ export const djangoRoutesExtractor = {
259
259
  };
260
260
  },
261
261
  extract(context) {
262
- const urlFiles = findImportFiles(context.paths, (filePath) => /\/urls\.py$/i.test(filePath));
262
+ const urlFiles = findPrimaryImportFiles(context.paths, (filePath) => /\/urls\.py$/i.test(filePath));
263
263
  const moduleMap = new Map(urlFiles.map((filePath) => [moduleNameForFile(context.paths.repoRoot, filePath), filePath]));
264
264
  const viewIndex = buildViewIndex(context.paths);
265
265
  const rootFiles = urlFiles.filter((filePath) => !/\/apps\//.test(filePath));
@@ -2,7 +2,7 @@ import path from "node:path";
2
2
 
3
3
  import {
4
4
  dedupeCandidateRecords,
5
- findImportFiles,
5
+ findPrimaryImportFiles,
6
6
  inferApiEntityIdFromPath,
7
7
  inferRouteAuthHint,
8
8
  inferRouteCapabilityId,
@@ -94,18 +94,18 @@ export const expressExtractor = {
94
94
  id: "api.express",
95
95
  track: "api",
96
96
  detect(context) {
97
- const routeFiles = findImportFiles(context.paths, (filePath) => /src\/routes\/.+\.(ts|js|mjs|cjs)$/i.test(filePath));
97
+ const routeFiles = findPrimaryImportFiles(context.paths, (filePath) => /src\/routes\/.+\.(ts|js|mjs|cjs)$/i.test(filePath));
98
98
  return {
99
99
  score: routeFiles.length > 0 ? 85 : 0,
100
100
  reasons: routeFiles.length > 0 ? ["Found Express route modules"] : []
101
101
  };
102
102
  },
103
103
  extract(context) {
104
- const permissionsFile = findImportFiles(context.paths, (filePath) => /src\/helpers\/permissions\.(ts|js|mjs|cjs)$/i.test(filePath))[0];
104
+ const permissionsFile = findPrimaryImportFiles(context.paths, (filePath) => /src\/helpers\/permissions\.(ts|js|mjs|cjs)$/i.test(filePath))[0];
105
105
  const permissionsText = permissionsFile ? context.helpers.readTextIfExists(permissionsFile) || "" : "";
106
106
  const apiRoutes = parseApiRoutesMap(permissionsText);
107
107
  const permissionMeta = parsePermissionsMetadata(permissionsText);
108
- const routeFiles = findImportFiles(context.paths, (filePath) => /src\/routes\/.+\.(ts|js|mjs|cjs)$/i.test(filePath));
108
+ const routeFiles = findPrimaryImportFiles(context.paths, (filePath) => /src\/routes\/.+\.(ts|js|mjs|cjs)$/i.test(filePath));
109
109
  const routes = routeFiles.flatMap((filePath) =>
110
110
  parseExpressRouteCalls(filePath, context.helpers.readTextIfExists(filePath) || "", apiRoutes, permissionMeta)
111
111
  );